| 1 | package instancectl |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "io" |
| 8 | "net" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strconv" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/provision" |
| 15 | "github.com/carlosframework/platform/internal/safefile" |
| 16 | ) |
| 17 | |
| 18 | // ClaimExecPermit is root's one-launch reservation. SafeUntil is CLOCK_BOOTTIME |
| 19 | // nanoseconds on BootID; the trampoline compares it immediately before ForkExec. |
| 20 | type ClaimExecPermit struct { |
| 21 | Reservation, Inputs, BootID string |
| 22 | SafeUntil int64 |
| 23 | Containment uint8 `json:",omitempty"` |
| 24 | } |
| 25 | |
| 26 | func (p ClaimExecPermit) Valid() bool { |
| 27 | return p.Reservation != "" && p.Inputs != "" && p.BootID != "" && p.SafeUntil > 0 |
| 28 | } |
| 29 | |
| 30 | const ClaimExecSocket = "/run/carlos/claim-exec.sock" |
| 31 | const ClaimExecSnapshot = "/run/carlos/claim-exec.json" |
| 32 | const ClaimExecBootID = "/run/carlos/claim-boot-id" |
| 33 | |
| 34 | func (u ClaimUnits) StartReserved(ctx context.Context, in ClaimUnit, l Launch, p ClaimExecPermit, check func() error) (ClaimUnitStarted, error) { |
| 35 | if !p.Valid() || p.Containment != 1 || check == nil { |
| 36 | return ClaimUnitStarted{}, ErrClaimUnit |
| 37 | } |
| 38 | return u.start(ctx, in, &l, check, &p) |
| 39 | } |
| 40 | |
| 41 | type claimExecGate struct { |
| 42 | listener *net.UnixListener |
| 43 | path string |
| 44 | permit ClaimExecPermit |
| 45 | } |
| 46 | |
| 47 | // Keep the closed socket inode for ExecStopPost namespace setup. clearLaunch |
| 48 | // removes it only after the unit has stopped and its cgroup is empty. |
| 49 | func (g *claimExecGate) close() { g.listener.Close() } |
| 50 | func (u ClaimUnits) prepareExecGate(ctx context.Context, host string, p ClaimExecPermit) (*claimExecGate, error) { |
| 51 | source, dropin, err := u.launchPaths(host) |
| 52 | if err != nil { |
| 53 | return nil, err |
| 54 | } |
| 55 | raw, err := json.Marshal(p) |
| 56 | if err != nil { |
| 57 | return nil, err |
| 58 | } |
| 59 | if err = safefile.WriteFile(filepath.Join(source, "permit.json"), raw, 0444); err != nil { |
| 60 | return nil, err |
| 61 | } |
| 62 | socket := filepath.Join(source, "exec.sock") |
| 63 | // An old socket is removed only after start proved the old unit stopped. |
| 64 | if err = safefile.Remove(socket); err != nil && !errors.Is(err, os.ErrNotExist) { |
| 65 | return nil, err |
| 66 | } |
| 67 | ln, err := claimListenExecSocket(source) |
| 68 | if err != nil { |
| 69 | return nil, err |
| 70 | } |
| 71 | ln.SetUnlinkOnClose(false) |
| 72 | gate := &claimExecGate{ln, socket, p} |
| 73 | fail := func(e error) (*claimExecGate, error) { gate.close(); return nil, e } |
| 74 | // Host ancestry remains root-private; inside the namespace peer identity is |
| 75 | // checked against the pinned unit MainPID, never against a supplied PID. |
| 76 | body, err := safefile.ReadFile(filepath.Join(dropin, "60-claim-launch.conf")) |
| 77 | if err != nil { |
| 78 | return fail(err) |
| 79 | } |
| 80 | instance := "%I" |
| 81 | if u.BoundUnit != "" { |
| 82 | instance = provision.InstanceID(host) |
| 83 | } |
| 84 | body = append(body, []byte("BindReadOnlyPaths="+socket+":"+ClaimExecSocket+"\nBindReadOnlyPaths="+filepath.Join(source, "permit.json")+":"+ClaimExecSnapshot+"\nBindReadOnlyPaths=/proc/sys/kernel/random/boot_id:"+ClaimExecBootID+"\nExecStart=\nExecStart="+SystemBinary+" instance-exec -claim-reservation="+ClaimExecSnapshot+" run "+instance+"\n")...) |
| 85 | if err = safefile.WriteFile(filepath.Join(dropin, "60-claim-launch.conf"), body, 0644); err != nil { |
| 86 | return fail(err) |
| 87 | } |
| 88 | if _, err = u.Systemctl(ctx, "daemon-reload"); err != nil { |
| 89 | return fail(err) |
| 90 | } |
| 91 | return gate, nil |
| 92 | } |
| 93 | |
| 94 | func (g *claimExecGate) authorize(ctx context.Context, u ClaimUnits, s *ClaimUnitStarted, check func() error) error { |
| 95 | deadline := time.Now().Add(u.budget()) |
| 96 | if d, ok := ctx.Deadline(); ok && d.Before(deadline) { |
| 97 | deadline = d |
| 98 | } |
| 99 | if err := g.listener.SetDeadline(deadline); err != nil { |
| 100 | return err |
| 101 | } |
| 102 | stop := context.AfterFunc(ctx, func() { g.listener.Close() }) |
| 103 | defer stop() |
| 104 | conn, err := g.listener.AcceptUnix() |
| 105 | if err != nil { |
| 106 | return err |
| 107 | } |
| 108 | defer conn.Close() |
| 109 | closeOnCancel := context.AfterFunc(ctx, func() { conn.Close() }) |
| 110 | defer closeOnCancel() |
| 111 | if err = conn.SetDeadline(deadline); err != nil { |
| 112 | return err |
| 113 | } |
| 114 | if err = u.pinExecPeer(ctx, conn, s); err != nil { |
| 115 | return err |
| 116 | } |
| 117 | if err = u.Same(ctx, *s); err != nil { |
| 118 | return err |
| 119 | } |
| 120 | var asked ClaimExecPermit |
| 121 | dec := json.NewDecoder(io.LimitReader(conn, 4096)) |
| 122 | if err = dec.Decode(&asked); err != nil { |
| 123 | return err |
| 124 | } |
| 125 | if asked != g.permit { |
| 126 | return ErrClaimUnit |
| 127 | } |
| 128 | if err = check(); err != nil { |
| 129 | return err |
| 130 | } |
| 131 | if err = u.Same(ctx, *s); err != nil { |
| 132 | return err |
| 133 | } |
| 134 | if u.BoundAddr != "" { |
| 135 | if err = u.restrictBoundNetwork(*s); err != nil { |
| 136 | return err |
| 137 | } |
| 138 | if err = u.Same(ctx, *s); err != nil { |
| 139 | return err |
| 140 | } |
| 141 | } |
| 142 | // One connection, one permit. Closing/restarting root cannot replay it. |
| 143 | return json.NewEncoder(conn).Encode(g.permit) |
| 144 | } |
| 145 | |
| 146 | // PrivatePIDs may replace systemd's setup PID with the namespace init PID. |
| 147 | // Only this pre-exec handshake may pin that transition. The invocation and |
| 148 | // cgroup must stay fixed, and systemd must identify the socket peer as MainPID. |
| 149 | func (u ClaimUnits) pinExecPeer(ctx context.Context, conn net.Conn, s *ClaimUnitStarted) error { |
| 150 | for { |
| 151 | if err := ctx.Err(); err != nil { |
| 152 | return err |
| 153 | } |
| 154 | if err := claimExecPeer(conn, s.MainPID); err == nil { |
| 155 | return nil |
| 156 | } |
| 157 | state, err := u.show(ctx, s.Unit, "InvocationID", "MainPID", "ControlGroup", "ActiveState") |
| 158 | if err != nil { |
| 159 | return err |
| 160 | } |
| 161 | if state["InvocationID"] != s.InvocationID || state["ControlGroup"] != "/"+s.ControlGroup || state["ActiveState"] != "active" { |
| 162 | return ErrClaimUnit |
| 163 | } |
| 164 | pid, err := strconv.Atoi(state["MainPID"]) |
| 165 | if err != nil || pid <= 0 { |
| 166 | return ErrClaimUnit |
| 167 | } |
| 168 | if err := claimExecPeer(conn, pid); err == nil { |
| 169 | s.MainPID = pid |
| 170 | return nil |
| 171 | } |
| 172 | select { |
| 173 | case <-ctx.Done(): |
| 174 | return ctx.Err() |
| 175 | case <-time.After(u.poll()): |
| 176 | } |
| 177 | } |
| 178 | } |
| 179 | |