1098 lines
44 KiB
Go
1098 lines
44 KiB
Go
package main
|
|
|
|
// uisetup.go — the local UI's SET IT UP page (phase 3 of the local UI).
|
|
//
|
|
// WHY IT EXISTS. Every capability on this page already has a CLI verb, and
|
|
// every one of them is painful or impossible for a person who does not live in
|
|
// a terminal. The QR code for a second factor is printed as block glyphs into a
|
|
// console window (stepup.go:786) that, after a self-update on Windows, does not
|
|
// exist. `claim-anchor` on a box serving three hosts refuses and prints the
|
|
// names to choose from (anchor.go:902) — a list with one choice is what that
|
|
// refusal is asking for. `add-folder` takes an absolute path typed blind
|
|
// (addfolder.go:57). These are the onboarding steps, in the order they happen,
|
|
// and they are where a person falls off.
|
|
//
|
|
// THE LINE THIS DOES NOT CROSS is ui.go's: the page reads local state and acts
|
|
// on THIS MACHINE. It never originates a request to the host. `add-folder` is
|
|
// the one outward-facing thing here and it stays inside the line for the reason
|
|
// the design gives: the draft rides the tunnel and is inert until the
|
|
// user approves it in their host (addfolder.go:132-137).
|
|
//
|
|
// ── WHY THE ANCHOR AND THE OWNER NAME ARE CHOOSERS, NOT DOERS ───────────────
|
|
//
|
|
// Both are gated by requireMachineAuthority (anchor.go:816, owner.go:83), which
|
|
// asks processIsRoot(): euid 0 on unix (session_unix.go:31), elevated and in
|
|
// Administrators on Windows (privilege_windows.go:47). That is a question about
|
|
// the PROCESS — and this page runs inside that process. startUIServer is called
|
|
// from cmdRun (run.go:131), and deploy/install.sh:154 writes the networked unit
|
|
// with no `User=` line, deliberately: "the service runs as root". So on the box
|
|
// where an anchor picker is needed at all — a networked one, serving several
|
|
// hosts — the gate would simply pass if this page called the function.
|
|
//
|
|
// So this is not "the page cannot". It is "the page must not", and the reason
|
|
// has to be written down or someone will helpfully add the button later:
|
|
//
|
|
// - The credential behind this page is a 12-hour cookie in a BROWSER
|
|
// (uiauth.go:66). The credential behind `sudo -H` is a password prompt
|
|
// answered seconds ago at a terminal. Moving the machine's constitutional
|
|
// act — who approves every future pairing here, for as long as the box
|
|
// lives — from the second to the first lowers the bar that root is there to
|
|
// hold, whatever the euid of the process happens to be.
|
|
// - The design already draws this line for the anchor's siblings:
|
|
// `claim-anchor --rotate` is CLI because it is rare, consequential, and
|
|
// deserves the friction — show the state in the UI, keep the action in the
|
|
// CLI. A first claim is the same act with no predecessor to consent.
|
|
// - And on a networked box the person reading this page is already root. The
|
|
// UI secret is 0600 in the daemon's own config dir (uiauth.go:127), and a
|
|
// non-root `privatae-bastion ui` reads a DIFFERENT config dir, mints a
|
|
// different secret and is refused by /auth/ticket. So the terminal this
|
|
// page hands a command to is open in front of them.
|
|
//
|
|
// What the page does instead is the part that was actually missing: it shows
|
|
// the state, it makes the choice a click instead of a hostname to transcribe,
|
|
// it checks the choice against the same rules the command will apply, and it
|
|
// writes out the exact line with the chosen host already in it. No escalation
|
|
// path is invented here, and none should be.
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/base32"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"rsc.io/qr"
|
|
)
|
|
|
|
// uiEnrolTTL bounds a step-up enrolment that has been started and not confirmed.
|
|
// For that long the new TOTP secret lives in this process's memory and nowhere
|
|
// else — not on disk, not in a URL, not in the page.
|
|
//
|
|
// Ten minutes, because the number is set by what the person is doing: fetching
|
|
// a phone, unlocking it, finding the authenticator, scanning, typing six
|
|
// digits. uiTicketTTL (uiauth.go:63) is 60 seconds because it only has to
|
|
// survive a browser launch; this one has to survive a human walking to a drawer.
|
|
const uiEnrolTTL = 10 * time.Minute
|
|
|
|
// ── pending enrolments ──────────────────────────────────────────────────────
|
|
|
|
// uiEnrol is one half-finished step-up enrolment.
|
|
//
|
|
// SecretB32 is a CREDENTIAL and is treated like one: it is rendered as PIXELS
|
|
// (the QR route), or as text only when the person explicitly asks for it, and
|
|
// it reaches disk only once a code proves the authenticator holds it. The CLI
|
|
// prints both the otpauth URI and the base32 key into a terminal's scrollback
|
|
// (stepup.go:792-794) for a reason it states — a QR is useless over a serial
|
|
// console — and that reason does not apply in a browser, so the default here is
|
|
// the stricter one.
|
|
type uiEnrol struct {
|
|
// Machine: the owner's own code (stepup_machine.go), not a Host's. Host
|
|
// is empty then.
|
|
Machine bool
|
|
Host string
|
|
SecretB32 string
|
|
URI string
|
|
Expires time.Time
|
|
}
|
|
|
|
type uiEnrolStore struct {
|
|
mu sync.Mutex
|
|
m map[string]*uiEnrol // handle -> enrolment
|
|
}
|
|
|
|
func newUIEnrolStore() *uiEnrolStore { return &uiEnrolStore{m: map[string]*uiEnrol{}} }
|
|
|
|
// put replaces any enrolment already in flight FOR THE SAME HOST. Starting over
|
|
// is the ordinary way out of a scan that did not take, and leaving the previous
|
|
// secret sitting in memory would mean a code from the abandoned one still
|
|
// enrolled something.
|
|
func (s *uiEnrolStore) put(e *uiEnrol) (string, error) {
|
|
h, err := genSecret(32)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.sweepLocked()
|
|
for k, v := range s.m {
|
|
if v.Host == e.Host {
|
|
delete(s.m, k)
|
|
}
|
|
}
|
|
s.m[h] = e
|
|
return h, nil
|
|
}
|
|
|
|
func (s *uiEnrolStore) get(handle string) *uiEnrol {
|
|
if handle == "" {
|
|
return nil
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.sweepLocked()
|
|
return s.m[handle]
|
|
}
|
|
|
|
// forHost is what the page renders from: after any re-render the enrolment in
|
|
// flight has to be found again without the browser holding the handle.
|
|
func (s *uiEnrolStore) forHost(host string) (string, *uiEnrol) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.sweepLocked()
|
|
for h, e := range s.m {
|
|
if e.Host == host {
|
|
return h, e
|
|
}
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func (s *uiEnrolStore) drop(handle string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.m, handle)
|
|
}
|
|
|
|
func (s *uiEnrolStore) sweepLocked() {
|
|
now := uiNow()
|
|
for k, e := range s.m {
|
|
if now.After(e.Expires) {
|
|
delete(s.m, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── the server half ─────────────────────────────────────────────────────────
|
|
|
|
// uiSetup holds the state this page needs that the machine does not already
|
|
// have on disk: enrolments that have been started and not finished.
|
|
//
|
|
// It hangs off the mux rather than off uiServer so registering these routes is
|
|
// one line in ui.go's handler(), and so two servers in one test process cannot
|
|
// see each other's half-finished enrolments.
|
|
type uiSetup struct {
|
|
u *uiServer
|
|
enrols *uiEnrolStore
|
|
}
|
|
|
|
// setupRoutes registers phase 3. Every route goes through u.guarded, which runs
|
|
// the Host check before anything else (uiauth.go:258) and the Origin check on
|
|
// anything that is not a read.
|
|
//
|
|
// The method check lives INSIDE each handler rather than in the mux pattern,
|
|
// and that is deliberate: Go's method patterns would answer a rebound host with
|
|
// 405 before uiRequestAllowed ever ran, and the rebinding wall is supposed to
|
|
// be the outermost thing on every route (ui.go:464, and the test that walks
|
|
// them all).
|
|
//
|
|
// It returns the state it registered, which ui.go ignores and the tests use:
|
|
// an enrolment in flight lives in memory by design, so a test that cannot reach
|
|
// it can only assert that the page does not show a secret — never that the
|
|
// right secret was the one enrolled.
|
|
func (u *uiServer) setupRoutes(mux *http.ServeMux) *uiSetup {
|
|
s := &uiSetup{u: u, enrols: newUIEnrolStore()}
|
|
mux.HandleFunc("/setup", u.guarded(s.page))
|
|
mux.HandleFunc("/setup/pair", u.adminOnly(s.pair))
|
|
mux.HandleFunc("/setup/stepup/start", u.adminOnly(s.stepupStart))
|
|
mux.HandleFunc("/setup/stepup/qr", u.adminOnly(s.stepupQR))
|
|
mux.HandleFunc("/setup/stepup/key", u.adminOnly(s.stepupKey))
|
|
mux.HandleFunc("/setup/stepup/confirm", u.adminOnly(s.stepupConfirm))
|
|
// The folder check stats any path the page posts, as the daemon: root on a
|
|
// whole-machine install. It exists only to help fill in the proposal,
|
|
// which is the administrator's, so it is the administrator's too; from the
|
|
// owner's lane it would tell a program running as the person what exists
|
|
// in folders that account cannot read.
|
|
mux.HandleFunc("/setup/folder/check", u.adminOnly(s.folderCheck))
|
|
mux.HandleFunc("/setup/folder/propose", u.adminOnly(s.folderPropose))
|
|
mux.HandleFunc("/setup/anchor/choose", u.adminOnly(s.anchorChoose))
|
|
mux.HandleFunc("/setup/owner/name", u.guarded(s.ownerName))
|
|
// The owner's acts on a Host (uisetup_owner.go).
|
|
mux.HandleFunc("/setup/host/disconnect", u.guarded(s.hostDisconnect))
|
|
mux.HandleFunc("/setup/host/release", u.guarded(s.hostRelease))
|
|
return s
|
|
}
|
|
|
|
// ── what the page shows ─────────────────────────────────────────────────────
|
|
|
|
type uiSetupHost struct {
|
|
Name string
|
|
State string
|
|
Active bool
|
|
IsAnchor bool
|
|
// QuarantinedUntil: this machine refuses the Host's writes until then
|
|
// (quarantine.go); Release takes the owner's code.
|
|
QuarantinedUntil string
|
|
Serving bool // its loopback control port answers — add-folder goes there
|
|
Stepup bool // a second factor is enrolled for this pairing
|
|
}
|
|
|
|
// uiEnrolView is an enrolment in flight. The SECRET IS NOT IN IT: the handle
|
|
// addresses it server-side and the QR route turns it into pixels. Key is filled
|
|
// only by the one route the person has to press a button to reach.
|
|
type uiEnrolView struct {
|
|
Handle string
|
|
Machine bool
|
|
Host string
|
|
Key string
|
|
}
|
|
|
|
// uiFolderCheck is one typed path resolved against the real filesystem, before
|
|
// anything is proposed to anyone.
|
|
type uiFolderCheck struct {
|
|
Typed string
|
|
Resolved string
|
|
Class string // ok | warn | bad
|
|
Line string
|
|
Note string
|
|
}
|
|
|
|
// uiCommand is a line the person runs themselves, because this page will not.
|
|
type uiCommand struct {
|
|
Command string
|
|
How string
|
|
Why string
|
|
Class string // ok | warn | bad — bad means "this cannot be run yet"
|
|
}
|
|
|
|
// Admin is uiStatus.Admin for the setup page.
|
|
func (uiSetupView) Admin(cmd string) string { return adminCommandFor(runtime.GOOS == "windows", cmd) }
|
|
|
|
type uiSetupView struct {
|
|
Version string
|
|
Mode string
|
|
Networked bool
|
|
Hosts []uiSetupHost
|
|
Servable []uiSetupHost // active pairings: what a folder or a factor attaches to
|
|
AnchorHost string
|
|
AnchorOwner string
|
|
AnchorRequired bool
|
|
Owner string
|
|
// OwnerCode: the machine owner's code is set up (stepup_machine.go).
|
|
OwnerCode bool
|
|
// Whole: the bastion runs for the whole machine, so the owner acts that
|
|
// need the machine's administrator can be done from this page.
|
|
Whole bool
|
|
// Limited: opened from the owner's own account (consolelane.go). Setting
|
|
// up the code, connecting a Host, the Host in charge and folder proposals
|
|
// are the administrator's there.
|
|
Limited bool
|
|
|
|
// ReadError is why the pairing list could not be read — see uiGiven's copy
|
|
// of this field. Returning the bare view here showed an empty setup page
|
|
// for a machine whose records simply could not be read.
|
|
ReadError string
|
|
|
|
Flash string
|
|
FlashClass string
|
|
|
|
Enrol *uiEnrolView
|
|
Check *uiFolderCheck
|
|
Cmd *uiCommand
|
|
}
|
|
|
|
// collectSetupView re-reads the machine every time, for the same reason
|
|
// renderWith does (ui.go:336): after a pair or an enrolment the previous view
|
|
// is wrong, and a remembered one would leave the user looking at the state they
|
|
// just changed.
|
|
func (s *uiSetup) view() uiSetupView {
|
|
v := uiSetupView{
|
|
Version: clientVersion,
|
|
Mode: boxMode(),
|
|
Networked: boxMode() == modeNetworked,
|
|
}
|
|
if a := currentAnchor(); a != nil {
|
|
v.AnchorHost, v.AnchorOwner = a.AnchorHost, a.Owner
|
|
} else if v.Networked {
|
|
// anchor.go:496 — a networked box with no anchor serves nothing at all.
|
|
v.AnchorRequired = true
|
|
}
|
|
if o := currentOwnerClaim(); o != nil {
|
|
v.Owner = o.Owner
|
|
}
|
|
v.OwnerCode = stepupEnrolled(existingMachineFactorDir())
|
|
v.Whole = machineAuthorityHeld()
|
|
cfgs, err := loadPairings()
|
|
if err != nil {
|
|
v.ReadError = err.Error()
|
|
return v
|
|
}
|
|
for _, c := range cfgs {
|
|
h := uiSetupHost{
|
|
Name: c.HostName,
|
|
State: c.stateName(),
|
|
Active: c.active(),
|
|
IsAnchor: v.AnchorHost != "" && c.HostName == v.AnchorHost,
|
|
}
|
|
if until := quarantinedUntil(c.HostName, time.Now()); !until.IsZero() {
|
|
h.QuarantinedUntil = until.Local().Format("Jan 2 15:04")
|
|
}
|
|
// dcnDir() is MkdirAll-on-read (config.go:115), so asking a PENDING
|
|
// pairing whether it has a factor would create its state directory —
|
|
// the side effect runPairing guards every one of these behind
|
|
// cfg.active() to prevent (run.go:311). Rendering a page must not be
|
|
// the thing that creates state for a host the anchor has not approved.
|
|
if h.Active {
|
|
h.Stepup = stepupEnrolled(c.dcnDir())
|
|
h.Serving = bastionListening(c.ListenAddr)
|
|
}
|
|
v.Hosts = append(v.Hosts, h)
|
|
if h.Active {
|
|
v.Servable = append(v.Servable, h)
|
|
}
|
|
}
|
|
return v
|
|
}
|
|
|
|
// render mirrors ui.go's renderWith headers exactly — no-store, the same CSP
|
|
// with no 'unsafe-inline', nosniff. It is a second function rather than a call
|
|
// into that one because the view model differs; TestTheSetupPageIsHeldToTheSame
|
|
// HeadersAsTheStatusPage is what stops the two drifting apart.
|
|
func (s *uiSetup) render(w http.ResponseWriter, name string, v uiSetupView) {
|
|
v.Limited = limitedWriter(w)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; form-action 'self'")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
if err := s.u.tpl.ExecuteTemplate(w, name, v); err != nil {
|
|
uilogf("render %s: %v\n", name, err)
|
|
}
|
|
}
|
|
|
|
// flash re-reads the machine and renders the whole page body with an outcome.
|
|
func (s *uiSetup) flash(w http.ResponseWriter, msg, class string) {
|
|
v := s.view()
|
|
v.Flash, v.FlashClass = msg, class
|
|
s.fill(&v)
|
|
s.render(w, "setup", v)
|
|
}
|
|
|
|
// fill puts any enrolment in flight back into the view, so a re-render caused
|
|
// by something else does not lose the QR the person is looking at.
|
|
func (s *uiSetup) fill(v *uiSetupView) {
|
|
for _, h := range v.Hosts {
|
|
if handle, e := s.enrols.forHost(h.Name); e != nil {
|
|
v.Enrol = &uiEnrolView{Handle: handle, Host: e.Host}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *uiSetup) page(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
v := s.view()
|
|
s.fill(&v)
|
|
s.render(w, "setup-page", v)
|
|
}
|
|
|
|
// setupHost resolves a posted host name against this machine's pairings. Looked
|
|
// UP rather than trusted, for the reason approvalTarget gives (ui.go:350): a
|
|
// handler that joins a posted string onto a directory is the shape of bug that
|
|
// turns a local UI into a filesystem reach, and the pairing list is right here.
|
|
func setupHost(host string) (*Config, error) {
|
|
cfgs, err := loadPairings()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(cfgs) == 0 {
|
|
return nil, fmt.Errorf("no host is paired with this machine yet")
|
|
}
|
|
host = strings.TrimSpace(host)
|
|
if host == "" && len(cfgs) == 1 {
|
|
return cfgs[0], nil
|
|
}
|
|
if host == "" {
|
|
return nil, fmt.Errorf("this machine serves %d hosts — choose which one", len(cfgs))
|
|
}
|
|
for _, c := range cfgs {
|
|
if c.HostName == host {
|
|
return c, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("this machine does not serve %q", host)
|
|
}
|
|
|
|
// ── 1. pair ─────────────────────────────────────────────────────────────────
|
|
|
|
// uiWithoutToken keeps a pairing token out of anything the page renders.
|
|
//
|
|
// Nothing in pairWithToken's error paths echoes the token today — decodeToken
|
|
// answers "malformed token" without quoting it (pair.go:53) and the redeem
|
|
// errors name the host. But the token is single-use CREDENTIAL material that
|
|
// arrived in this request, the error text comes from six functions and a remote
|
|
// host's JSON, and the cost of being sure is four lines.
|
|
func uiWithoutToken(msg, token string) string {
|
|
if len(token) < 8 {
|
|
return msg
|
|
}
|
|
return strings.ReplaceAll(msg, token, "the token")
|
|
}
|
|
|
|
// uiPairPlan is what the RUNNING daemon must do about a pairing that just
|
|
// changed. Decided separately from being done, so the decision can be tested
|
|
// without standing up a WireGuard device.
|
|
//
|
|
// THE SUBTLETY THIS EXISTS FOR. The UI server lives inside cmdRun (run.go:131),
|
|
// and cmdRun started one runPairing goroutine per pairing at boot
|
|
// (run.go:118). Pairing from this page changes that set underneath the running
|
|
// daemon, and the CLI's answer — "Start (or restart) the tunnels" (pair.go:200)
|
|
// — is not available to a page: there is no terminal, and restarting the daemon
|
|
// from inside itself to serve one new host would drop every other host's tunnel.
|
|
//
|
|
// superviseOnce (run.go:254) already reconciles disk against what is running,
|
|
// every 15 seconds, and that is the catch-up. This plan is the same three rules
|
|
// applied AT ONCE so the person is not left looking at a pairing that does
|
|
// nothing for a quarter of a minute with no explanation:
|
|
//
|
|
// - a --replace rotated the credentials, so the lane still running for that
|
|
// host holds dead ones and must be stopped before the new one starts;
|
|
// - a --replace that lands a different host_name removes the old pairing file
|
|
// (pair.go:370), so the lane running under the OLD name must stop too;
|
|
// - a pairing written PENDING starts nothing, and says so, because that is
|
|
// what anchoring means (pair.go:186).
|
|
type uiPairPlan struct {
|
|
Stop []string
|
|
Start string // "" when the pairing is not active
|
|
Note string
|
|
Class string
|
|
}
|
|
|
|
func planPairChange(before []string, after []*Config, cfg *Config) uiPairPlan {
|
|
onDisk := map[string]bool{}
|
|
for _, c := range after {
|
|
onDisk[c.HostName] = true
|
|
}
|
|
p := uiPairPlan{}
|
|
for _, h := range before {
|
|
// Stop what is stale: a host whose file is gone (renamed away by a
|
|
// --replace), and the re-paired host itself, whose running lane holds
|
|
// the credentials this redeem has just rotated out.
|
|
if !onDisk[h] || h == cfg.HostName {
|
|
p.Stop = append(p.Stop, h)
|
|
}
|
|
}
|
|
if !cfg.active() {
|
|
p.Note = "Paired with " + cfg.HostName + " — PENDING. Nothing is started for it yet: no tunnel, no lanes."
|
|
if who := admitter(); who != "" {
|
|
p.Note = "Paired with " + cfg.HostName + " — PENDING, waiting for " + who + " to admit it. The code was used, " +
|
|
"but nothing runs for it yet: " + who + " is the Host in charge of this machine, and it asks you to " +
|
|
"approve " + cfg.HostName + ". Once you do, it is served at once."
|
|
}
|
|
p.Class = "warn"
|
|
return p
|
|
}
|
|
p.Start = cfg.HostName
|
|
p.Note = "Paired with " + cfg.HostName + "."
|
|
p.Class = "ok"
|
|
return p
|
|
}
|
|
|
|
// applyPairPlan carries the plan out against the running daemon and returns the
|
|
// outcome in the words the person gets. What it says depends on what actually
|
|
// happened, not on what was planned: supervisorStart answers false from a
|
|
// process that is not serving (run.go:174), and claiming a tunnel is open when
|
|
// it is not is the failure this whole function exists to avoid.
|
|
func applyPairPlan(p uiPairPlan, cfg *Config) (string, string) {
|
|
for _, h := range p.Stop {
|
|
stopPairing(h)
|
|
}
|
|
if p.Start == "" {
|
|
return p.Note, p.Class
|
|
}
|
|
if supervisorStart(cfg) {
|
|
return p.Note + " Its tunnel and lanes are open now — nothing to restart.", p.Class
|
|
}
|
|
return p.Note + " Its lanes open on this machine's next sweep, within 15 seconds.", p.Class
|
|
}
|
|
|
|
// pair redeems a token from the host's "Set up a bastion".
|
|
//
|
|
// The token arrives in a POST body and is never written back into the page, a
|
|
// log line or a URL: it is single-use credential material, and uiauth.go's
|
|
// whole front door exists because a secret in an address bar outlives the
|
|
// moment it was useful.
|
|
func (s *uiSetup) pair(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
token := strings.TrimSpace(r.FormValue("token"))
|
|
replace := r.FormValue("replace") != ""
|
|
if token == "" {
|
|
s.flash(w, "Paste the token from your host's “Set up a bastion”.", "warn")
|
|
return
|
|
}
|
|
before, err := loadPairings()
|
|
if err != nil {
|
|
s.flash(w, "Cannot read this machine's pairings: "+err.Error(), "bad")
|
|
return
|
|
}
|
|
cfg, all, err := pairWithToken(token, replace, false)
|
|
if err != nil {
|
|
s.flash(w, uiWithoutToken(err.Error(), token), "bad")
|
|
return
|
|
}
|
|
note, class := applyPairPlan(planPairChange(hostNames(before), all, cfg), cfg)
|
|
s.flash(w, note, class)
|
|
}
|
|
|
|
// ── 2. step-up enrolment, with a real QR ────────────────────────────────────
|
|
|
|
// stepupStart mints a secret and holds it in memory. Nothing reaches disk until
|
|
// a code proves the authenticator has it — the same order cmdStepupEnroll uses
|
|
// (stepup.go:799-803), so a scan that silently failed cannot leave the machine
|
|
// holding a factor nobody can produce a code for.
|
|
func (s *uiSetup) stepupStart(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if r.FormValue("target") == "machine" {
|
|
s.stepupStartMachine(w, r)
|
|
return
|
|
}
|
|
cfg, err := setupHost(r.FormValue("host"))
|
|
if err != nil {
|
|
s.flash(w, err.Error(), "bad")
|
|
return
|
|
}
|
|
// A PENDING OR REVOKED PAIRING GETS NOTHING, INCLUDING A DIRECTORY.
|
|
// cfg.dcnDir() is MkdirAll-on-read (config.go:115), and runPairing guards
|
|
// every one of those behind cfg.active() precisely so "the order of the code
|
|
// below can never leak a side effect for a host the anchor has not approved"
|
|
// (run.go:311-318). Enrolling a factor for a pending host would create that
|
|
// host's state dir from a page — and the factor could never be used anyway,
|
|
// because it gates dcn ops and a pending pairing has no lanes to run them on.
|
|
if !cfg.active() {
|
|
s.flash(w, cfg.HostName+" is "+cfg.stateName()+" on this machine. Nothing is created for "+
|
|
"it here until the anchor host approves the pairing — including a second factor, "+
|
|
"which gates ops it cannot run yet.", "warn")
|
|
return
|
|
}
|
|
dir := cfg.dcnDir()
|
|
if dir == "" {
|
|
s.flash(w, "Cannot open this pairing's state directory on this machine.", "bad")
|
|
return
|
|
}
|
|
// Replacing an enrolled factor is --rotate (stepup.go:767), and it stops the
|
|
// authenticator already enrolled here from working. So it takes a deliberate
|
|
// tick, the same way the flag is deliberate.
|
|
if stepupEnrolled(dir) && r.FormValue("replace") == "" {
|
|
s.flash(w, "A second factor is already enrolled for "+cfg.HostName+" on this machine. "+
|
|
"Tick “replace it” to enrol a new one — the authenticator enrolled now stops working.", "warn")
|
|
return
|
|
}
|
|
var secret [20]byte
|
|
if _, err := rand.Read(secret[:]); err != nil {
|
|
s.flash(w, "Could not generate a secret on this machine: "+err.Error(), "bad")
|
|
return
|
|
}
|
|
b32 := strings.TrimRight(base32.StdEncoding.EncodeToString(secret[:]), "=")
|
|
hn, _ := os.Hostname()
|
|
// Byte-identical to the URI the CLI builds (stepup.go:777-779) so the same
|
|
// authenticator entry appears however it was enrolled.
|
|
label := url.PathEscape("Privatae NAC:" + hn + " for " + cfg.HostName)
|
|
uri := "otpauth://totp/" + label + "?secret=" + b32 + "&issuer=" + url.QueryEscape("Privatae NAC") +
|
|
"&digits=6&period=30"
|
|
handle, err := s.enrols.put(&uiEnrol{
|
|
Host: cfg.HostName, SecretB32: b32, URI: uri, Expires: uiNow().Add(uiEnrolTTL),
|
|
})
|
|
if err != nil {
|
|
s.flash(w, "Could not start an enrolment: "+err.Error(), "bad")
|
|
return
|
|
}
|
|
v := s.view()
|
|
v.Enrol = &uiEnrolView{Handle: handle, Host: cfg.HostName}
|
|
v.Flash = "Scan the square with your authenticator app, then type a code from it below. " +
|
|
"Nothing is enrolled on this machine until that code matches."
|
|
v.FlashClass = "ok"
|
|
s.render(w, "setup", v)
|
|
}
|
|
|
|
// stepupQR renders the enrolment as a PNG.
|
|
//
|
|
// A GET, because an <img> cannot be anything else. It carries the HANDLE, never
|
|
// the secret: an address bar, a history file and a referrer header are exactly
|
|
// the places uiauth.go refuses to put credentials, and a QR is a picture of one.
|
|
// The handle is 32 random bytes, dies with the enrolment, and the route still
|
|
// takes the session and the Host check like every other one. A GET is where the
|
|
// Origin check stops applying (uiauth.go:264) and it does not matter here:
|
|
// SameSite=Strict means no cookie is attached to a cross-site <img> at all, so
|
|
// the request arrives with no session and is refused — and a cross-origin image
|
|
// is unreadable to the page that loaded it either way.
|
|
//
|
|
// The image is also no-store: a browser that cached it would hand the next
|
|
// person at this machine a picture of a live secret.
|
|
func (s *uiSetup) stepupQR(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
e := s.enrols.get(r.URL.Query().Get("e"))
|
|
if e == nil {
|
|
http.Error(w, "that enrolment has expired — start it again", http.StatusNotFound)
|
|
return
|
|
}
|
|
// qr.M is ~15% recovery, the level qrTerminal picked for the same payload
|
|
// (stepup_qr.go:38). On a screen it buys tolerance for a phone held at an
|
|
// angle; the cost is a slightly larger symbol, which a browser has room for.
|
|
c, err := qr.Encode(e.URI, qr.M)
|
|
if err != nil {
|
|
http.Error(w, "could not render the code", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// PNG() writes 4 modules of quiet zone on every side ((size+8)*scale) — the
|
|
// margin scanners need to find the symbol, and the single most common reason
|
|
// a hand-rolled QR does not scan (stepup_qr.go:22).
|
|
png := c.PNG()
|
|
w.Header().Set("Content-Type", "image/png")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
_, _ = w.Write(png)
|
|
}
|
|
|
|
// stepupKey shows the base32 key as text, and only when asked.
|
|
//
|
|
// It exists because an authenticator can be on THIS machine, where you cannot
|
|
// photograph your own screen — the one case the CLI's text fallback covers that
|
|
// a browser does not solve. Behind a button rather than in the page, so a
|
|
// secret is not in a screenshot, a screen share or a page left open; that is the
|
|
// same discipline uiauth.go applies to the ticket.
|
|
func (s *uiSetup) stepupKey(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
handle := r.FormValue("enrol")
|
|
e := s.enrols.get(handle)
|
|
if e == nil {
|
|
s.render(w, "stepup-key", uiSetupView{})
|
|
return
|
|
}
|
|
s.render(w, "stepup-key", uiSetupView{
|
|
Enrol: &uiEnrolView{Handle: handle, Host: e.Host, Key: e.SecretB32},
|
|
})
|
|
}
|
|
|
|
// stepupConfirm verifies a code against the secret held in memory and only then
|
|
// writes it. Same check and same file shape as the CLI (stepup.go:799-804) —
|
|
// stepupMatchedCounter and stepupSave, not a second implementation of either.
|
|
func (s *uiSetup) stepupConfirm(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
handle := r.FormValue("enrol")
|
|
e := s.enrols.get(handle)
|
|
if e == nil {
|
|
s.flash(w, "That enrolment has expired — start it again. Nothing was enrolled.", "warn")
|
|
return
|
|
}
|
|
code := strings.TrimSpace(r.FormValue("code"))
|
|
if code == "" {
|
|
s.flash(w, "Type a code from the authenticator you just scanned with.", "warn")
|
|
return
|
|
}
|
|
if e.Machine {
|
|
s.stepupConfirmMachine(w, handle, e, code)
|
|
return
|
|
}
|
|
cfg, err := setupHost(e.Host)
|
|
if err != nil {
|
|
s.flash(w, err.Error(), "bad")
|
|
return
|
|
}
|
|
// Re-checked here and not only at start: a pairing can be revoked by the
|
|
// anchor between scanning the square and typing the code, and the write
|
|
// below would create its state dir.
|
|
if !cfg.active() {
|
|
s.enrols.drop(handle)
|
|
s.flash(w, cfg.HostName+" became "+cfg.stateName()+" while you were enrolling, so "+
|
|
"nothing was written. Nothing is created for it here until the anchor host "+
|
|
"approves the pairing.", "warn")
|
|
return
|
|
}
|
|
dir := cfg.dcnDir()
|
|
if dir == "" {
|
|
s.flash(w, "Cannot open this pairing's state directory on this machine.", "bad")
|
|
return
|
|
}
|
|
if stepupMatchedCounter(e.SecretB32, code, time.Now()) < 0 {
|
|
s.flash(w, "That code did not match — nothing was enrolled. Check the app is showing a "+
|
|
"code for “Privatae NAC”, and try the next one.", "bad")
|
|
return
|
|
}
|
|
if err := stepupSave(dir, &stepupFile{
|
|
SecretB32: e.SecretB32,
|
|
EnrolledAt: time.Now().UTC().Format(time.RFC3339),
|
|
LastCounter: -1,
|
|
}); err != nil {
|
|
s.flash(w, "Could not write the factor to this machine: "+err.Error(), "bad")
|
|
return
|
|
}
|
|
s.enrols.drop(handle)
|
|
s.flash(w, "Enrolled for "+cfg.HostName+". From now on that host can only reach a folder or "+
|
|
"move files here after you approve the request ON THIS MACHINE — the approval appears on "+
|
|
"the front page and your host never sees the code.", "ok")
|
|
}
|
|
|
|
// ── 3. the folder picker ────────────────────────────────────────────────────
|
|
|
|
// uiCheckFolder resolves a typed path exactly the way the daemon will
|
|
// (addfolder.go:122 — expandHome then filepath.Abs) and reports what is
|
|
// actually there. It is ADVISORY: the rule that decides is dcnProposeLocal's,
|
|
// and this must never be able to disagree with it about a path.
|
|
//
|
|
// filepath.IsAbs is the platform's own answer, and this runs on the machine
|
|
// whose filesystem it is describing, so the Windows question is asked by a
|
|
// Windows binary.
|
|
func uiCheckFolder(typed string) uiFolderCheck {
|
|
c := uiFolderCheck{Typed: typed}
|
|
typed = strings.TrimSpace(typed)
|
|
if typed == "" {
|
|
c.Class, c.Line = "warn", "Type the folder's full path."
|
|
return c
|
|
}
|
|
expanded := expandHome(typed)
|
|
abs, err := filepath.Abs(expanded)
|
|
if err != nil {
|
|
c.Class, c.Line = "bad", "That is not a path this machine can resolve."
|
|
return c
|
|
}
|
|
c.Resolved = abs
|
|
// A RELATIVE path is refused here and nowhere else, and the reason is the
|
|
// browser. filepath.Abs resolves against the process's working directory,
|
|
// and this process is a daemon: under systemd that is "/", so "Documents"
|
|
// becomes "/Documents" and "etc" becomes "/etc" — which exists. The CLI has
|
|
// the same behaviour and it is harmless there, because the working directory
|
|
// is the shell the person is standing in. On a page there is no such thing to
|
|
// reason about, so a relative path is a guess about what someone meant.
|
|
if !filepath.IsAbs(expanded) {
|
|
c.Class = "bad"
|
|
c.Line = "Give the full path, starting at the top of the disk."
|
|
c.Note = "Typed as it is, this machine reads it against the bastion's own working " +
|
|
"directory and gets " + abs + " — which is almost certainly not the folder you mean."
|
|
return c
|
|
}
|
|
info, err := os.Stat(abs)
|
|
switch {
|
|
case err != nil:
|
|
c.Class, c.Line = "bad", "There is nothing at that path on this machine."
|
|
case !info.IsDir():
|
|
c.Class, c.Line = "bad", "That is a file, not a folder."
|
|
default:
|
|
c.Class, c.Line = "ok", "A folder on this machine."
|
|
// Listing it is not part of the daemon's rule, so this is a note and
|
|
// never a refusal. It is worth saying now because the alternative is
|
|
// finding out weeks later, when the host asks for a file and cannot
|
|
// have it.
|
|
if f, err := os.Open(abs); err != nil {
|
|
c.Class = "warn"
|
|
c.Note = "The bastion cannot open it (" + err.Error() + "), so it would have nothing to index."
|
|
} else {
|
|
_, rerr := f.Readdirnames(1)
|
|
_ = f.Close()
|
|
if rerr != nil && rerr != io.EOF {
|
|
c.Class = "warn"
|
|
c.Note = "The bastion cannot list what is inside it (" + rerr.Error() + ")."
|
|
}
|
|
}
|
|
}
|
|
return c
|
|
}
|
|
|
|
func (s *uiSetup) folderCheck(w http.ResponseWriter, r *http.Request) {
|
|
// POST for a read, on purpose: a folder path is the person's own business
|
|
// and a GET would put it in a query string. It costs nothing — this route
|
|
// changes nothing — and it means the Origin check applies here too.
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
c := uiCheckFolder(r.FormValue("path"))
|
|
s.render(w, "folder-check", uiSetupView{Check: &c})
|
|
}
|
|
|
|
// folderPropose posts the draft to the SAME loopback endpoint add-folder posts
|
|
// to (addfolder.go:66). Not a second call to dcnProposeLocal's internals and
|
|
// not a reimplementation of its checks: the daemon's own validation, its own
|
|
// bearer, its own upstream call. If the two ever disagree about a path it will
|
|
// be because someone changed one of them, and there is only one of them.
|
|
func (s *uiSetup) folderPropose(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
cfg, err := setupHost(r.FormValue("host"))
|
|
if err != nil {
|
|
s.flash(w, err.Error(), "bad")
|
|
return
|
|
}
|
|
path := strings.TrimSpace(r.FormValue("path"))
|
|
if c := uiCheckFolder(path); c.Class == "bad" {
|
|
line := c.Line
|
|
if c.Note != "" {
|
|
line += " " + c.Note
|
|
}
|
|
s.flash(w, "Nothing was proposed: "+line, "bad")
|
|
return
|
|
}
|
|
if !cfg.active() {
|
|
s.flash(w, cfg.HostName+" is "+cfg.stateName()+" on this machine, so it has no lanes open — "+
|
|
"a folder cannot be proposed to it until the anchor host approves the pairing.", "bad")
|
|
return
|
|
}
|
|
body, _ := json.Marshal(map[string]string{
|
|
"root": path, "display_name": strings.TrimSpace(r.FormValue("name")),
|
|
})
|
|
req, err := http.NewRequest(http.MethodPost,
|
|
"http://"+cfg.ListenAddr+"/dcn/propose", bytes.NewReader(body))
|
|
if err != nil {
|
|
s.flash(w, err.Error(), "bad")
|
|
return
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+cfg.OperatorSecret)
|
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
|
if err != nil {
|
|
// The CLI's wording here is "the bastion daemon isn't running"
|
|
// (addfolder.go:76), which cannot be what happened: this page is served
|
|
// BY that daemon. What it means from here is that this host's own lane
|
|
// is down.
|
|
s.flash(w, "This machine is not answering for "+cfg.HostName+" on "+cfg.ListenAddr+
|
|
", so nothing was proposed. Its tunnel and lanes are not up.", "bad")
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
s.flash(w, "Nothing was proposed: "+strings.TrimSpace(string(raw)), "bad")
|
|
return
|
|
}
|
|
abs, _ := filepath.Abs(expandHome(path))
|
|
s.flash(w, "Draft created for "+abs+". Your files stay on this machine — your host keeps only "+
|
|
"the index. Nothing is granted yet: approve the draft on your host (Context Manager → "+
|
|
"Storage → Your network) with your 6-digit code.", "ok")
|
|
}
|
|
|
|
// ── 4. the anchor picker ────────────────────────────────────────────────────
|
|
|
|
// uiAnchorCommandFor writes the claim-anchor line with the chosen host in it.
|
|
//
|
|
// TAKES THE PLATFORM AS AN ARGUMENT for the reason restartHintFor states
|
|
// (privilege.go:88): a platform branch nothing off-platform can reach is a
|
|
// platform branch nothing checks, and the only machine that could ever test the
|
|
// Windows string would be a Windows machine.
|
|
//
|
|
// It cannot simply append to anchorCommandHintFor (privilege.go:58) because the
|
|
// Windows form of that ends in a parenthetical saying where to run it, and a
|
|
// flag after that reads as part of the sentence. The two are pinned together by
|
|
// a test instead.
|
|
func uiAnchorCommandFor(goos, host string) (cmd, how string) {
|
|
if goos == "windows" {
|
|
return "privatae-bastion claim-anchor --host " + host,
|
|
"Run it from an Administrator terminal (right-click → Run as administrator)."
|
|
}
|
|
return "sudo -H privatae-bastion claim-anchor --host " + host,
|
|
"Run it in a terminal on this machine."
|
|
}
|
|
|
|
// anchorChoose turns "which host holds the anchor" into a click, checks the
|
|
// choice against the rules claim-anchor will apply, and writes out the line.
|
|
//
|
|
// It performs nothing. See this file's header for why, at length.
|
|
func (s *uiSetup) anchorChoose(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
cmdView := func(c uiCommand) { s.render(w, "anchor-cmd", uiSetupView{Cmd: &c}) }
|
|
cfg, err := setupHost(r.FormValue("host"))
|
|
if err != nil {
|
|
cmdView(uiCommand{Class: "bad", Why: err.Error()})
|
|
return
|
|
}
|
|
// Every rule below is one claim-anchor enforces, checked here so the person
|
|
// is not sent to a terminal to be refused. None of them is a new rule.
|
|
if boxMode() != modeNetworked {
|
|
// anchor.go:827. set-mode is root too (mode.go), so both lines are given
|
|
// in the order they have to be run.
|
|
cmdView(uiCommand{
|
|
Class: "bad",
|
|
Command: setModeCommandHint(),
|
|
How: adminHowTo() + ", then claim the anchor.",
|
|
Why: "This machine is in local mode, where it serves only the Hosts you said yes to here and needs no anchor. " +
|
|
"An anchor only means something on a networked bastion.",
|
|
})
|
|
return
|
|
}
|
|
if prev := currentAnchor(); prev != nil {
|
|
// anchor.go:835. Moving it is --rotate, which the design keeps in the
|
|
// CLI on purpose: rare, consequential, and it deserves friction.
|
|
// The line is written with <token> left in it, the way main.go:55 prints
|
|
// it: a rotation names a host this box does not serve, so that host has
|
|
// to issue the token and this page cannot know it.
|
|
rotate := "sudo -H privatae-bastion claim-anchor --rotate <token>"
|
|
how := "Run it in a terminal on this machine, with a token from the host taking the anchor."
|
|
if runtime.GOOS == "windows" {
|
|
rotate = "privatae-bastion claim-anchor --rotate <token>"
|
|
how = "Run it from an Administrator terminal, with a token from the host taking the anchor."
|
|
}
|
|
cmdView(uiCommand{
|
|
Class: "warn",
|
|
Command: rotate,
|
|
How: how,
|
|
Why: "This machine is already anchored to " + prev.AnchorHost + ". Moving the anchor " +
|
|
"to another host needs that anchor's signed consent, or 14 days of silence from it.",
|
|
})
|
|
return
|
|
}
|
|
if cfg.State == pairingRevoked {
|
|
// anchor.go:910 — revoked is a decision, not a queue.
|
|
cmdView(uiCommand{
|
|
Class: "bad",
|
|
Why: cfg.HostName + " is revoked on this machine. Claiming the anchor will not " +
|
|
"reinstate it — re-pair that host first.",
|
|
})
|
|
return
|
|
}
|
|
cmd, how := uiAnchorCommandFor(runtime.GOOS, cfg.HostName)
|
|
cmdView(uiCommand{
|
|
Class: "ok",
|
|
Command: cmd,
|
|
How: how,
|
|
Why: "From then on every host paired here waits, pending, until " + cfg.HostName +
|
|
" approves it. The hosts already paired keep the lanes they have.",
|
|
})
|
|
}
|
|
|
|
// ── 5. the owner name ───────────────────────────────────────────────────────
|
|
|
|
// uiShellSafeName reports whether a name survives being put in double quotes on
|
|
// a command line unchanged. A name that does not is not refused — it is a
|
|
// perfectly good owner name — but this page will not hand someone a line that
|
|
// does something other than what it says.
|
|
func uiShellSafeName(name string) bool { return !strings.ContainsAny(name, "\"\\$`") }
|
|
|
|
// ownerName validates the name with the same function the command uses and
|
|
// writes out the line. Root-gated like the anchor (owner.go:83), and not
|
|
// performed here for the same reasons.
|
|
func (s *uiSetup) ownerName(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
cmdView := func(c uiCommand) { s.render(w, "owner-cmd", uiSetupView{Cmd: &c}) }
|
|
name := strings.TrimSpace(r.FormValue("owner"))
|
|
if machineAuthorityHeld() && stepupEnrolled(existingMachineFactorDir()) {
|
|
s.ownerNameHere(w, name, r.FormValue("code"))
|
|
return
|
|
}
|
|
// claimNameOK (anchor.go:121) is the real rule: it is what stops a name
|
|
// being read as more than one field of the signed input.
|
|
if err := claimNameOK(name); err != nil {
|
|
cmdView(uiCommand{Class: "bad", Why: "That name will be refused: " + err.Error() + "."})
|
|
return
|
|
}
|
|
if !uiShellSafeName(name) {
|
|
cmdView(uiCommand{Class: "warn", Why: "That name is fine, but it contains a character a " +
|
|
"shell would read (a quote, a backslash, a $ or a backtick), so this page will not " +
|
|
"write the line for you — type it with the name quoted the way your shell needs."})
|
|
return
|
|
}
|
|
// `sudo -H`, not the bare `sudo` in main.go's usage text. -H is what keeps
|
|
// HOME pointing at root's own config dir, and claim-owner WRITES there
|
|
// (owner.go:49 → configDir): a sudo without it writes the claim into the
|
|
// calling user's directory, which is the same hazard cmdRun warns about for
|
|
// pair (run.go:73-77). adminHowTo (privilege.go:31) already says -H, and it
|
|
// is what the refusal the person would otherwise see says.
|
|
cmd := "sudo -H privatae-bastion claim-owner \"" + name + "\""
|
|
how := "Run it in a terminal on this machine."
|
|
if runtime.GOOS == "windows" {
|
|
cmd = "privatae-bastion claim-owner \"" + name + "\""
|
|
how = "Run it from an Administrator terminal (right-click → Run as administrator)."
|
|
}
|
|
cmdView(uiCommand{
|
|
Class: "ok", Command: cmd, How: how,
|
|
Why: "It travels on the next check-in to every host this machine serves and shows as the " +
|
|
"owner on this machine's card. It grants nothing anywhere.",
|
|
})
|
|
}
|
|
|
|
// uiSetupTemplates is the assertion that the page's templates exist, used by
|
|
// the tests so a rename of one of them fails loudly rather than rendering
|
|
// nothing into a browser.
|
|
func uiSetupTemplates() []string {
|
|
return []string{"setup-page", "setup", "folder-check", "stepup-key", "anchor-cmd", "owner-cmd"}
|
|
}
|
|
|
|
// ── the owner's own code ────────────────────────────────────────────────────
|
|
|
|
// stepupStartMachine is stepupStart for the machine owner's code: the same
|
|
// in-memory secret, QR and confirm-before-write, written to the owner's
|
|
// factor directory (stepup_machine.go) instead of a pairing's. It needs no
|
|
// Host, which is the point: on a machine with none it is the only code there is.
|
|
func (s *uiSetup) stepupStartMachine(w http.ResponseWriter, r *http.Request) {
|
|
if stepupEnrolled(existingMachineFactorDir()) && r.FormValue("replace") == "" {
|
|
s.flash(w, "Your code is already set up on this machine. Tick “replace it” to set up a new one — "+
|
|
"the authenticator set up now stops working.", "warn")
|
|
return
|
|
}
|
|
var secret [20]byte
|
|
if _, err := rand.Read(secret[:]); err != nil {
|
|
s.flash(w, "Could not generate a secret on this machine: "+err.Error(), "bad")
|
|
return
|
|
}
|
|
b32 := strings.TrimRight(base32.StdEncoding.EncodeToString(secret[:]), "=")
|
|
hn, _ := os.Hostname()
|
|
// The same URI the CLI builds for --machine (stepup.go cmdStepupEnroll).
|
|
uri := "otpauth://totp/" + url.PathEscape("Privatae bastion:"+hn) + "?secret=" + b32 +
|
|
"&issuer=" + url.QueryEscape("Privatae bastion") + "&digits=6&period=30"
|
|
handle, err := s.enrols.put(&uiEnrol{Machine: true, SecretB32: b32, URI: uri, Expires: uiNow().Add(uiEnrolTTL)})
|
|
if err != nil {
|
|
s.flash(w, "Could not start setting up your code: "+err.Error(), "bad")
|
|
return
|
|
}
|
|
v := s.view()
|
|
v.Enrol = &uiEnrolView{Handle: handle, Machine: true}
|
|
v.Flash = "Scan the square with your authenticator app, then type a code from it below. " +
|
|
"Nothing is written to this machine until that code matches."
|
|
v.FlashClass = "ok"
|
|
s.render(w, "setup", v)
|
|
}
|
|
|
|
func (s *uiSetup) stepupConfirmMachine(w http.ResponseWriter, handle string, e *uiEnrol, code string) {
|
|
if stepupMatchedCounter(e.SecretB32, code, time.Now()) < 0 {
|
|
s.flash(w, "That code did not match — nothing was set up. Check the app is showing a code for "+
|
|
"“Privatae bastion”, and try the next one.", "bad")
|
|
return
|
|
}
|
|
dir, err := machineFactorDir()
|
|
if err != nil {
|
|
s.flash(w, "Could not open this machine's place for your code: "+err.Error(), "bad")
|
|
return
|
|
}
|
|
if err := stepupSave(dir, &stepupFile{
|
|
SecretB32: e.SecretB32,
|
|
EnrolledAt: time.Now().UTC().Format(time.RFC3339),
|
|
LastCounter: -1,
|
|
}); err != nil {
|
|
s.flash(w, "Could not write your code to this machine: "+err.Error(), "bad")
|
|
return
|
|
}
|
|
s.enrols.drop(handle)
|
|
s.flash(w, "Your code is set up. Stopping an agent, marking one as yours, and answering what is "+
|
|
"asked of this machine now take a code from that app.", "ok")
|
|
}
|