usermod --gid regroups every file under an account's home, and accounts were made with / as home: they now have /nonexistent, and an older account's home is moved there (without its files) before its group changes. A sub-path asked for later that holds data not in the group waits for the operator as the root would. The take act gives the group to hardlinked files an account owns (the *arr apps' downloads) and leaves root's; a file's setuid or setgid bit the regroup takes is never put back, so no setgid file hands out the library's group; each is counted. An apply no longer waits for a running take walk.
372 lines
14 KiB
Go
372 lines
14 KiB
Go
package apply
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
"github.com/novox/mesh-host/internal/store"
|
|
)
|
|
|
|
// The engine's half of ADR 0325 §3 and §11 (issue 567): every module given an account and a group of its own with
|
|
// the numbers the controller allocated, a container told to run as it, and never a root number.
|
|
|
|
// accountsMachine is a user and group database the runner answers from, and every command that changed it.
|
|
type accountsMachine struct {
|
|
users, groups map[string][2]int // name → id, primary gid (a group's second is unused)
|
|
// in is each account's added groups, by name; homes each account's home, "/" when not set.
|
|
in map[string][]string
|
|
homes map[string]string
|
|
ran []string
|
|
}
|
|
|
|
// groupNamed is the name of gid in m, or the number.
|
|
func (m *accountsMachine) groupNamed(gid int) string {
|
|
for n, ids := range m.groups {
|
|
if ids[0] == gid {
|
|
return n
|
|
}
|
|
}
|
|
return strconv.Itoa(gid)
|
|
}
|
|
|
|
func (m *accountsMachine) run(_ context.Context, name string, args ...string) (string, error) {
|
|
line := name + " " + strings.Join(args, " ")
|
|
switch name {
|
|
case "getent":
|
|
db, key := args[0], args[1]
|
|
table := m.users
|
|
if db == "group" {
|
|
table = m.groups
|
|
}
|
|
for n, ids := range table {
|
|
if n == key || strconv.Itoa(ids[0]) == key {
|
|
if db == "group" {
|
|
return n + ":x:" + strconv.Itoa(ids[0]) + ":\n", nil
|
|
}
|
|
home := m.homes[n]
|
|
if home == "" {
|
|
home = "/"
|
|
}
|
|
return n + ":x:" + strconv.Itoa(ids[0]) + ":" + strconv.Itoa(ids[1]) + "::" + home + ":/usr/bin/nologin\n", nil
|
|
}
|
|
}
|
|
return "", errors.New("getent exited 2")
|
|
case "id":
|
|
name := args[len(args)-1]
|
|
u, ok := m.users[name]
|
|
if !ok {
|
|
return "", errors.New("id exited 1")
|
|
}
|
|
return strings.Join(append([]string{m.groupNamed(u[1])}, m.in[name]...), " ") + "\n", nil
|
|
case "groupadd":
|
|
m.groups[args[len(args)-1]] = [2]int{mustAtoi(args[2]), 0}
|
|
case "useradd":
|
|
name := args[len(args)-1]
|
|
m.users[name] = [2]int{mustAtoi(args[2]), mustAtoi(args[4])}
|
|
if args[5] == "--groups" {
|
|
m.setIn(name, strings.Split(args[6], ","))
|
|
}
|
|
if m.homes == nil {
|
|
m.homes = map[string]string{}
|
|
}
|
|
m.homes[name] = "/nonexistent"
|
|
case "usermod":
|
|
name := args[len(args)-1]
|
|
if args[0] == "--append" {
|
|
m.setIn(name, append(m.in[name], args[2]))
|
|
} else if args[0] == "--home" {
|
|
if m.homes == nil {
|
|
m.homes = map[string]string{}
|
|
}
|
|
m.homes[name] = args[1]
|
|
} else {
|
|
u := m.users[name]
|
|
m.users[name] = [2]int{u[0], mustAtoi(args[1])}
|
|
m.setIn(name, strings.Split(args[3], ","))
|
|
}
|
|
case "gpasswd":
|
|
var kept []string
|
|
for _, g := range m.in[args[1]] {
|
|
if g != args[2] {
|
|
kept = append(kept, g)
|
|
}
|
|
}
|
|
m.setIn(args[1], kept)
|
|
case "userdel":
|
|
delete(m.users, args[0])
|
|
case "groupdel":
|
|
delete(m.groups, args[0])
|
|
default:
|
|
return "", nil
|
|
}
|
|
m.ran = append(m.ran, line)
|
|
return "", nil
|
|
}
|
|
|
|
func (m *accountsMachine) setIn(name string, groups []string) {
|
|
if m.in == nil {
|
|
m.in = map[string][]string{}
|
|
}
|
|
var out []string
|
|
for _, g := range groups {
|
|
if g != "" {
|
|
out = append(out, g)
|
|
}
|
|
}
|
|
m.in[name] = out
|
|
}
|
|
|
|
func mustAtoi(s string) int {
|
|
n, _ := strconv.Atoi(s)
|
|
return n
|
|
}
|
|
|
|
// withGroups is a declaration with library groups, identities and resources.
|
|
func withGroups(t *testing.T, groups, identities, resources string) *declaration.Declaration {
|
|
t.Helper()
|
|
nothing := ""
|
|
if resources == "" {
|
|
nothing = `"owns_nothing":true,`
|
|
}
|
|
d, err := declaration.Parse([]byte(`{"declaration":1,"groups":[` + groups + `],"identities":[` + identities +
|
|
`],` + nothing + `"resources":[` + resources + `]}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return d
|
|
}
|
|
|
|
func withIdentities(t *testing.T, identities, resources string) *declaration.Declaration {
|
|
t.Helper()
|
|
d, err := declaration.Parse([]byte(`{"declaration":1,"identities":[` + identities + `],"resources":[` +
|
|
resources + `]}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return d
|
|
}
|
|
|
|
// Row 3: the account made as a system account with exactly its numbers, recorded, and removed — only what was
|
|
// made — when no longer declared.
|
|
func TestAModulesIdentityIsMadeWithItsNumbersAndRemovedWhenUndeclared(t *testing.T) {
|
|
m := &accountsMachine{users: map[string][2]int{}, groups: map[string][2]int{}}
|
|
dir := t.TempDir()
|
|
d := withIdentities(t, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001}`,
|
|
`{"id":"radarr.config","type":"directory","path":"`+dir+`/config"}`)
|
|
report, known, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []string{
|
|
"groupadd --system --gid 61001 mesh-radarr",
|
|
"useradd --system --uid 61001 --gid 61001 --no-create-home --home-dir /nonexistent --shell /usr/bin/nologin mesh-radarr",
|
|
}
|
|
if strings.Join(m.ran, "\n") != strings.Join(want, "\n") {
|
|
t.Fatalf("made with:\n%s\nwant:\n%s", strings.Join(m.ran, "\n"), strings.Join(want, "\n"))
|
|
}
|
|
if o := outcomeOf(report, "identity mesh-radarr"); o.Action != "created" {
|
|
t.Errorf("the identity's outcome: %+v", o)
|
|
}
|
|
if len(known.Identities) != 1 || !known.Identities[0].MadeUser || !known.Identities[0].MadeGroup {
|
|
t.Fatalf("what was made is not recorded: %+v", known.Identities)
|
|
}
|
|
|
|
// Applied again: nothing is made twice.
|
|
m.ran = nil
|
|
if _, known, err = Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, m.run, nil, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(m.ran) != 0 {
|
|
t.Fatalf("an identity already here was made again: %v", m.ran)
|
|
}
|
|
|
|
// Undeclared, with a group the machine had before for another identity: only what was made goes.
|
|
m.groups["mesh-sonarr"] = [2]int{61002, 0}
|
|
other := withIdentities(t, `{"module":"sonarr","name":"mesh-sonarr","uid":61002,"gid":61002}`,
|
|
`{"id":"x.dir","type":"directory","path":"`+dir+`/x"}`)
|
|
if _, known, err = Apply(context.Background(), archHost(t), other, known, store.OriginDeclared, m.run, nil, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, has := m.users["mesh-radarr"]; has {
|
|
t.Error("an identity no longer declared kept its account")
|
|
}
|
|
if _, has := m.groups["mesh-radarr"]; has {
|
|
t.Error("an identity no longer declared kept its group")
|
|
}
|
|
if rec := madeIdentity(&known, "mesh-sonarr"); rec.MadeGroup {
|
|
t.Error("a group that was there before is recorded as the mesh's")
|
|
}
|
|
}
|
|
|
|
// Row 3: a name that exists with other numbers, or numbers another name holds, is refused naming whose, and the
|
|
// module is not attempted; the rest of the machine is.
|
|
func TestAnIdentityWhoseNameOrNumbersAreAnothersIsRefused(t *testing.T) {
|
|
dir := t.TempDir()
|
|
for _, tc := range []struct {
|
|
users, groups map[string][2]int
|
|
says string
|
|
}{
|
|
{users: map[string][2]int{"mesh-radarr": {1500, 1500}}, groups: map[string][2]int{}, says: "uid 1500"},
|
|
{users: map[string][2]int{"someone": {61001, 100}}, groups: map[string][2]int{}, says: "someone"},
|
|
{users: map[string][2]int{}, groups: map[string][2]int{"media": {61001, 0}}, says: "media"},
|
|
} {
|
|
m := &accountsMachine{users: tc.users, groups: tc.groups}
|
|
d := withIdentities(t, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001}`,
|
|
`{"id":"radarr.config","type":"directory","path":"`+dir+`/radarr"},`+
|
|
`{"id":"plex.config","type":"directory","path":"`+dir+`/plex"}`)
|
|
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil)
|
|
if err == nil || !strings.Contains(err.Error(), tc.says) {
|
|
t.Errorf("not refused naming %s: %v", tc.says, err)
|
|
}
|
|
if len(m.ran) != 0 {
|
|
t.Errorf("something was made for an identity that is another's: %v", m.ran)
|
|
}
|
|
if o := outcomeOf(report, "radarr.config"); o.Action != "skipped" {
|
|
t.Errorf("the module of a refused identity was attempted: %+v", o)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "plex")); err != nil {
|
|
t.Errorf("the rest of the machine was not applied: %v", err)
|
|
}
|
|
os.RemoveAll(filepath.Join(dir, "plex"))
|
|
}
|
|
}
|
|
|
|
// Row 11: a declaration naming a root group for an account or an image is refused with its reason, and nothing is
|
|
// made or started. A number 0 is refused on arrival, by the parser.
|
|
func TestNoModuleIdentityIsRoot(t *testing.T) {
|
|
was := RootGroupIDs
|
|
RootGroupIDs = func() map[int]string { return map[int]string{10: "wheel"} }
|
|
defer func() { RootGroupIDs = was }()
|
|
dir := t.TempDir()
|
|
for _, d := range []*declaration.Declaration{
|
|
withGroups(t, `{"name":"mesh-media","gid":10}`, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":10}`,
|
|
`{"id":"radarr.config","type":"directory","path":"`+dir+`/config"}`),
|
|
withGroups(t, `{"name":"mesh-media","gid":10}`, ``,
|
|
`{"id":"radarr.config","type":"directory","path":"`+dir+`/config"}`),
|
|
withIdentities(t, ``, `{"id":"radarr.config","type":"directory","path":"`+dir+`/config"},`+
|
|
`{"id":"radarr.app","type":"container","name":"radarr","image":"`+pinned+`",`+
|
|
`"identity":{"mode":"run","uid":61001,"gid":10}}`),
|
|
} {
|
|
m := &accountsMachine{users: map[string][2]int{}, groups: map[string][2]int{}}
|
|
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "wheel") || !strings.Contains(err.Error(), "never") {
|
|
t.Errorf("a root group was not refused naming why: %v", err)
|
|
}
|
|
if len(m.ran) != 0 {
|
|
t.Errorf("something was made or started: %v", m.ran)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "config")); err == nil {
|
|
t.Error("a resource of a declaration naming a root group was applied")
|
|
}
|
|
}
|
|
for _, raw := range []string{
|
|
`{"declaration":1,"identities":[{"module":"m","name":"mesh-m","uid":0,"gid":61001}],"resources":[]}`,
|
|
`{"declaration":1,"resources":[{"id":"m.app","type":"container","name":"m","image":"` + pinned + `",` +
|
|
`"identity":{"mode":"env","user_env":"PUID","group_env":"PGID","uid":61001,"gid":0}}]}`,
|
|
} {
|
|
if _, err := declaration.Parse([]byte(raw)); err == nil || !strings.Contains(err.Error(), "never root") {
|
|
t.Errorf("a number 0 was not refused on arrival: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Row 3: a container declaring identity is told it — by the variables its image reads, or the runtime's user
|
|
// option — and a changed identity is a container recreated.
|
|
func TestAContainerIsToldItsModulesIdentity(t *testing.T) {
|
|
env := &declaration.Container{ID: "radarr.app", Name: "radarr", Image: pinned, RunAs: &declaration.ContainerIdentity{
|
|
Mode: declaration.IdentityByEnv, UserEnv: "PUID", GroupEnv: "PGID", UID: 61001, GID: 61001}}
|
|
run := &declaration.Container{ID: "plex.app", Name: "plex", Image: pinned, RunAs: &declaration.ContainerIdentity{
|
|
Mode: declaration.IdentityByRun, UID: 61002, GID: 61002}}
|
|
if got := strings.Join(foregroundRunArgs(env, "x"), " "); !strings.Contains(got, "--env PUID=61001 --env PGID=61001") {
|
|
t.Errorf("an image told by environment was not: %s", got)
|
|
}
|
|
if got := strings.Join(foregroundRunArgs(run, "x"), " "); !strings.Contains(got, "--user 61002:61002") {
|
|
t.Errorf("an image told by the runtime's user option was not: %s", got)
|
|
}
|
|
plain := *env
|
|
plain.RunAs = nil
|
|
moved := *env
|
|
moved.RunAs = &declaration.ContainerIdentity{Mode: declaration.IdentityByEnv, UserEnv: "PUID", GroupEnv: "PGID",
|
|
UID: 61009, GID: 61009}
|
|
if containerSpec(env, inputs{}) == containerSpec(&plain, inputs{}) ||
|
|
containerSpec(env, inputs{}) == containerSpec(&moved, inputs{}) {
|
|
t.Error("an identity told, or renumbered, does not recreate the container")
|
|
}
|
|
}
|
|
|
|
// ADR 0325 §5 supersedes ADR 0312's group grant: what a grant put an account in is taken back on the first apply of
|
|
// the access, and nothing is granted again.
|
|
func TestWhatAGroupGrantPutAnAccountInIsTakenBack(t *testing.T) {
|
|
dir := t.TempDir()
|
|
groups := "nox media"
|
|
var ran []string
|
|
run := func(_ context.Context, name string, args ...string) (string, error) {
|
|
ran = append(ran, name+" "+strings.Join(args, " "))
|
|
switch name {
|
|
case "id":
|
|
return groups + "\n", nil
|
|
case "gpasswd":
|
|
groups = "nox"
|
|
}
|
|
return "", nil
|
|
}
|
|
known := store.State{Resources: []store.Applied{{ID: "importer.books", Type: "access", Target: dir,
|
|
Origin: store.OriginDeclared, User: "nox", Groups: []string{"media"}}}}
|
|
d := declare(t, `{"id":"importer.books","type":"access","path":"`+dir+`","mode":"read-write","grant":{"account":"nox"}}`)
|
|
report, known, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if groups != "nox" || !strings.Contains(strings.Join(ran, "\n"), "gpasswd --delete nox media") {
|
|
t.Fatalf("the grant's group was not taken back: %v", ran)
|
|
}
|
|
if o := outcomeOf(report, "importer.books"); o.Action != "updated" || !strings.Contains(o.Detail, "taken back") {
|
|
t.Errorf("the take-back is not said: %+v", o)
|
|
}
|
|
if rec, _ := known.Find("importer.books"); len(rec.Groups) != 0 {
|
|
t.Errorf("the record still holds the grant's groups: %+v", rec)
|
|
}
|
|
}
|
|
|
|
// Review L7: a group the grant put an account in that could not be left stays recorded, and is tried again.
|
|
func TestAGrantsGroupThatCouldNotBeLeftIsTriedAgain(t *testing.T) {
|
|
dir := t.TempDir()
|
|
refuse := true
|
|
groups := "nox media"
|
|
run := func(_ context.Context, name string, args ...string) (string, error) {
|
|
switch name {
|
|
case "id":
|
|
return groups + "\n", nil
|
|
case "gpasswd":
|
|
if refuse {
|
|
return "", errors.New("gpasswd exited 1: locked")
|
|
}
|
|
groups = "nox"
|
|
}
|
|
return "", nil
|
|
}
|
|
known := store.State{Resources: []store.Applied{{ID: "importer.books", Type: "access", Target: dir,
|
|
Origin: store.OriginDeclared, User: "nox", Groups: []string{"media"}}}}
|
|
d := declare(t, `{"id":"importer.books","type":"access","path":"`+dir+`","mode":"read-write"}`)
|
|
_, known, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec, _ := known.Find("importer.books"); rec.User != "nox" || len(rec.Groups) != 1 {
|
|
t.Fatalf("a group that could not be left was forgotten: %+v", rec)
|
|
}
|
|
refuse = false
|
|
if _, known, err = Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec, _ := known.Find("importer.books"); len(rec.Groups) != 0 || groups != "nox" {
|
|
t.Fatalf("the group was not taken back on the next apply: %+v (%s)", rec, groups)
|
|
}
|
|
}
|