An SSH config parser library for Swift with a fancy API
The SshConfig makes it quick and easy to load, parse, and decode/encode the SSH configs. It also helps to resolve the properties by hostname and use them safely in your apps (thanks for Optional and static types in Swift).
Swift 5.1+
| iOS | watchOS | tvOS | macOS |
|---|---|---|---|
| 13+ | 6+ | 13+ | 10.15+ |
NOTE: the instructions below are for using
SwiftPMwithout theXcode UI. It’s the easiest to go to your Project Settings -> Swift Packages and add SshConfig from there.
Swift Package Manager - is the recommended installation method. All you need is to add the following as a dependency to your Package.swift file:
.package(url: "https://github.com/xxlabaza/SshConfig.git", from: "1.0.1"),
So, your Package.swift may look like below:
// swift-tools-version:5.1
import PackageDescription
let package = Package(
name: "MyPackage",
platforms: [ // The SshConfig requires the versions below as a minimum.
.iOS(.v13),
.watchOS(.v6),
.tvOS(.v13),
.macOS(.v10_15),
],
products: [
.library(name: "MyPackage", targets: ["MyPackage"]),
],
dependencies: [
.package(url: "https://github.com/xxlabaza/SshConfig.git", from: "1.0.1"),
],
targets: [
.target(name: "MyPackage", dependencies: ["SshConfig"])
]
)
And then import wherever needed:
import SshConfig
CocoaPods is a dependency manager for Cocoa projects. For usage and installation instructions, visit their website. To integrate SshConfig into your Xcode project using CocoaPods, specify it in your Podfile:
pod 'SshConfig'
Eventually, your Podfile should look like this one:
# The SshConfig requires the versions below as a minimum.
# platform :ios, '13.0'
platform :osx, '10.15'
# platform :tvos, '13.0'
# platform :watchos, '6.0'
target 'MyApp' do
use_frameworks!
pod 'SshConfig'
end
And then run:
pod install
After installing the cocoapod into your project import SshConfig with:
import SshConfig
Let’s try to load, parse and decode an SSH config file and look at a base scenario of how to work with it.
~/.ssh/config file content:
Host gitlab.com github.com
PreferredAuthentications publickey
IdentityFile ~/.ssh/id_rsa
User xxlabaza
Host my*
User admin
Port 2021
Host *
SetEnv POPA=3000
Code example:
import SshConfig
let config = try! ssh.Config.load(path: "~/.ssh/config")
let github = config.resolve(for: "github.com")
assert(github.preferredAuthentications == [.publickey])
assert(github.identityFile == ["~/.ssh/id_rsa"])
assert(github.user == "xxlabaza")
assert(github.setEnv == ["POPA": "3000"]) // from 'Host *'
assert(github.port == 22) // the default one
// github.com and gitlab.com resolve the same properties set
assert(github == config.resolve(for: "gitlab.com"))
let myserver = config.resolve(for: "myserver")
assert(myserver.user == "admin")
assert(myserver.port == 2021)
assert(myserver.setEnv == ["POPA": "3000"]) // from 'Host *'
let backend = config.resolve(for: "backend")
assert(backend.user == nil) // the default one
assert(backend.port == 22) // the default one
assert(backend.setEnv == ["POPA": "3000"]) // from 'Host *'
The same ssh.Config instance can be constructed programmatically, like this:
NOTE: I use variadic list of closures for setting needed properties for
ssh.Propertiesinstance, which creates inside thessh.Hostinitializer. I found that approach more similar to the original ssh config file format, plus you can ignore thessh.Properties’s fields order.
import SshConfig
let config = ssh.Config(
ssh.Host("gitlab.com github.com",
{ $0.preferredAuthentications = [.publickey] },
{ $0.identityFile = ["~/.ssh/id_rsa"] },
{ $0.user = "xxlabaza" }
),
ssh.Host("my*",
{ $0.user = "admin" },
{ $0.port = 2021 }
),
ssh.Host("*",
{ $0.setEnv = ["POPA": "3000"] }
)
)
...
More code samples and examples are available in the website and in the tests (especially in UsageExample.swift file).
To parses a string into a collection of the hosts and their properties (without any decoding, just raw parsing):
NOTE: if you would like to parse and decode your config into an
ssh.Configinstance, see an appropriate section below.
let content = """
Host myserv
User alice
Port 2021
"""
let parser = ssh.ConfigParser()
let parsedConfig = try! parser.parse(content)
assert(parsedConfig.count == 1)
let (host, properties) = parsedConfig[0]
assert(host == "myserv")
assert(properties["user"] == ["alice"])
assert(properties["port"] == ["2021"])
To decode a string config into a ssh.Config instance:
let content = """
Host myserv
User alice
Port 2021
"""
let decoder = ssh.ConfigDecoder()
let config = try! decoder.decode(from: content)
assert(config.hosts.count == 1)
assert(config.hosts[0].alias == "myserv")
assert(config.hosts[0].properties.user == "alice")
assert(config.hosts[0].properties.port == 2021)
The same as above, but with an implicit ssh.ConfigParser call:
let content = """
Host myserv
port 2021
"""
let config = try! ssh.Config.parse(content)
assert(config.hosts.count == 1)
assert(config.hosts[0].alias == "myserv")
assert(config.hosts[0].properties.port == 2021)
To encode your existent ssh.Config instance to a string:
let config = ssh.Config(
ssh.Host("myserv",
{ $0.user = "alice" },
{ $0.port = 2021 }
)
)
let encoder = ssh.ConfigEncoder()
let string = try! encoder.encode(config)
assert(string == """
Host myserv
Port 2021
User alice
""")
The same as above, but with an implicit ssh.ConfigEncoder call:
let config = ssh.Config(
ssh.Host("myserv", { $0.port = 15 })
)
let string = try! config.toString()
assert(string == """
Host myserv
Port 15
""")
When you have an ssh.Config, I assume you would like to resolve it for different hosts by their names. You can do it like this:
let config = ssh.Config(
ssh.Host("github.com gitlab.com",
{ $0.user = "xxlabaza" }
),
ssh.Host("my*",
{ $0.user = "admin" },
{ $0.port = 56 }
),
ssh.Host("*",
{ $0.user = "artem" },
{ $0.port = 2020 }
)
)
let github = config.resolve(for: "github.com")
assert(github.user == "xxlabaza")
assert(github.port == 2020)
let gitlab = config.resolve(for: "gitlab.com")
assert(gitlab.user == "xxlabaza")
assert(gitlab.port == 2020)
let myserv = config.resolve(for: "myserv")
assert(myserv.user == "admin")
assert(myserv.port == 56)
let example = config.resolve(for: "example.com")
assert(example.user == "artem")
assert(example.port == 2020)
You can dump an ssh.Config instance into a file:
let config = ssh.Config(
ssh.Host("myserv", { $0.port = 15 })
)
try! config.dump(to: "~/my-ssh-config")
let filePath = NSString(string: "~/my-ssh-config").expandingTildeInPath
assert(FileManager.default.fileExists(atPath: filePath))
assert(try! String(contentsOfFile: filePath) == """
Host myserv
Port 15
""")
And read a config like this:
let content = """
Host myserv
port 2021
"""
try content.write(
toFile: NSString(string: "~/my-ssh-config").expandingTildeInPath,
atomically: true,
encoding: String.Encoding.utf8
)
let config = try! ssh.Config.load(path: "~/my-ssh-config")
assert(config.hosts.count == 1)
assert(config.hosts[0].alias == "myserv")
assert(config.hosts[0].properties.port == 2021)
Writing SshConfig to a JSON string:
let config = ssh.Config(
ssh.Host("myserv", { $0.port = 15 })
)
let json = try! config.toJsonString()
assert(jsonEquals(
actual: json,
expected: #"{"hosts":[{"alias":"myserv","properties":{"port":15}}]}"#
))
Writing SshConfig to a JSON Data:
let config = ssh.Config(
ssh.Host("myserv", { $0.port = 15 })
)
let data = try! config.toJsonData()
assert(jsonEquals(
actual: data,
expected: #"{"hosts":[{"alias":"myserv","properties":{"port":15}}]}"#
))
Reading SshConfig from a JSON string:
let string = """
{
"hosts": [
{
"alias": "myserv",
"properties": {
"port": 2021
}
}
]
}
"""
let config = try! ssh.Config.from(json: string)
assert(config.hosts.count == 1)
assert(config.hosts[0].alias == "myserv")
assert(config.hosts[0].properties.port == 2021)
Reading SshConfig from a JSON Data:
let string = """
{
"hosts": [
{
"alias": "myserv",
"properties": {
"port": 2021
}
}
]
}
"""
let data = string.data(using: .utf8)!
let config = try! ssh.Config.from(json: data)
assert(config.hosts.count == 1)
assert(config.hosts[0].alias == "myserv")
assert(config.hosts[0].properties.port == 2021)
In the sections below, I describe the different ssh.*Errors which can occur during working with the library and examples of such errors.
This error could be thrown by ssh.ConfigParsers if there was an empty string for an expected key token (Host or property name).
let content = """
Host myserv
=user
"" 2021
"""
var errorCatched = false
do {
_ = try ssh.ConfigParser().parse(content)
} catch ssh.ConfigParserError.emptyKeyToken {
errorCatched = true
} catch {}
assert(errorCatched)
This error could be thrown by ssh.ConfigParsers if there was an empty string for an expected value token (host’s alias or property value).
let content = """
Host myserv
user
"""
var errorCatched = false
do {
_ = try ssh.ConfigParser().parse(content)
} catch ssh.ConfigParserError.emptyValueToken {
errorCatched = true
} catch {}
assert(errorCatched)
Found an invalid delimiter character after a token.
let content = """
Host myserv
user?xxlabaza
"""
var errorCatched = false
do {
_ = try ssh.ConfigParser().parse(content)
} catch let ssh.ConfigParserError.illegalTokensDelimiter(after, delimiter) {
assert(after == "user")
assert(delimiter == "?")
errorCatched = true
} catch {}
assert(errorCatched)
A token is in an unexpected place.
let content = """
host
"""
var errorCatched = false
do {
_ = try ssh.ConfigParser().parse(content)
} catch let ssh.ConfigParserError.unexpectedToken(token) {
assert(token == "key(\"host\")")
errorCatched = true
} catch {}
assert(errorCatched)
There is no alias for parsing Host config’s section.
let content = """
Host " "
"""
var errorCatched = false
do {
_ = try ssh.ConfigParser().parse(content)
} catch ssh.ConfigParserError.noAliasForHost {
errorCatched = true
} catch {}
assert(errorCatched)
A Host config section doesn’t have any property inside.
let content = """
Host myserv
"""
var errorCatched = false
do {
_ = try ssh.ConfigParser().parse(content)
} catch ssh.ConfigParserError.noPropertiesForHost {
errorCatched = true
} catch {}
assert(errorCatched)
An attempt was made to decode a value by path as type.
let content = """
Host *
port abc
"""
var errorCatched = false
do {
_ = try ssh.ConfigDecoder().decode(from: content)
} catch let ssh.ConfigDecoderError.unableToDecode(path, value, type) {
assert(path == "port")
assert(value == "abc")
assert(type == UInt16.self)
errorCatched = true
} catch {}
assert(errorCatched)
Can’t load a config by path because of cause.
var errorCatched = false
do {
_ = try ssh.Config.load(path: "/nonexistent/path")
} catch let ssh.ConfigError.unableToLoad(path, cause) {
assert(path == "/nonexistent/path")
assert(type(of: cause) == NSError.self)
// Different messages depend on OS and Swift version,
// but they say the same - 'No such file'.
assert(cause.localizedDescription.contains("o such file"))
errorCatched = true
} catch {}
assert(errorCatched)
Can’t dump a config by path because of cause.
let config = ssh.Config()
var errorCatched = false
do {
try config.dump(to: "/nonexistent/path")
} catch let ssh.ConfigError.unableToDump(path, cause) {
assert(path == "/nonexistent/path")
assert(type(of: cause) == NSError.self)
// Different messages depend on OS and Swift version,
// but they say the same - 'file doesn’t exist'.
assert(cause.localizedDescription.contains(" doesn’t exist."))
errorCatched = true
} catch {}
assert(errorCatched)