159 lines
5.6 KiB
Go
159 lines
5.6 KiB
Go
package main
|
|
|
|
// "Add folders to your network" — DCN store onboarding, CLI-first.
|
|
//
|
|
// The production bastion is this binary (the enrollment flow installs it; there is
|
|
// no local dashboard), so the onboarding front door is a CLI verb:
|
|
//
|
|
// privatae-bastion add-folder <path> [display name]
|
|
//
|
|
// The verb talks to the RUNNING daemon's loopback endpoint (POST /dcn/propose,
|
|
// OperatorSecret-guarded like /git-credential); the daemon validates the folder locally (this
|
|
// box can actually check, unlike the host) and forwards the draft over the live
|
|
// mTLS tunnel to the host's /api/network/nac/dcn/propose. The draft lands INERT —
|
|
// the user approves it in their host (Context Manager → Storage → Your network) with
|
|
// their step-up code. Nothing is granted from here, nothing is ever pre-selected,
|
|
// and skipping is first-class: the verb exists, run it or don't.
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func cmdAddFolder(host, path, name string) error {
|
|
cfgs, err := loadPairings()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(cfgs) == 0 {
|
|
return fmt.Errorf("not paired yet — run `privatae-bastion pair <token>` first")
|
|
}
|
|
// Multi-homed boxes must NAME the host (0.10.0): guessing here is how a folder
|
|
// ends up proposed to the wrong entity. One pairing needs no flag.
|
|
var cfg *Config
|
|
switch {
|
|
case host != "":
|
|
cfg, err = findPairing(cfgs, host)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cfg == nil {
|
|
return fmt.Errorf("no pairing matches --host '%s' — paired hosts:\n %s",
|
|
host, strings.Join(hostNames(cfgs), "\n "))
|
|
}
|
|
case len(cfgs) == 1:
|
|
cfg = cfgs[0]
|
|
default:
|
|
return fmt.Errorf("%d hosts are paired — say which one with --host:\n privatae-bastion add-folder --host <host> %s\npaired hosts:\n %s",
|
|
len(cfgs), path, strings.Join(hostNames(cfgs), "\n "))
|
|
}
|
|
abs, err := filepath.Abs(expandHome(path))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
info, err := os.Stat(abs)
|
|
if err != nil || !info.IsDir() {
|
|
return fmt.Errorf("'%s' is not a directory on this machine", abs)
|
|
}
|
|
body, _ := json.Marshal(map[string]string{"root": abs, "display_name": name})
|
|
req, err := http.NewRequest(http.MethodPost,
|
|
"http://"+cfg.ListenAddr+"/dcn/propose", bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+cfg.OperatorSecret)
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("the bastion daemon isn't running — start it with `privatae-bastion run` (%w)", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("draft failed: %s", string(raw))
|
|
}
|
|
fmt.Printf(`Draft created for %s.
|
|
|
|
Your files stay on this machine. Privatae Host keeps only the index — a route map of
|
|
what exists and where — so your host can find things instantly. Content is read
|
|
on demand, only from folders you approve, and every read is logged for you.
|
|
|
|
Nothing is granted yet: approve the draft on your Host (Context Manager → Storage →
|
|
Your network) with your 6-digit code.
|
|
`, abs)
|
|
return nil
|
|
}
|
|
|
|
func expandHome(p string) string {
|
|
if len(p) >= 2 && p[:2] == "~/" {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
return filepath.Join(home, p[2:])
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
// dcnProposeLocal is the daemon-side loopback handler (mounted on the :7878 mux in
|
|
// cmdRun): validate locally, then forward the draft over the live tunnel with the
|
|
// bastion's bearer. The hostname sent as `server` is this box's — the same identity
|
|
// the heartbeat reports and the signed grant will name.
|
|
func dcnProposeLocal(upstream *http.Client, hostName, bearer, operatorSecret string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if !uiSecretMatches(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), operatorSecret) {
|
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
var body struct {
|
|
Root string `json:"root"`
|
|
DisplayName string `json:"display_name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Root == "" {
|
|
http.Error(w, `{"error":"root (a folder path) is required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
abs, err := filepath.Abs(expandHome(body.Root))
|
|
if err != nil {
|
|
http.Error(w, `{"error":"bad path"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if info, err := os.Stat(abs); err != nil || !info.IsDir() {
|
|
http.Error(w, fmt.Sprintf(`{"error":"'%s' is not a directory on this machine"}`, abs),
|
|
http.StatusBadRequest)
|
|
return
|
|
}
|
|
// The bastion-authed draft lane (mirrors the heartbeat path family): the
|
|
// host derives `server` from OUR bearer's heartbeat hostname — a bastion
|
|
// can only propose folders on itself, so no server field is sent.
|
|
payload, _ := json.Marshal(map[string]string{
|
|
"root": abs, "display_name": body.DisplayName,
|
|
})
|
|
req, err := http.NewRequest(http.MethodPost,
|
|
fmt.Sprintf("https://%s:8443/api/network/bastions/dcn-propose", hostName),
|
|
bytes.NewReader(payload))
|
|
if err != nil {
|
|
http.Error(w, `{"error":"bad upstream request"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+bearer)
|
|
resp, err := upstream.Do(req)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf(`{"error":"could not reach your host: %s"}`, err),
|
|
http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(resp.StatusCode)
|
|
_, _ = io.Copy(w, resp.Body)
|
|
}
|
|
}
|