1package admin23// Backups: copies of the channel setup, to put it back after a lost disk4// or a change gone wrong. A backup is a tar.gz of the files that make each5// channel what it is, laid out as in the channels folder, and nothing6// bulky: every channel folder (those holding only videos too, so they can7// be made again), with its jellyfin.json, youtube.json, channel.json, logo8// and a YouTube channel's schedule (.playout.json, which keeps the guide9// going on); and the channels folder's .jellyfin.json (the Jellyfin10// account), .weather.json and weather logo. A manifest.json comes first:11// when, why and by which airwavesd it was made, and what's in it. Videos,12// YouTube catalogs (listed again) and yt-dlp's .info.json files are left13// out.14//15// They're kept in Server.Backups (/data/backups in the container), the16// files readable only by the server's user, as the account is a secret.17// One is made 30 seconds after the last change through the API (the18// page's and the agent tools'), one a day when the setup changed some19// other way, one before every restore (restore.go), and others by hand or20// by upload. The automatic ones are pruned (see prune); the others stay21// until deleted.2223import (24 "archive/tar"25 "bytes"26 "compress/gzip"27 "context"28 "crypto/sha256"29 "encoding/hex"30 "encoding/json"31 "errors"32 "fmt"33 "io"34 "io/fs"35 "log"36 "maps"37 "mime"38 "mime/multipart"39 "net/http"40 "os"41 "path"42 "path/filepath"43 "regexp"44 "slices"45 "strings"46 "sync"47 "time"48 "unicode"49 "unicode/utf8"5051 "airwaves/internal/vchan"52)5354const (55 backupVersion = 156 manifestFile = "manifest.json"57 backupExt = ".tar.gz"58 // playoutFile is a YouTube channel's schedule (see vchan.YouTube).59 playoutFile = ".playout.json"60 // backupDelay is how long after a change through the API the setup is61 // backed up, so a burst of changes makes one backup.62 backupDelay = 30 * time.Second63 // The setup is checked for changes made some other way (by hand, or64 // lost) a minute after the server starts, then daily.65 firstCheck = time.Minute66 backupEvery = 24 * time.Hour67 // Automatic backups kept: the newest keepRecent, and the newest of68 // each of the last keepDays days.69 keepRecent = 3070 keepDays = 1471 // Limits on a backup: its size, unpacked and not, how many files it72 // has, and the size of each.73 maxBackup = 16 << 2074 maxUnpacked = 64 << 2075 maxBackupFiles = 1000076 maxSetupFile = 8 << 2077 maxManifest = 1 << 2078 maxLabel = 6079)8081// Why a backup was made. The first three are automatic.82const (83 afterChange = "change"84 daily = "daily"85 beforeRestore = "restore"86 byHand = "manual"87 uploaded = "uploaded"88)8990func automatic(reason string) bool {91 return reason == afterChange || reason == daily || reason == beforeRestore92}9394var (95 backupNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,150}\.tar\.gz$`)96 // folderNumberRe is a channel folder's name with a number, as the97 // library reads it.98 folderNumberRe = regexp.MustCompile(`^(\d+[.-]\d+)\s+(.+)$`)99 errBackupsOff = errors.New("backups are off: airwavesd was started without a data folder for them")100 errNotBackup = errors.New("that isn't an Airwaves backup: it should be a .tar.gz of the channel setup")101)102103// backupState is how backups are getting on.104type backupState struct {105 run sync.Mutex // held while a backup or restore runs: one at a time106 mu sync.Mutex // the rest107 timer *time.Timer108 pending bool // a change waits to be backed up109 stopped bool110 delay time.Duration // backupDelay, but in tests111 read map[string]readBackup112}113114// readBackup is a backup file as last read, kept while it's unchanged.115type readBackup struct {116 size int64117 mod time.Time118 info backupJSON119}120121// ---------- the setup ----------122123// setup is a channel setup: the channel folders, and the files that124// matter in them and beside them, by path from the channels folder125// ("201.0 Northernlion/youtube.json", ".jellyfin.json").126type setup struct {127 folders []string // sorted128 files map[string][]byte129}130131// inFolder reports whether a file in a channel folder is part of the132// setup: its settings, details, logo or schedule.133func inFolder(name string) bool {134 switch name {135 case channelFile, youtubeFile, vchan.DetailsFile, playoutFile:136 return true137 }138 return isLogo(name, vchan.LogoName)139}140141// atTop reports whether a file beside the channel folders is part of the142// setup: the Jellyfin account, or the weather channel's record or logo.143func atTop(name string) bool {144 return name == accountFile || name == vchan.WeatherFile || isLogo(name, vchan.WeatherLogoName)145}146147// isLogo reports whether name is a logo file named base ("logo.png").148func isLogo(name, base string) bool {149 ext, ok := strings.CutPrefix(name, base)150 return ok && strings.LastIndexByte(ext, '.') == 0 && vchan.LogoTypes[strings.ToLower(ext)] != ""151}152153// goodFolder reports whether name can be a channel folder's: one plain154// path element, not hidden.155func goodFolder(name string) bool {156 return name != "" && len(name) <= 255 && !strings.HasPrefix(name, ".") && !strings.ContainsAny(name, `/\`) &&157 utf8.ValidString(name) && strings.IndexFunc(name, unicode.IsControl) < 0158}159160// private reports whether a setup file holds a password or token, so only161// the server's user may read it.162func private(name string) bool {163 return path.Base(name) == accountFile || path.Base(name) == channelFile164}165166// readSetup reads the setup from the channels folder. A folder that isn't167// there has none.168func readSetup(root string) (setup, error) {169 st := setup{files: map[string][]byte{}}170 entries, err := os.ReadDir(root)171 if errors.Is(err, fs.ErrNotExist) {172 return st, nil173 } else if err != nil {174 return st, err175 }176 read := func(rel string) error {177 p := filepath.Join(root, filepath.FromSlash(rel))178 info, err := os.Stat(p)179 if errors.Is(err, fs.ErrNotExist) {180 return nil // gone since: a schedule being replaced, say181 } else if err != nil {182 return err183 }184 if info.Size() > maxSetupFile {185 log.Printf("admin: backups leave out %s: it's over %d MB", rel, maxSetupFile>>20)186 return nil187 }188 data, err := os.ReadFile(p)189 if errors.Is(err, fs.ErrNotExist) {190 return nil // gone since: a schedule being replaced, say191 }192 st.files[rel] = data193 return err194 }195 for _, e := range entries {196 name := e.Name()197 switch {198 case e.IsDir() && goodFolder(name):199 st.folders = append(st.folders, name)200 files, err := os.ReadDir(filepath.Join(root, name))201 if err != nil {202 return st, err203 }204 for _, f := range files {205 if f.Type().IsRegular() && inFolder(f.Name()) {206 if err := read(name + "/" + f.Name()); err != nil {207 return st, err208 }209 }210 }211 case e.Type().IsRegular() && atTop(name):212 if err := read(name); err != nil {213 return st, err214 }215 }216 }217 slices.Sort(st.folders)218 return st, nil219}220221// in lists the setup's files in a folder, by name.222func (st setup) in(folder string) map[string][]byte {223 out := map[string][]byte{}224 for p, data := range st.files {225 if dir, name, ok := strings.Cut(p, "/"); ok && dir == folder {226 out[name] = data227 }228 }229 return out230}231232// top lists the files beside the folders, by name.233func (st setup) top() map[string][]byte {234 out := map[string][]byte{}235 for p, data := range st.files {236 if !strings.Contains(p, "/") {237 out[p] = data238 }239 }240 return out241}242243// kindOf is a folder's kind by its files, as the library tells it.244func kindOf(files map[string][]byte) string {245 switch {246 case files[channelFile] != nil:247 return "jellyfin"248 case files[youtubeFile] != nil:249 return "youtube"250 }251 return "folder"252}253254// digest sums the setup up, schedules aside (they change by the hour), to255// tell a change from a copy.256func (st setup) digest() string {257 h := sha256.New()258 for _, f := range st.folders {259 fmt.Fprintf(h, "d %q\n", f)260 }261 for _, p := range slices.Sorted(maps.Keys(st.files)) {262 if path.Base(p) == playoutFile {263 continue264 }265 fmt.Fprintf(h, "f %q %d\n", p, len(st.files[p]))266 h.Write(st.files[p])267 }268 return hex.EncodeToString(h.Sum(nil))269}270271// manifest describes a backup. It's the first file in the archive.272type manifest struct {273 Version int `json:"version"`274 Created time.Time `json:"created"`275 Airwavesd string `json:"airwavesd,omitempty"`276 Reason string `json:"reason"` // "change", "daily", "restore", "manual" or "uploaded"277 Label string `json:"label,omitempty"`278 Folders []backupFolder `json:"folders"`279 Account bool `json:"account"` // the Jellyfin account280 Weather bool `json:"weather"` // the weather channel's record281 Schedules bool `json:"schedules"` // YouTube channels' schedules282}283284// backupFolder is a channel folder in a backup.285type backupFolder struct {286 Folder string `json:"folder"`287 Kind string `json:"kind"` // "folder", "jellyfin" or "youtube"288 Files []string `json:"files,omitempty"`289}290291func (st setup) manifest(reason, label, version string, at time.Time) manifest {292 m := manifest{Version: backupVersion, Created: at, Airwavesd: version, Reason: reason, Label: label, Folders: []backupFolder{}}293 for _, f := range st.folders {294 files := st.in(f)295 bf := backupFolder{Folder: f, Kind: kindOf(files), Files: slices.Sorted(maps.Keys(files))}296 m.Schedules = m.Schedules || files[playoutFile] != nil297 m.Folders = append(m.Folders, bf)298 }299 m.Account = st.files[accountFile] != nil300 m.Weather = st.files[vchan.WeatherFile] != nil301 return m302}303304// ---------- the archive ----------305306// writeArchive writes a backup: the manifest, the files beside the307// folders, then each folder and its files.308func writeArchive(w io.Writer, m manifest, st setup) error {309 zw := gzip.NewWriter(w)310 tw := tar.NewWriter(zw)311 file := func(name string, data []byte) error {312 mode := int64(0o644)313 if private(name) {314 mode = 0o600315 }316 if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeReg, Name: name, Mode: mode, Size: int64(len(data)), ModTime: m.Created}); err != nil {317 return err318 }319 _, err := tw.Write(data)320 return err321 }322 raw, err := json.MarshalIndent(m, "", " ")323 if err != nil {324 return err325 }326 if err := file(manifestFile, append(raw, '\n')); err != nil {327 return err328 }329 top := st.top()330 for _, name := range slices.Sorted(maps.Keys(top)) {331 if err := file(name, top[name]); err != nil {332 return err333 }334 }335 for _, f := range st.folders {336 if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeDir, Name: f + "/", Mode: 0o755, ModTime: m.Created}); err != nil {337 return err338 }339 files := st.in(f)340 for _, name := range slices.Sorted(maps.Keys(files)) {341 if err := file(f+"/"+name, files[name]); err != nil {342 return err343 }344 }345 }346 if err := tw.Close(); err != nil {347 return err348 }349 return zw.Close()350}351352// archive is a backup as read.353type archive struct {354 man *manifest // nil for a backup without one355 setup setup356 newest time.Time // its newest file's time357 skipped []string // files in it that aren't part of a setup, left out358}359360// readArchive reads a backup, checking it on the way: no links, devices,361// absolute paths or "..", at most maxBackupFiles files and maxUnpacked in362// all, and each file a setup has must be sound (JSON, or an image for a363// logo). Files that aren't part of a setup are left out and listed. It364// reads backups without a manifest too, like those made by hand before365// Airwaves made its own (a tar.gz of the channels folder's settings and366// logos), whose channels are the folders their files are in.367func readArchive(r io.Reader) (archive, error) {368 a := archive{setup: setup{files: map[string][]byte{}}}369 zr, err := gzip.NewReader(r)370 if err != nil {371 return a, errNotBackup372 }373 tr := tar.NewReader(zr)374 folders := map[string]bool{}375 var unpacked int64376 for n := 0; ; n++ {377 hdr, err := tr.Next()378 if err == io.EOF {379 break380 }381 if err != nil {382 if n == 0 {383 return a, errNotBackup384 }385 return a, fmt.Errorf("the backup is damaged: %w", err)386 }387 if n >= maxBackupFiles {388 return a, fmt.Errorf("the backup has more than %d files; a channel setup has far fewer", maxBackupFiles)389 }390 if unpacked += max(hdr.Size, 0); unpacked > maxUnpacked {391 return a, fmt.Errorf("the backup unpacks to over %d MB; a channel setup is far smaller", maxUnpacked>>20)392 }393 if hdr.Typeflag == tar.TypeXGlobalHeader {394 continue395 }396 rel, err := entryPath(hdr.Name)397 if err != nil {398 return a, err399 }400 if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeDir {401 return a, fmt.Errorf("the backup has a link or device in it (%q), which a backup never has", hdr.Name)402 }403 if rel == "" {404 continue // the channels folder itself405 }406 parts := strings.Split(rel, "/")407 if hdr.Typeflag == tar.TypeDir {408 if len(parts) == 1 && goodFolder(rel) {409 folders[rel] = true410 } else {411 a.skipped = append(a.skipped, rel+"/")412 }413 continue414 }415 want := rel == manifestFile || len(parts) == 1 && atTop(rel) || len(parts) == 2 && goodFolder(parts[0]) && inFolder(parts[1])416 if !want {417 a.skipped = append(a.skipped, rel)418 continue419 }420 limit := int64(maxSetupFile)421 if rel == manifestFile {422 limit = maxManifest423 }424 if hdr.Size > limit {425 return a, fmt.Errorf("%s in the backup is too big, at over %d MB", rel, limit>>20)426 }427 data, err := io.ReadAll(tr)428 if err != nil {429 return a, fmt.Errorf("the backup is damaged: %w", err)430 }431 if hdr.ModTime.After(a.newest) {432 a.newest = hdr.ModTime433 }434 if rel == manifestFile {435 var m manifest436 if err := json.Unmarshal(data, &m); err != nil {437 return a, fmt.Errorf("the backup's manifest is damaged: %w", err)438 }439 a.man = &m440 continue441 }442 if err := checkSetupFile(path.Base(rel), data); err != nil {443 return a, fmt.Errorf("%s in the backup %w", rel, err)444 }445 a.setup.files[rel] = data446 if len(parts) == 2 {447 folders[parts[0]] = true448 }449 }450 if a.man != nil {451 if a.man.Version > backupVersion {452 return a, errors.New("the backup was made by a newer Airwaves; update airwavesd to restore it")453 }454 // Folders with nothing but videos are only in the manifest.455 for _, f := range a.man.Folders {456 if goodFolder(f.Folder) {457 folders[f.Folder] = true458 }459 }460 }461 a.setup.folders = slices.Sorted(maps.Keys(folders))462 if len(a.setup.folders) == 0 && len(a.setup.files) == 0 {463 return a, errors.New("there are no channels in that backup")464 }465 return a, nil466}467468// entryPath is an archive entry's path from the channels folder, without469// "./" or a trailing slash; "" for the folder itself. One that would land470// outside the folder is an error.471func entryPath(name string) (string, error) {472 p := name473 for strings.HasPrefix(p, "./") {474 p = p[2:]475 }476 p = strings.TrimSuffix(p, "/")477 if p == "" || p == "." {478 return "", nil479 }480 outside := fmt.Errorf("the backup has a file outside the channels folder (%q), which a backup never has", name)481 if strings.HasPrefix(p, "/") || strings.ContainsAny(p, "\\\x00") || !utf8.ValidString(p) {482 return "", outside483 }484 for _, el := range strings.Split(p, "/") {485 if el == "" || el == "." || el == ".." {486 return "", outside487 }488 }489 return p, nil490}491492// checkSetupFile checks a setup file's contents: a settings, details or493// schedule file is a JSON object, and a logo an image.494func checkSetupFile(name string, data []byte) error {495 if strings.HasSuffix(name, ".json") {496 var m map[string]any497 if err := json.Unmarshal(data, &m); err != nil {498 return errors.New("is damaged: it isn't a JSON object")499 }500 return nil501 }502 if logoType(data) == "" {503 return errors.New("isn't an image")504 }505 return nil506}507508// ---------- the store ----------509510// backupJSON is a backup as the page and agents see it.511type backupJSON struct {512 File string `json:"file"` // its name, which is also how the API knows it513 Size int64 `json:"size"`514 Created time.Time `json:"created"`515 Reason string `json:"reason"`516 Label string `json:"label,omitempty"`517 Airwavesd string `json:"airwavesd,omitempty"`518 Auto bool `json:"auto"` // made by itself, so pruned in time519 Folders []backupFolder `json:"folders"`520 Account bool `json:"account"`521 Weather bool `json:"weather"`522 Schedules bool `json:"schedules"`523 // Older is a backup made by hand without a manifest (a tar.gz of the524 // channels folder's settings), which restores the same.525 Older bool `json:"older,omitempty"`526 Error string `json:"error,omitempty"` // why it can't be read527528 digest string529}530531func describeBackup(file string, size int64, mod time.Time, a archive) backupJSON {532 m := a.man533 if m == nil {534 at := a.newest535 if at.IsZero() {536 at = mod537 }538 made := a.setup.manifest(byHand, "", "", at)539 m = &made540 }541 b := backupJSON{File: file, Size: size, Created: m.Created, Reason: m.Reason, Label: m.Label, Airwavesd: m.Airwavesd,542 Auto: automatic(m.Reason), Older: a.man == nil, digest: a.setup.digest()}543 if b.Created.IsZero() {544 b.Created = mod545 }546 // What's in it, as read rather than as the manifest says.547 in := a.setup.manifest("", "", "", b.Created)548 b.Folders, b.Account, b.Weather, b.Schedules = in.Folders, in.Account, in.Weather, in.Schedules549 return b550}551552// backupPath is where the backup named file is, or "" when that can't be553// a backup's name.554func (s *Server) backupPath(file string) string {555 if !backupNameRe.MatchString(file) {556 return ""557 }558 return filepath.Join(s.Backups, file)559}560561// openBackup reads a stored backup.562func (s *Server) openBackup(file string) (archive, backupJSON, error) {563 p := s.backupPath(file)564 if p == "" {565 return archive{}, backupJSON{}, fs.ErrNotExist566 }567 f, err := os.Open(p)568 if err != nil {569 return archive{}, backupJSON{}, err570 }571 defer f.Close()572 info, err := f.Stat()573 if err != nil {574 return archive{}, backupJSON{}, err575 }576 if !info.Mode().IsRegular() {577 return archive{}, backupJSON{}, fs.ErrNotExist578 }579 if info.Size() > maxBackup {580 return archive{}, backupJSON{}, fmt.Errorf("it's over %d MB, too big to be a backup", maxBackup>>20)581 }582 a, err := readArchive(f)583 if err != nil {584 return a, backupJSON{}, err585 }586 return a, describeBackup(file, info.Size(), info.ModTime(), a), nil587}588589// backupList lists the backups, newest first.590func (s *Server) backupList() ([]backupJSON, error) {591 list := []backupJSON{}592 entries, err := os.ReadDir(s.Backups)593 if errors.Is(err, fs.ErrNotExist) {594 return list, nil595 } else if err != nil {596 return list, err597 }598 s.bk.mu.Lock()599 defer s.bk.mu.Unlock()600 seen := map[string]readBackup{}601 for _, e := range entries {602 if !e.Type().IsRegular() || !backupNameRe.MatchString(e.Name()) {603 continue604 }605 info, err := e.Info()606 if err != nil {607 continue608 }609 r, ok := s.bk.read[e.Name()]610 if !ok || r.size != info.Size() || !r.mod.Equal(info.ModTime()) {611 r = readBackup{size: info.Size(), mod: info.ModTime()}612 if _, b, err := s.openBackup(e.Name()); err != nil {613 r.info = backupJSON{File: e.Name(), Size: info.Size(), Created: info.ModTime(), Reason: byHand, Folders: []backupFolder{}, Error: err.Error()}614 } else {615 r.info = b616 }617 }618 seen[e.Name()] = r619 list = append(list, r.info)620 }621 s.bk.read = seen622 slices.SortFunc(list, func(a, b backupJSON) int {623 if c := b.Created.Compare(a.Created); c != 0 {624 return c625 }626 return strings.Compare(b.File, a.File)627 })628 return list, nil629}630631// saveBackup writes a backup of st, described by m, and returns it.632func (s *Server) saveBackup(m manifest, st setup) (backupJSON, error) {633 if err := os.MkdirAll(s.Backups, 0o700); err != nil {634 return backupJSON{}, err635 }636 // Only the server's user may look in: the account is in there.637 _ = os.Chmod(s.Backups, 0o700)638 base := "airwaves-" + m.Created.Local().Format("20060102-150405") + "-" + m.Reason639 name := base + backupExt640 for i := 2; ; i++ {641 if _, err := os.Lstat(filepath.Join(s.Backups, name)); errors.Is(err, fs.ErrNotExist) {642 break643 }644 name = fmt.Sprintf("%s-%d%s", base, i, backupExt)645 }646 tmp, err := os.CreateTemp(s.Backups, ".tmp-*")647 if err != nil {648 return backupJSON{}, err649 }650 defer os.Remove(tmp.Name())651 var buf bytes.Buffer652 if err := writeArchive(&buf, m, st); err != nil {653 tmp.Close()654 return backupJSON{}, err655 }656 _, err = tmp.Write(buf.Bytes())657 if err == nil {658 err = tmp.Chmod(0o600)659 }660 if err == nil {661 err = tmp.Sync()662 }663 if cerr := tmp.Close(); err == nil {664 err = cerr665 }666 if err == nil {667 err = os.Rename(tmp.Name(), filepath.Join(s.Backups, name))668 }669 if err != nil {670 return backupJSON{}, err671 }672 a := archive{man: &m, setup: st}673 return describeBackup(name, int64(buf.Len()), m.Created, a), nil674}675676// backUp saves the setup as it is now and prunes the automatic backups.677// An automatic backup is skipped when the setup is as the newest backup678// has it; that one is returned, and made is false.679func (s *Server) backUp(reason, label string) (b backupJSON, made bool, err error) {680 s.bk.run.Lock()681 defer s.bk.run.Unlock()682 return s.backUpLocked(reason, label)683}684685// backUpLocked is backUp with s.bk.run held.686func (s *Server) backUpLocked(reason, label string) (backupJSON, bool, error) {687 if s.Backups == "" {688 return backupJSON{}, false, errBackupsOff689 }690 if automatic(reason) && reason != beforeRestore && s.backupsStopped() {691 return backupJSON{}, false, nil692 }693 st, err := readSetup(s.Library.Root)694 if err != nil {695 return backupJSON{}, false, fmt.Errorf("reading the channel setup: %w", err)696 }697 if automatic(reason) {698 if list, err := s.backupList(); err == nil && len(list) > 0 && list[0].Error == "" && list[0].digest == st.digest() {699 return list[0], false, nil700 }701 }702 b, err := s.saveBackup(st.manifest(reason, label, s.Version, time.Now()), st)703 if err != nil {704 return b, false, fmt.Errorf("saving a backup: %w", err)705 }706 log.Printf("admin: backed up the channel setup (%s) as %s", reason, b.File)707 if automatic(reason) {708 s.pruneBackups()709 }710 return b, true, nil711}712713// prune picks the automatic backups to remove from list, newest first:714// all but the newest keepRecent, and the newest of each of the last715// keepDays days (today and the 13 before). Backups made by hand or716// uploaded, and files that can't be read, are never removed.717func prune(list []backupJSON, now time.Time) []backupJSON {718 y, m, d := now.Date()719 since := time.Date(y, m, d-keepDays+1, 0, 0, 0, 0, now.Location())720 var drop []backupJSON721 recent := 0722 days := map[string]bool{}723 for _, b := range list {724 if !b.Auto || b.Error != "" {725 continue726 }727 recent++728 day := b.Created.In(now.Location()).Format(time.DateOnly)729 first := !days[day]730 days[day] = true731 if recent <= keepRecent || first && !b.Created.Before(since) {732 continue733 }734 drop = append(drop, b)735 }736 return drop737}738739func (s *Server) pruneBackups() {740 list, err := s.backupList()741 if err != nil {742 log.Printf("admin: listing backups: %v", err)743 return744 }745 for _, b := range prune(list, time.Now()) {746 if err := os.Remove(filepath.Join(s.Backups, b.File)); err != nil {747 log.Printf("admin: removing an old backup: %v", err)748 } else {749 log.Printf("admin: removed backup %s, which the automatic ones no longer keep", b.File)750 }751 }752}753754// ---------- automatic backups ----------755756// backupSoon backs the setup up once changes stop for a while: each757// change through the API starts the wait again.758func (s *Server) backupSoon() {759 if s.Backups == "" {760 return761 }762 b := &s.bk763 b.mu.Lock()764 defer b.mu.Unlock()765 if b.stopped {766 return767 }768 wait := b.delay769 if wait == 0 {770 wait = backupDelay771 }772 b.pending = true773 if b.timer == nil {774 b.timer = time.AfterFunc(wait, s.backupPending)775 } else {776 b.timer.Reset(wait)777 }778}779780// backupPending makes the backup changes are waiting for.781func (s *Server) backupPending() {782 b := &s.bk783 b.mu.Lock()784 if !b.pending || b.stopped {785 b.mu.Unlock()786 return787 }788 b.pending = false789 b.mu.Unlock()790 if _, _, err := s.backUp(afterChange, ""); err != nil {791 log.Printf("admin: backing up the channel setup: %v", err)792 }793}794795func (s *Server) backupWaiting() bool {796 s.bk.mu.Lock()797 defer s.bk.mu.Unlock()798 return s.bk.pending799}800801func (s *Server) backupsStopped() bool {802 s.bk.mu.Lock()803 defer s.bk.mu.Unlock()804 return s.bk.stopped805}806807// stopBackups makes no more automatic backups, once one under way is808// done.809func (s *Server) stopBackups() {810 s.bk.mu.Lock()811 s.bk.stopped = true812 if s.bk.timer != nil {813 s.bk.timer.Stop()814 }815 s.bk.mu.Unlock()816 s.bk.run.Lock()817 s.bk.run.Unlock()818}819820// Run makes the daily backups until ctx ends: a minute after it starts,821// then every day, the setup is backed up if it changed since the newest822// backup (by hand, say, or with a change the server stopped before823// backing up). Then changes waiting to be backed up are, and backups stop.824func (s *Server) Run(ctx context.Context) {825 if s.Backups == "" {826 return827 }828 t := time.NewTimer(firstCheck)829 defer t.Stop()830 for {831 select {832 case <-ctx.Done():833 if s.backupWaiting() {834 s.backupPending()835 }836 s.stopBackups()837 return838 case <-t.C:839 if _, _, err := s.backUp(daily, ""); err != nil {840 log.Printf("admin: backing up the channel setup: %v", err)841 }842 t.Reset(backupEvery)843 }844 }845}846847// codeWriter notes the status code of an answer.848type codeWriter struct {849 http.ResponseWriter850 code int851}852853func (w *codeWriter) WriteHeader(code int) {854 if w.code == 0 {855 w.code = code856 }857 w.ResponseWriter.WriteHeader(code)858}859860func (w *codeWriter) Write(p []byte) (int, error) {861 if w.code == 0 {862 w.code = http.StatusOK863 }864 return w.ResponseWriter.Write(p)865}866867// watch backs the setup up after changes through the API: every request868// that may change something (not a GET), and works, starts the wait for869// one again. The backups' own requests don't count (a restore starts the870// wait itself).871func (s *Server) watch(next http.Handler) http.Handler {872 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {873 p := r.URL.Path874 if r.Method == http.MethodGet || r.Method == http.MethodHead || !strings.HasPrefix(p, "/admin/api/") || strings.HasPrefix(p, "/admin/api/backups") {875 next.ServeHTTP(w, r)876 return877 }878 cw := &codeWriter{ResponseWriter: w}879 next.ServeHTTP(cw, r)880 if cw.code < http.StatusBadRequest {881 s.backupSoon()882 }883 })884}885886// ---------- API ----------887888func (s *Server) addBackupRoutes(mux *http.ServeMux) {889 mux.HandleFunc("GET /admin/api/backups", s.getBackups)890 mux.HandleFunc("POST /admin/api/backups", s.postBackup)891 mux.HandleFunc("POST /admin/api/backups/upload", s.uploadBackup)892 mux.HandleFunc("GET /admin/api/backups/{file}", s.downloadBackup)893 mux.HandleFunc("DELETE /admin/api/backups/{file}", s.deleteBackup)894 mux.HandleFunc("POST /admin/api/backups/{file}/restore", s.restoreBackup)895}896897// backupsOn answers for the backups when they're off.898func (s *Server) backupsOn(w http.ResponseWriter) bool {899 if s.Backups == "" {900 writeErr(w, http.StatusServiceUnavailable, errBackupsOff)901 return false902 }903 return true904}905906// getBackups lists the backups, newest first, with where they're kept and907// whether changes are waiting to be backed up.908func (s *Server) getBackups(w http.ResponseWriter, r *http.Request) {909 if !s.backupsOn(w) {910 return911 }912 list, err := s.backupList()913 if err != nil {914 writeErr(w, http.StatusInternalServerError, err)915 return916 }917 writeJSON(w, map[string]any{"backups": list, "folder": s.Backups, "pending": s.backupWaiting(),918 "keep": map[string]any{"recent": keepRecent, "days": keepDays, "delaySeconds": int(backupDelay.Seconds())}})919}920921// cleanLabel tidies a backup's label: one line of up to maxLabel922// characters.923func cleanLabel(label string) (string, error) {924 label = strings.Join(strings.FieldsFunc(label, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }), " ")925 if utf8.RuneCountInString(label) > maxLabel {926 return "", fmt.Errorf("the label is up to %d characters", maxLabel)927 }928 return label, nil929}930931// postBackup backs the setup up now, with an optional label.932func (s *Server) postBackup(w http.ResponseWriter, r *http.Request) {933 if !s.backupsOn(w) {934 return935 }936 var req struct {937 Label string `json:"label"`938 }939 if !decodeOptional(w, r, &req) {940 return941 }942 label, err := cleanLabel(req.Label)943 if err != nil {944 writeErr(w, http.StatusBadRequest, err)945 return946 }947 b, _, err := s.backUp(byHand, label)948 if err != nil {949 writeErr(w, http.StatusInternalServerError, err)950 return951 }952 w.Header().Set("Content-Type", "application/json")953 w.WriteHeader(http.StatusCreated)954 _ = json.NewEncoder(w).Encode(b)955}956957// decodeOptional reads a JSON body that may be left out.958func decodeOptional(w http.ResponseWriter, r *http.Request, v any) bool {959 err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(v)960 if err != nil && !errors.Is(err, io.EOF) {961 writeErr(w, http.StatusBadRequest, err)962 return false963 }964 return true965}966967func (s *Server) downloadBackup(w http.ResponseWriter, r *http.Request) {968 if !s.backupsOn(w) {969 return970 }971 file := r.PathValue("file")972 p := s.backupPath(file)973 f, err := os.Open(p)974 if p == "" || err != nil {975 writeErr(w, http.StatusNotFound, fmt.Errorf("no backup %s", file))976 return977 }978 defer f.Close()979 info, err := f.Stat()980 if err != nil || !info.Mode().IsRegular() {981 writeErr(w, http.StatusNotFound, fmt.Errorf("no backup %s", file))982 return983 }984 w.Header().Set("Content-Type", "application/gzip")985 w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": file}))986 w.Header().Set("Cache-Control", "no-store")987 http.ServeContent(w, r, "", info.ModTime(), f)988}989990func (s *Server) deleteBackup(w http.ResponseWriter, r *http.Request) {991 if !s.backupsOn(w) {992 return993 }994 file := r.PathValue("file")995 p := s.backupPath(file)996 if info, err := os.Lstat(p); p == "" || err != nil || !info.Mode().IsRegular() {997 writeErr(w, http.StatusNotFound, fmt.Errorf("no backup %s", file))998 return999 }1000 s.bk.run.Lock()1001 err := os.Remove(p)1002 s.bk.run.Unlock()1003 if err != nil {1004 writeErr(w, http.StatusInternalServerError, err)1005 return1006 }1007 log.Printf("admin: deleted backup %s", file)1008 w.WriteHeader(http.StatusNoContent)1009}10101011// uploadBackup takes a backup file (the body, or the first file in a1012// multipart form), checks it, and keeps it with the others, to restore1013// from. It's kept as an Airwaves backup, whatever its format: with the1014// time it was made (or its newest file's), and labelled with its file1015// name. A backup that's there already is answered with as it is.1016func (s *Server) uploadBackup(w http.ResponseWriter, r *http.Request) {1017 if !s.backupsOn(w) {1018 return1019 }1020 data, name, err := readUpload(w, r)1021 if err != nil {1022 code := http.StatusBadRequest1023 if tooBig(err) {1024 code, err = http.StatusRequestEntityTooLarge, fmt.Errorf("a backup is under %d MB", maxBackup>>20)1025 }1026 writeErr(w, code, err)1027 return1028 }1029 a, err := readArchive(bytes.NewReader(data))1030 if err != nil {1031 writeErr(w, http.StatusBadRequest, err)1032 return1033 }1034 m := a.setup.manifest(uploaded, "", "", a.newest)1035 if a.man != nil {1036 m.Created, m.Airwavesd, m.Label = a.man.Created, a.man.Airwavesd, a.man.Label1037 }1038 if m.Created.IsZero() {1039 m.Created = time.Now()1040 }1041 if m.Label == "" {1042 base := filepath.Base(strings.ReplaceAll(name, `\`, "/"))1043 for _, ext := range []string{backupExt, ".tgz", ".gz"} {1044 base = strings.TrimSuffix(base, ext)1045 }1046 m.Label, _ = cleanLabel(string([]rune(base)[:min(utf8.RuneCountInString(base), maxLabel)]))1047 }1048 s.bk.run.Lock()1049 defer s.bk.run.Unlock()1050 digest := a.setup.digest()1051 if list, err := s.backupList(); err == nil {1052 for _, b := range list {1053 if b.digest == digest && b.Created.Equal(m.Created) {1054 writeJSON(w, map[string]any{"backup": b, "skipped": orEmpty(a.skipped), "existing": true})1055 return1056 }1057 }1058 }1059 b, err := s.saveBackup(m, a.setup)1060 if err != nil {1061 writeErr(w, http.StatusInternalServerError, err)1062 return1063 }1064 log.Printf("admin: took in an uploaded backup as %s", b.File)1065 w.Header().Set("Content-Type", "application/json")1066 w.WriteHeader(http.StatusCreated)1067 _ = json.NewEncoder(w).Encode(map[string]any{"backup": b, "skipped": orEmpty(a.skipped)})1068}10691070// readUpload reads an uploaded file: the request's body (named by ?name=),1071// or the first file in a multipart form.1072func readUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error) {1073 r.Body = http.MaxBytesReader(w, r.Body, maxBackup+64<<10)1074 var src io.Reader = r.Body1075 name := r.URL.Query().Get("name")1076 if kind, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")); err == nil && strings.HasPrefix(kind, "multipart/") {1077 form := multipart.NewReader(r.Body, params["boundary"])1078 for {1079 part, err := form.NextPart()1080 if err != nil {1081 if tooBig(err) {1082 return nil, "", err1083 }1084 return nil, "", errors.New("the form has no backup file")1085 }1086 if part.FileName() != "" {1087 src, name = part, part.FileName()1088 break1089 }1090 }1091 }1092 data, err := io.ReadAll(io.LimitReader(src, maxBackup+1))1093 switch {1094 case err != nil:1095 return nil, "", err1096 case len(data) > maxBackup:1097 return nil, "", &http.MaxBytesError{Limit: maxBackup}1098 case len(data) == 0:1099 return nil, "", errors.New("send the backup file")1100 }1101 return data, name, nil1102}