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
SwiftPM
without 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.Properties
instance, which creates inside thessh.Host
initializer. 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.Config
instance, 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.*Error
s which can occur during working with the library and examples of such errors.
This error could be thrown by ssh.ConfigParser
s 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.ConfigParser
s 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)