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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user