3 Commits
7 changed files with 161 additions and 3 deletions
+22
View File
@@ -0,0 +1,22 @@
package backup
import "testing"
// user_mfa holds an encrypted TOTP secret. Absent from this map, verify's live
// probe reports "this database stores no ciphertext yet" and the one gate that
// catches a wrong encryption key becomes a no-op for MFA secrets.
func TestUserMFACiphertextIsMirrored(t *testing.T) {
fields, ok := ciphertextFields["user_mfa"]
if !ok {
t.Fatal("ciphertextFields has no entry for user_mfa")
}
found := false
for _, f := range fields {
if f == "totp_secret_enc" {
found = true
}
}
if !found {
t.Fatalf("user_mfa entry %v does not name totp_secret_enc", fields)
}
}
+2
View File
@@ -128,6 +128,7 @@ func probe(ctx context.Context, opt VerifyOptions, rep *VerifyReport) error {
// secrets - models/secret.go: encrypted_value
// auth_providers - models/auth_provider.go: client_secret_enc
// console_sessions - models/console_session.go: rdp_user_enc, rdp_pass_enc
// user_mfa - models/user_mfa.go: totp_secret_enc
//
// settings is deliberately absent: it holds no ciphertext at all. The ESO read
// token is stored as a SHA-256 hash, which no key opens.
@@ -136,6 +137,7 @@ var ciphertextFields = map[string][]string{
"secrets": {"encrypted_value"},
"auth_providers": {"client_secret_enc"},
"console_sessions": {"rdp_user_enc", "rdp_pass_enc"},
"user_mfa": {"totp_secret_enc"},
}
func findCiphertext(ctx context.Context, db *mongo.Database, coll string) (string, bool, error) {
+46
View File
@@ -0,0 +1,46 @@
package pb
import (
"encoding/json"
"testing"
)
// An empty ApplyUpdatesCmd must stay an empty object on the wire, so an old
// agent and a new server, or a new agent and an old server, agree that it
// means "install everything, no reboot, no deadline".
func TestApplyUpdatesCmdEmptyIsEmptyObject(t *testing.T) {
b, err := json.Marshal(ApplyUpdatesCmd{})
if err != nil {
t.Fatal(err)
}
if string(b) != "{}" {
t.Fatalf("got %s, want {}", b)
}
}
func TestPatchResultRoundTrip(t *testing.T) {
in := AgentMessage{PatchResult: &PatchResult{
CommandId: "c1", Status: PatchStatusOK, OutputTail: "done",
PendingAfter: 0, RebootRequired: true, Rebooting: true,
}}
b, err := json.Marshal(in)
if err != nil {
t.Fatal(err)
}
var out AgentMessage
if err := json.Unmarshal(b, &out); err != nil {
t.Fatal(err)
}
if out.PatchResult == nil || *out.PatchResult != *in.PatchResult {
t.Fatalf("round trip lost data: %+v", out.PatchResult)
}
}
func TestInventoryBootTimeOnWire(t *testing.T) {
b, _ := json.Marshal(InventoryReport{BootTimeUnix: 1757800000})
var m map[string]any
_ = json.Unmarshal(b, &m)
if m["boot_time_unix"] != float64(1757800000) {
t.Fatalf("boot_time_unix missing: %s", b)
}
}
+23
View File
@@ -0,0 +1,23 @@
package pb
import (
"encoding/json"
"strings"
"testing"
)
// A phased update is flagged on the wire; an ordinary one carries no field at
// all, so an old server or agent sees exactly the shape it always did.
func TestPackageUpdatePhasedOnWire(t *testing.T) {
b, err := json.Marshal(PackageUpdate{Name: "libkrb5-3", NewVersion: "1.20", Phased: true})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"phased":true`) {
t.Fatalf("phased missing: %s", b)
}
b, _ = json.Marshal(PackageUpdate{Name: "curl", NewVersion: "8"})
if strings.Contains(string(b), "phased") {
t.Fatalf("an ordinary update must not carry phased: %s", b)
}
}
+40 -1
View File
@@ -85,6 +85,10 @@ type PackageUpdate struct {
Name string `json:"name"`
CurrentVersion string `json:"current_version,omitempty"`
NewVersion string `json:"new_version"`
// Phased marks an Ubuntu phased update this host is not yet selected for:
// apt lists it as upgradable, but an upgrade defers it until the host's
// phase comes up. It is pending, not installable, so counts leave it out.
Phased bool `json:"phased,omitempty"`
}
type ReportUpdatesRequest struct {
@@ -123,6 +127,7 @@ type InventoryReport struct {
Partitions []PartitionReport `json:"partitions,omitempty"`
Kernel string `json:"kernel,omitempty"`
RebootRequired bool `json:"reboot_required,omitempty"`
BootTimeUnix int64 `json:"boot_time_unix,omitempty"` // every report; proves a reboot happened
}
type InventoryReportResponse struct{}
@@ -161,7 +166,40 @@ type ReportChecksRequest struct {
}
type ReportChecksResponse struct{}
type ApplyUpdatesCmd struct{}
// ApplyUpdatesCmd installs pending OS updates. The zero value means what the
// command always meant: every pending update, no reboot, no deadline. That is
// what keeps old servers and new agents, and new servers and old agents,
// compatible - but only in that direction for Scope: an agent that predates
// these fields installs everything even when asked for security only, which
// is why the control plane gates on agent version before sending a scope.
type ApplyUpdatesCmd struct {
Scope string `json:"scope,omitempty"` // "" or PatchScopeAll | PatchScopeSecurity
RebootIfRequired bool `json:"reboot_if_required,omitempty"` // reboot only if the OS reports one is owed
DeadlineUnix int64 `json:"deadline_unix,omitempty"` // 0 = none; the agent caps the upgrade at 2h
}
const (
PatchScopeAll = "all"
PatchScopeSecurity = "security"
PatchStatusOK = "ok"
PatchStatusFailed = "failed"
PatchStatusUnsupported = "unsupported"
PatchStatusBusy = "busy"
)
// PatchResult answers an ApplyUpdatesCmd. Rebooting is sent immediately before
// the agent restarts the host, so the control plane knows to wait for a
// post-boot inventory report rather than a second result.
type PatchResult struct {
CommandId string `json:"command_id"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
OutputTail string `json:"output_tail,omitempty"` // at most 64KB, newest bytes
PendingAfter int32 `json:"pending_after"` // -1 when the post-apply check failed
RebootRequired bool `json:"reboot_required,omitempty"`
Rebooting bool `json:"rebooting,omitempty"`
}
type OpenProxyCmd struct {
ProxyId string `json:"proxy_id"`
@@ -240,6 +278,7 @@ type AgentMessage struct {
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
WorkloadLogsResult *WorkloadLogsResult `json:"workload_logs_result,omitempty"`
PatchResult *PatchResult `json:"patch_result,omitempty"`
}
type AgentReady struct{}
+12
View File
@@ -35,6 +35,12 @@ type Settings struct {
// upgrade. Nil means enabled.
LocalLoginEnabled *bool `bson:"local_login_enabled,omitempty" json:"local_login_enabled,omitempty"`
// RequireMFA forces every password-authenticated member to hold a second
// factor. A pointer for the same reason LocalLoginEnabled is: absent must
// mean off, and a plain bool read from an old document would lock out an
// entire instance at upgrade.
RequireMFA *bool `bson:"require_mfa,omitempty" json:"require_mfa,omitempty"`
// VulnFindingRetentionDays is a pointer for the same reason
// WorkflowLogRetentionDays is: absent must mean the default, not zero.
// Nil is 90 days, 0 is forever. Only "fixed" findings are ever swept.
@@ -62,6 +68,12 @@ func LocalLoginEnabled(s *Settings) bool {
return *s.LocalLoginEnabled
}
// RequireMFA reads the MFA policy with its absent-means-off default. Every
// caller must go through this rather than dereferencing the field.
func RequireMFA(s *Settings) bool {
return s != nil && s.RequireMFA != nil && *s.RequireMFA
}
// APITokenMaxDays reads the token lifetime cap with its absent-means-uncapped
// default. 0 means no cap. Every caller must go through this rather than
// dereferencing the field.
+16 -2
View File
@@ -109,6 +109,7 @@ message AgentMessage {
StepResult step_result = 5;
StepOutputChunk step_output = 6;
WorkloadLogsResult workload_logs_result = 7;
PatchResult patch_result = 8;
}
}
@@ -126,6 +127,7 @@ message PackageUpdate {
string name = 1;
string current_version = 2;
string new_version = 3;
bool phased = 4; // Ubuntu phased update this host is not yet selected for; deferred by apt
}
message ReportUpdatesRequest {
@@ -168,9 +170,9 @@ message InventoryReport {
uint64 swap_used = 7;
repeated PartitionReport partitions = 8;
string kernel = 9;
// Set on static snapshots only. The agent never reboots; it reports that one
// is owed and leaves the decision to a person or a workflow.
// Set on static snapshots only. The agent reboots a host only when an ApplyUpdatesCmd asks it to and the OS reports a reboot is owed.
bool reboot_required = 10;
int64 boot_time_unix = 11; // every report; proves a reboot happened
}
message InventoryReportResponse {
@@ -220,7 +222,19 @@ message ReportChecksResponse {
}
message ApplyUpdatesCmd {
string scope = 1; // "" or "all" | "security"
bool reboot_if_required = 2; // reboot only if the OS reports one is owed
int64 deadline_unix = 3; // 0 = none; the agent caps the upgrade at 2h
}
message PatchResult {
string command_id = 1;
string status = 2; // ok | failed | unsupported | busy
string message = 3;
string output_tail = 4; // at most 64KB, newest bytes kept
int32 pending_after = 5; // -1 when the post-apply check failed
bool reboot_required = 6;
bool rebooting = 7; // sent just before the agent reboots itself
}
message ServerCommand {