Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion OpenVision/Services/AIBackend/HermesGateway.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ enum HermesDashboard {
/// The dashboard base for whatever the user typed (`https://host`, a proxy prefix like
/// `https://host/hermes`, trailing slash optional). Nil if it isn't http(s).
nonisolated static func base(from text: String) -> URL? {
var text = text.trimmingCharacters(in: .whitespacesAndNewlines)
var text = HermesService.withScheme(text)
while text.hasSuffix("/") { text.removeLast() }
guard let url = URL(string: text), let scheme = url.scheme?.lowercased(),
scheme == "https" || scheme == "http", url.host != nil else { return nil }
Expand Down
32 changes: 24 additions & 8 deletions OpenVision/Services/AIBackend/HermesService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ final class HermesService: ObservableObject {
/// The `/v1` base for whatever the user typed: `https://host`, `https://host/v1`, a profile
/// prefix (`https://host/p/work`), with or without a trailing slash. Nil if it isn't http(s).
nonisolated static func apiBase(from serverURL: String) -> URL? {
var text = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
var text = withScheme(serverURL)
while text.hasSuffix("/") { text.removeLast() }
if text.hasSuffix("/v1") { text.removeLast(3) }
guard let url = URL(string: text), let scheme = url.scheme?.lowercased(),
Expand All @@ -166,17 +166,33 @@ final class HermesService: ObservableObject {
nonisolated static func isUnencryptedRemote(_ serverURL: String) -> Bool {
guard let base = apiBase(from: serverURL), base.scheme?.lowercased() == "http",
let host = base.host?.lowercased() else { return false }
if host == "localhost" || host.hasSuffix(".local") || host.hasSuffix(".ts.net") { return false }
return !isLocalHost(host) && !host.hasSuffix(".ts.net")
}

/// 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 }
let octets = host.split(separator: ".").compactMap { Int($0) }
guard octets.count == 4 else { return true }
guard octets.count == 4 else { return false }
switch (octets[0], octets[1]) {
case (10, _), (127, _), (192, 168): return false
case (172, let b) where (16...31).contains(b): return false
case (100, let b) where (64...127).contains(b): return false // Tailscale CGNAT range
default: return true
case (10, _), (127, _), (192, 168): return true
case (172, let b) where (16...31).contains(b): return true
case (100, let b) where (64...127).contains(b): return true // Tailscale CGNAT range
default: return false
}
}

/// 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.
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
}

private static func authorize(_ request: inout URLRequest, key: String, sessionKey: String?) {
request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
if let sessionKey { request.setValue(sessionKey, forHTTPHeaderField: "X-Hermes-Session-Key") }
Expand Down Expand Up @@ -207,7 +223,7 @@ final class HermesService: ObservableObject {
var errorDescription: String? {
switch self {
case .notConfigured: return "Hermes isn't configured. Add your server and sign in, or an API key, in Settings → Hermes."
case .badURL: return "Enter the server address, e.g. https://hermes.example.com."
case .badURL: return "That isn't a server address. Enter one like hermes.example.com or 100.88.1.2:8642."
case .unauthorized: return "The server rejected the API key. Use the API_SERVER_KEY from your Hermes server."
case .notHermes: return "That address answered, but it isn't a Hermes API server."
case .server(let detail): return "Hermes: \(detail)"
Expand Down
11 changes: 9 additions & 2 deletions OpenVisionTests/HermesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,17 @@ final class HermesTests: XCTestCase {
"https://hermes.example.com/p/work/v1")
}

func testAddressWithoutSchemeGetsOne() {
XCTAssertEqual(HermesService.apiBase(from: "hermes.example.com")?.absoluteString, "https://hermes.example.com/v1")
XCTAssertEqual(HermesService.apiBase(from: "myhost.tailnet.ts.net")?.absoluteString, "https://myhost.tailnet.ts.net/v1")
XCTAssertEqual(HermesService.apiBase(from: " 100.88.1.2:8642 ")?.absoluteString, "http://100.88.1.2:8642/v1")
XCTAssertEqual(HermesService.apiBase(from: "192.168.1.20:8642/p/work")?.absoluteString, "http://192.168.1.20:8642/p/work/v1")
XCTAssertEqual(HermesDashboard.base(from: "hermes.example.com/hermes")?.absoluteString, "https://hermes.example.com/hermes")
XCTAssertEqual(HermesService.withScheme("http://8.8.8.8"), "http://8.8.8.8", "a typed scheme is kept")
}

func testAPIBaseRejectsNonHTTP() {
XCTAssertNil(HermesService.apiBase(from: ""))
XCTAssertNil(HermesService.apiBase(from: "hermes.example.com"))
XCTAssertNil(HermesService.apiBase(from: "ftp://hermes.example.com"))
}

Expand Down Expand Up @@ -102,7 +110,6 @@ final class HermesTests: XCTestCase {
"wss://hermes.example.com/hermes/api/ws?ticket=t1")
XCTAssertEqual(HermesDashboard.webSocketURL(base: URL(string: "http://100.88.1.2:9119")!, ticket: "t")?.absoluteString,
"ws://100.88.1.2:9119/api/ws?ticket=t")
XCTAssertNil(HermesDashboard.base(from: "hermes.example.com"))
XCTAssertNil(HermesDashboard.base(from: "http://hermes.example.com"), "no tokens over plain http on the internet")
XCTAssertNotNil(HermesDashboard.base(from: "http://192.168.1.20:9119"))
}
Expand Down
Loading