Add signed updates from a bundle or a URL

router-release signs a release as a binary cache; router-update verifies
it against the router's update keys, migrates settings.json, and applies
it with commit-confirm. Includes the web UI page and a VM test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-10-29 13:34:21 +01:00
co-authored by Claude Sonnet 5.5
parent 2a111d012b
commit b9c63273e4
21 changed files with 1500 additions and 21 deletions
+32
View File
@@ -0,0 +1,32 @@
# router-migrate-settings RELEASE FILE: bring a settings.json up to the
# schema of a release by running its migrations/N.jq in order.
{
writeShellApplication,
coreutils,
jq,
}:
writeShellApplication {
name = "router-migrate-settings";
runtimeInputs = [
coreutils
jq
];
text = ''
[ $# -eq 2 ] || { echo "usage: router-migrate-settings RELEASE SETTINGS.json" >&2; exit 2; }
release=$1 file=$2
schema=$(cat "$release/schema")
version=$(jq -r '.version // 0' "$file")
if [ "$version" -gt "$schema" ]; then
echo "the settings are at version $version, newer than this release ($schema)" >&2
exit 1
fi
while [ "$version" -lt "$schema" ]; do
next=$((version + 1))
echo "migrating the settings to version $next"
jq -f "$release/migrations/$next.jq" "$file" | jq --argjson v "$next" '.version = $v' > "$file.new"
chmod 0600 "$file.new"
mv "$file.new" "$file"
version=$next
done
'';
}
+2
View File
@@ -18,6 +18,8 @@ import (
type System struct {
ConfigDir string // the configuration flake, settings.json in it
StateDir string // /var/lib/router: secrets, UI state
// ReleaseFile describes the installed nixos-router; /etc/router-release if empty.
ReleaseFile string
}
func (s System) settingsPath() string { return filepath.Join(s.ConfigDir, "settings.json") }
+1
View File
@@ -14,6 +14,7 @@
<nav>
<a href="/">Overview</a>
<a href="/network">Network</a>
<a href="/update">Update</a>
<a href="/advanced">Advanced</a>
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>
</nav>
+63
View File
@@ -0,0 +1,63 @@
{{define "content"}}
{{$d := .Data}}
<section>
<h2>Installed</h2>
<dl>
<dt>Version</dt><dd>{{or $d.Installed.Version "unknown"}}{{if $d.Installed.Rev}} <span class="muted">{{$d.Installed.Rev}}</span>{{end}}</dd>
<dt>Settings schema</dt><dd>{{$d.Installed.Schema}}</dd>
</dl>
</section>
{{if $d.Status.Running}}
<section class="busy"><h2>Updating…</h2><p>Verifying, importing and building the new version. This page refreshes on its own;
the web UI restarts when the new version is switched on.</p></section>
{{else if $d.Pending}}
<section class="pending"><h2>Waiting for confirmation</h2>
<p>A new configuration is active and not confirmed yet. Confirm it on the <a href="/">Overview</a>, or the router
goes back to the previous version.</p></section>
{{end}}
<section>
<h2>Install a bundle</h2>
<p>A <code>.bundle</code> file made with <code>nix run .#release</code>. It is installed only if it is signed with
this router's update key. The settings are kept (and migrated if the new version needs it); the new version
must be confirmed afterwards, or the router goes back.</p>
<form method="post" action="/update/upload?csrf={{.CSRF}}" enctype="multipart/form-data">
<p><label><input type="checkbox" name="force"> also install a version that is not newer</label></p>
<p><input type="file" name="bundle" required> <button>Upload and install</button></p>
</form>
</section>
<section>
<h2>Update server</h2>
<form method="post" action="/update/url" class="actions">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Address <input name="url" value="{{$d.URL}}" size="40" placeholder="https://updates.example.com/router"></label>
<button class="secondary">Save</button>
</form>
{{if $d.URL}}
<form method="post" action="/update/check" class="actions">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="secondary">Check for updates</button>
</form>
{{end}}
{{if $d.OfferErr}}<p class="error">Could not check: {{$d.OfferErr}}</p>{{end}}
{{with $d.Offer}}
<p>{{if .Newer}}Available: <strong>{{.Version}}</strong>{{if .Rev}} <span class="muted">{{.Rev}}</span>{{end}}.{{else}}Nothing newer: the server has {{.Version}}.{{end}}</p>
<form method="post" action="/update/install">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
{{if not .Newer}}<p><label><input type="checkbox" name="force"> install it anyway</label></p>{{end}}
<button>{{if .Newer}}Install {{.Version}}{{else}}Install{{end}}</button>
</form>
{{end}}
<p class="hint">The server only has to serve static files: the <code>latest</code> file and <code>cache/</code> that
<code>nix run .#release</code> writes. The data is signed, so plain HTTP works too.</p>
</section>
{{if $d.Status.Log}}
<section>
<h2>Last update{{if $d.Status.Result}} — {{if eq $d.Status.Result "success"}}succeeded{{else}}failed ({{$d.Status.Result}}){{end}}{{end}}</h2>
<pre class="log">{{$d.Status.Log}}</pre>
</section>
{{end}}
{{end}}
+387
View File
@@ -0,0 +1,387 @@
package main
import (
"archive/tar"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"time"
)
// Updates arrive as a tar of a signed binary cache (see lib.mkRelease):
//
// latest
// cache/nix-cache-info
// cache/<hash>.narinfo
// cache/nar/<hash>.nar[.zst|.xz]
//
// The UI only stages the files; router-update (root, a systemd unit) verifies
// the signatures. Extraction is still strict: only these names, only regular
// files, bounded in size, so a hostile bundle cannot write elsewhere.
const (
maxBundle = 8 << 30 // bytes in all
maxLatest = 64 << 10
)
var bundleNames = []*regexp.Regexp{
regexp.MustCompile(`^latest$`),
regexp.MustCompile(`^cache/nix-cache-info$`),
regexp.MustCompile(`^cache/[0-9a-z]{32}\.narinfo$`),
regexp.MustCompile(`^cache/nar/[0-9a-z]{20,64}\.nar(\.[a-z0-9]{1,5})?$`),
}
// unbundle extracts a bundle into dir, which it replaces.
func unbundle(r io.Reader, dir string) error {
dir = filepath.Clean(dir)
tmp := dir + ".tmp"
if err := os.RemoveAll(tmp); err != nil {
return err
}
if err := os.MkdirAll(filepath.Join(tmp, "cache", "nar"), 0o700); err != nil {
return err
}
if err := extract(tar.NewReader(r), tmp); err != nil {
os.RemoveAll(tmp)
return err
}
for _, f := range []string{"latest", "cache/nix-cache-info"} {
if _, err := os.Stat(filepath.Join(tmp, f)); err != nil {
os.RemoveAll(tmp)
return fmt.Errorf("not an update bundle: %s is missing", f)
}
}
if err := os.RemoveAll(dir); err != nil {
return err
}
return os.Rename(tmp, dir)
}
func extract(tr *tar.Reader, root string) error {
var total int64
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return fmt.Errorf("not an update bundle: %w", err)
}
name := path.Clean(strings.TrimPrefix(hdr.Name, "./"))
switch hdr.Typeflag {
case tar.TypeDir:
// Directories are made here; the entries only have to be harmless.
switch name {
case ".", "cache", "cache/nar", "cache/log", "cache/realisations":
continue
}
return fmt.Errorf("unexpected directory %q in the bundle", hdr.Name)
case tar.TypeReg:
default:
return fmt.Errorf("unexpected entry %q in the bundle (only plain files are allowed)", hdr.Name)
}
ok := false
for _, re := range bundleNames {
ok = ok || re.MatchString(name)
}
if !ok {
return fmt.Errorf("unexpected file %q in the bundle", hdr.Name)
}
limit := int64(maxBundle) - total
if name == "latest" {
limit = min(limit, maxLatest)
}
if hdr.Size > limit {
return fmt.Errorf("the bundle is too large (%q)", hdr.Name)
}
f, err := os.OpenFile(filepath.Join(root, filepath.FromSlash(name)), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return fmt.Errorf("%q appears twice in the bundle", hdr.Name)
}
n, err := io.Copy(f, tr)
total += n
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
return err
}
}
}
// Release describes a version of nixos-router (/etc/router-release, and the
// latest file of an update).
type Release struct {
Version string `json:"version"`
Serial int64 `json:"serial"`
Rev string `json:"rev"`
Schema int `json:"schema"`
}
func (s System) releasePath() string {
if s.ReleaseFile != "" {
return s.ReleaseFile
}
return "/etc/router-release"
}
func (s System) installedRelease() Release {
var r Release
if data, err := os.ReadFile(s.releasePath()); err == nil {
json.Unmarshal(data, &r)
}
return r
}
func (s System) updateDir() string { return filepath.Join(s.StateDir, "update") }
func (s System) updateURLPath() string { return filepath.Join(s.StateDir, "webui", "update.json") }
func (s System) updateURL() string {
var v struct {
URL string `json:"url"`
}
if data, err := os.ReadFile(s.updateURLPath()); err == nil {
json.Unmarshal(data, &v)
}
return v.URL
}
func (s System) saveUpdateURL(u string) error {
data, _ := json.Marshal(struct {
URL string `json:"url"`
}{u})
if err := os.MkdirAll(filepath.Dir(s.updateURLPath()), 0o700); err != nil {
return err
}
return writeFileAtomic(s.updateURLPath(), data, 0o600)
}
// parseUpdateURL accepts the base of an update server: http or https, no
// credentials, no query. The data is signed, so plain HTTP is no risk to the
// router; it only lets an attacker withhold updates.
func parseUpdateURL(v string) (string, error) {
v = strings.TrimRight(strings.TrimSpace(v), "/")
u, err := url.Parse(v)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil ||
u.RawQuery != "" || u.Fragment != "" {
return "", errors.New("an http:// or https:// address without credentials")
}
return v, nil
}
// Available is what the update server offers.
type Available struct {
Release
Newer bool
}
func (s System) checkUpdate(base string) (Available, error) {
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Get(base + "/latest")
if err != nil {
return Available{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Available{}, fmt.Errorf("the server answered %s", resp.Status)
}
var a Available
if err := json.NewDecoder(io.LimitReader(resp.Body, maxLatest)).Decode(&a); err != nil || a.Version == "" {
return Available{}, errors.New("the server's latest file is not an update description")
}
a.Newer = a.Serial > s.installedRelease().Serial
return a, nil
}
// request asks the router-update unit to install; it reads this file.
func (s System) requestUpdate(mode, url string, force bool) error {
data, _ := json.Marshal(map[string]any{"mode": mode, "url": url, "force": force})
if err := os.MkdirAll(s.updateDir(), 0o700); err != nil {
return err
}
if err := writeFileAtomic(filepath.Join(s.updateDir(), "request.json"), data, 0o600); err != nil {
return err
}
return startUnit("router-update.service")
}
// startUnit is replaced in tests.
var startUnit = func(unit string) error {
_, err := command("systemctl", "start", "--no-block", unit)
return err
}
// UpdateStatus is the state of the router-update unit.
type UpdateStatus struct {
Running bool
Result string // of the last run; "" if none
Log string
}
func (s System) UpdateStatus() UpdateStatus {
st := UpdateStatus{}
st.Running = unitProperty("router-update.service", "ActiveState") == "activating"
if inv := unitProperty("router-update.service", "InvocationID"); inv != "" {
if !st.Running {
st.Result = unitProperty("router-update.service", "Result")
}
st.Log, _ = command("journalctl", "--no-pager", "-o", "cat", "-n", "200", "_SYSTEMD_INVOCATION_ID="+inv)
}
return st
}
type updateData struct {
Installed Release
Status UpdateStatus
URL string
Offer *Available
OfferErr string
Pending bool // an update waits for confirmation
}
func (a *app) updateData() updateData {
st := a.sys.Status()
return updateData{
Installed: a.sys.installedRelease(),
Status: a.sys.UpdateStatus(),
URL: a.sys.updateURL(),
Pending: st.RollbackIn > 0,
}
}
func (a *app) updatePage(w http.ResponseWriter, r *http.Request, sess session) {
d := a.updateData()
p := page{Title: "Update", CSRF: sess.csrf, LoggedIn: true, Data: d, Flash: r.URL.Query().Get("msg")}
if d.Status.Running {
p.Refresh = 3
}
a.render(w, "update", p)
}
func (a *app) updateBack(w http.ResponseWriter, r *http.Request, msg string) {
http.Redirect(w, r, "/update?msg="+url.QueryEscape(msg), http.StatusSeeOther)
}
func (a *app) busy() bool {
st := a.sys.Status()
return st.Applying || st.ConfirmRunning || st.RollbackIn > 0 || a.sys.UpdateStatus().Running
}
func (a *app) updateSaveURL(w http.ResponseWriter, r *http.Request, _ session) {
v := strings.TrimSpace(r.PostFormValue("url"))
if v != "" {
var err error
if v, err = parseUpdateURL(v); err != nil {
a.updateBack(w, r, "Not saved: "+err.Error())
return
}
}
if err := a.sys.saveUpdateURL(v); err != nil {
a.updateBack(w, r, "Not saved: "+err.Error())
return
}
a.updateBack(w, r, "Saved.")
}
func (a *app) updateCheck(w http.ResponseWriter, r *http.Request, sess session) {
d := a.updateData()
p := page{Title: "Update", CSRF: sess.csrf, LoggedIn: true}
if d.URL == "" {
a.updateBack(w, r, "Set the update server first.")
return
}
offer, err := a.sys.checkUpdate(d.URL)
if err != nil {
d.OfferErr = err.Error()
} else {
d.Offer = &offer
}
p.Data = d
a.render(w, "update", p)
}
func (a *app) updateInstall(w http.ResponseWriter, r *http.Request, _ session) {
if a.busy() {
a.updateBack(w, r, "Another change is in progress or waiting for confirmation.")
return
}
u := a.sys.updateURL()
if u == "" {
a.updateBack(w, r, "Set the update server first.")
return
}
if err := a.sys.requestUpdate("url", u, r.PostFormValue("force") == "on"); err != nil {
a.updateBack(w, r, "Could not start the update: "+err.Error())
return
}
a.updateBack(w, r, "Updating.")
}
// updateUpload takes a bundle as multipart/form-data and streams it into the
// staging directory; a bundle is hundreds of megabytes. The CSRF token is in
// the query string, since the body must not be read before it is checked.
func (a *app) updateUpload(w http.ResponseWriter, r *http.Request) {
sess, ok := a.session(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if !sameOrigin(r) || r.URL.Query().Get("csrf") != sess.csrf {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if a.busy() {
a.updateBack(w, r, "Another change is in progress or waiting for confirmation.")
return
}
mr, err := r.MultipartReader()
if err != nil {
a.updateBack(w, r, "Not an upload: "+err.Error())
return
}
force := false
for {
part, err := mr.NextPart()
if errors.Is(err, io.EOF) {
a.updateBack(w, r, "No file was uploaded.")
return
}
if err != nil {
a.updateBack(w, r, "Upload failed: "+err.Error())
return
}
if part.FormName() == "force" {
force = true
continue
}
if part.FormName() == "bundle" && part.FileName() != "" {
a.stageBundle(w, r, part, force)
return
}
}
}
func (a *app) stageBundle(w http.ResponseWriter, r *http.Request, part *multipart.Part, force bool) {
if err := os.MkdirAll(a.sys.updateDir(), 0o700); err != nil {
a.updateBack(w, r, "Could not stage the bundle: "+err.Error())
return
}
if err := unbundle(part, filepath.Join(a.sys.updateDir(), "staged")); err != nil {
a.updateBack(w, r, "Rejected: "+err.Error())
return
}
if err := a.sys.requestUpdate("staged", "", force); err != nil {
a.updateBack(w, r, "Could not start the update: "+err.Error())
return
}
a.updateBack(w, r, "Bundle received; verifying and installing.")
}
+246
View File
@@ -0,0 +1,246 @@
package main
import (
"archive/tar"
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
type entry struct {
name string
body string
typ byte
link string
}
func makeBundle(t *testing.T, entries ...entry) []byte {
t.Helper()
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
for _, e := range entries {
typ := e.typ
if typ == 0 {
typ = tar.TypeReg
}
hdr := &tar.Header{Name: e.name, Typeflag: typ, Mode: 0o644, Size: int64(len(e.body)), Linkname: e.link}
if typ != tar.TypeReg {
hdr.Size = 0
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if typ == tar.TypeReg {
tw.Write([]byte(e.body))
}
}
tw.Close()
return buf.Bytes()
}
const hash32 = "00fs4zz9c0jjcvs62wg39b91dydz7zqb"
func goodBundle(t *testing.T) []byte {
return makeBundle(t,
entry{name: "latest", body: `{"release":"/nix/store/x","version":"0.3.0","serial":2000,"schema":0}`},
entry{name: "cache/", typ: tar.TypeDir},
entry{name: "cache/log/", typ: tar.TypeDir},
entry{name: "cache/nix-cache-info", body: "StoreDir: /nix/store\n"},
entry{name: "cache/" + hash32 + ".narinfo", body: "StorePath: ..."},
entry{name: "cache/nar/" + strings.Repeat("a1", 26) + ".nar.zst", body: "nar"},
)
}
func TestUnbundle(t *testing.T) {
dir := filepath.Join(t.TempDir(), "staged")
os.MkdirAll(dir, 0o700)
os.WriteFile(filepath.Join(dir, "old"), []byte("x"), 0o600)
if err := unbundle(bytes.NewReader(goodBundle(t)), dir); err != nil {
t.Fatal(err)
}
for _, f := range []string{"latest", "cache/nix-cache-info", "cache/" + hash32 + ".narinfo"} {
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
t.Fatalf("%s: %v", f, err)
}
}
if _, err := os.Stat(filepath.Join(dir, "old")); err == nil {
t.Fatal("the previous staging was kept")
}
}
func TestUnbundleRefusesHostileBundles(t *testing.T) {
good := []entry{
{name: "latest", body: "{}"},
{name: "cache/nix-cache-info", body: "x"},
}
for name, extra := range map[string]entry{
"traversal": {name: "../../etc/passwd", body: "x"},
"absolute": {name: "/etc/passwd", body: "x"},
"nested": {name: "cache/nar/../../../x", body: "x"},
"other file": {name: "cache/evil.sh", body: "x"},
"symlink": {name: "cache/" + hash32 + ".narinfo", typ: tar.TypeSymlink, link: "/etc/passwd"},
"hardlink": {name: "cache/" + hash32 + ".narinfo", typ: tar.TypeLink, link: "/etc/passwd"},
"directory": {name: "etc/", typ: tar.TypeDir},
"duplicate": {name: "latest", body: "{}"},
"huge latest": {name: "latest", body: strings.Repeat("x", maxLatest+1)},
} {
dir := filepath.Join(t.TempDir(), "staged")
entries := append([]entry{}, good...)
if name == "huge latest" {
entries = good[1:] // so the size, not a duplicate, is what is refused
}
err := unbundle(bytes.NewReader(makeBundle(t, append(entries, extra)...)), dir)
if err == nil {
t.Errorf("%s: accepted", name)
}
if _, statErr := os.Stat(dir); statErr == nil {
t.Errorf("%s: left %s behind", name, dir)
}
if _, statErr := os.Stat(dir + ".tmp"); statErr == nil {
t.Errorf("%s: left a temporary directory", name)
}
}
// Incomplete or not a tar at all.
if err := unbundle(bytes.NewReader(makeBundle(t, entry{name: "latest", body: "{}"})), filepath.Join(t.TempDir(), "s")); err == nil {
t.Error("a bundle without a cache was accepted")
}
if err := unbundle(strings.NewReader("this is not a tar file"), filepath.Join(t.TempDir(), "s")); err == nil {
t.Error("garbage was accepted")
}
}
func TestParseUpdateURL(t *testing.T) {
for in, want := range map[string]string{
"https://updates.example.com/router/": "https://updates.example.com/router",
" http://192.168.1.5:8000 ": "http://192.168.1.5:8000",
} {
if got, err := parseUpdateURL(in); err != nil || got != want {
t.Errorf("%q: %q %v", in, got, err)
}
}
for _, in := range []string{"", "ftp://x/y", "file:///etc", "https://user:pw@host/x", "https://host/x?y=1", "updates.example.com", "https://"} {
if _, err := parseUpdateURL(in); err == nil {
t.Errorf("%q accepted", in)
}
}
}
func TestCheckUpdate(t *testing.T) {
a, sys := newTestApp(t)
_ = a
sys.ReleaseFile = filepath.Join(t.TempDir(), "router-release")
os.WriteFile(sys.ReleaseFile, []byte(`{"version":"0.2.0","serial":1000,"schema":0}`), 0o600)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/new/latest":
w.Write([]byte(`{"release":"/nix/store/x","version":"0.3.0","serial":2000,"schema":1}`))
case "/same/latest":
w.Write([]byte(`{"version":"0.2.0","serial":1000}`))
case "/junk/latest":
w.Write([]byte(`hello`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
if o, err := sys.checkUpdate(srv.URL + "/new"); err != nil || !o.Newer || o.Version != "0.3.0" {
t.Fatalf("new: %+v %v", o, err)
}
if o, err := sys.checkUpdate(srv.URL + "/same"); err != nil || o.Newer {
t.Fatalf("same: %+v %v", o, err)
}
for _, p := range []string{"/junk", "/missing"} {
if _, err := sys.checkUpdate(srv.URL + p); err == nil {
t.Fatalf("%s: no error", p)
}
}
}
func upload(t *testing.T, h http.Handler, token string, cookie *http.Cookie, bundle []byte, force bool) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
if force {
mw.WriteField("force", "on")
}
fw, _ := mw.CreateFormFile("bundle", "router-update.bundle")
fw.Write(bundle)
mw.Close()
req := httptest.NewRequest("POST", "/update/upload?csrf="+url.QueryEscape(token), &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestUpdateUploadAndInstall(t *testing.T) {
a, sys := newTestApp(t)
var started []string
startUnit = func(unit string) error { started = append(started, unit); return nil }
orig := startUnit
defer func() { startUnit = orig }()
h := a.routes()
cookie := login(t, h)
token := csrfFrom(t, do(h, "GET", "/update", nil, cookie).Body.String())
if rec := upload(t, h, "wrong", cookie, goodBundle(t), false); rec.Code != http.StatusForbidden {
t.Fatalf("upload with a wrong token: %d", rec.Code)
}
if rec := upload(t, h, token, nil, goodBundle(t), false); rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/login") {
t.Fatalf("upload without a session: %d %s", rec.Code, rec.Header().Get("Location"))
}
if len(started) != 0 {
t.Fatal("the update unit was started by a rejected upload")
}
rec := upload(t, h, token, cookie, makeBundle(t, entry{name: "evil", body: "x"}), false)
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "Rejected") || len(started) != 0 {
t.Fatalf("hostile bundle: %q, started %v", loc, started)
}
rec = upload(t, h, token, cookie, goodBundle(t), true)
if loc := rec.Header().Get("Location"); !strings.Contains(loc, "Bundle+received") {
t.Fatalf("upload: %d %q", rec.Code, loc)
}
if len(started) != 1 || started[0] != "router-update.service" {
t.Fatalf("started %v", started)
}
var req struct {
Mode string `json:"mode"`
Force bool `json:"force"`
}
data, _ := os.ReadFile(filepath.Join(sys.updateDir(), "request.json"))
if err := json.Unmarshal(data, &req); err != nil || req.Mode != "staged" || !req.Force {
t.Fatalf("request: %s", data)
}
if _, err := os.Stat(filepath.Join(sys.updateDir(), "staged", "latest")); err != nil {
t.Fatalf("not staged: %v", err)
}
// Pulling: the saved server, through install.
if rec := do(h, "POST", "/update/url", url.Values{"csrf": {token}, "url": {"ftp://nope"}}, cookie); !strings.Contains(rec.Header().Get("Location"), "Not+saved") {
t.Fatalf("bad url: %q", rec.Header().Get("Location"))
}
do(h, "POST", "/update/url", url.Values{"csrf": {token}, "url": {"http://192.168.10.60:8000/"}}, cookie)
if sys.updateURL() != "http://192.168.10.60:8000" {
t.Fatalf("url: %q", sys.updateURL())
}
do(h, "POST", "/update/install", url.Values{"csrf": {token}}, cookie)
data, _ = os.ReadFile(filepath.Join(sys.updateDir(), "request.json"))
if !strings.Contains(string(data), `"mode":"url"`) || !strings.Contains(string(data), "192.168.10.60:8000") {
t.Fatalf("request: %s", data)
}
if page := do(h, "GET", "/update", nil, cookie).Body.String(); !strings.Contains(page, "192.168.10.60:8000") {
t.Fatalf("the page lacks the saved server:\n%s", page)
}
}