From 7424b1625bd68bf8ccde13678fc7b36a3f49fe8e Mon Sep 17 00:00:00 2001 From: Raphael Barreiros Date: Sun, 4 Oct 2026 21:35:45 +0200 Subject: [PATCH 1/2] Fix: Better scheme guesses for Hermes addresses Follow-up to the #67 review: - A dot-less host (nuc, hermes-box) is a LAN machine: it gets http:// and counts as local, like .local names, so its web UI can sign in. - A *.ts.net name typed with a port gets http:// (tailscale serve answers https on 443, so a port means the server itself). - The address fields' placeholders drop https://, matching the error message. Co-Authored-By: Claude Opus 5.5 (1M context) --- OpenVision/Services/AIBackend/HermesService.swift | 11 ++++++++--- OpenVision/Views/Settings/HermesSettingsView.swift | 4 ++-- OpenVisionTests/HermesTests.swift | 10 ++++++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/OpenVision/Services/AIBackend/HermesService.swift b/OpenVision/Services/AIBackend/HermesService.swift index 2738201..56bc105 100644 --- a/OpenVision/Services/AIBackend/HermesService.swift +++ b/OpenVision/Services/AIBackend/HermesService.swift @@ -169,10 +169,12 @@ final class HermesService: ObservableObject { return !isLocalHost(host) && !host.hasSuffix(".ts.net") } - /// Loopback, `.local`, or a private / Tailscale IPv4 address. + /// Loopback, `.local`, a dot-less name (`nuc`, `hermes-box`: only the local network or its + /// search domain resolves those), or a private / Tailscale IPv4 address. nonisolated static func isLocalHost(_ host: String) -> Bool { let host = host.lowercased() if host == "localhost" || host.hasSuffix(".local") { return true } + if !host.isEmpty, !host.contains("."), !host.contains(":") { return true } let octets = host.split(separator: ".").compactMap { Int($0) } guard octets.count == 4 else { return false } switch (octets[0], octets[1]) { @@ -184,13 +186,16 @@ final class HermesService: ObservableObject { } /// The address with a scheme, for one typed without (`myhost.ts.net`, `100.88.1.2:8642`): - /// http for a local or Tailscale IP (servers there rarely have a certificate), https otherwise. + /// http for a local host or Tailscale IP (servers there rarely have a certificate), and for a + /// `*.ts.net` name with a port (`tailscale serve` answers https on 443, so a port means the + /// server itself); https otherwise. nonisolated static func withScheme(_ address: String) -> String { let text = address.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, !text.contains("://") else { return text } let hostAndPort = text.split(separator: "/", maxSplits: 1).first.map(String.init) ?? text let host = hostAndPort.split(separator: ":").first.map(String.init) ?? hostAndPort - return (isLocalHost(host) ? "http://" : "https://") + text + let tailnetPort = host.lowercased().hasSuffix(".ts.net") && hostAndPort.contains(":") + return (isLocalHost(host) || tailnetPort ? "http://" : "https://") + text } private static func authorize(_ request: inout URLRequest, key: String, sessionKey: String?) { diff --git a/OpenVision/Views/Settings/HermesSettingsView.swift b/OpenVision/Views/Settings/HermesSettingsView.swift index a1898c7..a62f607 100644 --- a/OpenVision/Views/Settings/HermesSettingsView.swift +++ b/OpenVision/Views/Settings/HermesSettingsView.swift @@ -78,7 +78,7 @@ struct HermesSettingsView: View { @ViewBuilder private var apiKeySections: some View { Section { - TextField("https://hermes.example.com", text: $serverURL) + TextField("hermes.example.com", text: $serverURL) .autocapitalization(.none) .autocorrectionDisabled() .keyboardType(.URL) @@ -137,7 +137,7 @@ struct HermesSettingsView: View { @ViewBuilder private var passwordSections: some View { Section { - TextField("https://hermes.example.com", text: $dashboardURL) + TextField("hermes.example.com", text: $dashboardURL) .autocapitalization(.none) .autocorrectionDisabled() .keyboardType(.URL) diff --git a/OpenVisionTests/HermesTests.swift b/OpenVisionTests/HermesTests.swift index 27b4ece..4f6d3a0 100644 --- a/OpenVisionTests/HermesTests.swift +++ b/OpenVisionTests/HermesTests.swift @@ -32,6 +32,16 @@ final class HermesTests: XCTestCase { XCTAssertEqual(HermesService.withScheme("http://8.8.8.8"), "http://8.8.8.8", "a typed scheme is kept") } + func testDotlessAndTailnetPortAddressesUsePlainHTTP() { + XCTAssertEqual(HermesService.apiBase(from: "nuc:8642")?.absoluteString, "http://nuc:8642/v1") + XCTAssertEqual(HermesDashboard.base(from: "hermes-box")?.absoluteString, "http://hermes-box", + "a LAN name can sign in over plain http") + XCTAssertEqual(HermesService.apiBase(from: "myhost.tailnet.ts.net:8642")?.absoluteString, + "http://myhost.tailnet.ts.net:8642/v1", "a port means not tailscale serve") + XCTAssertFalse(HermesService.isUnencryptedRemote("http://nuc:8642")) + XCTAssertTrue(HermesService.isUnencryptedRemote("http://hermes.example.com"), "public hosts still warn") + } + func testAPIBaseRejectsNonHTTP() { XCTAssertNil(HermesService.apiBase(from: "")) XCTAssertNil(HermesService.apiBase(from: "ftp://hermes.example.com")) From 8dc782e1cfccb264ae10969387a2994b193dbd5a Mon Sep 17 00:00:00 2001 From: Raphael Barreiros Date: Sun, 4 Oct 2026 21:44:36 +0200 Subject: [PATCH 2/2] Fix: Don't trust dot-less Hermes hosts for plain http A dot-less name still gets http:// as the guess, but it no longer counts as local: it can also resolve through a search domain. So the API key warning shows for it and the web UI can't sign in over http, as before this PR. Co-Authored-By: Claude Opus 5.5 (1M context) --- OpenVision/Services/AIBackend/HermesService.swift | 15 ++++++++------- OpenVisionTests/HermesTests.swift | 7 ++++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/OpenVision/Services/AIBackend/HermesService.swift b/OpenVision/Services/AIBackend/HermesService.swift index 56bc105..1336a99 100644 --- a/OpenVision/Services/AIBackend/HermesService.swift +++ b/OpenVision/Services/AIBackend/HermesService.swift @@ -169,12 +169,10 @@ final class HermesService: ObservableObject { return !isLocalHost(host) && !host.hasSuffix(".ts.net") } - /// Loopback, `.local`, a dot-less name (`nuc`, `hermes-box`: only the local network or its - /// search domain resolves those), or a private / Tailscale IPv4 address. + /// Loopback, `.local`, or a private / Tailscale IPv4 address. nonisolated static func isLocalHost(_ host: String) -> Bool { let host = host.lowercased() if host == "localhost" || host.hasSuffix(".local") { return true } - if !host.isEmpty, !host.contains("."), !host.contains(":") { return true } let octets = host.split(separator: ".").compactMap { Int($0) } guard octets.count == 4 else { return false } switch (octets[0], octets[1]) { @@ -186,16 +184,19 @@ final class HermesService: ObservableObject { } /// The address with a scheme, for one typed without (`myhost.ts.net`, `100.88.1.2:8642`): - /// http for a local host or Tailscale IP (servers there rarely have a certificate), and for a - /// `*.ts.net` name with a port (`tailscale serve` answers https on 443, so a port means the - /// server itself); https otherwise. + /// http for a local host or Tailscale IP (servers there rarely have a certificate), for a + /// dot-less name (`nuc`, almost always a LAN machine), and for a `*.ts.net` name with a port + /// (`tailscale serve` answers https on 443, so a port means the server itself); https + /// otherwise. Only a guess: a dot-less name could also resolve through a search domain, so + /// it still gets the plain-http warning and can't sign in over http. nonisolated static func withScheme(_ address: String) -> String { let text = address.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, !text.contains("://") else { return text } let hostAndPort = text.split(separator: "/", maxSplits: 1).first.map(String.init) ?? text let host = hostAndPort.split(separator: ":").first.map(String.init) ?? hostAndPort let tailnetPort = host.lowercased().hasSuffix(".ts.net") && hostAndPort.contains(":") - return (isLocalHost(host) || tailnetPort ? "http://" : "https://") + text + let singleLabel = !host.contains(".") && !host.contains(":") + return (isLocalHost(host) || singleLabel || tailnetPort ? "http://" : "https://") + text } private static func authorize(_ request: inout URLRequest, key: String, sessionKey: String?) { diff --git a/OpenVisionTests/HermesTests.swift b/OpenVisionTests/HermesTests.swift index 4f6d3a0..4e9ca06 100644 --- a/OpenVisionTests/HermesTests.swift +++ b/OpenVisionTests/HermesTests.swift @@ -34,11 +34,12 @@ final class HermesTests: XCTestCase { func testDotlessAndTailnetPortAddressesUsePlainHTTP() { XCTAssertEqual(HermesService.apiBase(from: "nuc:8642")?.absoluteString, "http://nuc:8642/v1") - XCTAssertEqual(HermesDashboard.base(from: "hermes-box")?.absoluteString, "http://hermes-box", - "a LAN name can sign in over plain http") XCTAssertEqual(HermesService.apiBase(from: "myhost.tailnet.ts.net:8642")?.absoluteString, "http://myhost.tailnet.ts.net:8642/v1", "a port means not tailscale serve") - XCTAssertFalse(HermesService.isUnencryptedRemote("http://nuc:8642")) + // The guess isn't trust: a dot-less name may resolve through a search domain. + XCTAssertTrue(HermesService.isUnencryptedRemote("nuc:8642"), "still warns before sending the API key") + XCTAssertNil(HermesDashboard.base(from: "hermes-box"), "no plain-http sign-in on a dot-less name") + XCTAssertFalse(HermesService.isUnencryptedRemote("http://localhost")) XCTAssertTrue(HermesService.isUnencryptedRemote("http://hermes.example.com"), "public hosts still warn") }