diff --git a/BACKLOG.md b/BACKLOG.md index dd96df3..c154b5c 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -2071,6 +2071,16 @@ lent, partition`); the leader tells each named node's agent (root) what to be, t from the portal's Profile or `ctl slurm users keys`. A user already on a machine with another UID is left alone and said. The site has AD (not yet live): users from it, through sssd, are the next step (`slurm.users` empty today leaves them to the site). +- Logging in to a node, done 2026-11-14: llmgw's users reach the controller freely, another Slurm + machine only while a job of theirs runs there (pam_slurm_adopt, installed by the agent; PrologFlags= + Contain). Only the users listed in the agent's users file are held: root and the machine's own users + log in as before, so a wrong line never locks out who would fix it. pam_systemd would move the session + out of the job, so sshd's stack takes the session file in place of its include, with pam_systemd + skipped for the listed users alone; where that cannot be done safely (nested includes, jumps across + it) they are held to having a job, not to its cores and cards, and the agent says so. Tried on the + VMs: ayse without a job refused ("no active jobs on this node"), with one in + job_N/step_extern (nproc 1), cut off when it ended; mehmet refused; admin's session unchanged. + Left: sites whose users come from AD (sssd) are not held yet; a group of theirs would be the list. - P6, Slurm's jobs in llmgw's accounting (written 2026-11-12, not started). The tenancy tree to Slurm: each project an account (`sacctmgr add account`, parent its team and organization), each member a user of it, kept by the leader from the store; `sbatch --account=PROJECT`. The records back: the leader reads diff --git a/CHANGELOG.md b/CHANGELOG.md index e8d66d6..0e4547b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,11 @@ APIs change between minors without a deprecation period. llmgw` (`ctl slurm users on`) makes each active account a Linux user on every Slurm machine, the same login and UID (from 200000, never reused) everywhere, its home on the shared path; people add SSH keys in the - portal's Profile (`ctl slurm users keys` for an operator). + portal's Profile (`ctl slurm users keys` for an operator). They log in to + the controller; to another Slurm machine only while a job of theirs runs + there, their session put in the job (pam_slurm_adopt), so nobody uses a + node's cards outside Slurm, nor on a node llmgw serves from. Root and the + machines' own users are not held to it. ### Changed diff --git a/docs/install.md b/docs/install.md index 00a19bb..079effa 100644 --- a/docs/install.md +++ b/docs/install.md @@ -210,7 +210,10 @@ active llmgw account is a Linux user on every Slurm machine 200000 up that is never given to anyone else, its home under `/shared/home`. People add their SSH public keys in the portal's **Profile** and log in to this machine to run `sbatch`; an account suspended -or deleted loses its user, its files stay. A site whose users come from its +or deleted loses its user, its files stay. On the other Slurm machines they +are let in only while a job of theirs runs there, and their ssh session is +put in that job, held to its cores and cards (pam_slurm_adopt, which the +agents install); root and the machines' own users log in as before. A site whose users come from its directory (sssd against Active Directory or LDAP) passes `--slurm-users site`, or later `llmgw ctl slurm users off`. diff --git a/docs/reference.md b/docs/reference.md index 0c0c506..89b3453 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -3595,8 +3595,9 @@ Takes a [SharedStorage](#sharedstorage) block. Users is who keeps the Linux users that run jobs, the same on every machine of the Slurm cluster: "llmgw" makes one for each active llmgw account (its login from the e-mail address, a UID of llmgw's from -200000 up, its SSH keys from the portal); empty leaves users to the -site (a directory through sssd, or by hand). +200000 up, its SSH keys from the portal), let in to the controller, +and to a node only while a job of theirs runs there (pam_slurm_adopt); +empty leaves users to the site (a directory through sssd, or by hand). ## StrataConfig diff --git a/internal/agent/slurmnode/shared_test.go b/internal/agent/slurmnode/shared_test.go index 3fd286b..d435451 100644 --- a/internal/agent/slurmnode/shared_test.go +++ b/internal/agent/slurmnode/shared_test.go @@ -24,6 +24,7 @@ func TestTheServerExportsAndMakesUsersWithHomes(t *testing.T) { m.Shared = &llmgwv1.SlurmShared{Path: "/shared", Serve: true, Clients: []string{"10.0.0.22", "10.0.0.21"}} m.ManageUsers, m.MakeHomes, m.Users = true, true, []*llmgwv1.SlurmUser{ayse()} h.fail["test -d /shared/home/ayse"] = true + h.files[slurm.PAMSSHD] = []byte(pamStack) st := k.Reconcile(context.Background(), m) if st.GetError() != "" { t.Fatal(st.GetError()) @@ -122,3 +123,49 @@ func TestAClientMountsAndLeavesOthersUsersAlone(t *testing.T) { t.Errorf("mount kept:\n%s", strings.Join(h.ran, "\n")) } } + +const pamStack = "@include common-auth\naccount required pam_nologin.so\n@include common-account\n@include common-session\n" + +func TestUsersReachANodeOnlyThroughTheirJobs(t *testing.T) { + h := newFake() + inst := &fakeInst{host: h} + k := New(h, inst, "/var/lib/llmgw-agent/slurm", nil) + h.files[slurm.PAMSSHD] = []byte(pamStack) + // The module is not there until its package is installed. + for _, d := range slurm.ModuleDirs { + h.fail["test -f "+d+"/"+slurm.AdoptModule] = true + } + m := nodeMsg(true) + m.ManageUsers, m.Users = true, []*llmgwv1.SlurmUser{ayse()} + k.Reconcile(context.Background(), m) + if !slices.Contains(inst.got["deb"], "libpam-slurm-adopt") { + t.Fatalf("not installed: %v", inst.got) + } + if got := string(h.files[slurm.PAMSSHD]); got != pamStack { + t.Fatalf("lines put in with the module still missing:\n%s", got) + } + delete(h.fail, "test -f "+slurm.ModuleDirs[0]+"/"+slurm.AdoptModule) + st := k.Reconcile(context.Background(), m) + got := string(h.files[slurm.PAMSSHD]) + if !strings.Contains(got, "pam_listfile.so item=user sense=allow file="+k.usersMarker()) || + !strings.Contains(got, "account required "+slurm.AdoptModule) { + t.Fatalf("not held to their jobs (%s):\n%s", st.GetError(), got) + } + + // The controller is where they log in: not held there. + m.Controller = true + m.Files = map[string]string{"slurm.conf": "x"} + k.Reconcile(context.Background(), m) + if string(h.files[slurm.PAMSSHD]) != pamStack { + t.Errorf("held on the controller:\n%s", h.files[slurm.PAMSSHD]) + } + + // No users of llmgw's any more: the lines go. + m.Controller, m.Files = false, nil + k.Reconcile(context.Background(), m) + m.Users = nil + k.Reconcile(context.Background(), m) + if string(h.files[slurm.PAMSSHD]) != pamStack { + t.Errorf("lines left with no users:\n%s", h.files[slurm.PAMSSHD]) + } +} diff --git a/internal/agent/slurmnode/users.go b/internal/agent/slurmnode/users.go index 91fcd18..906d34d 100644 --- a/internal/agent/slurmnode/users.go +++ b/internal/agent/slurmnode/users.go @@ -52,9 +52,87 @@ func (k *Keeper) users(ctx context.Context, want *llmgwv1.Slurm) (int32, error) if err := k.sshConf(ctx, len(keep) > 0); err != nil { errs = append(errs, err) } + // The controller is where they log in and submit from; a node only + // while a job of theirs runs there. + if err := k.nodeLogin(ctx, len(keep) > 0 && want.GetNode() && !want.GetController()); err != nil { + errs = append(errs, err) + } return n, errors.Join(errs...) } +// nodeLogin holds the users llmgw made here to their jobs when they log in +// (pam_slurm_adopt), or no longer. Only they are: root and the site's +// administrators log in as before, so a mistake here never locks out the +// people who would put it right. +func (k *Keeper) nodeLogin(ctx context.Context, on bool) error { + old, err := k.host.ReadFile(slurm.PAMSSHD) + if err != nil { + if !on { + return nil + } + return fmt.Errorf("reading %s: %w", slurm.PAMSSHD, err) + } + if on { + if err := k.adoptModule(ctx); err != nil { + return err + } + } + read := func(name string) (string, error) { + b, err := k.host.ReadFile(filepath.Join(filepath.Dir(slurm.PAMSSHD), name)) + return string(b), err + } + stack, contained, err := slurm.PAMAdopt(string(old), k.usersMarker(), read, on) + if err != nil { + return err + } + if stack == string(old) { + return nil + } + if on { + k.log.Info("llmgw's Slurm users log in to this node only while a job of theirs runs here", + "in_the_job", contained) + if !contained { + k.log.Warn("pam_systemd could not be skipped for them safely here: their ssh sessions are not held " + + "to their job's cores and cards, only to having one") + } + } else { + k.log.Info("llmgw's Slurm users are no longer held to their jobs when they log in here") + } + // Read by sshd for each login: nothing to reload. + _, err = k.write(slurm.PAMSSHD, []byte(stack), 0o644, "") + return err +} + +// adoptModule is there, or is installed. +func (k *Keeper) adoptModule(ctx context.Context) error { + if k.installed[slurm.AdoptModule] { + return nil + } + find := func() bool { + for _, d := range slurm.ModuleDirs { + if _, err := k.host.Run(ctx, nil, "test", "-f", filepath.Join(d, slurm.AdoptModule)); err == nil { + return true + } + } + return false + } + if !find() { + if k.inst == nil { + return fmt.Errorf("%s missing, and this agent installs nothing", slurm.AdoptModule) + } + k.log.Info("installing pam_slurm_adopt, which holds llmgw's Slurm users to their jobs here") + if err := k.inst.InstallPackages(ctx, slurm.AdoptPackages); err != nil { + return err + } + if !find() { + return fmt.Errorf("%s is still missing after installing %s", slurm.AdoptModule, + strings.Join(slurm.AdoptPackages["deb"], ", ")) + } + } + k.installed[slurm.AdoptModule] = true + return nil +} + // errNotMade is a user llmgw could not make here, nor took for its own. var errNotMade = errors.New("not made") @@ -162,6 +240,9 @@ func (k *Keeper) removeUsers(ctx context.Context, made, keep []string) error { if err := k.sshConf(ctx, false); err != nil { errs = append(errs, err) } + if err := k.nodeLogin(ctx, false); err != nil { + errs = append(errs, err) + } } return errors.Join(errs...) } diff --git a/internal/config/slurm.go b/internal/config/slurm.go index c2dfb7a..99c3110 100644 --- a/internal/config/slurm.go +++ b/internal/config/slurm.go @@ -50,8 +50,9 @@ type SlurmSettings struct { // Users is who keeps the Linux users that run jobs, the same on every // machine of the Slurm cluster: "llmgw" makes one for each active llmgw // account (its login from the e-mail address, a UID of llmgw's from - // 200000 up, its SSH keys from the portal); empty leaves users to the - // site (a directory through sssd, or by hand). + // 200000 up, its SSH keys from the portal), let in to the controller, + // and to a node only while a job of theirs runs there (pam_slurm_adopt); + // empty leaves users to the site (a directory through sssd, or by hand). Users string `koanf:"users"` } diff --git a/internal/docgen/reference_gen.go b/internal/docgen/reference_gen.go index 3cbc98a..09c7da2 100644 --- a/internal/docgen/reference_gen.go +++ b/internal/docgen/reference_gen.go @@ -527,7 +527,7 @@ var Reference = []Block{ {Key: "lent", Type: "[]string", Doc: "Lent are the nodes of Nodes Slurm may run jobs on now. A node put here\nis drained of llmgw's replicas first, as `ctl drain` does, and resumed\nin Slurm once none is left; a node taken out is drained in Slurm and\ngiven back to llmgw when its last job ends.", Nested: ""}, {Key: "partition", Type: "string", Doc: "Partition is the one partition every node is in. Empty is \"gpu\".", Nested: ""}, {Key: "shared", Type: "SharedStorage", Doc: "Shared is the file system every machine of the Slurm cluster sees at\nthe same path: home directories, datasets, the jobs' output and\ncheckpoints. Empty path: none, and a batch job's output stays on the\nnode it ran on.", Nested: "config.SharedStorage"}, - {Key: "users", Type: "string", Doc: "Users is who keeps the Linux users that run jobs, the same on every\nmachine of the Slurm cluster: \"llmgw\" makes one for each active llmgw\naccount (its login from the e-mail address, a UID of llmgw's from\n200000 up, its SSH keys from the portal); empty leaves users to the\nsite (a directory through sssd, or by hand).", Nested: ""}, + {Key: "users", Type: "string", Doc: "Users is who keeps the Linux users that run jobs, the same on every\nmachine of the Slurm cluster: \"llmgw\" makes one for each active llmgw\naccount (its login from the e-mail address, a UID of llmgw's from\n200000 up, its SSH keys from the portal), let in to the controller,\nand to a node only while a job of theirs runs there (pam_slurm_adopt);\nempty leaves users to the site (a directory through sssd, or by hand).", Nested: ""}, }}, {Package: "engines", Name: "StrataConfig", Title: "StrataConfig", Doc: "", Fields: []Field{ {Key: "dir", Type: "string", Doc: "Dir is Strata's source directory, where setup.py and serve/ are.", Nested: ""}, diff --git a/internal/slurm/pam.go b/internal/slurm/pam.go new file mode 100644 index 0000000..42c3db2 --- /dev/null +++ b/internal/slurm/pam.go @@ -0,0 +1,229 @@ +package slurm + +import ( + "errors" + "fmt" + "path" + "regexp" + "strconv" + "strings" +) + +// Logging in to a node: llmgw's users reach the controller, the machine +// they submit from, whenever they like, and a node only while a job of +// theirs runs there, their session put in that job (pam_slurm_adopt). +// Without it a user could ssh to a node and use its cards outside Slurm, +// on a node llmgw serves models from too. +const ( + // PAMSSHD is sshd's PAM stack, where the lines go. + PAMSSHD = "/etc/pam.d/sshd" + // AdoptModule is the module's file name. + AdoptModule = "pam_slurm_adopt.so" + + pamBegin = "# llmgw (BACKLOG P): llmgw's Slurm users reach this node only while a job of theirs runs here." + pamSession = "# llmgw (BACKLOG P): pam_systemd skipped for llmgw's Slurm users, so their session stays in the job pam_slurm_adopt put it in." + pamWas = "# llmgw, in place of: " + pamEnd = "# llmgw end" +) + +// AdoptPackages are the packages with AdoptModule, by family. +var AdoptPackages = map[string][]string{"deb": {"libpam-slurm-adopt"}, "rpm": {"slurm-pam_slurm"}} + +// ModuleDirs are where PAM's modules are, on Debian's and Red Hat's. +var ModuleDirs = []string{ + "/usr/lib/x86_64-linux-gnu/security", "/lib/x86_64-linux-gnu/security", + "/usr/lib/aarch64-linux-gnu/security", "/lib/aarch64-linux-gnu/security", + "/usr/lib64/security", "/lib64/security", "/usr/lib/security", "/lib/security", +} + +// PAMAdopt is sshd's PAM stack with llmgw's lines in it (on) or taken out +// and the stack as it was. Only the users listed in the file list, one a +// line, are held to their jobs; anyone else, root and the site's +// administrators, logs in as before. +// +// The account lines go after the last account line, so a user the +// machine's own rules refuse is still refused. pam_systemd, which would +// move the session out of the job into a scope of its own, is skipped for +// the listed users: the file sshd includes it from (read through read) is +// put in sshd's stack in its place, with the one line in front of +// pam_systemd. contained is whether that was done; when it cannot be done +// safely, the users are still held to their jobs, their sessions not to +// the job's cards and cores. +func PAMAdopt(stack, list string, read func(name string) (string, error), on bool) (out string, contained bool, err error) { + lines, err := pamStrip(stack) + if err != nil { + return "", false, err + } + if !on { + return join(lines), false, nil + } + last := -1 + for i, l := range lines { + f := strings.Fields(l) + if len(f) > 0 && (f[0] == "account" || (f[0] == "@include" && len(f) > 1 && strings.Contains(f[1], "account"))) { + last = i + } + } + if last < 0 { + return "", false, errors.New(PAMSSHD + " has no account lines to put llmgw's after") + } + block := []string{ + pamBegin, + // Listed: on to the next line; not listed, or no list: past it. + "account [success=ignore default=1] pam_listfile.so item=user sense=allow file=" + list + " onerr=fail", + "account required " + AdoptModule, + pamEnd, + } + lines = append(lines[:last+1:last+1], append(block, lines[last+1:]...)...) + lines, contained = pamSkipSystemd(lines, list, read) + return join(lines), contained, nil +} + +func join(lines []string) string { return strings.Join(lines, "\n") + "\n" } + +// pamStrip takes llmgw's blocks out, putting back the lines they stand in +// place of. +func pamStrip(stack string) ([]string, error) { + var lines []string + in := false + for _, l := range strings.Split(strings.TrimSuffix(stack, "\n"), "\n") { + switch { + case !in && (l == pamBegin || l == pamSession): + in = true + case in && l == pamEnd: + in = false + case in && strings.HasPrefix(l, pamWas): + lines = append(lines, strings.TrimPrefix(l, pamWas)) + case !in: + lines = append(lines, l) + } + } + if in { + return nil, errors.New("llmgw's lines in " + PAMSSHD + " have no end; put them right by hand") + } + return lines, nil +} + +// pamLine is a PAM line's type, control and module, the control with its +// brackets. +var pamLine = regexp.MustCompile(`^\s*(-?[a-z]+)\s+(\[[^\]]*\]|\S+)\s+(\S+)`) + +// pamJump is a jump of N in a bracketed control. +var pamJump = regexp.MustCompile(`=(\d+)`) + +func isSession(l string) bool { + m := pamLine.FindStringSubmatch(l) + return m != nil && strings.TrimPrefix(m[1], "-") == "session" +} + +// pamSkipSystemd puts the guard in front of pam_systemd, in the session +// file sshd includes it from or in sshd's own lines; false, and lines as +// they were, when it is in neither or cannot be done without changing +// what any other jump in the stack lands on. +func pamSkipSystemd(lines []string, list string, read func(string) (string, error)) ([]string, bool) { + guard := "session [success=1 default=ignore] pam_listfile.so item=user sense=allow file=" + list + " onerr=fail" + hasSystemd := func(l string) bool { + m := pamLine.FindStringSubmatch(l) + return m != nil && strings.TrimPrefix(m[1], "-") == "session" && path.Base(m[3]) == "pam_systemd.so" + } + for i, l := range lines { + f := strings.Fields(l) + var body []string + switch { + case hasSystemd(l): + body = []string{l} + case len(f) == 2 && f[0] == "@include" && strings.Contains(f[1], "session"), + len(f) == 3 && f[0] == "session" && f[1] == "include": + name := f[len(f)-1] + if strings.Contains(name, "/") { + continue + } + text, err := read(name) + if err != nil { + return lines, false + } + for _, il := range strings.Split(text, "\n") { + t := strings.TrimSpace(il) + if t == "" || strings.HasPrefix(t, "#") { + continue + } + if f[0] == "session" && !isSession(t) { + continue // include takes only the lines of its own type + } + body = append(body, t) + } + default: + continue + } + at := -1 + for j, b := range body { + if hasSystemd(b) { + at = j + } + if g := strings.Fields(b); len(g) > 0 && (g[0] == "@include" || strings.Contains(b, "include") || strings.Contains(b, "substack")) { + return lines, false // nested: not followed + } + } + if at < 0 { + continue + } + // A jump in sshd's own session lines may cross what is put in. + for j, o := range lines { + if j != i && isSession(o) && pamJump.MatchString(pamControl(o)) { + return lines, false + } + } + body, ok := pamInsert(body, at, guard) + if !ok { + return lines, false + } + block := append([]string{pamSession, pamWas + l}, body...) + block = append(block, pamEnd) + return append(lines[:i:i], append(block, lines[i+1:]...)...), true + } + return lines, false +} + +func pamControl(l string) string { + if m := pamLine.FindStringSubmatch(l); m != nil { + return m[2] + } + return "" +} + +// pamInsert puts guard in front of body[at], a jump that crosses it made +// one longer so it lands where it did. +func pamInsert(body []string, at int, guard string) ([]string, bool) { + // Each session line's place among the session lines. + idx := make([]int, len(body)) + n := 0 + for j, b := range body { + idx[j] = n + if isSession(b) { + n++ + } + } + out := make([]string, 0, len(body)+1) + for j, b := range body { + if j == at { + out = append(out, guard) + } + ctl := pamControl(b) + if isSession(b) && j < at && strings.HasPrefix(ctl, "[") { + from, target := idx[j], idx[at] + fixed := pamJump.ReplaceAllStringFunc(ctl, func(s string) string { + k, err := strconv.Atoi(s[1:]) + if err != nil { + return s + } + if from < target && target <= from+k { + k++ + } + return fmt.Sprintf("=%d", k) + }) + b = strings.Replace(b, ctl, fixed, 1) + } + out = append(out, b) + } + return out, true +} diff --git a/internal/slurm/pam_test.go b/internal/slurm/pam_test.go new file mode 100644 index 0000000..3dc1232 --- /dev/null +++ b/internal/slurm/pam_test.go @@ -0,0 +1,113 @@ +package slurm + +import ( + "errors" + "strings" + "testing" +) + +const ubuntuSSHD = `# PAM configuration for the Secure Shell service +@include common-auth +account required pam_nologin.so +@include common-account +session [success=ok ignore=ignore module_unknown=ignore default=bad] pam_selinux.so close +session required pam_loginuid.so +@include common-session +session required pam_limits.so +@include common-password +` + +const ubuntuSession = `# here are the per-package modules (the "Primary" block) +session [default=1] pam_permit.so +session requisite pam_deny.so +session required pam_permit.so +session optional pam_umask.so +session required pam_unix.so +session optional pam_systemd.so +# end of pam-auth-update config +` + +const redhatSSHD = `#%PAM-1.0 +auth substack password-auth +account required pam_sepermit.so +account required pam_nologin.so +account include password-auth +password include password-auth +session required pam_loginuid.so +session include password-auth +session include postlogin +` + +const redhatPasswordAuth = `auth required pam_env.so +account required pam_unix.so +-session optional pam_keyinit.so revoke +session required pam_limits.so +-session optional pam_systemd.so +session [success=1 default=ignore] pam_succeed_if.so service in crond quiet use_uid +session required pam_unix.so +` + +func files(m map[string]string) func(string) (string, error) { + return func(n string) (string, error) { + if s, ok := m[n]; ok { + return s, nil + } + return "", errors.New("no such file") + } +} + +func TestPAMAdopt(t *testing.T) { + read := files(map[string]string{"common-session": ubuntuSession, "password-auth": redhatPasswordAuth, "postlogin": "session optional pam_lastlog.so\n"}) + for name, stack := range map[string]string{"ubuntu": ubuntuSSHD, "redhat": redhatSSHD} { + on, contained, err := PAMAdopt(stack, "/var/lib/x-users", read, true) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if !contained { + t.Errorf("%s: pam_systemd not skipped:\n%s", name, on) + } + lines := strings.Split(on, "\n") + if !strings.Contains(on, "account [success=ignore default=1] pam_listfile.so item=user sense=allow file=/var/lib/x-users onerr=fail") { + t.Fatalf("%s: lines not put in:\n%s", name, on) + } + for j, l := range lines { + if l == pamBegin && !strings.Contains(lines[j-1], "account") { + t.Errorf("%s: put after %q", name, lines[j-1]) + } + if strings.Contains(l, "pam_systemd.so") && + lines[j-1] != "session [success=1 default=ignore] pam_listfile.so item=user sense=allow file=/var/lib/x-users onerr=fail" { + t.Errorf("%s: pam_systemd not guarded, after %q", name, lines[j-1]) + } + } + again, _, _ := PAMAdopt(on, "/var/lib/x-users", read, true) + if again != on { + t.Errorf("%s: put in twice:\n%s", name, again) + } + off, _, err := PAMAdopt(on, "", read, false) + if err != nil || off != stack { + t.Errorf("%s: taken out, not as it was (%v):\n%s", name, err, off) + } + } + // The jump over pam_deny lands where it did; the one past crond's line too. + on, _, _ := PAMAdopt(ubuntuSSHD, "/l", read, true) + if !strings.Contains(on, "[default=1]\t\t\tpam_permit.so") { + t.Errorf("a jump that does not cross the guard was changed:\n%s", on) + } + body := []string{"session [default=2] pam_permit.so", "session required pam_unix.so", "session optional pam_systemd.so", "session required pam_x.so"} + got, _ := pamInsert(body, 2, "G") + if got[0] != "session [default=3] pam_permit.so" || got[2] != "G" { + t.Errorf("a jump across the guard not made longer: %q", got) + } + + if _, _, err := PAMAdopt("auth required pam_unix.so\n", "/x", read, true); err == nil { + t.Error("a stack with no account lines taken") + } + if _, _, err := PAMAdopt(pamBegin+"\naccount required x\n", "/x", read, false); err == nil { + t.Error("lines with no end taken") + } + // The session file unreadable: held to their jobs all the same. + on, contained, err := PAMAdopt(ubuntuSSHD, "/l", files(nil), true) + if err != nil || contained || !strings.Contains(on, AdoptModule) { + t.Errorf("contained %v, err %v:\n%s", contained, err, on) + } +} diff --git a/internal/slurm/slurm.go b/internal/slurm/slurm.go index 07c1e52..5612701 100644 --- a/internal/slurm/slurm.go +++ b/internal/slurm/slurm.go @@ -214,6 +214,9 @@ SlurmdLogFile=` + LogDir + `/slurmd.log SlurmctldPidFile=/run/slurmctld.pid SlurmdPidFile=/run/slurmd.pid ProctrackType=proctrack/cgroup +# Each job has a step from its start, where pam_slurm_adopt puts a user's +# ssh session on the node. +PrologFlags=Contain TaskPlugin=task/cgroup,task/affinity # What each job used, from its cgroup, for the records; the cards too. JobAcctGatherType=jobacct_gather/cgroup