package host import ( "errors" "fmt" "log" "os" "os/user" "path/filepath" "strconv" "strings" "github.com/carlosframework/platform/internal/registry" ) // Root passes keep SQLite in a child running as the service user. func openServiceRegistry(path string) (*registry.Registry, error) { return openRegistryConverging(os.Geteuid(), serviceUser, path) } // openRegistryConverging is openServiceRegistry with the two facts it reads // from the process passed in; see chownRegistryFiles. func openRegistryConverging(euid int, svcUser, path string) (*registry.Registry, error) { if euid != 0 { return registry.Open(path) } u, err := user.Lookup(svcUser) if err != nil { return nil, err } uid, err := strconv.Atoi(u.Uid) if err != nil { return nil, err } gid, err := strconv.Atoi(u.Gid) if err != nil { return nil, err } if uid <= 0 || gid < 0 { return nil, fmt.Errorf("registry: invalid service credentials") } if err := chownRegistryFiles(euid, svcUser, path); err != nil { return nil, err } return openRegistryChild(path, uid, gid) } // chownRegistryToService repairs old root-owned files through checked descriptors. func chownRegistryToService(path string) { // Geteuid reports -1 on platforms without the concept (the CLI // cross-compiles to windows), which takes the same early exit. chownRegistryFiles(os.Geteuid(), serviceUser, path) } // chownRegistryFiles is chownRegistryToService with the process facts — the // effective uid and the service user's name — made arguments, so a test can // drive the root arm, and both of its failure arms, from a test binary that // is not root on a box that has no carlos user. func chownRegistryFiles(euid int, svcUser, path string) error { if euid != 0 { return nil } u, err := user.Lookup(svcUser) if err != nil { log.Printf("adopt: registry ownership: no %s user: %v", svcUser, err) return err } uid, err := strconv.Atoi(u.Uid) if err != nil { return err } gid, err := strconv.Atoi(u.Gid) if err != nil { return err } var refused error for _, p := range registryFiles(path) { changed, err := ownRegistryFile(p, uid, gid) if errors.Is(err, os.ErrNotExist) { continue } if err != nil { log.Printf("adopt: ALERT: chown %s to %s: %v", p, svcUser, err) refused = errors.Join(refused, err) continue } if changed { log.Printf("adopt: %s was not owned by %s — fixed; the edge runs as %s and could not have written it", p, svcUser, svcUser) } } return refused } // registryFiles is the database and the two sidecars SQLite keeps beside it // in WAL mode. Ownership has to cover all three: a carlos-owned database with // a root-owned -wal is just as unwritable as a root-owned database. func registryFiles(path string) []string { return []string{path, path + "-wal", path + "-shm"} } // registryOpenHint turns SQLite's words into the box's words. // // "attempt to write a readonly database (8)" is what the edge printed on all // three boxes, and it sent the operator looking at disk space and mount // options before file ownership. The hint exists so the next person does not // lose that time — which means it has to be RIGHT and not merely helpful: a // hint that names ownership where ownership is not the cause sends them // somewhere just as wrong, with more confidence behind it. // // So it says one of three things, and the process's own euid picks which: // // - As ROOT, ownership is never the answer. The root units drop no // capabilities, so root holds CAP_DAC_OVERRIDE and writes the file // whatever its owner and mode; a chown is the one action that cannot // help. What remains possible for root is a read-only remount, an // immutable attribute, a hot journal, or a MAC denial — and those are // what it names. // - Off root, holding a file this process does not own, ownership IS the // answer and it gives the chown. // - Off root with everything already owned correctly, ownership is not the // answer either, so it points at the mount and the unit sandbox rather // than repeating advice the operator has already taken. // // It stats all three of registryFiles rather than the database alone. A // carlos-owned database with a root-owned -wal reports the identical error, // and that is exactly the shape a hand chown of the database alone leaves // behind — the second failure in #8, and why two of the three boxes needed // the chown twice. Reporting the database alone printed "owned by carlos … // must be owned by carlos" there: a self-contradiction, on the very case that // motivated the hint. // // It returns "" for any other error, so a genuinely full disk still reads as // itself. func registryOpenHint(path string, err error) string { return registryOpenHintFor(os.Geteuid(), path, err) } // registryOpenHintFor is registryOpenHint with the effective uid passed in, // so the root and non-root wordings are both reachable from one test binary. func registryOpenHintFor(euid int, path string, err error) string { if err == nil || !strings.Contains(strings.ToLower(err.Error()), "readonly database") { return "" } var b strings.Builder fmt.Fprintf(&b, "the registry is not writable by this process (uid %d).", euid) // present counts the files that exist; mine, the ones this process owns. // They are equal only when there is nothing left for a chown to fix. var present, mine int for _, p := range registryFiles(path) { fi, statErr := os.Stat(p) if statErr != nil { // A missing -wal/-shm is the normal state between connections, // not a finding to report. continue } present++ uid, gid, ok := fileOwner(fi) if !ok { // Off unix there is no owner to read. Counted as present but // never as mine, so the hint never claims ownership is fine on // evidence it does not have. fmt.Fprintf(&b, " %s exists (owner unreadable on this platform).", p) continue } if uid == euid { mine++ } fmt.Fprintf(&b, " %s is owned by %s (uid %d, gid %d).", p, ownerName(uid), uid, gid) } dir := filepath.Dir(path) switch { case euid == 0: fmt.Fprintf(&b, " Running as root, this is NOT an ownership problem: the root units drop"+ " no capabilities, so root holds CAP_DAC_OVERRIDE and writes the file whatever its"+ " owner and mode — changing the owner would change nothing. What can still produce"+ " it for root: the filesystem remounted read-only after an I/O error"+ " (`dmesg -T | tail`, `findmnt -no OPTIONS %s`), an immutable attribute on one of"+ " the files (`lsattr %s*`), a hot journal or -wal left by a crash that SQLite"+ " cannot replay, or a MAC denial (SELinux/AppArmor) on the write.", dir, path) case present > 0 && present == mine: fmt.Fprintf(&b, " Ownership is already correct — every file that exists is owned by this"+ " uid — so setting it again will not help. Look instead at the mount (a read-only"+ " remount after an I/O error, or a read-only %s: `findmnt -no OPTIONS %s`) and at"+ " this unit's sandbox (ProtectSystem= or ReadOnlyPaths=, or %s missing from"+ " ReadWritePaths=).", dir, dir, dir) default: fmt.Fprintf(&b, " It and its -wal/-shm sidecars must be owned by %s, the user"+ " carlos-edge.service runs as. A root `carlos adopt` pass that reached the file"+ " before the edge did is the usual cause. Fix: chown %s:%s %s*", serviceUser, serviceUser, serviceUser, path) } return b.String() } // ownerName resolves a uid to a username, falling back to the number itself. // A box whose passwd lookup is broken still gets a usable hint. func ownerName(uid int) string { if u, err := user.LookupId(strconv.Itoa(uid)); err == nil && u.Username != "" { return u.Username } return strconv.Itoa(uid) }