Wait for the server's tailnet peer before connecting at app startup
This commit is contained in:
@@ -144,7 +144,7 @@ func parseOrigin(_ text: String) -> URL? {
|
||||
}
|
||||
var origin = URLComponents()
|
||||
origin.scheme = "https"
|
||||
origin.host = host.lowercased()
|
||||
origin.host = (host.hasSuffix(".") ? String(host.dropLast()) : host).lowercased()
|
||||
origin.port = c.port
|
||||
return origin.url
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ final class Remote {
|
||||
case .success:
|
||||
self.receive(task)
|
||||
case .failure(let error):
|
||||
log.notice("client connection closed:\(errorText(error), privacy: .public)")
|
||||
log.notice("client connection closed:\(errorText(error), privacy: .public); \(tailnetNode.peerState(self.api.origin.host()!), privacy: .public)")
|
||||
self.ws = nil
|
||||
try? await Task.sleep(for: retryAfter)
|
||||
self.start()
|
||||
|
||||
@@ -198,7 +198,7 @@ final class Connection {
|
||||
|
||||
private func start() async {
|
||||
let old = store
|
||||
while !stopped, !Tailnet.shared.running {
|
||||
while !stopped, !Tailnet.shared.reachable(origin) {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
}
|
||||
while !stopped, !(await attempt(old)) {
|
||||
@@ -252,6 +252,7 @@ final class Connection {
|
||||
Notifier.shared.deliver()
|
||||
return true
|
||||
} catch {
|
||||
log.notice("connect \(self.origin.absoluteString, privacy: .public) failed: \(errorText(error), privacy: .public); \(tailnetNode.peerState(self.origin.host()!), privacy: .public)")
|
||||
await store.stop()
|
||||
if !stopped {
|
||||
phase = .failed(explain(error, origin))
|
||||
|
||||
@@ -40,6 +40,7 @@ final class TailnetNode: Sendable {
|
||||
Self.check(sd, "set hostname", unsafe tailscale_set_hostname(sd, hostname))
|
||||
Self.check(sd, "set dir", unsafe tailscale_set_dir(sd, dir.path))
|
||||
Self.check(sd, "set ephemeral", tailscale_set_ephemeral(sd, 0))
|
||||
Self.check(sd, "set logfd", tailscale_set_logfd(sd, Self.logPipe()))
|
||||
Self.check(sd, "start", tailscale_start(sd))
|
||||
var addr = [CChar](repeating: 0, count: 64)
|
||||
var proxyCred = [CChar](repeating: 0, count: 33)
|
||||
@@ -60,6 +61,49 @@ final class TailnetNode: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private static let logs = Pipe()
|
||||
|
||||
private static func logPipe() -> Int32 {
|
||||
let lines = LineBuffer()
|
||||
logs.fileHandleForReading.readabilityHandler = { handle in
|
||||
for line in lines.add(handle.availableData) {
|
||||
log.notice("tsnet: \(line, privacy: .public)")
|
||||
}
|
||||
}
|
||||
return dup(logs.fileHandleForWriting.fileDescriptor)
|
||||
}
|
||||
|
||||
func peerState(_ host: String) -> String {
|
||||
var out: UnsafeMutablePointer<CChar>?
|
||||
guard unsafe tailscale_status_json(sd, &out) == 0, let out = unsafe out else {
|
||||
return "no status: \(Self.errorMessage(sd))"
|
||||
}
|
||||
defer {
|
||||
unsafe free(out)
|
||||
}
|
||||
struct Peer: Decodable {
|
||||
let DNSName: String
|
||||
let Online: Bool
|
||||
let InNetworkMap: Bool
|
||||
let InMagicSock: Bool
|
||||
let InEngine: Bool
|
||||
let LastHandshake: String
|
||||
let CurAddr: String
|
||||
let Relay: String
|
||||
}
|
||||
struct Status: Decodable {
|
||||
let BackendState: String
|
||||
let Peer: [String: Peer]?
|
||||
}
|
||||
guard let status = try? JSONDecoder().decode(Status.self, from: unsafe Data(bytes: out, count: strlen(out))) else {
|
||||
return "undecodable status"
|
||||
}
|
||||
guard let p = (status.Peer ?? [:]).values.first(where: { $0.DNSName == host + "." || $0.DNSName == host }) else {
|
||||
return "state=\(status.BackendState) peer \(host) not in status"
|
||||
}
|
||||
return "state=\(status.BackendState) online=\(p.Online) netmap=\(p.InNetworkMap) magicsock=\(p.InMagicSock) engine=\(p.InEngine) handshake=\(p.LastHandshake) addr=\(p.CurAddr) relay=\(p.Relay)"
|
||||
}
|
||||
|
||||
static func errorMessage(_ sd: tailscale) -> String {
|
||||
var buf = [CChar](repeating: 0, count: 1024)
|
||||
_ = unsafe tailscale_errmsg(sd, &buf, buf.count)
|
||||
@@ -72,7 +116,7 @@ final class TailnetNode: Sendable {
|
||||
return proxy
|
||||
}
|
||||
|
||||
func status() -> (state: String, authURL: URL?, servers: [URL])? {
|
||||
func status() -> (state: String, authURL: URL?, servers: [URL], peers: Set<String>)? {
|
||||
var out: UnsafeMutablePointer<CChar>?
|
||||
guard unsafe tailscale_status_json(sd, &out) == 0, let out = unsafe out else {
|
||||
return nil
|
||||
@@ -99,15 +143,18 @@ final class TailnetNode: Sendable {
|
||||
guard let status = try? JSONDecoder().decode(Status.self, from: unsafe Data(bytes: out, count: strlen(out))) else {
|
||||
return nil
|
||||
}
|
||||
let servers = (status.Peer ?? [:]).values.compactMap { peer -> URL? in
|
||||
let host = peer.DNSName.hasSuffix(".") ? String(peer.DNSName.dropLast()) : peer.DNSName
|
||||
let name = host.split(separator: ".").first ?? ""
|
||||
let peers = (status.Peer ?? [:]).values
|
||||
let host = { (peer: Node) in
|
||||
(peer.DNSName.hasSuffix(".") ? String(peer.DNSName.dropLast()) : peer.DNSName).lowercased()
|
||||
}
|
||||
let servers = peers.compactMap { peer -> URL? in
|
||||
let name = host(peer).split(separator: ".").first ?? ""
|
||||
guard peer.Online, peer.UserID == status.me?.UserID, name.hasPrefix("byre-"), !name.hasPrefix("byre-app-") else {
|
||||
return nil
|
||||
}
|
||||
return parseOrigin("https://\(host)")
|
||||
return parseOrigin("https://\(host(peer))")
|
||||
}
|
||||
return (status.BackendState, URL(string: status.AuthURL), servers)
|
||||
return (status.BackendState, URL(string: status.AuthURL), servers, Set(peers.map(host)))
|
||||
}
|
||||
|
||||
// Blocks until the connection is made or fails.
|
||||
@@ -120,6 +167,20 @@ final class TailnetNode: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private final class LineBuffer: @unchecked Sendable {
|
||||
private var pending = Data()
|
||||
|
||||
func add(_ data: Data) -> [String] {
|
||||
pending.append(data)
|
||||
var lines: [String] = []
|
||||
while let nl = pending.firstIndex(of: 0x0A) {
|
||||
lines.append(String(decoding: pending[pending.startIndex..<nl], as: UTF8.self))
|
||||
pending.removeSubrange(pending.startIndex...nl)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
}
|
||||
|
||||
let tailnetNode = TailnetNode()
|
||||
|
||||
@MainActor
|
||||
@@ -131,12 +192,18 @@ final class Tailnet {
|
||||
private(set) var authURL: URL?
|
||||
private(set) var service: TailnetInfo?
|
||||
private(set) var searched = false
|
||||
private(set) var peers: Set<String> = []
|
||||
@ObservationIgnored private var servers: [URL] = []
|
||||
|
||||
var running: Bool {
|
||||
state == "Running"
|
||||
}
|
||||
|
||||
// A node starting from its cached netmap reports Running before it knows any peer, and dials made then fail.
|
||||
func reachable(_ origin: URL) -> Bool {
|
||||
running && peers.contains(origin.host()!.lowercased())
|
||||
}
|
||||
|
||||
var needsLogin: Bool {
|
||||
state == "NeedsLogin" || state == "NeedsMachineAuth" || service?.state == "NeedsLogin"
|
||||
}
|
||||
@@ -148,6 +215,9 @@ final class Tailnet {
|
||||
state = status.state
|
||||
authURL = status.authURL
|
||||
servers = status.servers
|
||||
if peers != status.peers {
|
||||
peers = status.peers
|
||||
}
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user