iOS: create and manage setup links like the web interface

Add peer and Issue new config offer the same choice as the web UI: show
the config now or send a one-time setup link (1 h, 24 h or 7 d, PIN on by
default). The link sheet shares the link through the iOS share sheet and
shows the PIN, expiry and a QR code of the link. The peer page gets a
setup link card with share, copy and revoke.
This commit is contained in:
Daniel Redetzke
2026-10-03 23:29:28 +03:00
parent ef1988e4d0
commit 28415b867e
8 changed files with 385 additions and 41 deletions
+8
View File
@@ -97,4 +97,12 @@ final class API {
func send<T: Decodable>(_ method: String, _ path: String, _ body: [String: Any?]? = nil) async throws -> T { func send<T: Decodable>(_ method: String, _ path: String, _ body: [String: Any?]? = nil) async throws -> T {
try Self.decoder.decode(T.self, from: try await data(method, path, body: body)) try Self.decoder.decode(T.self, from: try await data(method, path, body: body))
} }
/// Creates a peer or issues a config. The server answers with the config,
/// or with a setup link when the body asked for one.
func issue(_ path: String, _ body: [String: Any?]?) async throws -> IssueOutcome {
let d = try await data("POST", path, body: body)
if let l = try? Self.decoder.decode(LinkCreated.self, from: d) { return .link(l) }
return .config(try Self.decoder.decode(IssuedConfig.self, from: d))
}
} }
+4
View File
@@ -33,6 +33,10 @@ func fmtDate(_ date: Date?) -> String {
return date.formatted(date: .abbreviated, time: .omitted) return date.formatted(date: .abbreviated, time: .omitted)
} }
func fmtStamp(_ date: Date) -> String {
date.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())
}
/// "35 min", "2 h 5 min", "3 days". /// "35 min", "2 h 5 min", "3 days".
func fmtDuration(_ seconds: Int64) -> String { func fmtDuration(_ seconds: Int64) -> String {
if seconds < 60 { return "under 1 min" } if seconds < 60 { return "under 1 min" }
+1 -19
View File
@@ -30,9 +30,7 @@ struct IssuedConfigContent: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
Notice(text: issued.includesPrivateKey Notice(text: "This is the only time the private key is shown. Scan or share it now: it is not stored on the server.")
? "This is the only time the private key is shown. Scan or share it now: it is not stored on the server."
: "The device keeps its own private key. Put it into the PrivateKey line.")
if let img = qrImage { if let img = qrImage {
Image(uiImage: img) Image(uiImage: img)
.interpolation(.none) .interpolation(.none)
@@ -73,19 +71,3 @@ struct IssuedConfigContent: View {
.background(Color.gwGround) .background(Color.gwGround)
} }
} }
/// IssuedConfigContent in its own sheet, for re-issued configs.
struct IssuedConfigView: View {
let issued: IssuedConfig
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
IssuedConfigContent(issued: issued)
.navigationTitle("Config for \(issued.peer.name)")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
}
.interactiveDismissDisabled()
}
}
+47
View File
@@ -145,6 +145,53 @@ nonisolated struct IssuedConfig: Decodable, Identifiable {
var id: String { peer.id + peer.publicKey } var id: String { peer.id + peer.publicKey }
} }
/// A setup link for the admin to send. It sets up a device once.
nonisolated struct SetupSecret: Decodable, Hashable {
let url: String
let path: String
let pin: String?
let expires: Date
let qr: String
}
/// The answer to creating a peer or issuing a config with a setup link.
nonisolated struct LinkCreated: Decodable {
let peer: Peer
let setup: SetupSecret
let applyError: String
}
/// A config shown now, or a setup link to send.
enum IssueOutcome: Identifiable {
case config(IssuedConfig)
case link(LinkCreated)
var id: String {
switch self {
case .config(let c): c.id
case .link(let l): l.setup.path
}
}
var peer: Peer {
switch self {
case .config(let c): c.peer
case .link(let l): l.peer
}
}
var applyError: String {
switch self {
case .config(let c): c.applyError
case .link(let l): l.applyError
}
}
}
nonisolated struct PeerOnly: Decodable {
let peer: Peer
}
nonisolated struct ClientDefaults: Codable, Equatable { nonisolated struct ClientDefaults: Codable, Equatable {
var dns: [String] var dns: [String]
var allowedIPs: [String] var allowedIPs: [String]
+69 -16
View File
@@ -9,8 +9,11 @@ struct PeerDetailView: View {
@State private var range = "7d" @State private var range = "7d"
@State private var points: [StatPoint] = [] @State private var points: [StatPoint] = []
@State private var error: String? @State private var error: String?
@State private var issued: IssuedConfig? @State private var issuing = false
@State private var confirmIssue = false @State private var issueWithLink = false
@State private var showingLink = false
@State private var confirmRevoke = false
@State private var copiedLink = false
@State private var confirmDelete = false @State private var confirmDelete = false
@State private var editing = false @State private var editing = false
@State private var sessions: [ConnSession] = [] @State private var sessions: [ConnSession] = []
@@ -23,6 +26,7 @@ struct PeerDetailView: View {
if let error { Notice(text: error, isError: true) } if let error { Notice(text: error, isError: true) }
if let p = peer { if let p = peer {
header(p) header(p)
if let s = p.setup { setupLink(p, s) }
traffic traffic
connection(p) connection(p)
history.id("history") history.id("history")
@@ -39,6 +43,8 @@ struct PeerDetailView: View {
// Development: `-scrollToHistory YES` for screenshots of the history. // Development: `-scrollToHistory YES` for screenshots of the history.
.task(id: sessions.count) { .task(id: sessions.count) {
if UserDefaults.standard.bool(forKey: "scrollToHistory"), !sessions.isEmpty { proxy.scrollTo("history", anchor: .top) } if UserDefaults.standard.bool(forKey: "scrollToHistory"), !sessions.isEmpty { proxy.scrollTo("history", anchor: .top) }
// `-showSetupLink YES` opens the pending setup link.
if UserDefaults.standard.bool(forKey: "showSetupLink"), peer?.setup != nil { showingLink = true }
} }
#endif #endif
} }
@@ -61,12 +67,17 @@ struct PeerDetailView: View {
} message: { } message: {
Text("The device loses access immediately. Its traffic history is deleted too. This cannot be undone.") 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) { .confirmationDialog("Revoke the setup link?", isPresented: $confirmRevoke, titleVisibility: .visible) {
Button("Issue new config") { Task { await issue() } } Button("Revoke link", role: .destructive) { Task { await revoke() } }
} message: { } message: {
Text("New keys are created. The device that uses the current config stops working until it gets the new one.") Text("The link stops working immediately.")
}
.sheet(isPresented: $issuing, onDismiss: { Task { await load() } }) {
if let p = peer { IssueSheet(peer: p, startWithLink: issueWithLink) }
}
.sheet(isPresented: $showingLink) {
if let p = peer { SetupLinkSheet(peer: p) }
} }
.sheet(item: $issued, onDismiss: { Task { await load() } }) { IssuedConfigView(issued: $0) }
.sheet(isPresented: $editing, onDismiss: { Task { await load() } }) { .sheet(isPresented: $editing, onDismiss: { Task { await load() } }) {
if let p = peer, let s = server { PeerEditView(peer: p, server: s) } if let p = peer, let s = server { PeerEditView(peer: p, server: s) }
} }
@@ -181,14 +192,10 @@ struct PeerDetailView: View {
Text("This server doesn't keep the peer's private key. To set up a device again, issue a new config. The old one stops working.") Text("This server doesn't keep the peer's private key. To set up a device again, issue a new config. The old one stops working.")
.font(.footnote) .font(.footnote)
.foregroundStyle(Color.gwText2) .foregroundStyle(Color.gwText2)
Button { confirmIssue = true } label: { Label("Issue new config & QR", systemImage: "qrcode") } Button { issueWithLink = false; issuing = true } label: {
.buttonStyle(PrimaryButtonStyle()) Label(p.publicKey.isEmpty ? "Issue config…" : "Issue new config…", systemImage: "qrcode")
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)
} }
.buttonStyle(PrimaryButtonStyle())
Text(p.configIssued.map { "Last issued \(fmtDate($0))." } ?? "No config issued yet.") Text(p.configIssued.map { "Last issued \(fmtDate($0))." } ?? "No config issued yet.")
.font(.caption) .font(.caption)
.foregroundStyle(Color.gwText2) .foregroundStyle(Color.gwText2)
@@ -251,11 +258,57 @@ struct PeerDetailView: View {
} }
} }
private func issue() async { private func setupLink(_ p: Peer, _ s: SetupInfo) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
SectionTitle(text: "Setup link")
Spacer()
Text(s.expired ? "Expired" : "Not opened yet").font(.footnote).foregroundStyle(Color.gwText2)
}
KV(key: s.expired ? "Expired" : "Expires", value: fmtStamp(s.expires))
KV(key: "PIN", value: s.pinRequired ? "Required · \(s.pinFails) of 5 wrong tries" : "Not required")
if !p.publicKey.isEmpty {
KV(key: "Current config", value: "Keeps working until the link is opened")
}
if s.expired {
Button("New link…") { issueWithLink = true; issuing = true }
.buttonStyle(PrimaryButtonStyle())
Button("Remove") { Task { await revoke() } }
.buttonStyle(SecondaryButtonStyle())
} else {
Button { showingLink = true } label: {
Label(s.pinRequired ? "Share link & PIN" : "Share link", systemImage: "square.and.arrow.up")
}
.buttonStyle(PrimaryButtonStyle())
HStack(spacing: 12) {
Button { Task { await copyLink() } } label: {
Label(copiedLink ? "Copied" : "Copy link", systemImage: copiedLink ? "checkmark" : "doc.on.doc")
}
.buttonStyle(SecondaryButtonStyle())
Button("Revoke", role: .destructive) { confirmRevoke = true }
.buttonStyle(SecondaryButtonStyle(ink: .gwErrInk))
}
}
}
.card()
}
private func copyLink() async {
guard let api = session.api else { return } guard let api = session.api else { return }
do { do {
let body: [String: Any?]? = nil let s: SetupSecret = try await api.get("/peers/\(peerID)/setup")
issued = try await api.send("POST", "/peers/\(peerID)/issue-config", body) UIPasteboard.general.string = s.url
copiedLink = true
} catch {
session.alert = session.message(for: error)
}
}
private func revoke() async {
guard let api = session.api else { return }
do {
let r: PeerOnly = try await api.send("DELETE", "/peers/\(peerID)/setup")
peer = r.peer
} catch { } catch {
session.alert = session.message(for: error) session.alert = session.message(for: error)
} }
+9 -5
View File
@@ -110,22 +110,23 @@ struct AddPeerView: View {
@State private var ipv4 = "" @State private var ipv4 = ""
@State private var overrides = PeerOverrides() @State private var overrides = PeerOverrides()
@State private var psk = true @State private var psk = true
@State private var handover = Handover()
@State private var error: String? @State private var error: String?
@State private var busy = false @State private var busy = false
@State private var issued: IssuedConfig? @State private var issued: IssueOutcome?
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Group { Group {
if let issued { if let issued {
IssuedConfigContent(issued: issued) IssueOutcomeContent(outcome: issued)
} else if let server { } else if let server {
form(server) form(server)
} else { } else {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.gwGround) ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.gwGround)
} }
} }
.navigationTitle(issued == nil ? "Add peer" : "Config for \(issued?.peer.name ?? "")") .navigationTitle(issued.map(outcomeTitle) ?? "Add peer")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
if issued == nil { if issued == nil {
@@ -166,9 +167,11 @@ struct AddPeerView: View {
} header: { } header: {
Text("Keys") Text("Keys")
} footer: { } footer: {
Text("The private key appears once in the config and QR code. It isn't stored.") Text("The private key is never stored on the server.")
} }
HandoverSection(h: $handover)
if let error { if let error {
Section { Text(error).foregroundStyle(Color.gwErrInk) } Section { Text(error).foregroundStyle(Color.gwErrInk) }
} }
@@ -187,7 +190,8 @@ struct AddPeerView: View {
body["note"] = note.trimmingCharacters(in: .whitespaces) body["note"] = note.trimmingCharacters(in: .whitespaces)
body["ipv4"] = ipv4.trimmingCharacters(in: .whitespaces) body["ipv4"] = ipv4.trimmingCharacters(in: .whitespaces)
body["presharedKey"] = psk body["presharedKey"] = psk
let r: IssuedConfig = try await api.send("POST", "/peers", body) body.merge(handover.body) { _, new in new }
let r = try await api.issue("/peers", body)
session.reportApply(r.applyError) session.reportApply(r.applyError)
issued = r issued = r
} catch { } catch {
+245
View File
@@ -0,0 +1,245 @@
import SwiftUI
/// "Show it here" or "Send a setup link", with the link's options. Used when
/// a peer is created and when its config is issued again.
struct Handover {
var link = false
var hours = 24
var pin = true
var body: [String: Any?] {
link ? ["delivery": "link", "linkHours": hours, "linkPIN": pin] : [:]
}
}
struct HandoverSection: View {
@Binding var h: Handover
var showHint = "QR code and .conf right after you tap Create. Best when the device is next to you."
var linkHint = "A one-time link you send to the device's owner. Keys are made when the link is opened and never stored."
var body: some View {
Section {
Picker("Hand over the config", selection: $h.link) {
Text("Show it here").tag(false)
Text("Send a setup link").tag(true)
}
.pickerStyle(.inline)
.labelsHidden()
if h.link {
Picker("Link valid for", selection: $h.hours) {
Text("1 hour").tag(1)
Text("24 hours").tag(24)
Text("7 days").tag(168)
}
Toggle("Require a PIN", isOn: $h.pin)
}
} header: {
Text("Hand over the config")
} footer: {
Text(h.link ? linkHint + (h.pin ? " Send the PIN by another channel than the link." : "") : showHint)
}
}
}
func qrImage(_ dataURL: String?) -> UIImage? {
guard let s = dataURL, let comma = s.firstIndex(of: ","),
let data = Data(base64Encoded: String(s[s.index(after: comma)...])) else { return nil }
return UIImage(data: data)
}
/// Shows a setup link to send: share, copy, PIN, QR code of the link.
struct SetupLinkContent: View {
let name: String
let setup: SetupSecret
@State private var copied: String?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Notice(text: setup.pin != nil
? "Anyone with this link and the PIN can set up this peer once. Send the PIN separately, e.g. by phone or another messenger."
: "Anyone with this link can set up this peer once. Send it only to the device's owner.")
VStack(alignment: .leading, spacing: 12) {
KV(key: "Link", value: setup.url, mono: true)
HStack(spacing: 12) {
if let url = URL(string: setup.url) {
ShareLink(item: url, subject: Text("VPN setup"), message: Text("Your VPN setup link for \(name)")) {
Label("Share link", systemImage: "square.and.arrow.up")
}
.buttonStyle(PrimaryButtonStyle())
}
copyButton("Copy", value: setup.url)
}
}
.card()
if let pin = setup.pin {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("PIN").font(.caption).foregroundStyle(Color.gwText2)
Text(pin).font(.mono(.title, weight: .semibold)).tracking(6).textSelection(.enabled)
}
Spacer()
copyButton("Copy PIN", value: pin).frame(maxWidth: 150)
}
.card()
}
VStack(alignment: .leading, spacing: 12) {
KV(key: "Valid until", value: fmtStamp(setup.expires))
KV(key: "Uses", value: "Once. Then the link stops working.")
}
.card()
if let img = qrImage(setup.qr) {
VStack(spacing: 8) {
Image(uiImage: img)
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(maxWidth: 220)
.padding(12)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
.accessibilityLabel("QR code of the setup link for \(name)")
Text("The QR code holds only the link, not the config.")
.font(.footnote)
.foregroundStyle(Color.gwText2)
}
.frame(maxWidth: .infinity)
}
Text("Until the link is used, you can share it again or revoke it on the peer's page.")
.font(.footnote)
.foregroundStyle(Color.gwText2)
}
.padding(16)
}
.background(Color.gwGround)
}
private func copyButton(_ title: String, value: String) -> some View {
Button {
UIPasteboard.general.string = value
copied = value
} label: {
Label(copied == value ? "Copied" : title, systemImage: copied == value ? "checkmark" : "doc.on.doc")
}
.buttonStyle(SecondaryButtonStyle())
}
}
/// The result of issuing: the config, or the setup link.
struct IssueOutcomeContent: View {
let outcome: IssueOutcome
var body: some View {
switch outcome {
case .config(let c): IssuedConfigContent(issued: c)
case .link(let l): SetupLinkContent(name: l.peer.name, setup: l.setup)
}
}
}
func outcomeTitle(_ o: IssueOutcome) -> String {
switch o {
case .config(let c): "Config for \(c.peer.name)"
case .link(let l): "Setup link for \(l.peer.name)"
}
}
/// Issue a config for an existing peer: choose how to hand it over, then
/// show the result in the same sheet.
struct IssueSheet: View {
let peer: Peer
var startWithLink = false
@Environment(AppSession.self) private var session
@Environment(\.dismiss) private var dismiss
@State private var h = Handover()
@State private var outcome: IssueOutcome?
@State private var busy = false
@State private var error: String?
var body: some View {
NavigationStack {
Group {
if let outcome {
IssueOutcomeContent(outcome: outcome)
} else {
Form {
Section {
if !peer.publicKey.isEmpty {
Text("New keys are created. The device that uses the current config stops working once it is replaced.")
}
if peer.setup != nil {
Text("This replaces the current setup link.")
}
}
.font(.footnote)
.foregroundStyle(Color.gwText2)
HandoverSection(h: $h,
showHint: "New keys now; QR code and .conf on this screen.",
linkHint: peer.publicKey.isEmpty ? "A one-time link you send to the device's owner."
: "The current config keeps working until the link is opened.")
if let error {
Section { Text(error).foregroundStyle(Color.gwErrInk) }
}
}
.groundBackground()
}
}
.navigationTitle(outcome.map(outcomeTitle) ?? (peer.publicKey.isEmpty ? "Issue config" : "Issue new config"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if outcome == nil {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("Continue") { Task { await issue() } }.disabled(busy)
}
} else {
ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } }
}
}
}
.interactiveDismissDisabled(outcome != nil)
.onAppear { h.link = startWithLink }
}
private func issue() async {
guard let api = session.api else { return }
busy = true
defer { busy = false }
error = nil
do {
let o = try await api.issue("/peers/\(peer.id)/issue-config", h.link ? h.body : nil)
session.reportApply(o.applyError)
outcome = o
} catch {
self.error = session.message(for: error)
}
}
}
/// Fetches a pending setup link again and shows it.
struct SetupLinkSheet: View {
let peer: Peer
@Environment(AppSession.self) private var session
@Environment(\.dismiss) private var dismiss
@State private var setup: SetupSecret?
@State private var error: String?
var body: some View {
NavigationStack {
Group {
if let setup {
SetupLinkContent(name: peer.name, setup: setup)
} else if let error {
ScrollView { Notice(text: error, isError: true).padding(16) }.background(Color.gwGround)
} else {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.gwGround)
}
}
.navigationTitle("Setup link for \(peer.name)")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
.task {
guard let api = session.api else { return }
do { setup = try await api.get("/peers/\(peer.id)/setup") } catch { self.error = session.message(for: error) }
}
}
}
}
+2 -1
View File
@@ -78,11 +78,12 @@ struct PrimaryButtonStyle: ButtonStyle {
} }
struct SecondaryButtonStyle: ButtonStyle { struct SecondaryButtonStyle: ButtonStyle {
var ink = Color.gwText
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
configuration.label configuration.label
.font(.body.weight(.medium)) .font(.body.weight(.medium))
.frame(maxWidth: .infinity, minHeight: 48) .frame(maxWidth: .infinity, minHeight: 48)
.foregroundStyle(Color.gwText) .foregroundStyle(ink)
.background(Color.gwSurface.opacity(configuration.isPressed ? 0.7 : 1), in: RoundedRectangle(cornerRadius: 10)) .background(Color.gwSurface.opacity(configuration.isPressed ? 0.7 : 1), in: RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gwLine)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gwLine))
} }