Compare commits
3 Commits
a4a4a6a4b8
...
a332459b92
Author | SHA1 | Date | |
---|---|---|---|
a332459b92 | |||
5ebe20a210 | |||
df3c9feb53 |
16
README.md
16
README.md
@@ -5,9 +5,10 @@ A terminal user interface (TUI) application for SSH port forwarding that reads f
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **SSH Config Integration**: Automatically reads from `~/.ssh/config`
|
- **SSH Config Integration**: Automatically reads from `~/.ssh/config`
|
||||||
|
- **Include Support**: Supports SSH config `Include` directive with glob patterns
|
||||||
- **Interactive Host Selection**: Choose from configured SSH hosts using arrow keys
|
- **Interactive Host Selection**: Choose from configured SSH hosts using arrow keys
|
||||||
- **Automatic Port Detection**: Scans remote host for listening ports using `netstat`, `ss`, or `lsof`
|
- **Automatic Port Detection**: Scans remote host for listening ports using `netstat`, `ss`, or `lsof`
|
||||||
- **Manual Port Forwarding**: Option to manually specify remote ports
|
- **Manual Port Forwarding**: Option to manually specify remote ports with improved UI
|
||||||
- **Real-time Port Forwarding**: Creates SSH tunnels similar to VSCode's remote SSH port forwarding
|
- **Real-time Port Forwarding**: Creates SSH tunnels similar to VSCode's remote SSH port forwarding
|
||||||
- **Clean TUI Interface**: Built with Bubble Tea for a smooth terminal experience
|
- **Clean TUI Interface**: Built with Bubble Tea for a smooth terminal experience
|
||||||
|
|
||||||
@@ -74,8 +75,21 @@ Host dev-box
|
|||||||
HostName dev.example.com
|
HostName dev.example.com
|
||||||
User developer
|
User developer
|
||||||
Port 2222
|
Port 2222
|
||||||
|
|
||||||
|
# Include additional config files
|
||||||
|
Include ~/.ssh/config.d/*
|
||||||
|
Include ~/.ssh/work-config
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Include Support
|
||||||
|
|
||||||
|
kport supports the SSH `Include` directive, allowing you to organize your SSH configuration across multiple files:
|
||||||
|
|
||||||
|
- **Glob patterns**: `Include ~/.ssh/config.d/*`
|
||||||
|
- **Specific files**: `Include ~/.ssh/work-config`
|
||||||
|
- **Relative paths**: `Include config.d/servers`
|
||||||
|
- **Cycle detection**: Prevents infinite loops from circular includes
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
The application supports:
|
The application supports:
|
||||||
|
@@ -42,9 +42,27 @@ func (sc *SSHConfig) LoadConfig() error {
|
|||||||
|
|
||||||
// LoadConfigFromFile loads SSH configuration from a specific file
|
// LoadConfigFromFile loads SSH configuration from a specific file
|
||||||
func (sc *SSHConfig) LoadConfigFromFile(path string) error {
|
func (sc *SSHConfig) LoadConfigFromFile(path string) error {
|
||||||
|
return sc.loadConfigFromFileRecursive(path, make(map[string]bool))
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfigFromFileRecursive loads SSH config with include support and cycle detection
|
||||||
|
func (sc *SSHConfig) loadConfigFromFileRecursive(path string, visited map[string]bool) error {
|
||||||
|
// Resolve absolute path to detect cycles
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to resolve path %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for cycles
|
||||||
|
if visited[absPath] {
|
||||||
|
return fmt.Errorf("circular include detected: %s", absPath)
|
||||||
|
}
|
||||||
|
visited[absPath] = true
|
||||||
|
defer delete(visited, absPath)
|
||||||
|
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open SSH config file: %w", err)
|
return fmt.Errorf("failed to open SSH config file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
@@ -68,6 +86,12 @@ func (sc *SSHConfig) LoadConfigFromFile(path string) error {
|
|||||||
value := strings.Join(parts[1:], " ")
|
value := strings.Join(parts[1:], " ")
|
||||||
|
|
||||||
switch key {
|
switch key {
|
||||||
|
case "include":
|
||||||
|
// Handle include directive
|
||||||
|
if err := sc.processInclude(value, visited); err != nil {
|
||||||
|
// Log error but continue processing
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: failed to process include %s: %v\n", value, err)
|
||||||
|
}
|
||||||
case "host":
|
case "host":
|
||||||
// Save previous host if exists
|
// Save previous host if exists
|
||||||
if currentHost != nil {
|
if currentHost != nil {
|
||||||
@@ -103,7 +127,40 @@ func (sc *SSHConfig) LoadConfigFromFile(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
return fmt.Errorf("error reading SSH config file: %w", err)
|
return fmt.Errorf("error reading SSH config file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// processInclude handles SSH config include directives
|
||||||
|
func (sc *SSHConfig) processInclude(pattern string, visited map[string]bool) error {
|
||||||
|
// Expand tilde to home directory
|
||||||
|
if strings.HasPrefix(pattern, "~/") {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get home directory: %w", err)
|
||||||
|
}
|
||||||
|
pattern = filepath.Join(homeDir, pattern[2:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle glob patterns
|
||||||
|
matches, err := filepath.Glob(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid glob pattern %s: %w", pattern, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each matching file
|
||||||
|
for _, match := range matches {
|
||||||
|
// Skip directories
|
||||||
|
if info, err := os.Stat(match); err == nil && info.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively load the included file
|
||||||
|
if err := sc.loadConfigFromFileRecursive(match, visited); err != nil {
|
||||||
|
return fmt.Errorf("failed to load included file %s: %w", match, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
48
tui.go
48
tui.go
@@ -357,19 +357,53 @@ func (m *Model) renderManualPort() string {
|
|||||||
var s strings.Builder
|
var s strings.Builder
|
||||||
|
|
||||||
host := m.hosts[m.selectedHost]
|
host := m.hosts[m.selectedHost]
|
||||||
s.WriteString(fmt.Sprintf("Manual port forwarding for %s:\n\n", host.Name))
|
hostStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#7D56F4")).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
s.WriteString("Enter remote port number: ")
|
s.WriteString(fmt.Sprintf("Manual port forwarding for %s:\n\n", hostStyle.Render(host.Name)))
|
||||||
|
|
||||||
inputStyle := lipgloss.NewStyle().
|
// Label for the input
|
||||||
Border(lipgloss.RoundedBorder()).
|
labelStyle := lipgloss.NewStyle().
|
||||||
Padding(0, 1)
|
Foreground(lipgloss.Color("#FAFAFA")).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
s.WriteString(inputStyle.Render(m.manualPort))
|
s.WriteString(labelStyle.Render("Remote port number:"))
|
||||||
s.WriteString("\n\n")
|
s.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Input box styling
|
||||||
|
inputStyle := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("#7D56F4")).
|
||||||
|
Padding(0, 1).
|
||||||
|
Width(20).
|
||||||
|
Align(lipgloss.Left)
|
||||||
|
|
||||||
|
// Show placeholder or current input
|
||||||
|
displayText := m.manualPort
|
||||||
|
if displayText == "" {
|
||||||
|
placeholderStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#666666")).
|
||||||
|
Italic(true)
|
||||||
|
displayText = placeholderStyle.Render("e.g., 3000")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.WriteString(inputStyle.Render(displayText))
|
||||||
|
s.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Add cursor indicator
|
||||||
|
if m.manualPort != "" {
|
||||||
|
cursorStyle := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#FF75B7"))
|
||||||
|
s.WriteString(cursorStyle.Render("│"))
|
||||||
|
s.WriteString("\n\n")
|
||||||
|
} else {
|
||||||
|
s.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
s.WriteString("Controls:\n")
|
s.WriteString("Controls:\n")
|
||||||
s.WriteString(" Enter: Start forwarding Esc: Back q: Quit\n")
|
s.WriteString(" 0-9: Enter digits Backspace: Delete Enter: Start forwarding\n")
|
||||||
|
s.WriteString(" Esc: Back q: Quit\n")
|
||||||
|
|
||||||
return s.String()
|
return s.String()
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user