Add one-time setup links as an alternative to the QR code

A config can now be handed over as a one-time link, valid for 1 h, 24 h or
7 days and protected by a PIN by default. Keys are made only when the link
is opened; the link works once and is revoked after 5 wrong PINs. Issuing a
new config offers the same choice, and the current config keeps working
until the link is used.

Remove the option to paste a client's public key, in the web UI, the API
and the iOS app.
This commit is contained in:
Daniel Redetzke
2026-10-03 23:10:28 +03:00
parent 55aaaa3a78
commit ef1988e4d0
17 changed files with 885 additions and 119 deletions
+9
View File
@@ -112,9 +112,18 @@ nonisolated struct Peer: Decodable, Identifiable, Hashable {
let effectiveKeepalive: Int
let created: Date
let configIssued: Date?
let setup: SetupInfo? // pending setup link, nil if none
let stats: PeerStats
}
/// A pending setup link as peer lists show it. The link itself is not in it.
nonisolated struct SetupInfo: Decodable, Hashable {
let expires: Date
let expired: Bool
let pinRequired: Bool
let pinFails: Int
}
nonisolated struct PeerList: Decodable {
let peers: [Peer]
let capacity: Int
+11 -19
View File
@@ -12,8 +12,6 @@ struct PeerDetailView: View {
@State private var issued: IssuedConfig?
@State private var confirmIssue = false
@State private var confirmDelete = false
@State private var askKey = false
@State private var deviceKey = ""
@State private var editing = false
@State private var sessions: [ConnSession] = []
@State private var allSessions = false
@@ -64,20 +62,10 @@ struct PeerDetailView: View {
Text("The device loses access immediately. Its traffic history is deleted too. This cannot be undone.")
}
.confirmationDialog("Issue a new config?", isPresented: $confirmIssue, titleVisibility: .visible) {
Button("Issue new config") { Task { await issue(publicKey: nil) } }
Button("Issue new config") { Task { await issue() } }
} message: {
Text("New keys are created. The device that uses the current config stops working until it gets the new one.")
}
.alert("Use a key from the device", isPresented: $askKey) {
TextField("Public key", text: $deviceKey)
.font(.mono(.footnote))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Cancel", role: .cancel) {}
Button("Replace key") { Task { await issue(publicKey: deviceKey) } }
} message: {
Text("Paste the public key the device generated. The current config stops working.")
}
.sheet(item: $issued, onDismiss: { Task { await load() } }) { IssuedConfigView(issued: $0) }
.sheet(isPresented: $editing, onDismiss: { Task { await load() } }) {
if let p = peer, let s = server { PeerEditView(peer: p, server: s) }
@@ -120,7 +108,7 @@ struct PeerDetailView: View {
KV(key: "Endpoint", value: p.stats.endpoint.isEmpty ? "–" : p.stats.endpoint, mono: true)
KV(key: "Location", value: p.stats.location?.label.isEmpty == false ? p.stats.location!.label : "–")
KV(key: "Latest handshake", value: ago(p.stats.lastHandshake))
KV(key: "Public key", value: p.publicKey, mono: true)
KV(key: "Public key", value: p.publicKey.isEmpty ? "–" : p.publicKey, mono: true)
KV(key: "Preshared key", value: p.hasPresharedKey ? "Set" : "None")
KV(key: "All-time traffic", value: "Download \(fmtBytes(p.stats.downTotal)) · Upload \(fmtBytes(p.stats.upTotal))")
}
@@ -195,9 +183,13 @@ struct PeerDetailView: View {
.foregroundStyle(Color.gwText2)
Button { confirmIssue = true } label: { Label("Issue new config & QR", systemImage: "qrcode") }
.buttonStyle(PrimaryButtonStyle())
Button("Use a key from the device…") { deviceKey = ""; askKey = true }
.buttonStyle(SecondaryButtonStyle())
Text(p.configIssued.map { "Last issued \(fmtDate($0))." } ?? "Created with a key from the device.")
if let s = p.setup {
Text(s.expired ? "The setup link expired \(fmtDate(s.expires)). Manage setup links in the web interface."
: "A setup link is waiting to be opened (until \(fmtDate(s.expires))). Issuing a config here replaces it.")
.font(.footnote)
.foregroundStyle(Color.gwWarnInk)
}
Text(p.configIssued.map { "Last issued \(fmtDate($0))." } ?? "No config issued yet.")
.font(.caption)
.foregroundStyle(Color.gwText2)
}
@@ -259,10 +251,10 @@ struct PeerDetailView: View {
}
}
private func issue(publicKey: String?) async {
private func issue() async {
guard let api = session.api else { return }
do {
let body: [String: Any?]? = publicKey.map { ["publicKey": $0.trimmingCharacters(in: .whitespacesAndNewlines)] }
let body: [String: Any?]? = nil
issued = try await api.send("POST", "/peers/\(peerID)/issue-config", body)
} catch {
session.alert = session.message(for: error)
+1 -16
View File
@@ -109,8 +109,6 @@ struct AddPeerView: View {
@State private var note = ""
@State private var ipv4 = ""
@State private var overrides = PeerOverrides()
@State private var pasteKey = false
@State private var publicKey = ""
@State private var psk = true
@State private var error: String?
@State private var busy = false
@@ -164,23 +162,11 @@ struct AddPeerView: View {
OverrideSections(o: $overrides, server: server)
Section {
Picker("Keys", selection: $pasteKey) {
Text("Generate here").tag(false)
Text("Paste the client's public key").tag(true)
}
.pickerStyle(.inline)
.labelsHidden()
if pasteKey {
TextField("Public key", text: $publicKey)
.font(.mono(.footnote))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
Toggle("Add a preshared key", isOn: $psk)
} header: {
Text("Keys")
} footer: {
Text(pasteKey ? "For clients that make their own keys." : "The private key appears once in the config and QR code. It isn't stored.")
Text("The private key appears once in the config and QR code. It isn't stored.")
}
if let error {
@@ -201,7 +187,6 @@ struct AddPeerView: View {
body["note"] = note.trimmingCharacters(in: .whitespaces)
body["ipv4"] = ipv4.trimmingCharacters(in: .whitespaces)
body["presharedKey"] = psk
if pasteKey { body["publicKey"] = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) }
let r: IssuedConfig = try await api.send("POST", "/peers", body)
session.reportApply(r.applyError)
issued = r
+7 -3
View File
@@ -104,10 +104,11 @@ struct Notice: View {
}
enum PeerState {
case online(Date), offline(Date), never, disabled
case online(Date), offline(Date), never, disabled, waiting, noConfig
init(_ p: Peer) {
if !p.enabled { self = .disabled }
else if p.publicKey.isEmpty { self = p.setup.map { !$0.expired } == true ? .waiting : .noConfig }
else if let h = p.stats.lastHandshake { self = p.stats.online ? .online(h) : .offline(h) }
else { self = .never }
}
@@ -118,13 +119,15 @@ enum PeerState {
case .offline(let d): "Offline · " + ago(d)
case .never: "Never connected"
case .disabled: "Disabled"
case .waiting: "Waiting for setup"
case .noConfig: "No config yet"
}
}
var key: String {
switch self {
case .online: "online"
case .offline, .never: "offline"
case .offline, .never, .waiting, .noConfig: "offline"
case .disabled: "disabled"
}
}
@@ -136,7 +139,8 @@ struct StatusDot: View {
switch state {
case .online: Circle().fill(Color.gwGood).frame(width: 8, height: 8)
case .offline: Circle().fill(Color.gray).frame(width: 8, height: 8)
case .never: Circle().stroke(Color.gray, lineWidth: 1.5).frame(width: 8, height: 8)
case .never, .noConfig: Circle().stroke(Color.gray, lineWidth: 1.5).frame(width: 8, height: 8)
case .waiting: Circle().fill(Color.gwUp).frame(width: 8, height: 8)
case .disabled: Circle().fill(Color.gwBad).frame(width: 8, height: 8)
}
}