- 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>
504 lines
12 KiB
Go
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() }
|