445 lines
17 KiB
Go
445 lines
17 KiB
Go
package main
|
|
|
|
// uicontext.go — the routes of the user-run context surface (contextui.go).
|
|
//
|
|
// There are five, and the shape of the list is the point: three reads, one walk
|
|
// of the person's own directory, and one ASK. Nothing here grants anything.
|
|
//
|
|
// The ask is the interesting one. Handing a folder to a bastion agent is not
|
|
// something this process can do on its own authority, and it must not be: a
|
|
// folder becomes a context store only when the paired host approves the draft
|
|
// with a step-up code (addfolder.go:13-15, "the draft lands INERT"). So this
|
|
// surface does exactly what the `add-folder` verb does — validate locally,
|
|
// where the box can actually check, and forward a draft — and then says so
|
|
// plainly. The person sees "asked for", never "done".
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func (u *uiServer) contextRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("/fragments/context", u.guarded(u.contextFragment))
|
|
mux.HandleFunc("/context/scan", u.guarded(u.contextScan))
|
|
mux.HandleFunc("/context/stop", u.guarded(u.contextStop))
|
|
mux.HandleFunc("/context/ask", u.guarded(u.contextAsk))
|
|
mux.HandleFunc("/", u.root)
|
|
}
|
|
|
|
// uiContext is the page. It carries the map view whole rather than re-deriving
|
|
// it, because the treemap, the escaping and the accounting of what the walk
|
|
// could NOT read are the same problem on both surfaces and solving it twice is
|
|
// how the two drift apart.
|
|
type uiContext struct {
|
|
Version string
|
|
Home string
|
|
Map uiDiskMap
|
|
|
|
// Held is the context that ALREADY EXISTS on this machine — the folders a
|
|
// host has been granted and is indexing. Without it a page called "Your
|
|
// context" showed a file browser and a list of requests, and answered every
|
|
// question except the one its title asks.
|
|
Held []uiContextHeld
|
|
// HeldNote is why Held is empty or partial, in the box's own words. A page
|
|
// that shows nothing and says nothing reads as "you have no context", which
|
|
// on a machine whose records this process cannot read is a lie.
|
|
HeldNote string
|
|
|
|
// Asks is what this person has asked for, newest first, and it OUTLIVES the
|
|
// window (contextAsksPath). It was session-only in memory at first, which
|
|
// meant closing the page erased the only record that you had asked for
|
|
// something at all — the request is still out there waiting on somebody,
|
|
// and the person who made it could no longer see that they made it.
|
|
Asks []uiContextAsk
|
|
|
|
Flash string
|
|
FlashClass string
|
|
}
|
|
|
|
// uiContextAsk is one folder handed to a host, and what happened to the asking.
|
|
type uiContextAsk struct {
|
|
Path string
|
|
Name string
|
|
Host string
|
|
At string
|
|
// Sent distinguishes "the host has it" from "this machine could not even
|
|
// pass it on". The second is not a failure of the request, and saying so
|
|
// matters: the person did nothing wrong and there is nothing for them to
|
|
// retry until the machine can reach its host.
|
|
Sent bool
|
|
Note string
|
|
}
|
|
|
|
// uiContextHeld is one folder that IS context: a host holds a signed grant for
|
|
// it and the agent indexes it here.
|
|
type uiContextHeld struct {
|
|
Host string
|
|
Root string
|
|
What []string // the mask, in plain words
|
|
Sweep string
|
|
Expires string
|
|
Readable bool
|
|
StoreID string
|
|
}
|
|
|
|
// collectHeldContext reads what is already context on this machine.
|
|
//
|
|
// It reuses uiFoldersFor (uigiven.go) rather than re-reading the store file,
|
|
// because two readers of the same record drift and the admin console's version
|
|
// is the one with the pending/revoked guard in it — asking a non-active pairing
|
|
// for its directory CREATES that directory (config.go:115), which is the exact
|
|
// defect an earlier fix closed.
|
|
func collectHeldContext() ([]uiContextHeld, string) {
|
|
ps, err := loadPairings()
|
|
if err != nil {
|
|
return nil, "This machine's pairing records cannot be read from here (" + err.Error() +
|
|
"), so this page cannot say what is already context. That is a limit of what " +
|
|
"this window can see, not a statement that nothing is shared."
|
|
}
|
|
var out []uiContextHeld
|
|
var notes []string
|
|
for _, c := range ps {
|
|
if !c.active() {
|
|
continue
|
|
}
|
|
folders, why := uiFoldersFor(c, true)
|
|
if why != "" {
|
|
notes = append(notes, c.HostName+": "+why)
|
|
continue
|
|
}
|
|
for _, f := range folders {
|
|
out = append(out, uiContextHeld{
|
|
Host: c.HostName, Root: f.Root, What: f.MaskWords,
|
|
Sweep: f.Sweep, Expires: f.Expires,
|
|
Readable: f.Readable, StoreID: f.StoreID,
|
|
})
|
|
}
|
|
}
|
|
if len(out) == 0 && len(notes) == 0 {
|
|
return nil, "Nothing on this machine is context yet. Pick a folder below and ask."
|
|
}
|
|
return out, strings.Join(notes, " ")
|
|
}
|
|
|
|
func (u *uiServer) contextPage(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
u.renderContext(w, "context.html", r.URL.Query().Get("p"), "", "")
|
|
}
|
|
|
|
func (u *uiServer) contextFragment(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
u.renderContext(w, "context", r.URL.Query().Get("p"), "", "")
|
|
}
|
|
|
|
// contextScan walks the person's own directory. POST, so the Origin check
|
|
// applies: a walk is the one thing on this page that makes the machine work.
|
|
func (u *uiServer) contextScan(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
// ALWAYS the home scope, and it is not a parameter. There is no form value
|
|
// to widen here because there is nothing to widen TO: this process cannot
|
|
// read other people's files, so a whole-machine option would be a button
|
|
// that produced a map full of "permission denied" and told the person
|
|
// something false about what this surface is for.
|
|
if diskMap.dmStart(dmScopeHome) {
|
|
u.renderContext(w, "context", r.FormValue("p"),
|
|
"Counting up your files. It keeps going if you leave this page.", "ok")
|
|
return
|
|
}
|
|
u.renderContext(w, "context", r.FormValue("p"), "Already counting — this is it.", "warn")
|
|
}
|
|
|
|
func (u *uiServer) contextStop(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
diskMap.dmStop()
|
|
u.renderContext(w, "context", r.FormValue("p"),
|
|
"Stopped. What it had already counted is kept and is labelled as partial.", "warn")
|
|
}
|
|
|
|
// contextAsk forwards a draft store to a paired host. It is the ONLY route on
|
|
// this surface that leaves the machine.
|
|
func (u *uiServer) contextAsk(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
path := strings.TrimSpace(r.FormValue("path"))
|
|
name := strings.TrimSpace(r.FormValue("name"))
|
|
|
|
if why := u.contextRefuseOutsideHome(path); why != "" {
|
|
u.renderContext(w, "context", r.FormValue("p"), why, "bad")
|
|
return
|
|
}
|
|
// The same local validation the CLI verb does, in the same words: this box
|
|
// can actually check a path, and the host cannot.
|
|
if c := uiCheckFolder(path); c.Class == "bad" {
|
|
line := c.Line
|
|
if c.Note != "" {
|
|
line += " " + c.Note
|
|
}
|
|
u.renderContext(w, "context", r.FormValue("p"), "Nothing was asked for: "+line, "bad")
|
|
return
|
|
}
|
|
|
|
ask := uiContextAsk{Path: path, Name: name, At: uiNow().Format(uiEventTime)}
|
|
// THE SOCKET FIRST, THE DIRECT PATH SECOND, and the order is the whole
|
|
// design. If the daemon is serving a context socket, it can forward on this
|
|
// person's behalf with their identity read from the kernel — which is the
|
|
// only route that works when the daemon is not theirs. If it is not (an
|
|
// older bastion, a platform without it, or simply not running), the direct
|
|
// path is tried, and that one works exactly when the daemon IS theirs.
|
|
// Between them they cover both machines, and neither invents an identity.
|
|
sent, host, note, available := contextAskViaSock(path, name)
|
|
if !available {
|
|
sent, host, note = contextForwardDraft(path, name)
|
|
}
|
|
ask.Sent, ask.Host, ask.Note = sent, host, note
|
|
u.contextRecord(ask)
|
|
|
|
if sent {
|
|
u.renderContext(w, "context", r.FormValue("p"),
|
|
"Asked "+host+" for "+filepath.Base(path)+". It is not shared yet — somebody "+
|
|
"has to approve it there with a code before anything is read.", "ok")
|
|
return
|
|
}
|
|
u.renderContext(w, "context", r.FormValue("p"), note, "bad")
|
|
}
|
|
|
|
// contextRefuseOutsideHome is the one check this file makes about WHICH folder,
|
|
// and it is a courtesy rather than a control.
|
|
//
|
|
// The control is the kernel: this process runs as the person, so a path they
|
|
// cannot read produces a draft the bastion agent could never index, and the
|
|
// grant the host signs is enforced on the box against the same uid. What this
|
|
// adds is an honest refusal at the point of asking, instead of a request that
|
|
// travels to a host, waits for somebody to approve it, and only then fails.
|
|
func (u *uiServer) contextRefuseOutsideHome(path string) string {
|
|
if strings.TrimSpace(path) == "" {
|
|
return "No folder was named."
|
|
}
|
|
abs, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return "That path cannot be resolved: " + err.Error()
|
|
}
|
|
home := u.contextHome
|
|
if home == "" {
|
|
return "This surface does not know which directory is yours, so it will not " +
|
|
"forward anything."
|
|
}
|
|
if abs != home && !strings.HasPrefix(abs, ensureSep(home)) {
|
|
return "That folder is outside " + home + ". This page only offers your own " +
|
|
"files; a folder belonging to the machine as a whole is the machine " +
|
|
"administrator's to share, from `privatae-bastion ui`."
|
|
}
|
|
// A folder you cannot read is not one you can hand over. Checked HERE, as
|
|
// this user, which is the only place the answer is authoritative.
|
|
f, err := os.Open(abs)
|
|
if err != nil {
|
|
return "That folder cannot be opened by you: " + err.Error()
|
|
}
|
|
_ = f.Close()
|
|
return ""
|
|
}
|
|
|
|
// contextForwardDraft posts the draft to the running daemon, exactly as the
|
|
// `add-folder` verb and the setup page do.
|
|
//
|
|
// WHEN THIS CANNOT WORK, AND WHY THAT IS THE HONEST ANSWER FOR NOW. The
|
|
// daemon's loopback endpoint is guarded by the OperatorSecret, which lives in
|
|
// the daemon's own pairing file at 0600 (config.go). A person running this verb
|
|
// as themselves on a machine whose daemon runs as root cannot read it — and
|
|
// must not be able to, because that secret also opens the git-credential lane.
|
|
//
|
|
// So on a single-user machine, where the daemon is this person's own, the ask
|
|
// goes through. On a networked bastion it cannot yet, and the page says so in
|
|
// those words rather than failing vaguely. Closing that gap is a local socket
|
|
// where the daemon reads the asking user's identity from the KERNEL rather than
|
|
// from anything the client says — deliberately not bolted on here, because a
|
|
// new transport with a new identity check deserves its own review.
|
|
func contextForwardDraft(path, name string) (sent bool, host string, note string) {
|
|
return contextForwardDraftAs(path, name, "")
|
|
}
|
|
|
|
// contextForwardDraftAs is the same forward with the asking user named.
|
|
//
|
|
// `asUser` is EMPTY when this process is the one that can reach the daemon
|
|
// directly — it is then the person's own daemon and the ask is theirs by
|
|
// construction. It is set only by the socket path (contextsock.go), where the
|
|
// daemon is forwarding on somebody else's behalf and the name came from the
|
|
// KERNEL, never from the request. The display name carries it so the person
|
|
// approving on the host can see who asked without this machine having to be
|
|
// believed about it.
|
|
func contextForwardDraftAs(path, name, asUser string) (sent bool, host string, note string) {
|
|
ps, err := loadPairings()
|
|
if err != nil {
|
|
return false, "", "This machine's pairing records cannot be read from here (" +
|
|
err.Error() + "), so there is no host to ask. On a machine where the bastion " +
|
|
"runs as an administrator, ask its administrator — or run this on your own machine."
|
|
}
|
|
var cfg *Config
|
|
for _, p := range ps {
|
|
if p.active() {
|
|
cfg = p
|
|
break
|
|
}
|
|
}
|
|
if cfg == nil {
|
|
return false, "", "No host is paired with this machine yet, so there is nobody to " +
|
|
"ask. Pairing happens in the machine's own console."
|
|
}
|
|
display := name
|
|
if asUser != "" {
|
|
// Named in the draft rather than in a side channel: the host shows the
|
|
// display name to whoever approves, and "who asked" is the first thing
|
|
// they need. The bastion's own hostname already travels as `server`.
|
|
if display == "" {
|
|
display = filepath.Base(path)
|
|
}
|
|
display += " (asked by " + asUser + ")"
|
|
}
|
|
body, _ := json.Marshal(map[string]string{"root": path, "display_name": display})
|
|
req, err := http.NewRequest(http.MethodPost,
|
|
"http://"+cfg.ListenAddr+"/dcn/propose", bytes.NewReader(body))
|
|
if err != nil {
|
|
return false, cfg.HostName, err.Error()
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+cfg.OperatorSecret)
|
|
cl := &http.Client{Timeout: 20 * time.Second}
|
|
resp, err := cl.Do(req)
|
|
if err != nil {
|
|
return false, cfg.HostName, "The bastion on this machine did not answer (" +
|
|
err.Error() + "), so nothing was sent. It may not be running."
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode/100 != 2 {
|
|
b := make([]byte, 400)
|
|
n, _ := resp.Body.Read(b)
|
|
return false, cfg.HostName, fmt.Sprintf("The bastion refused to forward it (%d): %s",
|
|
resp.StatusCode, strings.TrimSpace(string(b[:n])))
|
|
}
|
|
return true, cfg.HostName, ""
|
|
}
|
|
|
|
// ── the asks a person has made ──────────────────────────────────────────────
|
|
|
|
// THE ASKS OUTLIVE THE WINDOW, and the first version did not.
|
|
//
|
|
// They were kept in memory on the server object, with a comment arguing that
|
|
// the durable record lives on the host and a second copy here would be a second
|
|
// thing to be wrong. That reasoning was about the GRANT, and it answered the
|
|
// wrong question. A request that is waiting on somebody else is not a grant —
|
|
// it is a thing the person did, and closing the window erased the only evidence
|
|
// they had done it. They are then left asking a host they cannot see whether
|
|
// they already asked.
|
|
//
|
|
// So this is written in the asking user's OWN config dir, by the process that
|
|
// runs as them. It is a record of what YOU asked for, not a copy of what the
|
|
// host decided; the host remains the only authority on the second.
|
|
func contextAsksPath() (string, error) {
|
|
d, err := configDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(d, "context-asks.json"), nil
|
|
}
|
|
|
|
const contextAsksKept = 50
|
|
|
|
func (u *uiServer) contextRecord(a uiContextAsk) {
|
|
u.contextMu.Lock()
|
|
defer u.contextMu.Unlock()
|
|
u.contextAsks = append([]uiContextAsk{a}, u.contextAsks...)
|
|
if len(u.contextAsks) > contextAsksKept {
|
|
u.contextAsks = u.contextAsks[:contextAsksKept]
|
|
}
|
|
// Best effort, and deliberately not fatal: failing to write the history of
|
|
// a request must never fail the request itself, which has already reached
|
|
// the host by the time this runs.
|
|
p, err := contextAsksPath()
|
|
if err != nil {
|
|
return
|
|
}
|
|
b, err := json.MarshalIndent(u.contextAsks, "", " ")
|
|
if err != nil {
|
|
return
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
|
return
|
|
}
|
|
if err := os.WriteFile(p, b, 0o600); err == nil {
|
|
// Re-chmod for the reason uiauth.go:108-111 gives: os.WriteFile applies
|
|
// its mode only on CREATE, so a file first written under a loose umask
|
|
// keeps those permissions for ever. This one names folders a person
|
|
// asked to share.
|
|
_ = os.Chmod(p, 0o600)
|
|
}
|
|
}
|
|
|
|
// contextLoadAsks reads the history back at start-up. An unreadable file is not
|
|
// an error the person needs: it means the list shows nothing, which is the same
|
|
// thing it showed before this existed.
|
|
func (u *uiServer) contextLoadAsks() {
|
|
p, err := contextAsksPath()
|
|
if err != nil {
|
|
return
|
|
}
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var out []uiContextAsk
|
|
if err := json.Unmarshal(b, &out); err != nil {
|
|
return
|
|
}
|
|
u.contextMu.Lock()
|
|
defer u.contextMu.Unlock()
|
|
u.contextAsks = out
|
|
}
|
|
|
|
func (u *uiServer) contextAsked() []uiContextAsk {
|
|
u.contextMu.Lock()
|
|
defer u.contextMu.Unlock()
|
|
return append([]uiContextAsk(nil), u.contextAsks...)
|
|
}
|
|
|
|
// renderContext sets the SAME four headers renderWith and renderDiskMap do. The
|
|
// duplication is made safe by the test that compares the responses header by
|
|
// header, for the reason uidiskmap.go gives.
|
|
func (u *uiServer) renderContext(w http.ResponseWriter, name, path, flash, class string) {
|
|
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")
|
|
held, heldNote := collectHeldContext()
|
|
v := uiContext{
|
|
Version: clientVersion,
|
|
Home: u.contextHome,
|
|
Map: collectDiskMap(path),
|
|
Held: held,
|
|
HeldNote: heldNote,
|
|
Asks: u.contextAsked(),
|
|
Flash: flash, FlashClass: class,
|
|
}
|
|
if err := u.tpl.ExecuteTemplate(w, name, v); err != nil {
|
|
uilogf("render %s: %v\n", name, err)
|
|
}
|
|
}
|