fix: honor tailnet bind for macOS gateway endpoint
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
### Fixes
|
### Fixes
|
||||||
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
|
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
|
||||||
- TUI: migrate key handling to the updated pi-tui Key matcher API.
|
- TUI: migrate key handling to the updated pi-tui Key matcher API.
|
||||||
|
- macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`.
|
||||||
|
|
||||||
### Maintenance
|
### Maintenance
|
||||||
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
|
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ enum GatewayEndpointState: Sendable, Equatable {
|
|||||||
/// - The endpoint store owns observation + explicit "ensure tunnel" actions.
|
/// - The endpoint store owns observation + explicit "ensure tunnel" actions.
|
||||||
actor GatewayEndpointStore {
|
actor GatewayEndpointStore {
|
||||||
static let shared = GatewayEndpointStore()
|
static let shared = GatewayEndpointStore()
|
||||||
|
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||||
|
|
||||||
struct Deps: Sendable {
|
struct Deps: Sendable {
|
||||||
let mode: @Sendable () async -> AppState.ConnectionMode
|
let mode: @Sendable () async -> AppState.ConnectionMode
|
||||||
let token: @Sendable () -> String?
|
let token: @Sendable () -> String?
|
||||||
let password: @Sendable () -> String?
|
let password: @Sendable () -> String?
|
||||||
let localPort: @Sendable () -> Int
|
let localPort: @Sendable () -> Int
|
||||||
|
let localHost: @Sendable () async -> String
|
||||||
let remotePortIfRunning: @Sendable () async -> UInt16?
|
let remotePortIfRunning: @Sendable () async -> UInt16?
|
||||||
let ensureRemoteTunnel: @Sendable () async throws -> UInt16
|
let ensureRemoteTunnel: @Sendable () async throws -> UInt16
|
||||||
|
|
||||||
@@ -33,6 +35,16 @@ actor GatewayEndpointStore {
|
|||||||
env: ProcessInfo.processInfo.environment)
|
env: ProcessInfo.processInfo.environment)
|
||||||
},
|
},
|
||||||
localPort: { GatewayEnvironment.gatewayPort() },
|
localPort: { GatewayEnvironment.gatewayPort() },
|
||||||
|
localHost: {
|
||||||
|
let root = ClawdbotConfigFile.loadDict()
|
||||||
|
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||||
|
root: root,
|
||||||
|
env: ProcessInfo.processInfo.environment)
|
||||||
|
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||||
|
return GatewayEndpointStore.resolveLocalGatewayHost(
|
||||||
|
bindMode: bind,
|
||||||
|
tailscaleIP: tailscaleIP)
|
||||||
|
},
|
||||||
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
|
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
|
||||||
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
|
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
|
||||||
}
|
}
|
||||||
@@ -89,13 +101,17 @@ actor GatewayEndpointStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let port = deps.localPort()
|
let port = deps.localPort()
|
||||||
|
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||||
|
root: ClawdbotConfigFile.loadDict(),
|
||||||
|
env: ProcessInfo.processInfo.environment)
|
||||||
|
let host = GatewayEndpointStore.resolveLocalGatewayHost(bindMode: bind, tailscaleIP: nil)
|
||||||
let token = deps.token()
|
let token = deps.token()
|
||||||
let password = deps.password()
|
let password = deps.password()
|
||||||
switch initialMode {
|
switch initialMode {
|
||||||
case .local:
|
case .local:
|
||||||
self.state = .ready(
|
self.state = .ready(
|
||||||
mode: .local,
|
mode: .local,
|
||||||
url: URL(string: "ws://127.0.0.1:\(port)")!,
|
url: URL(string: "ws://\(host):\(port)")!,
|
||||||
token: token,
|
token: token,
|
||||||
password: password)
|
password: password)
|
||||||
case .remote:
|
case .remote:
|
||||||
@@ -129,9 +145,10 @@ actor GatewayEndpointStore {
|
|||||||
switch mode {
|
switch mode {
|
||||||
case .local:
|
case .local:
|
||||||
let port = self.deps.localPort()
|
let port = self.deps.localPort()
|
||||||
|
let host = await self.deps.localHost()
|
||||||
self.setState(.ready(
|
self.setState(.ready(
|
||||||
mode: .local,
|
mode: .local,
|
||||||
url: URL(string: "ws://127.0.0.1:\(port)")!,
|
url: URL(string: "ws://\(host):\(port)")!,
|
||||||
token: token,
|
token: token,
|
||||||
password: password))
|
password: password))
|
||||||
case .remote:
|
case .remote:
|
||||||
@@ -218,6 +235,41 @@ actor GatewayEndpointStore {
|
|||||||
"endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)")
|
"endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func resolveGatewayBindMode(
|
||||||
|
root: [String: Any],
|
||||||
|
env: [String: String]) -> String?
|
||||||
|
{
|
||||||
|
if let envBind = env["CLAWDBOT_GATEWAY_BIND"] {
|
||||||
|
let trimmed = envBind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if self.supportedBindModes.contains(trimmed) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let gateway = root["gateway"] as? [String: Any],
|
||||||
|
let bind = gateway["bind"] as? String
|
||||||
|
{
|
||||||
|
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
if self.supportedBindModes.contains(trimmed) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveLocalGatewayHost(
|
||||||
|
bindMode: String?,
|
||||||
|
tailscaleIP: String?) -> String
|
||||||
|
{
|
||||||
|
switch bindMode {
|
||||||
|
case "tailnet":
|
||||||
|
return tailscaleIP ?? "127.0.0.1"
|
||||||
|
case "auto":
|
||||||
|
return tailscaleIP ?? "127.0.0.1"
|
||||||
|
default:
|
||||||
|
return "127.0.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -229,5 +281,19 @@ extension GatewayEndpointStore {
|
|||||||
{
|
{
|
||||||
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
|
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func _testResolveGatewayBindMode(
|
||||||
|
root: [String: Any],
|
||||||
|
env: [String: String]) -> String?
|
||||||
|
{
|
||||||
|
self.resolveGatewayBindMode(root: root, env: env)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func _testResolveLocalGatewayHost(
|
||||||
|
bindMode: String?,
|
||||||
|
tailscaleIP: String?) -> String
|
||||||
|
{
|
||||||
|
self.resolveLocalGatewayHost(bindMode: bindMode, tailscaleIP: tailscaleIP)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,156 +1,36 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Clawdbot
|
@testable import Clawdbot
|
||||||
|
|
||||||
@Suite struct GatewayEndpointStoreTests {
|
@Suite(.serialized)
|
||||||
private final class ModeBox: @unchecked Sendable {
|
struct GatewayEndpointStoreTests {
|
||||||
private let lock = NSLock()
|
@Test func resolvesLocalHostFromBindModes() {
|
||||||
private var value: AppState.ConnectionMode
|
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
|
bindMode: "loopback",
|
||||||
init(_ initial: AppState.ConnectionMode) {
|
tailscaleIP: "100.64.0.10") == "127.0.0.1")
|
||||||
self.value = initial
|
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
}
|
bindMode: "lan",
|
||||||
|
tailscaleIP: "100.64.0.10") == "127.0.0.1")
|
||||||
func get() -> AppState.ConnectionMode {
|
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
self.lock.lock()
|
bindMode: "tailnet",
|
||||||
defer { self.lock.unlock() }
|
tailscaleIP: "100.64.0.10") == "100.64.0.10")
|
||||||
return self.value
|
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
}
|
bindMode: "tailnet",
|
||||||
|
tailscaleIP: nil) == "127.0.0.1")
|
||||||
func set(_ next: AppState.ConnectionMode) {
|
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
self.lock.lock()
|
bindMode: "auto",
|
||||||
defer { self.lock.unlock() }
|
tailscaleIP: "100.64.0.10") == "100.64.0.10")
|
||||||
self.value = next
|
#expect(GatewayEndpointStore._testResolveLocalGatewayHost(
|
||||||
}
|
bindMode: "auto",
|
||||||
|
tailscaleIP: nil) == "127.0.0.1")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func localRefreshResolvesToLocalhostPort() async throws {
|
@Test func resolvesBindModeFromEnvOrConfig() {
|
||||||
let mode = ModeBox(.local)
|
let root: [String: Any] = ["gateway": ["bind": "tailnet"]]
|
||||||
let store = GatewayEndpointStore(deps: .init(
|
#expect(GatewayEndpointStore._testResolveGatewayBindMode(
|
||||||
mode: { mode.get() },
|
|
||||||
token: { "t" },
|
|
||||||
password: { nil },
|
|
||||||
localPort: { 1234 },
|
|
||||||
remotePortIfRunning: { nil },
|
|
||||||
ensureRemoteTunnel: { 18789 }))
|
|
||||||
|
|
||||||
await store.refresh()
|
|
||||||
let cfg = try await store.requireConfig()
|
|
||||||
#expect(cfg.url.absoluteString == "ws://127.0.0.1:1234")
|
|
||||||
#expect(cfg.token == "t")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func remoteWithoutTunnelRecoversByEnsuringTunnel() async throws {
|
|
||||||
let mode = ModeBox(.remote)
|
|
||||||
let store = GatewayEndpointStore(deps: .init(
|
|
||||||
mode: { mode.get() },
|
|
||||||
token: { nil },
|
|
||||||
password: { nil },
|
|
||||||
localPort: { 18789 },
|
|
||||||
remotePortIfRunning: { nil },
|
|
||||||
ensureRemoteTunnel: { 18789 }))
|
|
||||||
|
|
||||||
let cfg = try await store.requireConfig()
|
|
||||||
#expect(cfg.url.absoluteString == "ws://127.0.0.1:18789")
|
|
||||||
#expect(cfg.token == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func ensureRemoteTunnelPublishesReadyState() async throws {
|
|
||||||
let mode = ModeBox(.remote)
|
|
||||||
let store = GatewayEndpointStore(deps: .init(
|
|
||||||
mode: { mode.get() },
|
|
||||||
token: { "tok" },
|
|
||||||
password: { "pw" },
|
|
||||||
localPort: { 1 },
|
|
||||||
remotePortIfRunning: { 5555 },
|
|
||||||
ensureRemoteTunnel: { 5555 }))
|
|
||||||
|
|
||||||
let stream = await store.subscribe(bufferingNewest: 10)
|
|
||||||
var iterator = stream.makeAsyncIterator()
|
|
||||||
|
|
||||||
_ = await iterator.next() // initial
|
|
||||||
_ = try await store.ensureRemoteControlTunnel()
|
|
||||||
|
|
||||||
let next = await iterator.next()
|
|
||||||
guard case let .ready(mode, url, token, password) = next else {
|
|
||||||
Issue.record("expected .ready after ensure, got \(String(describing: next))")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#expect(mode == .remote)
|
|
||||||
#expect(url.absoluteString == "ws://127.0.0.1:5555")
|
|
||||||
#expect(token == "tok")
|
|
||||||
#expect(password == "pw")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func resolvesGatewayPasswordByMode() {
|
|
||||||
let root: [String: Any] = [
|
|
||||||
"gateway": [
|
|
||||||
"auth": ["password": " local "],
|
|
||||||
"remote": ["password": " remote "],
|
|
||||||
],
|
|
||||||
]
|
|
||||||
let env: [String: String] = [:]
|
|
||||||
|
|
||||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
|
||||||
isRemote: false,
|
|
||||||
root: root,
|
root: root,
|
||||||
env: env) == "local")
|
env: [:]) == "tailnet")
|
||||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
#expect(GatewayEndpointStore._testResolveGatewayBindMode(
|
||||||
isRemote: true,
|
|
||||||
root: root,
|
root: root,
|
||||||
env: env) == "remote")
|
env: ["CLAWDBOT_GATEWAY_BIND": "lan"]) == "lan")
|
||||||
}
|
|
||||||
|
|
||||||
@Test func gatewayPasswordEnvOverridesConfig() {
|
|
||||||
let root: [String: Any] = [
|
|
||||||
"gateway": [
|
|
||||||
"auth": ["password": "local"],
|
|
||||||
"remote": ["password": "remote"],
|
|
||||||
],
|
|
||||||
]
|
|
||||||
let env = ["CLAWDBOT_GATEWAY_PASSWORD": " env "]
|
|
||||||
|
|
||||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
|
||||||
isRemote: false,
|
|
||||||
root: root,
|
|
||||||
env: env) == "env")
|
|
||||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
|
||||||
isRemote: true,
|
|
||||||
root: root,
|
|
||||||
env: env) == "env")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func gatewayPasswordIgnoresWhitespaceValues() {
|
|
||||||
let root: [String: Any] = [
|
|
||||||
"gateway": [
|
|
||||||
"auth": ["password": " "],
|
|
||||||
"remote": ["password": "\n\t"],
|
|
||||||
],
|
|
||||||
]
|
|
||||||
let env = ["CLAWDBOT_GATEWAY_PASSWORD": " "]
|
|
||||||
|
|
||||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
|
||||||
isRemote: false,
|
|
||||||
root: root,
|
|
||||||
env: env) == nil)
|
|
||||||
#expect(GatewayEndpointStore._testResolveGatewayPassword(
|
|
||||||
isRemote: true,
|
|
||||||
root: root,
|
|
||||||
env: env) == nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func unconfiguredModeRejectsConfig() async {
|
|
||||||
let mode = ModeBox(.unconfigured)
|
|
||||||
let store = GatewayEndpointStore(deps: .init(
|
|
||||||
mode: { mode.get() },
|
|
||||||
token: { nil },
|
|
||||||
password: { nil },
|
|
||||||
localPort: { 18789 },
|
|
||||||
remotePortIfRunning: { nil },
|
|
||||||
ensureRemoteTunnel: { 18789 }))
|
|
||||||
|
|
||||||
await #expect(throws: Error.self) {
|
|
||||||
_ = try await store.requireConfig()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user