Skip to content

Commit c3142d2

Browse files
authored
Merge pull request #14 from samzong/feature/comprehensive-ssh-config
feat: Implement comprehensive SSH configuration support
2 parents 3449c39 + c00c4fc commit c3142d2

File tree

14 files changed

+527
-304
lines changed

14 files changed

+527
-304
lines changed

CLI/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ Connect to an SSH host:
3030
cf ssh connect <host-name>
3131
```
3232

33+
Show all configured directives for a specific SSH host:
34+
```bash
35+
cf ssh show <host-name>
36+
# or
37+
cf ssh s <host-name>
38+
```
39+
This command displays every directive (e.g., HostName, User, Port, IdentityFile, as well as any custom options) defined for the specified host in your `~/.ssh/config` file.
40+
3341
### Kubernetes Commands
3442

3543
List all Kubernetes contexts:

CLI/Sources/ConfigForgeCLI/Commands/SSHCommand.swift

Lines changed: 12 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -41,28 +41,14 @@ struct SSHListCommand: ParsableCommand {
4141

4242
print("Available SSH hosts:")
4343
for (index, entry) in entries.enumerated() {
44-
let hostName = entry.host ?? "unnamed"
44+
let hostName = entry.host
4545
let number = index + 1
4646

4747
if detail {
4848
print(" \(number). \(hostName)")
49-
if let user = entry.user {
50-
print(" User: \(user)")
51-
}
52-
if let hostname = entry.hostname {
53-
print(" Hostname: \(hostname)")
54-
}
55-
if let port = entry.port {
56-
print(" Port: \(port)")
57-
}
58-
if let identityFile = entry.identityFile {
59-
print(" IdentityFile: \(identityFile)")
60-
}
61-
if let forwardAgent = entry.forwardAgent {
62-
print(" ForwardAgent: \(forwardAgent)")
63-
}
64-
if let proxyCommand = entry.proxyCommand {
65-
print(" ProxyCommand: \(proxyCommand)")
49+
// Display all directives for this host
50+
for directive in entry.directives {
51+
print(" \(directive.key): \(directive.value)")
6652
}
6753
print("")
6854
} else {
@@ -113,47 +99,11 @@ struct SSHShowCommand: ParsableCommand {
11399
entry = foundEntry
114100
}
115101

116-
print("Host: \(entry.host ?? "unnamed")")
117-
118-
if let hostname = entry.hostname {
119-
print(" Hostname: \(hostname)")
120-
}
121-
if let user = entry.user {
122-
print(" User: \(user)")
123-
}
124-
if let port = entry.port {
125-
print(" Port: \(port)")
126-
}
127-
if let identityFile = entry.identityFile {
128-
print(" IdentityFile: \(identityFile)")
129-
}
130-
if let forwardAgent = entry.forwardAgent {
131-
print(" ForwardAgent: \(forwardAgent)")
132-
}
133-
if let proxyCommand = entry.proxyCommand {
134-
print(" ProxyCommand: \(proxyCommand)")
135-
}
136-
if let serverAliveInterval = entry.serverAliveInterval {
137-
print(" ServerAliveInterval: \(serverAliveInterval)")
138-
}
139-
if let serverAliveCountMax = entry.serverAliveCountMax {
140-
print(" ServerAliveCountMax: \(serverAliveCountMax)")
141-
}
142-
if let strictHostKeyChecking = entry.strictHostKeyChecking {
143-
print(" StrictHostKeyChecking: \(strictHostKeyChecking)")
144-
}
145-
if let userKnownHostsFile = entry.userKnownHostsFile {
146-
print(" UserKnownHostsFile: \(userKnownHostsFile)")
147-
}
148-
if let connectTimeout = entry.connectTimeout {
149-
print(" ConnectTimeout: \(connectTimeout)")
150-
}
102+
print("Host: \(entry.host)")
151103

152-
if !entry.otherOptions.isEmpty {
153-
print(" Other Options:")
154-
for (key, value) in entry.otherOptions {
155-
print(" \(key): \(value)")
156-
}
104+
// Iterate through all directives and print them
105+
for directive in entry.directives {
106+
print(" \(directive.key): \(directive.value)")
157107
}
158108
} catch {
159109
print("Error: \(error.localizedDescription)")
@@ -188,7 +138,7 @@ struct SSHConnectCommand: ParsableCommand {
188138
// Check if input is a number
189139
if let index = Int(host), index > 0, index <= entries.count {
190140
hostEntry = entries[index - 1]
191-
hostDisplayName = "\(index). \(hostEntry.host ?? "unnamed")"
141+
hostDisplayName = "\(index). \(hostEntry.host)"
192142
} else {
193143
// Search by host name
194144
guard let foundEntry = entries.first(where: { $0.host == host }) else {
@@ -197,7 +147,7 @@ struct SSHConnectCommand: ParsableCommand {
197147
throw ExitCode.failure
198148
}
199149
hostEntry = foundEntry
200-
hostDisplayName = hostEntry.host ?? "unnamed"
150+
hostDisplayName = hostEntry.host
201151
}
202152

203153
if isDebugMode {
@@ -229,7 +179,7 @@ struct SSHConnectCommand: ParsableCommand {
229179
if let username = hostEntry.user, let hostname = hostEntry.hostname {
230180
targetAddress = "\(username)@\(hostname)"
231181
} else {
232-
targetAddress = hostEntry.host ?? host
182+
targetAddress = hostEntry.host // Updated since host is now non-optional
233183
}
234184
argsArray.append(targetAddress)
235185

@@ -257,7 +207,7 @@ struct SSHConnectCommand: ParsableCommand {
257207
print("System version: \(ProcessInfo.processInfo.operatingSystemVersionString)")
258208

259209
print("\n===== Host Configuration Information =====")
260-
print("Host: \(hostEntry.host ?? "unnamed")")
210+
print("Host: \(hostEntry.host)")
261211
print("Hostname: \(hostEntry.hostname ?? "not specified")")
262212
print("User: \(hostEntry.user ?? "not specified")")
263213
print("Port: \(hostEntry.port ?? "not specified (default: 22)")")
Lines changed: 77 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,83 @@
11
import Foundation
22

33
struct SSHConfigEntry {
4-
var host: String?
5-
var hostname: String?
6-
var user: String?
7-
var port: String?
8-
var identityFile: String?
9-
var forwardAgent: String?
10-
var proxyCommand: String?
11-
var serverAliveInterval: String?
12-
var serverAliveCountMax: String?
13-
var strictHostKeyChecking: String?
14-
var userKnownHostsFile: String?
15-
var connectTimeout: String?
16-
var otherOptions: [String: String] = [:]
17-
18-
init(host: String? = nil, hostname: String? = nil, user: String? = nil,
19-
port: String? = nil, identityFile: String? = nil, forwardAgent: String? = nil,
20-
proxyCommand: String? = nil, serverAliveInterval: String? = nil,
21-
serverAliveCountMax: String? = nil, strictHostKeyChecking: String? = nil,
22-
userKnownHostsFile: String? = nil, connectTimeout: String? = nil,
23-
otherOptions: [String: String] = [:]) {
4+
let id: UUID
5+
var host: String
6+
var directives: [(key: String, value: String)] = []
7+
8+
// Default initializer that generates a new UUID
9+
init(host: String, directives: [(key: String, value: String)] = []) {
10+
self.id = UUID()
11+
self.host = host
12+
self.directives = directives
13+
}
14+
15+
// Initializer that accepts a specific UUID (for updates)
16+
init(id: UUID, host: String, directives: [(key: String, value: String)] = []) {
17+
self.id = id
2418
self.host = host
25-
self.hostname = hostname
26-
self.user = user
27-
self.port = port
28-
self.identityFile = identityFile
29-
self.forwardAgent = forwardAgent
30-
self.proxyCommand = proxyCommand
31-
self.serverAliveInterval = serverAliveInterval
32-
self.serverAliveCountMax = serverAliveCountMax
33-
self.strictHostKeyChecking = strictHostKeyChecking
34-
self.userKnownHostsFile = userKnownHostsFile
35-
self.connectTimeout = connectTimeout
36-
self.otherOptions = otherOptions
19+
self.directives = directives
20+
}
21+
22+
// Computed properties for backward compatibility and easy access
23+
var hostname: String? {
24+
directives.first { $0.key.lowercased() == "hostname" }?.value
25+
}
26+
27+
var user: String? {
28+
directives.first { $0.key.lowercased() == "user" }?.value
29+
}
30+
31+
var port: String? {
32+
directives.first { $0.key.lowercased() == "port" }?.value
33+
}
34+
35+
var identityFile: String? {
36+
directives.first { $0.key.lowercased() == "identityfile" }?.value
37+
}
38+
39+
var forwardAgent: String? {
40+
directives.first { $0.key.lowercased() == "forwardagent" }?.value
41+
}
42+
43+
var proxyCommand: String? {
44+
directives.first { $0.key.lowercased() == "proxycommand" }?.value
45+
}
46+
47+
var serverAliveInterval: String? {
48+
directives.first { $0.key.lowercased() == "serveraliveinterval" }?.value
49+
}
50+
51+
var serverAliveCountMax: String? {
52+
directives.first { $0.key.lowercased() == "serveralivecountmax" }?.value
53+
}
54+
55+
var strictHostKeyChecking: String? {
56+
directives.first { $0.key.lowercased() == "stricthostkeychecking" }?.value
57+
}
58+
59+
var userKnownHostsFile: String? {
60+
directives.first { $0.key.lowercased() == "userknownhostsfile" }?.value
61+
}
62+
63+
var connectTimeout: String? {
64+
directives.first { $0.key.lowercased() == "connecttimeout" }?.value
65+
}
66+
67+
// Get other options that are not in the standard properties
68+
var otherOptions: [String: String] {
69+
let standardKeys = Set([
70+
"hostname", "user", "port", "identityfile", "forwardagent",
71+
"proxycommand", "serveraliveinterval", "serveralivecountmax",
72+
"stricthostkeychecking", "userknownhostsfile", "connecttimeout"
73+
])
74+
75+
var others: [String: String] = [:]
76+
for directive in directives {
77+
if !standardKeys.contains(directive.key.lowercased()) {
78+
others[directive.key] = directive.value
79+
}
80+
}
81+
return others
3782
}
3883
}

CLI/Sources/ConfigForgeCLI/Services/CLISSHConfigFileManager.swift

Lines changed: 63 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,34 @@ class CLISSHConfigFileManager {
2626
class CLISSHConfigParser {
2727
func parseConfig(content: String) throws -> [SSHConfigEntry] {
2828
var entries: [SSHConfigEntry] = []
29-
var currentEntry: SSHConfigEntry?
29+
var currentHost: String?
30+
var currentDirectives: [(key: String, value: String)] = []
31+
var inMultilineValue = false
32+
var multilineKey: String?
3033

3134
let lines = content.components(separatedBy: .newlines)
3235

33-
for line in lines {
36+
for lineIndex in 0..<lines.count {
37+
let line = lines[lineIndex]
38+
39+
// Handle multiline values (lines starting with whitespace)
40+
if line.first?.isWhitespace == true && inMultilineValue && multilineKey != nil {
41+
if var lastDirective = currentDirectives.popLast(), lastDirective.key == multilineKey {
42+
var lineContent = line.trimmingCharacters(in: .whitespaces)
43+
if lineContent.hasSuffix("\\") {
44+
lineContent = String(lineContent.dropLast())
45+
lastDirective.value += " " + lineContent
46+
currentDirectives.append(lastDirective)
47+
} else {
48+
lastDirective.value += " " + lineContent
49+
currentDirectives.append(lastDirective)
50+
inMultilineValue = false
51+
multilineKey = nil
52+
}
53+
}
54+
continue
55+
}
56+
3457
let trimmedLine = line.trimmingCharacters(in: .whitespaces)
3558

3659
// Skip comments and empty lines
@@ -42,56 +65,58 @@ class CLISSHConfigParser {
4265
let components = trimmedLine.components(separatedBy: .whitespaces)
4366
guard components.count >= 2 else { continue }
4467

45-
let key = components[0].lowercased()
68+
let key = components[0]
4669
let value = components[1...].joined(separator: " ")
70+
let keyword = key.lowercased()
4771

4872
// New Host entry starts
49-
if key == "host" {
73+
if keyword == "host" {
5074
// Save the previous entry if exists
51-
if let entry = currentEntry {
52-
entries.append(entry)
75+
if let host = currentHost, !host.isEmpty {
76+
entries.append(SSHConfigEntry(host: host, directives: currentDirectives))
5377
}
5478

5579
// Start a new entry
56-
currentEntry = SSHConfigEntry(host: value)
57-
} else if var entry = currentEntry {
58-
// Add the option to the current entry
59-
switch key {
60-
case "hostname":
61-
entry.hostname = value
62-
case "user":
63-
entry.user = value
64-
case "port":
65-
entry.port = value
66-
case "identityfile":
67-
entry.identityFile = value
68-
case "forwardagent":
69-
entry.forwardAgent = value
70-
case "proxycommand":
71-
entry.proxyCommand = value
72-
case "serveraliveinterval":
73-
entry.serverAliveInterval = value
74-
case "serveralivecountmax":
75-
entry.serverAliveCountMax = value
76-
case "stricthostkeychecking":
77-
entry.strictHostKeyChecking = value
78-
case "userknownhostsfile":
79-
entry.userKnownHostsFile = value
80-
case "connecttimeout":
81-
entry.connectTimeout = value
82-
default:
83-
entry.otherOptions[key] = value
84-
}
80+
currentHost = value
81+
currentDirectives = []
82+
} else if currentHost != nil {
83+
// Format the key properly
84+
let formattedKey = formatPropertyKey(keyword)
8585

86-
currentEntry = entry
86+
// Handle multiline values
87+
if value.hasSuffix("\\") {
88+
inMultilineValue = true
89+
multilineKey = formattedKey
90+
currentDirectives.append((key: formattedKey, value: String(value.dropLast())))
91+
} else {
92+
currentDirectives.append((key: formattedKey, value: value))
93+
}
8794
}
8895
}
8996

9097
// Add the last entry
91-
if let entry = currentEntry {
92-
entries.append(entry)
98+
if let host = currentHost, !host.isEmpty {
99+
entries.append(SSHConfigEntry(host: host, directives: currentDirectives))
93100
}
94101

95102
return entries
96103
}
104+
105+
private func formatPropertyKey(_ key: String) -> String {
106+
let propertyMappings: [String: String] = [
107+
"hostname": "HostName",
108+
"user": "User",
109+
"port": "Port",
110+
"identityfile": "IdentityFile",
111+
"forwardagent": "ForwardAgent",
112+
"proxycommand": "ProxyCommand",
113+
"serveraliveinterval": "ServerAliveInterval",
114+
"serveralivecountmax": "ServerAliveCountMax",
115+
"stricthostkeychecking": "StrictHostKeyChecking",
116+
"userknownhostsfile": "UserKnownHostsFile",
117+
"connecttimeout": "ConnectTimeout"
118+
]
119+
120+
return propertyMappings[key] ?? key.capitalized
121+
}
97122
}

0 commit comments

Comments
 (0)