Compare commits

..

3 Commits

Author SHA1 Message Date
Ona
a332459b92 Update README with new features
- Document SSH config Include support
- Mention improved manual port input UI
- Add examples of include usage with glob patterns
- Explain cycle detection feature

Co-authored-by: Ona <no-reply@ona.com>
2025-09-26 00:10:42 +00:00
Ona
5ebe20a210 Improve manual port input UI styling
- Enhanced visual styling for port number input box
- Added proper border and padding with consistent colors
- Improved label positioning and typography
- Added placeholder text when input is empty
- Better visual cursor indication
- More detailed control instructions
- Consistent color scheme with rest of the application

The port input box now has better visual hierarchy and is easier to use.

Co-authored-by: Ona <no-reply@ona.com>
2025-09-26 00:09:11 +00:00
Ona
df3c9feb53 Add support for SSH config includes
- Support Include directive in SSH config files
- Handle glob patterns (e.g., Include ~/.ssh/config.d/*)
- Prevent circular includes with cycle detection
- Gracefully handle missing or invalid include files
- Maintain compatibility with existing config parsing

This allows users to organize their SSH configs across multiple files
as supported by OpenSSH.

Co-authored-by: Ona <no-reply@ona.com>
2025-09-26 00:08:52 +00:00
3 changed files with 115 additions and 10 deletions

View File

@@ -5,9 +5,10 @@ A terminal user interface (TUI) application for SSH port forwarding that reads f
## Features
- **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
- **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
- **Clean TUI Interface**: Built with Bubble Tea for a smooth terminal experience
@@ -74,8 +75,21 @@ Host dev-box
HostName dev.example.com
User developer
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
The application supports:

View File

@@ -42,9 +42,27 @@ func (sc *SSHConfig) LoadConfig() error {
// LoadConfigFromFile loads SSH configuration from a specific file
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)
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()
@@ -68,6 +86,12 @@ func (sc *SSHConfig) LoadConfigFromFile(path string) error {
value := strings.Join(parts[1:], " ")
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":
// Save previous host if exists
if currentHost != nil {
@@ -103,7 +127,40 @@ func (sc *SSHConfig) LoadConfigFromFile(path string) error {
}
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

48
tui.go
View File

@@ -357,19 +357,53 @@ func (m *Model) renderManualPort() string {
var s strings.Builder
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().
Border(lipgloss.RoundedBorder()).
Padding(0, 1)
// Label for the input
labelStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Bold(true)
s.WriteString(inputStyle.Render(m.manualPort))
s.WriteString(labelStyle.Render("Remote port number:"))
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(" 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()
}