Files
hamlab/internal/files/files.go
T
Mitchell-JohnsonandClaude Fable 5.1 488d929733 Portable install: the binary installs itself; static builds on five architectures; root via sudo, doas or uid 0; every package manager; older systemd; no lsblk
- hamlab install / uninstall (internal/setup): systemd or OpenRC, elevates through
  sudo or doas, runs as the invoking user, sets or generates the password, names only
  journal groups that exist, waits for /healthz, reports an active firewalld/ufw.
  --purge and the data chown never touch a directory that isn't Ham Lab's alone.
- install.sh: curl-able bootstrap (arch detection, SHA256SUMS, offline beside the
  binaries, noexec /tmp fallback). make release builds dist/ for amd64, arm64, arm
  (ARMv6), 386, riscv64. Every build is CGO_ENABLED=0; the native one used to link glibc.
- internal/priv replaces every literal `sudo -n`: uid 0, sudo or doas.
- Updates acts on dnf, zypper, pacman and apk as well as apt; dnf shows installed versions.
- Services falls back to the plain systemctl table before systemd 246 and says so on
  hosts without systemd. Disks falls back to /sys/block without lsblk.
- Listener binds with IP_FREEBIND, so a LAN address that isn't up at boot is no failure.
- Self-update: the new build must pass `check` against the live config, is put in
  place by atomic rename, and the daemon re-execs itself (same under any supervisor).
- State and config writes are fsynced; a corrupt state.json is set aside, not fatal.
- Terminal shutdown no longer empties the saved list, so names come back after a
  restart by design rather than by winning a race.
- Config default is ~/.config/hamlab; an existing ~/apps/hamlab is still found.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 23:00:20 -07:00

504 lines
12 KiB
Go

// Package files is the Files tab: browse, read, write, upload, download, mkdir,
// rename, chmod and delete anywhere on the host.
//
// Two backends share one interface. Local runs in-process as the daemon's user. Sudo
// re-executes this same binary as root (`sudo -n hamlab fs …`) for one operation at a
// time, streaming file bodies over its stdin/stdout, so root access uses exactly the
// same code and the same validation as the unprivileged path.
package files
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/user"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
"hamlab/internal/priv"
)
type Entry struct {
Name string `json:"name"`
Type string `json:"type"` // dir | file | link | other
Size int64 `json:"size"`
Mode string `json:"mode"` // "-rw-r--r--"
Perm string `json:"perm"` // "0644"
Mtime int64 `json:"mtime"`
User string `json:"user"`
Group string `json:"group"`
Target string `json:"target,omitempty"` // link target
Broken bool `json:"broken,omitempty"` // dangling link
}
type Listing struct {
Path string `json:"path"`
Parent string `json:"parent,omitempty"`
Entries []Entry `json:"entries"`
}
type Info struct {
Entry
Path string `json:"path"`
Text bool `json:"text"` // looks like text (for the editor)
}
// FS is what the API talks to.
type FS interface {
List(ctx context.Context, path string) (Listing, error)
Stat(ctx context.Context, path string) (Info, error)
Open(ctx context.Context, path string) (io.ReadCloser, error)
Write(ctx context.Context, path string, r io.Reader) error
Mkdir(ctx context.Context, path string) error
Touch(ctx context.Context, path string) error
Rename(ctx context.Context, from, to string) error
Delete(ctx context.Context, path string, recursive bool) error
Chmod(ctx context.Context, path string, perm os.FileMode) error
}
// ---- path rules ----
// Clean rejects relative paths and normalises the rest. Everything else on the host is
// reachable on purpose: this is a root-capable console, the gate is the login.
func Clean(p string) (string, error) {
if p == "" {
return "", errors.New("path required")
}
if !strings.HasPrefix(p, "/") {
return "", errors.New("path must be absolute")
}
c := filepath.Clean(p)
if strings.ContainsRune(c, 0) {
return "", errors.New("bad path")
}
return c, nil
}
// protected are never deleted or renamed as a whole, no matter who asks.
var protected = map[string]bool{"/": true, "/bin": true, "/boot": true, "/dev": true, "/etc": true, "/home": true, "/lib": true, "/lib64": true,
"/opt": true, "/proc": true, "/root": true, "/run": true, "/sbin": true, "/srv": true, "/sys": true, "/tmp": true, "/usr": true, "/var": true}
func ParsePerm(s string) (os.FileMode, error) {
v, err := strconv.ParseUint(strings.TrimPrefix(s, "0"), 8, 32)
if err != nil || v > 0o7777 {
return 0, errors.New("mode must be octal like 0644")
}
return os.FileMode(v), nil
}
// ---- Local ----
type Local struct{}
var uidCache, gidCache = map[uint32]string{}, map[uint32]string{}
func owner(st os.FileInfo) (string, string) {
sys, ok := st.Sys().(*syscall.Stat_t)
if !ok {
return "", ""
}
u, ok := uidCache[sys.Uid]
if !ok {
u = strconv.Itoa(int(sys.Uid))
if x, err := user.LookupId(u); err == nil {
u = x.Username
}
uidCache[sys.Uid] = u
}
g, ok := gidCache[sys.Gid]
if !ok {
g = strconv.Itoa(int(sys.Gid))
if x, err := user.LookupGroupId(g); err == nil {
g = x.Name
}
gidCache[sys.Gid] = g
}
return u, g
}
func entryOf(dir, name string, lst os.FileInfo) Entry {
e := Entry{Name: name, Size: lst.Size(), Mode: lst.Mode().String(), Perm: fmt.Sprintf("%04o", lst.Mode().Perm()), Mtime: lst.ModTime().UnixMilli()}
e.User, e.Group = owner(lst)
switch {
case lst.Mode()&os.ModeSymlink != 0:
e.Type = "link"
full := filepath.Join(dir, name)
e.Target, _ = os.Readlink(full)
if st, err := os.Stat(full); err != nil {
e.Broken = true
} else if st.IsDir() {
e.Type = "dir" // keep dir semantics for navigation; Target says it's a link
} else {
e.Size = st.Size()
}
case lst.IsDir():
e.Type = "dir"
case lst.Mode().IsRegular():
e.Type = "file"
default:
e.Type = "other"
}
return e
}
func (Local) List(_ context.Context, path string) (Listing, error) {
ents, err := os.ReadDir(path)
if err != nil {
return Listing{}, err
}
l := Listing{Path: path, Entries: make([]Entry, 0, len(ents))}
if path != "/" {
l.Parent = filepath.Dir(path)
}
for _, d := range ents {
lst, err := d.Info() // Lstat
if err != nil {
continue
}
l.Entries = append(l.Entries, entryOf(path, d.Name(), lst))
}
sort.Slice(l.Entries, func(i, j int) bool {
a, b := l.Entries[i], l.Entries[j]
if (a.Type == "dir") != (b.Type == "dir") {
return a.Type == "dir"
}
return strings.ToLower(a.Name) < strings.ToLower(b.Name)
})
return l, nil
}
func (Local) Stat(_ context.Context, path string) (Info, error) {
lst, err := os.Lstat(path)
if err != nil {
return Info{}, err
}
in := Info{Entry: entryOf(filepath.Dir(path), filepath.Base(path), lst), Path: path}
if in.Type == "file" && in.Size <= 8<<20 {
if f, err := os.Open(path); err == nil {
buf := make([]byte, 4096)
n, _ := f.Read(buf)
f.Close()
in.Text = looksText(buf[:n])
}
}
return in, nil
}
func looksText(b []byte) bool {
if len(b) == 0 {
return true
}
ctrl := 0
for _, c := range b {
if c == 0 {
return false
}
if c < 32 && c != '\n' && c != '\r' && c != '\t' && c != '\f' && c != 0x1b {
ctrl++
}
}
return ctrl*20 < len(b)
}
// Open refuses anything that isn't a regular file: devices stream forever (/dev/zero),
// FIFOs block on open, sockets can't be read. /proc files are regular and still work.
func (Local) Open(_ context.Context, path string) (io.ReadCloser, error) {
st, err := os.Stat(path)
if err != nil {
return nil, err
}
if st.IsDir() {
return nil, errors.New("is a directory")
}
if !st.Mode().IsRegular() {
return nil, errors.New("not a regular file (device, pipe or socket)")
}
return os.Open(path)
}
// Write replaces the file's contents, keeping its mode; new files get 0644 (umask).
func (Local) Write(_ context.Context, path string, r io.Reader) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
if _, err := io.Copy(f, r); err != nil {
f.Close()
return err
}
return f.Close()
}
func (Local) Mkdir(_ context.Context, path string) error { return os.Mkdir(path, 0o755) }
func (Local) Touch(_ context.Context, path string) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return err
}
return f.Close()
}
func (Local) Rename(_ context.Context, from, to string) error {
if protected[from] {
return errors.New("refusing to move " + from)
}
if _, err := os.Lstat(to); err == nil {
return errors.New("destination exists")
}
return os.Rename(from, to)
}
func (Local) Delete(_ context.Context, path string, recursive bool) error {
if protected[path] {
return errors.New("refusing to delete " + path)
}
lst, err := os.Lstat(path)
if err != nil {
return err
}
if lst.IsDir() && recursive {
return os.RemoveAll(path)
}
return os.Remove(path) // fails on a non-empty dir, by design
}
func (Local) Chmod(_ context.Context, path string, perm os.FileMode) error {
return os.Chmod(path, perm)
}
// ---- Sudo: the same operations, run as root in a child hamlab ----
type Sudo struct{ Self string }
func (s Sudo) cmd(ctx context.Context, args ...string) *exec.Cmd {
full := priv.Wrap(append([]string{s.Self, "fs"}, args...)...)
return exec.CommandContext(ctx, full[0], full[1:]...)
}
func (s Sudo) runJSON(ctx context.Context, out any, args ...string) error {
c := s.cmd(ctx, args...)
var errb strings.Builder
c.Stderr = &errb
b, err := c.Output()
if err != nil {
if msg := strings.TrimSpace(errb.String()); msg != "" {
return errors.New(msg)
}
return err
}
if out == nil {
return nil
}
return json.Unmarshal(b, out)
}
func (s Sudo) List(ctx context.Context, path string) (Listing, error) {
var l Listing
err := s.runJSON(ctx, &l, "list", path)
return l, err
}
func (s Sudo) Stat(ctx context.Context, path string) (Info, error) {
var in Info
err := s.runJSON(ctx, &in, "stat", path)
return in, err
}
type cmdReader struct {
io.ReadCloser
cmd *exec.Cmd
errb *strings.Builder
}
func (r *cmdReader) Close() error {
r.ReadCloser.Close()
err := r.cmd.Wait()
if err != nil && r.errb.Len() > 0 {
return errors.New(strings.TrimSpace(r.errb.String()))
}
return err
}
func (s Sudo) Open(ctx context.Context, path string) (io.ReadCloser, error) {
c := s.cmd(ctx, "read", path)
errb := &strings.Builder{}
c.Stderr = errb
out, err := c.StdoutPipe()
if err != nil {
return nil, err
}
if err := c.Start(); err != nil {
return nil, err
}
// Fail early on "no such file" etc.: the helper stats before writing anything.
head := make([]byte, 1)
n, rerr := out.Read(head)
if n == 0 && rerr != nil {
_ = c.Wait()
if msg := strings.TrimSpace(errb.String()); msg != "" {
return nil, errors.New(msg)
}
if rerr != io.EOF {
return nil, rerr
}
}
return &cmdReader{ReadCloser: struct {
io.Reader
io.Closer
}{io.MultiReader(strings.NewReader(string(head[:n])), out), out}, cmd: c, errb: errb}, nil
}
func (s Sudo) Write(ctx context.Context, path string, r io.Reader) error {
c := s.cmd(ctx, "write", path)
c.Stdin = r
var errb strings.Builder
c.Stderr = &errb
if err := c.Run(); err != nil {
if msg := strings.TrimSpace(errb.String()); msg != "" {
return errors.New(msg)
}
return err
}
return nil
}
func (s Sudo) Mkdir(ctx context.Context, path string) error {
return s.runJSON(ctx, nil, "mkdir", path)
}
func (s Sudo) Touch(ctx context.Context, path string) error {
return s.runJSON(ctx, nil, "touch", path)
}
func (s Sudo) Rename(ctx context.Context, from, to string) error {
return s.runJSON(ctx, nil, "rename", from, to)
}
func (s Sudo) Delete(ctx context.Context, path string, recursive bool) error {
if recursive {
return s.runJSON(ctx, nil, "delete", "-r", path)
}
return s.runJSON(ctx, nil, "delete", path)
}
func (s Sudo) Chmod(ctx context.Context, path string, perm os.FileMode) error {
return s.runJSON(ctx, nil, "chmod", fmt.Sprintf("%04o", perm), path)
}
// Helper implements `hamlab fs <op> …` for the Sudo backend. Returns the exit code.
func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
fail := func(err error) int { fmt.Fprintln(stderr, err.Error()); return 1 }
if len(args) < 2 {
return fail(errors.New("usage: hamlab fs <list|stat|read|write|mkdir|touch|rename|delete|chmod> …"))
}
ctx := context.Background()
var l Local
op := args[0]
clean := func(i int) (string, error) {
if i >= len(args) {
return "", errors.New("path required")
}
return Clean(args[i])
}
switch op {
case "list", "stat", "read", "write", "mkdir", "touch":
p, err := clean(1)
if err != nil {
return fail(err)
}
switch op {
case "list":
out, err := l.List(ctx, p)
if err != nil {
return fail(err)
}
return emit(stdout, out, stderr)
case "stat":
out, err := l.Stat(ctx, p)
if err != nil {
return fail(err)
}
return emit(stdout, out, stderr)
case "read":
f, err := l.Open(ctx, p)
if err != nil {
return fail(err)
}
defer f.Close()
if _, err := io.Copy(stdout, f); err != nil {
return fail(err)
}
return 0
case "write":
if err := l.Write(ctx, p, stdin); err != nil {
return fail(err)
}
case "mkdir":
if err := l.Mkdir(ctx, p); err != nil {
return fail(err)
}
case "touch":
if err := l.Touch(ctx, p); err != nil {
return fail(err)
}
}
case "rename":
from, err := clean(1)
if err != nil {
return fail(err)
}
to, err := clean(2)
if err != nil {
return fail(err)
}
if err := l.Rename(ctx, from, to); err != nil {
return fail(err)
}
case "delete":
rec := false
i := 1
if args[1] == "-r" {
rec, i = true, 2
}
p, err := clean(i)
if err != nil {
return fail(err)
}
if err := l.Delete(ctx, p, rec); err != nil {
return fail(err)
}
case "chmod":
perm, err := ParsePerm(args[1])
if err != nil {
return fail(err)
}
p, err := clean(2)
if err != nil {
return fail(err)
}
if err := l.Chmod(ctx, p, perm); err != nil {
return fail(err)
}
default:
return fail(errors.New("unknown op " + op))
}
fmt.Fprintln(stdout, "{}")
return 0
}
func emit(w io.Writer, v any, stderr io.Writer) int {
if err := json.NewEncoder(w).Encode(v); err != nil {
fmt.Fprintln(stderr, err.Error())
return 1
}
return 0
}
// ModTime helper for handlers.
func Now() time.Time { return time.Now() }