diff --git a/ios/GHOSTWIRE/API.swift b/ios/GHOSTWIRE/API.swift index bd5ca1f..5e7ae26 100644 --- a/ios/GHOSTWIRE/API.swift +++ b/ios/GHOSTWIRE/API.swift @@ -97,4 +97,12 @@ final class API { func send(_ 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)) } + + /// 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)) + } } diff --git a/ios/GHOSTWIRE/Format.swift b/ios/GHOSTWIRE/Format.swift index 7078532..e39bad4 100644 --- a/ios/GHOSTWIRE/Format.swift +++ b/ios/GHOSTWIRE/Format.swift @@ -33,6 +33,10 @@ func fmtDate(_ date: Date?) -> String { 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". func fmtDuration(_ seconds: Int64) -> String { if seconds < 60 { return "under 1 min" } diff --git a/ios/GHOSTWIRE/IssuedConfigView.swift b/ios/GHOSTWIRE/IssuedConfigView.swift index e0b63ce..2219e79 100644 --- a/ios/GHOSTWIRE/IssuedConfigView.swift +++ b/ios/GHOSTWIRE/IssuedConfigView.swift @@ -30,9 +30,7 @@ struct IssuedConfigContent: View { var body: some View { ScrollView { VStack(spacing: 16) { - Notice(text: issued.includesPrivateKey - ? "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.") + Notice(text: "This is the only time the private key is shown. Scan or share it now: it is not stored on the server.") if let img = qrImage { Image(uiImage: img) .interpolation(.none) @@ -73,19 +71,3 @@ struct IssuedConfigContent: View { .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() - } -} diff --git a/ios/GHOSTWIRE/Models.swift b/ios/GHOSTWIRE/Models.swift index 272cd8b..171cb2f 100644 --- a/ios/GHOSTWIRE/Models.swift +++ b/ios/GHOSTWIRE/Models.swift @@ -145,6 +145,53 @@ nonisolated struct IssuedConfig: Decodable, Identifiable { 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 { var dns: [String] var allowedIPs: [String] diff --git a/ios/GHOSTWIRE/PeerDetailView.swift b/ios/GHOSTWIRE/PeerDetailView.swift index 01bc1b5..e9b8491 100644 --- a/ios/GHOSTWIRE/PeerDetailView.swift +++ b/ios/GHOSTWIRE/PeerDetailView.swift @@ -9,8 +9,11 @@ struct PeerDetailView: View { @State private var range = "7d" @State private var points: [StatPoint] = [] @State private var error: String? - @State private var issued: IssuedConfig? - @State private var confirmIssue = false + @State private var issuing = 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 editing = false @State private var sessions: [ConnSession] = [] @@ -23,6 +26,7 @@ struct PeerDetailView: View { if let error { Notice(text: error, isError: true) } if let p = peer { header(p) + if let s = p.setup { setupLink(p, s) } traffic connection(p) history.id("history") @@ -39,6 +43,8 @@ struct PeerDetailView: View { // Development: `-scrollToHistory YES` for screenshots of the history. .task(id: sessions.count) { 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 } @@ -61,12 +67,17 @@ struct PeerDetailView: View { } message: { 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() } } + .confirmationDialog("Revoke the setup link?", isPresented: $confirmRevoke, titleVisibility: .visible) { + Button("Revoke link", role: .destructive) { Task { await revoke() } } } 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() } }) { 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.") .font(.footnote) .foregroundStyle(Color.gwText2) - Button { confirmIssue = true } label: { Label("Issue new config & QR", systemImage: "qrcode") } - .buttonStyle(PrimaryButtonStyle()) - 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) + Button { issueWithLink = false; issuing = true } label: { + Label(p.publicKey.isEmpty ? "Issue config…" : "Issue new config…", systemImage: "qrcode") } + .buttonStyle(PrimaryButtonStyle()) Text(p.configIssued.map { "Last issued \(fmtDate($0))." } ?? "No config issued yet.") .font(.caption) .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 } do { - let body: [String: Any?]? = nil - issued = try await api.send("POST", "/peers/\(peerID)/issue-config", body) + let s: SetupSecret = try await api.get("/peers/\(peerID)/setup") + 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 { session.alert = session.message(for: error) } diff --git a/ios/GHOSTWIRE/PeerForms.swift b/ios/GHOSTWIRE/PeerForms.swift index 0f88860..f2552da 100644 --- a/ios/GHOSTWIRE/PeerForms.swift +++ b/ios/GHOSTWIRE/PeerForms.swift @@ -110,22 +110,23 @@ struct AddPeerView: View { @State private var ipv4 = "" @State private var overrides = PeerOverrides() @State private var psk = true + @State private var handover = Handover() @State private var error: String? @State private var busy = false - @State private var issued: IssuedConfig? + @State private var issued: IssueOutcome? var body: some View { NavigationStack { Group { if let issued { - IssuedConfigContent(issued: issued) + IssueOutcomeContent(outcome: issued) } else if let server { form(server) } else { 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) .toolbar { if issued == nil { @@ -166,9 +167,11 @@ struct AddPeerView: View { } header: { Text("Keys") } 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 { Section { Text(error).foregroundStyle(Color.gwErrInk) } } @@ -187,7 +190,8 @@ struct AddPeerView: View { body["note"] = note.trimmingCharacters(in: .whitespaces) body["ipv4"] = ipv4.trimmingCharacters(in: .whitespaces) 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) issued = r } catch { diff --git a/ios/GHOSTWIRE/SetupLinkViews.swift b/ios/GHOSTWIRE/SetupLinkViews.swift new file mode 100644 index 0000000..59a7853 --- /dev/null +++ b/ios/GHOSTWIRE/SetupLinkViews.swift @@ -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) } + } + } + } +} diff --git a/ios/GHOSTWIRE/Theme.swift b/ios/GHOSTWIRE/Theme.swift index 18b03f7..4b32a88 100644 --- a/ios/GHOSTWIRE/Theme.swift +++ b/ios/GHOSTWIRE/Theme.swift @@ -78,11 +78,12 @@ struct PrimaryButtonStyle: ButtonStyle { } struct SecondaryButtonStyle: ButtonStyle { + var ink = Color.gwText func makeBody(configuration: Configuration) -> some View { configuration.label .font(.body.weight(.medium)) .frame(maxWidth: .infinity, minHeight: 48) - .foregroundStyle(Color.gwText) + .foregroundStyle(ink) .background(Color.gwSurface.opacity(configuration.isPressed ? 0.7 : 1), in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gwLine)) }