Files
openclaw/apps/ios/Sources/Settings/GatewayCustomHeadersSettingsView.swift
Peter Steinberger 7853aa35a4 feat(ios): per-gateway custom headers for gateways behind authenticating proxies (#100768)
* feat(ios): support per-gateway custom headers for proxied gateway connections

Per-gateway custom request headers (Cloudflare Access-style service tokens)
for gateways behind authenticating reverse proxies. Headers are credentials:
Keychain-stored per gateway stableID, masked in settings UI, injected on the
WebSocket upgrade at connect time via a provider so edits apply on the next
reconnect without re-pairing. Reserved handshake headers and control chars
are sanitized out. TLS pinning and pairing flows unchanged. Push relay and
other non-gateway destinations proven header-free by regression test.

Related: #100698

* chore(ios): sync native i18n inventory for custom header strings

* fix(ios): harden gateway custom headers

* chore(ios): resync native i18n inventory

* fix(ios): validate custom header names
2026-07-06 17:17:07 +01:00

157 lines
5.7 KiB
Swift

import OpenClawKit
import SwiftUI
/// Per-gateway custom header editor for gateways behind authenticating reverse proxies
/// (Cloudflare Access-style service tokens). Header values are credentials: they render
/// masked, persist in the Keychain, and must never appear in logs or diagnostics.
struct GatewayCustomHeadersSettingsView: View {
let gatewayStableID: String
@State private var entries: [HeaderEntry] = []
@State private var newName = ""
@State private var newValue = ""
@State private var loaded = false
private struct HeaderEntry: Identifiable, Equatable {
let id = UUID()
var name: String
var value: String
}
var body: some View {
Form {
if !self.entries.isEmpty {
Section {
ForEach(self.$entries) { $entry in
VStack(alignment: .leading, spacing: 4) {
Text(entry.name)
.font(OpenClawType.subheadSemiBold)
self.headerValueField("Value", text: $entry.value)
}
}
.onDelete(perform: self.removeEntries)
} header: {
Text("Headers")
.font(OpenClawType.captionSemiBold)
} footer: {
Text("Sent with foreground app connections to this gateway. "
+ "Changes apply on the next reconnect; Share extension delivery is not yet supported.")
.font(OpenClawType.caption)
}
}
Section {
TextField(text: self.$newName, prompt: Text("Header name").font(OpenClawType.subhead)) {
Text("Header name")
.font(OpenClawType.subhead)
}
.font(OpenClawType.subhead)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
self.headerValueField("Header value", text: self.$newValue)
Button {
self.addEntry()
} label: {
Label {
Text("Add Header")
.font(OpenClawType.subheadSemiBold)
} icon: {
Image(systemName: "plus")
}
}
.disabled(!self.canAddEntry)
} header: {
Text("Add Header")
.font(OpenClawType.captionSemiBold)
} footer: {
Text(self.addFooterText)
.font(OpenClawType.caption)
}
}
.navigationTitle("Custom Headers")
.toolbar {
EditButton()
.font(OpenClawType.subheadSemiBold)
}
.onAppear(perform: self.loadOnce)
.onChange(of: self.entries) { _, _ in
guard self.loaded else { return }
self.persist()
}
}
private var trimmedNewName: String {
self.newName.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var newNameIsReserved: Bool {
GatewayCustomHeaders.isReservedName(self.trimmedNewName)
}
private var newNameIsDuplicate: Bool {
let name = self.trimmedNewName
return self.entries.contains { $0.name.caseInsensitiveCompare(name) == .orderedSame }
}
private var canAddEntry: Bool {
!self.trimmedNewName.isEmpty && !self.newNameIsReserved && !self.newNameIsDuplicate
}
private var addFooterText: String {
if self.newNameIsReserved {
return "This header is managed by the connection and cannot be overridden."
}
if self.newNameIsDuplicate {
return "A header with this name already exists."
}
return "For secure gateways behind an authenticating proxy, for example Cloudflare Access "
+ "service token headers. Values are stored securely and sent only over TLS to this gateway."
}
private func headerValueField(_ placeholder: String, text: Binding<String>) -> some View {
ZStack(alignment: .leading) {
SecureField("", text: text)
.font(OpenClawType.subhead)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.accessibilityLabel(placeholder)
if text.wrappedValue.isEmpty {
Text(placeholder)
.font(OpenClawType.subhead)
.foregroundStyle(.tertiary)
.allowsHitTesting(false)
.accessibilityHidden(true)
}
}
}
private func loadOnce() {
guard !self.loaded else { return }
self.entries = GatewaySettingsStore.loadGatewayCustomHeaders(gatewayStableID: self.gatewayStableID)
.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending }
.map { HeaderEntry(name: $0.key, value: $0.value) }
self.loaded = true
}
private func addEntry() {
guard self.canAddEntry else { return }
self.entries.append(HeaderEntry(name: self.trimmedNewName, value: self.newValue))
self.newName = ""
self.newValue = ""
}
private func removeEntries(at offsets: IndexSet) {
self.entries.remove(atOffsets: offsets)
}
private func persist() {
var headers: [String: String] = [:]
for entry in self.entries {
let name = entry.name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { continue }
headers[name] = entry.value
}
GatewaySettingsStore.saveGatewayCustomHeaders(headers, gatewayStableID: self.gatewayStableID)
}
}