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
388 lines
10 KiB
Go
388 lines
10 KiB
Go
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.")
|
|
}
|