Files
archived-libtailscale/swift/TailscaleKit/TailscaleNode.swift
Jonathan Nobels 2948fe1136 swift: add NWConnection-inspired Swift 6 compatible Tailscale.framework
updates tailscale/tailscale#13937

Builds a TailscaleKit.framework which wraps libtailscale.a in an actor,
providing IncomingConnection, Listener, and OutgoingConnection
types which are more usable in pure async Swift code.  The
method signatures are intended to be similar in form to NWConnection.

We also provide an extension to URLSession so you can make URL
requests via the user-space proxy.

Adds a static library mirroring much of the test control utilities in go, minus
the dependency on testing, so that we can export the signatures and
link it to tests that cannot run in go.

Added functionality to get both the local interface IPs as well as
the remote IP of  incoming connections accepted by a listener.

Fixed a bug in the log writer so we append a newline.

This also updates to the latest go toolchain and tailscale version.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
2025-01-02 14:35:20 -05:00

248 lines
7.3 KiB
Swift

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
/// Configuration for a tailscale application node
public struct Configuration: Sendable {
let hostName: String ///< The hostname of the node/application instance
let path: String
let authKey: String? ///< An auth key. Leave empty to use web auth
let controlURL: String ///< URL for Tailscale control
let ephemeral: Bool
static let defaultControlURL = "https://controlplane.tailscale.com"
}
/// The layer 3 protocol to use
public enum NetProtocol: String {
case tcp = "tcp"
case udp = "udp"
}
public typealias IPAddresses = (ip4: String?, ip6: String?)
/// TSInterface creates and manages a single userspace Tailscale application
/// node. You may instantiate several "nodes" in a single application. Each
/// will get a unique IP address on the Tailnet.
///
/// The provided wrapper abstract away the C code and allow the writing of proper,
/// compiler checked thread-safe Swift 6.
public actor TailscaleNode {
/// Handle to the underlying Tailscale server. Use this when instantiating
/// new IncomingConnections or OutgoingConnections
let tailscale: TailscaleHandle?
private let logger: LogSink?
/// Instantiate a new TailscaleNode with the given configuration and
/// and optional LogSink. If no LogSink is provided, logs will be
/// discarded.
///
/// @See tailscale_set_* in Tailscale.h
/// @See tailscale_start in Tailscale.h
///
/// @throws TailscaleError on failure
public init(config: Configuration, logger: LogSink?) throws {
self.logger = logger ?? BlackholeLogger()
tailscale = tailscale_new()
guard let tailscale else {
throw TailscaleError.badInterfaceHandle
}
logger?.log("Tailscale starting: \(tailscale)")
if let fd = logger?.logFileHandle {
tailscale_set_logfd(tailscale, fd)
}
if let authKey = config.authKey {
tailscale_set_authkey(tailscale, authKey)
}
tailscale_set_hostname(tailscale, config.hostName)
tailscale_set_dir(tailscale, config.path)
tailscale_set_control_url(tailscale, config.controlURL)
tailscale_set_ephemeral(tailscale, config.ephemeral ? 1 : 0)
let res = tailscale_start(tailscale)
guard res == 0 else {
throw TailscaleError.fromPosixErrCode(res, tailscale.getErrorMessage())
}
logger?.log("Tailscale started... \(tailscale)")
}
deinit {
if let tailscale {
tailscale_close(tailscale)
}
}
/// Closes/stops the Tailscale server
///
/// @See tailscale_close in Tailscale.h
///
/// @Throws TailscaleError on failure
public func close() async throws {
guard let tailscale else {
throw TailscaleError.badInterfaceHandle
}
logger?.log("Closing Tailscale: \(tailscale)")
let res = tailscale_close(tailscale)
guard res == 0 else {
throw TailscaleError.fromPosixErrCode(res, tailscale.getErrorMessage())
}
logger?.log("Closed Tailscale:\(tailscale)")
}
/// Brings up the Tailscale server
///
/// @See tailscale_up in Tailscale.h
///
/// @throws TailscaleError on failure
public func up() async throws {
guard let tailscale else {
throw TailscaleError.badInterfaceHandle
}
logger?.log("Bringing Tailscale up :\(tailscale)")
let res = tailscale_up(tailscale)
guard res == 0 else {
throw TailscaleError.fromPosixErrCode(res, tailscale.getErrorMessage())
}
logger?.log("Brought Tailscale up:\(tailscale)")
}
/// Tears down the Tailscale server.
///
/// @See tailscale_down in Tailscale.h
///
/// @throws TailscaleError on failure
public func down() throws {
guard let tailscale else {
throw TailscaleError.badInterfaceHandle
}
logger?.log("Taking Tailscale down :\(tailscale)")
let res = tailscale_up(tailscale)
guard res == 0 else {
throw TailscaleError.fromPosixErrCode(res, tailscale.getErrorMessage())
}
logger?.log("Took Tailscale down:\(tailscale)")
}
/// Returns the addresses on the Tailscale server
///
/// @See tailscale_getips in Tailscale.h
///
/// @returns An ipV4 and ipV5 address tuple
/// @throws TailscaleError on failure
public func addrs() async throws -> IPAddresses {
guard let tailscale else {
throw TailscaleError.badInterfaceHandle
}
let buf = UnsafeMutablePointer<Int8>.allocate(capacity: 128)
defer {
buf.deallocate()
}
let res = tailscale_getips(tailscale, buf, 128)
guard res == 0 else {
throw TailscaleError.fromPosixErrCode(res, tailscale.getErrorMessage())
}
let ipList = String(cString: buf)
return ipList.toIPPair()
}
public struct LoopbackConfig: Sendable {
let address: String
let proxyCredential: String
let localAPIKey: String
}
private var loopbackConfig: LoopbackConfig?
/// Starts and returns the address and credentials of a SOCKS5 proxy which can also
/// be used to query the localAPI
public func loopback() throws -> LoopbackConfig {
guard let tailscale else {
throw TailscaleError.badInterfaceHandle
}
if let loopbackConfig = loopbackConfig {
return loopbackConfig
}
let addrBuf = UnsafeMutablePointer<Int8>.allocate(capacity: 64)
let proxyCredBuf = UnsafeMutablePointer<Int8>.allocate(capacity: 33)
let apiCredBuf = UnsafeMutablePointer<Int8>.allocate(capacity: 33)
defer {
addrBuf.deallocate()
proxyCredBuf.deallocate()
apiCredBuf.deallocate()
}
let res = tailscale_loopback(tailscale, addrBuf, 64, proxyCredBuf, apiCredBuf)
guard res == 0 else {
throw TailscaleError.fromPosixErrCode(res, tailscale.getErrorMessage())
}
loopbackConfig = LoopbackConfig(address: String(cString: addrBuf),
proxyCredential: String(cString: proxyCredBuf),
localAPIKey: String(cString: apiCredBuf))
return loopbackConfig!
}
}
// MARK: - IP String list to IPAddresses tuple
enum IPAddrType {
case v4
case v6
case none
}
extension String {
// tailscale.go sends us the tailnetIPs as a comma separated list. This will
// turn them into an IPAddresses tuple
func toIPPair() -> IPAddresses {
let ips = self.split(separator: ",").map { String($0) }
var result: IPAddresses = (nil, nil)
for ip in ips {
let type = ip.tsNetIPAddrType()
switch type {
case .v4:
result.ip4 = ip
case .v6:
result.ip6 = ip
case .none:
break
}
}
return result
}
// This can be naive since the backend is only vending well
// formed IPs to us.
func tsNetIPAddrType() -> IPAddrType {
if self.contains(".") {
return .v4
} else if self.contains(":") {
return .v6
}
return .none
}
}