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:
@@ -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.")
|
||||
}
|
||||
Reference in New Issue
Block a user