carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "errors"
5 "fmt"
6 "log"
7 "os"
8 "os/user"
9 "path/filepath"
10 "strconv"
11 "strings"
12
13 "github.com/carlosframework/platform/internal/registry"
14)
15
16// Root passes keep SQLite in a child running as the service user.
17func openServiceRegistry(path string) (*registry.Registry, error) {
18 return openRegistryConverging(os.Geteuid(), serviceUser, path)
19}
20
21// openRegistryConverging is openServiceRegistry with the two facts it reads
22// from the process passed in; see chownRegistryFiles.
23func openRegistryConverging(euid int, svcUser, path string) (*registry.Registry, error) {
24 if euid != 0 {
25 return registry.Open(path)
26 }
27 u, err := user.Lookup(svcUser)
28 if err != nil {
29 return nil, err
30 }
31 uid, err := strconv.Atoi(u.Uid)
32 if err != nil {
33 return nil, err
34 }
35 gid, err := strconv.Atoi(u.Gid)
36 if err != nil {
37 return nil, err
38 }
39 if uid <= 0 || gid < 0 {
40 return nil, fmt.Errorf("registry: invalid service credentials")
41 }
42 if err := chownRegistryFiles(euid, svcUser, path); err != nil {
43 return nil, err
44 }
45 return openRegistryChild(path, uid, gid)
46}
47
48// chownRegistryToService repairs old root-owned files through checked descriptors.
49func chownRegistryToService(path string) {
50 // Geteuid reports -1 on platforms without the concept (the CLI
51 // cross-compiles to windows), which takes the same early exit.
52 chownRegistryFiles(os.Geteuid(), serviceUser, path)
53}
54
55// chownRegistryFiles is chownRegistryToService with the process facts — the
56// effective uid and the service user's name — made arguments, so a test can
57// drive the root arm, and both of its failure arms, from a test binary that
58// is not root on a box that has no carlos user.
59func chownRegistryFiles(euid int, svcUser, path string) error {
60 if euid != 0 {
61 return nil
62 }
63 u, err := user.Lookup(svcUser)
64 if err != nil {
65 log.Printf("adopt: registry ownership: no %s user: %v", svcUser, err)
66 return err
67 }
68 uid, err := strconv.Atoi(u.Uid)
69 if err != nil {
70 return err
71 }
72 gid, err := strconv.Atoi(u.Gid)
73 if err != nil {
74 return err
75 }
76 var refused error
77 for _, p := range registryFiles(path) {
78 changed, err := ownRegistryFile(p, uid, gid)
79 if errors.Is(err, os.ErrNotExist) {
80 continue
81 }
82 if err != nil {
83 log.Printf("adopt: ALERT: chown %s to %s: %v", p, svcUser, err)
84 refused = errors.Join(refused, err)
85 continue
86 }
87 if changed {
88 log.Printf("adopt: %s was not owned by %s — fixed; the edge runs as %s and could not have written it", p, svcUser, svcUser)
89 }
90 }
91 return refused
92}
93
94// registryFiles is the database and the two sidecars SQLite keeps beside it
95// in WAL mode. Ownership has to cover all three: a carlos-owned database with
96// a root-owned -wal is just as unwritable as a root-owned database.
97func registryFiles(path string) []string {
98 return []string{path, path + "-wal", path + "-shm"}
99}
100
101// registryOpenHint turns SQLite's words into the box's words.
102//
103// "attempt to write a readonly database (8)" is what the edge printed on all
104// three boxes, and it sent the operator looking at disk space and mount
105// options before file ownership. The hint exists so the next person does not
106// lose that time — which means it has to be RIGHT and not merely helpful: a
107// hint that names ownership where ownership is not the cause sends them
108// somewhere just as wrong, with more confidence behind it.
109//
110// So it says one of three things, and the process's own euid picks which:
111//
112// - As ROOT, ownership is never the answer. The root units drop no
113// capabilities, so root holds CAP_DAC_OVERRIDE and writes the file
114// whatever its owner and mode; a chown is the one action that cannot
115// help. What remains possible for root is a read-only remount, an
116// immutable attribute, a hot journal, or a MAC denial — and those are
117// what it names.
118// - Off root, holding a file this process does not own, ownership IS the
119// answer and it gives the chown.
120// - Off root with everything already owned correctly, ownership is not the
121// answer either, so it points at the mount and the unit sandbox rather
122// than repeating advice the operator has already taken.
123//
124// It stats all three of registryFiles rather than the database alone. A
125// carlos-owned database with a root-owned -wal reports the identical error,
126// and that is exactly the shape a hand chown of the database alone leaves
127// behind — the second failure in #8, and why two of the three boxes needed
128// the chown twice. Reporting the database alone printed "owned by carlos …
129// must be owned by carlos" there: a self-contradiction, on the very case that
130// motivated the hint.
131//
132// It returns "" for any other error, so a genuinely full disk still reads as
133// itself.
134func registryOpenHint(path string, err error) string {
135 return registryOpenHintFor(os.Geteuid(), path, err)
136}
137
138// registryOpenHintFor is registryOpenHint with the effective uid passed in,
139// so the root and non-root wordings are both reachable from one test binary.
140func registryOpenHintFor(euid int, path string, err error) string {
141 if err == nil || !strings.Contains(strings.ToLower(err.Error()), "readonly database") {
142 return ""
143 }
144 var b strings.Builder
145 fmt.Fprintf(&b, "the registry is not writable by this process (uid %d).", euid)
146
147 // present counts the files that exist; mine, the ones this process owns.
148 // They are equal only when there is nothing left for a chown to fix.
149 var present, mine int
150 for _, p := range registryFiles(path) {
151 fi, statErr := os.Stat(p)
152 if statErr != nil {
153 // A missing -wal/-shm is the normal state between connections,
154 // not a finding to report.
155 continue
156 }
157 present++
158 uid, gid, ok := fileOwner(fi)
159 if !ok {
160 // Off unix there is no owner to read. Counted as present but
161 // never as mine, so the hint never claims ownership is fine on
162 // evidence it does not have.
163 fmt.Fprintf(&b, " %s exists (owner unreadable on this platform).", p)
164 continue
165 }
166 if uid == euid {
167 mine++
168 }
169 fmt.Fprintf(&b, " %s is owned by %s (uid %d, gid %d).", p, ownerName(uid), uid, gid)
170 }
171
172 dir := filepath.Dir(path)
173 switch {
174 case euid == 0:
175 fmt.Fprintf(&b, " Running as root, this is NOT an ownership problem: the root units drop"+
176 " no capabilities, so root holds CAP_DAC_OVERRIDE and writes the file whatever its"+
177 " owner and mode — changing the owner would change nothing. What can still produce"+
178 " it for root: the filesystem remounted read-only after an I/O error"+
179 " (`dmesg -T | tail`, `findmnt -no OPTIONS %s`), an immutable attribute on one of"+
180 " the files (`lsattr %s*`), a hot journal or -wal left by a crash that SQLite"+
181 " cannot replay, or a MAC denial (SELinux/AppArmor) on the write.", dir, path)
182 case present > 0 && present == mine:
183 fmt.Fprintf(&b, " Ownership is already correct — every file that exists is owned by this"+
184 " uid — so setting it again will not help. Look instead at the mount (a read-only"+
185 " remount after an I/O error, or a read-only %s: `findmnt -no OPTIONS %s`) and at"+
186 " this unit's sandbox (ProtectSystem= or ReadOnlyPaths=, or %s missing from"+
187 " ReadWritePaths=).", dir, dir, dir)
188 default:
189 fmt.Fprintf(&b, " It and its -wal/-shm sidecars must be owned by %s, the user"+
190 " carlos-edge.service runs as. A root `carlos adopt` pass that reached the file"+
191 " before the edge did is the usual cause. Fix: chown %s:%s %s*",
192 serviceUser, serviceUser, serviceUser, path)
193 }
194 return b.String()
195}
196
197// ownerName resolves a uid to a username, falling back to the number itself.
198// A box whose passwd lookup is broken still gets a usable hint.
199func ownerName(uid int) string {
200 if u, err := user.LookupId(strconv.Itoa(uid)); err == nil && u.Username != "" {
201 return u.Username
202 }
203 return strconv.Itoa(uid)
204}
205