diff --git a/OpenVision/Services/AIBackend/HermesService.swift b/OpenVision/Services/AIBackend/HermesService.swift index 2738201..1336a99 100644 --- a/OpenVision/Services/AIBackend/HermesService.swift +++ b/OpenVision/Services/AIBackend/HermesService.swift @@ -184,13 +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 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), 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 - return (isLocalHost(host) ? "http://" : "https://") + text + let tailnetPort = host.lowercased().hasSuffix(".ts.net") && hostAndPort.contains(":") + 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/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..4e9ca06 100644 --- a/OpenVisionTests/HermesTests.swift +++ b/OpenVisionTests/HermesTests.swift @@ -32,6 +32,17 @@ 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(HermesService.apiBase(from: "myhost.tailnet.ts.net:8642")?.absoluteString, + "http://myhost.tailnet.ts.net:8642/v1", "a port means not tailscale serve") + // 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") + } + func testAPIBaseRejectsNonHTTP() { XCTAssertNil(HermesService.apiBase(from: "")) XCTAssertNil(HermesService.apiBase(from: "ftp://hermes.example.com"))