12 Commits
25 changed files with 997 additions and 47 deletions
+1
View File
@@ -5,6 +5,7 @@ go 1.26
require (
github.com/google/uuid v1.6.0
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216
github.com/yuin/goldmark v1.8.6
go.mongodb.org/mongo-driver/v2 v2.8.0
golang.org/x/crypto v0.54.0
google.golang.org/grpc v1.64.0
+2
View File
@@ -21,6 +21,8 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+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)
}
}
+36 -1
View File
@@ -123,6 +123,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 +162,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 +274,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{}
+80
View File
@@ -0,0 +1,80 @@
package mail
import (
"bytes"
"fmt"
htmltmpl "html/template"
"strings"
"github.com/yuin/goldmark"
)
// Announcement is one product email to one person: a feature launch, a guide,
// an offer. vantage-admin's announce package builds it per recipient, because
// the unsubscribe links carry that person's token.
type Announcement struct {
CategoryLabel string // "New features"
Subject string
BodyMarkdown string
UnsubscribeURL string // RFC 8058 one-click target for this category
PreferencesURL string // the no-login preferences page
PostalAddress string // optional footer line
}
type announcementData struct {
Announcement
BodyHTML htmltmpl.HTML
}
// md renders with goldmark's defaults, which omit raw HTML: a pasted <script>
// never reaches an inbox.
var md = goldmark.New()
func announcementMessage(a Announcement, publicURL string) (message, error) {
var buf bytes.Buffer
if err := md.Convert([]byte(a.BodyMarkdown), &buf); err != nil {
return message{}, fmt.Errorf("mail: markdown: %w", err)
}
return render("announcement", announcementData{Announcement: a, BodyHTML: htmltmpl.HTML(buf.String())}, publicURL)
}
// RenderAnnouncement is the staff preview: exactly what a recipient gets.
func RenderAnnouncement(a Announcement, publicURL string) (subject, html, text string, err error) {
m, err := announcementMessage(a, publicURL)
if err != nil {
return "", "", "", err
}
return m.Subject, m.HTML, m.Text, nil
}
// SendAnnouncement sends one announcement to one address, with the
// List-Unsubscribe pair Gmail and Yahoo require of bulk senders.
func (s Sender) SendAnnouncement(to string, a Announcement) error {
m, err := s.announcementFor(to, a)
if err != nil {
return err
}
return s.send(m)
}
// announcementFor builds the one-recipient message SendAnnouncement puts on
// the wire. Every refusal is a *SendError at PhasePrepare: it is about this
// one message, never the mail server, so a caller moves on to the next row.
func (s Sender) announcementFor(to string, a Announcement) (message, error) {
if strings.Contains(to, ",") {
return message{}, &SendError{Phase: PhasePrepare, Err: fmt.Errorf("mail: an announcement goes to exactly one address")}
}
if a.UnsubscribeURL == "" {
return message{}, &SendError{Phase: PhasePrepare, Err: fmt.Errorf("mail: announcement without an unsubscribe URL")}
}
m, err := announcementMessage(a, s.PublicURL)
if err != nil {
return message{}, &SendError{Phase: PhasePrepare, Err: err}
}
m.To = to
m.Headers = map[string]string{
"List-Unsubscribe": "<" + a.UnsubscribeURL + ">",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}
return m, nil
}
+96
View File
@@ -0,0 +1,96 @@
package mail
import (
"bytes"
"errors"
"net/mail"
"strings"
"testing"
)
func sampleAnnouncement() Announcement {
return Announcement{
CategoryLabel: "New features",
Subject: "New in Vantage: scheduled workflows",
BodyMarkdown: "Workflows can now run **on a schedule**.\n\n<script>alert(1)</script>\n\n- cron syntax\n- time zones",
UnsubscribeURL: "https://api.example/email/unsubscribe?c=features&t=abc",
PreferencesURL: "https://hq.example/email-preferences?t=abc",
PostalAddress: "Hostxtra Ltd, 1 High Street, Leeds",
}
}
func TestRenderAnnouncement(t *testing.T) {
subject, html, text, err := RenderAnnouncement(sampleAnnouncement(), "https://hq.example")
if err != nil {
t.Fatal(err)
}
if subject != "New in Vantage: scheduled workflows" {
t.Errorf("subject = %q", subject)
}
if !strings.Contains(html, "<strong>on a schedule</strong>") {
t.Error("markdown not rendered to html")
}
if strings.Contains(html, "<script>") {
t.Error("raw html from markdown reached the email")
}
for label, body := range map[string]string{"html": html, "text": text} {
for _, want := range []string{"email/unsubscribe?c=features", "email-preferences?t=abc", "New features", "Hostxtra Ltd"} {
if !strings.Contains(body, want) {
t.Errorf("%s body missing %q", label, want)
}
}
if strings.Contains(body, "\u2014") {
t.Errorf("%s body contains an em dash", label)
}
}
if !strings.Contains(text, "**on a schedule**") {
t.Error("text part should carry the markdown source")
}
}
func TestRenderAnnouncementWithoutPostalAddress(t *testing.T) {
a := sampleAnnouncement()
a.PostalAddress = ""
_, html, _, err := RenderAnnouncement(a, "")
if err != nil {
t.Fatal(err)
}
if strings.Contains(html, "<no value>") {
t.Error("empty postal address rendered as <no value>")
}
}
func TestSendAnnouncementRefusesListsAndMissingLink(t *testing.T) {
s := Sender{Host: "smtp.invalid", From: "updates@example.com"}
prepare := func(what string, err error) {
t.Helper()
var se *SendError
if !errors.As(err, &se) || se.Phase != PhasePrepare {
t.Errorf("%s: err = %v, want a *SendError at PhasePrepare", what, err)
}
}
prepare("a comma-separated To", s.SendAnnouncement("a@example.com, b@example.com", sampleAnnouncement()))
a := sampleAnnouncement()
a.UnsubscribeURL = ""
prepare("no unsubscribe URL", s.SendAnnouncement("a@example.com", a))
}
// Gmail and Yahoo require both headers of the RFC 8058 pair on bulk mail.
func TestSendAnnouncementPutsListUnsubscribeOnTheWire(t *testing.T) {
port, got := fakeSMTPData(t, nil)
s := Sender{Host: "127.0.0.1", Port: port, From: "updates@example.com"}
a := sampleAnnouncement()
if err := s.SendAnnouncement("a@example.com", a); err != nil {
t.Fatal(err)
}
msg, err := mail.ReadMessage(bytes.NewReader(<-got))
if err != nil {
t.Fatal(err)
}
if h := msg.Header.Get("List-Unsubscribe"); h != "<"+a.UnsubscribeURL+">" {
t.Errorf("List-Unsubscribe = %q", h)
}
if h := msg.Header.Get("List-Unsubscribe-Post"); h != "List-Unsubscribe=One-Click" {
t.Errorf("List-Unsubscribe-Post = %q", h)
}
}
+62
View File
@@ -0,0 +1,62 @@
package mail
import "time"
// DisputeNotice is everything the four account-dispute emails say.
//
// vantage-admin's dispute package builds it. It never carries the staff note:
// the customer is told the reason category, not what staff wrote about them.
type DisputeNotice struct {
AccountName string
DisputeID string
Reason string
CloudNames []string
SelfHosted []SelfHostedNotice
DisputeEmail string
DisputeBy time.Time
}
// SelfHostedNotice names a self-hosted install and when its licence ends,
// because that is the one thing a dispute cannot change for it.
type SelfHostedNotice struct {
Name string
LicenceExpires time.Time
}
// InstanceRows lists the affected instances in the shape the layout's "rows"
// helper takes. A method rather than template logic, because a Go template
// has no way to build a list of maps.
func (n DisputeNotice) InstanceRows() []map[string]any {
rows := make([]map[string]any, 0, len(n.CloudNames)+len(n.SelfHosted))
for _, name := range n.CloudNames {
rows = append(rows, map[string]any{"k": "Cloud", "v": name})
}
for _, s := range n.SelfHosted {
rows = append(rows, map[string]any{
"k": "Self-hosted",
"v": s.Name + ", licence ends " + s.LicenceExpires.Format("2 January 2006"),
})
}
return rows
}
// SendAccountLocked tells the account a dispute has locked it, that nothing is
// deleted, and how to dispute. Replies go to the dispute address.
func (s Sender) SendAccountLocked(to string, n DisputeNotice) error {
return s.sendTemplate(to, n.DisputeEmail, "accountlocked", n)
}
// SendDisputeReminder repeats the dispute route shortly before the hold ends.
func (s Sender) SendDisputeReminder(to string, n DisputeNotice) error {
return s.sendTemplate(to, n.DisputeEmail, "disputereminder", n)
}
// SendAccountTerminated states the outcome of a failed dispute.
func (s Sender) SendAccountTerminated(to string, n DisputeNotice) error {
return s.sendTemplate(to, n.DisputeEmail, "accountterminated", n)
}
// SendAccountRestored states the outcome of an upheld dispute.
func (s Sender) SendAccountRestored(to string, n DisputeNotice) error {
return s.sendTemplate(to, "", "accountrestored", n)
}
+70
View File
@@ -0,0 +1,70 @@
package mail
import (
"strings"
"testing"
"time"
)
func sampleNotice() DisputeNotice {
return DisputeNotice{
AccountName: "Smith & Sons",
DisputeID: "dsp_123",
Reason: "Breach of the terms of service",
CloudNames: []string{"smith-prod"},
SelfHosted: []SelfHostedNotice{{Name: "smith-dc", LicenceExpires: time.Date(2026, 11, 2, 0, 0, 0, 0, time.UTC)}},
DisputeEmail: "disputes@vantage.example",
DisputeBy: time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC),
}
}
var disputeTemplates = []string{"accountlocked", "disputereminder", "accountterminated", "accountrestored"}
func TestDisputeTemplatesRender(t *testing.T) {
for _, name := range disputeTemplates {
t.Run(name, func(t *testing.T) {
m, err := render(name, sampleNotice())
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(m.Subject, "Smith & Sons") {
t.Errorf("subject %q does not name the account verbatim", m.Subject)
}
for label, body := range map[string]string{"text": m.Text, "html": m.HTML} {
if strings.Contains(body, "<no value>") {
t.Errorf("%s body has an unfilled field", label)
}
if strings.Contains(body, "—") {
t.Errorf("%s body contains an em dash", label)
}
}
})
}
}
func TestDisputeRouteIsStated(t *testing.T) {
for _, name := range []string{"accountlocked", "disputereminder"} {
m, err := render(name, sampleNotice())
if err != nil {
t.Fatalf("render %s: %v", name, err)
}
for _, want := range []string{"disputes@vantage.example", "dsp_123", "17 September 2026"} {
if !strings.Contains(m.Text, want) {
t.Errorf("%s text is missing %q", name, want)
}
}
}
}
func TestInstanceRows(t *testing.T) {
rows := sampleNotice().InstanceRows()
if len(rows) != 2 {
t.Fatalf("got %d rows, want 2", len(rows))
}
if rows[0]["v"] != "smith-prod" {
t.Errorf("cloud row = %v", rows[0])
}
if v, _ := rows[1]["v"].(string); !strings.Contains(v, "licence ends 2 November 2026") {
t.Errorf("self-hosted row = %v", rows[1])
}
}
+6 -2
View File
@@ -69,17 +69,21 @@ func perRender(hq, tone string) map[string]any {
// The subject comes from the text set, not the HTML one: html/template would
// escape an ampersand in an instance name into "&amp;" and mail clients show
// subjects verbatim.
func render(name string, data any, hq string) (message, error) {
func render(name string, data any, hq ...string) (message, error) {
s, ok := sets[name]
if !ok {
return message{}, fmt.Errorf("no such template")
}
var hqURL string
if len(hq) > 0 {
hqURL = hq[0]
}
var tone strings.Builder
if err := s.text.ExecuteTemplate(&tone, "tone", data); err != nil {
return message{}, err
}
per := perRender(hq, strings.TrimSpace(tone.String()))
per := perRender(hqURL, strings.TrimSpace(tone.String()))
ht, err := s.html.Clone()
if err != nil {
+20 -12
View File
@@ -20,18 +20,18 @@ func cases() map[string]any {
"invite": s.inviteData("sam@example.com", "Northwind Ops", "8a1b2c3d4e"),
"license": licenseData("northwind-prod",
"eyJ2IjoxLCJpbnN0YW5jZSI6Im5vcnRod2luZC1wcm9kIiwidGllciI6ImZyZWUiLCJleHAiOjE3OTk5OTk5OTl9.MEUCIQDk3v1rX0sX7kQ2m9WQ1o0q8pWm6lq3vRjz8yN0yX4bGQIgYk2Fh7s"),
"instanceready": instanceReadyData("northwind-prod", "https://northwind-prod.vantage.hostxtra.co.uk", now.Add(30*day)),
"instanceready/nologin": instanceReadyData("northwind-prod", "", now.Add(30*day)),
"renewed": renewedData("northwind-prod", now.Add(30*day)),
"expiring": expiringData("northwind-prod", s.PublicURL, now.Add(7*day)),
"expired": expiredData("northwind-prod", s.PublicURL, now.Add(14*day)),
"expired/noreaper": expiredData("northwind-prod", s.PublicURL, time.Time{}),
"deletionwarning": deletionWarningData("northwind-prod", s.PublicURL, now.Add(7*day), 7),
"deletionwarning/1day": deletionWarningData("northwind-prod", s.PublicURL, now.Add(day), 1),
"cancelled": s.billingData("northwind-prod"),
"pastdue": s.billingData("northwind-prod"),
"monitoralert": MonitorEvent{MonitorName: "api.northwind.io", Type: "http", OldStatus: "up", NewStatus: "down", Message: "GET /healthz returned 503 Service Unavailable after 2.4s", Time: now, Down: true},
"monitoralert/recovered": MonitorEvent{MonitorName: "api.northwind.io", Type: "http", OldStatus: "down", NewStatus: "up", Message: "200 OK in 182ms", Time: now},
"instanceready": instanceReadyData("northwind-prod", "https://northwind-prod.vantage.hostxtra.co.uk", now.Add(30*day)),
"instanceready/nologin": instanceReadyData("northwind-prod", "", now.Add(30*day)),
"renewed": renewedData("northwind-prod", now.Add(30*day)),
"expiring": expiringData("northwind-prod", s.PublicURL, now.Add(7*day)),
"expired": expiredData("northwind-prod", s.PublicURL, now.Add(14*day)),
"expired/noreaper": expiredData("northwind-prod", s.PublicURL, time.Time{}),
"deletionwarning": deletionWarningData("northwind-prod", s.PublicURL, now.Add(7*day), 7),
"deletionwarning/1day": deletionWarningData("northwind-prod", s.PublicURL, now.Add(day), 1),
"cancelled": s.billingData("northwind-prod"),
"pastdue": s.billingData("northwind-prod"),
"monitoralert": MonitorEvent{MonitorName: "api.northwind.io", Type: "http", OldStatus: "up", NewStatus: "down", Message: "GET /healthz returned 503 Service Unavailable after 2.4s", Time: now, Down: true},
"monitoralert/recovered": MonitorEvent{MonitorName: "api.northwind.io", Type: "http", OldStatus: "down", NewStatus: "up", Message: "200 OK in 182ms", Time: now},
"contact": struct {
Enquiry
Received string
@@ -47,6 +47,14 @@ func cases() map[string]any {
{CVEID: "CVE-2025-48112", Severity: "low", PackageName: "less", ServerName: "worker-03"},
},
More: 9, DBAge: "2 days"},
"announcement": announcementData{
Announcement: sampleAnnouncement(),
BodyHTML: "<p>Workflows can now run <strong>on a schedule</strong>.</p>",
},
"accountlocked": sampleNotice(),
"disputereminder": sampleNotice(),
"accountterminated": sampleNotice(),
"accountrestored": sampleNotice(),
}
}
+95 -30
View File
@@ -14,10 +14,13 @@ import (
"fmt"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
netmail "net/mail"
"net/smtp"
"net/textproto"
"os"
"sort"
"strings"
"time"
)
@@ -27,6 +30,29 @@ import (
// client gives up - and admin's signup rollback runs on that request's context.
const timeout = 15 * time.Second
// SMTP send phases. A caller that retries per recipient needs to know which
// one failed: a recipient or data reply names one person's problem, while a
// connect or quit failure says nothing about the message itself and a data
// failure with no reply means the outcome of that one send is unknown.
const (
PhasePrepare = "prepare"
PhaseConnect = "connect"
PhaseRecipient = "recipient"
PhaseData = "data"
PhaseQuit = "quit"
)
// SendError names which phase of the SMTP conversation failed. Err is kept as
// the original wrapped error, so errors.As(err, &textprotoErr) still reaches
// a *textproto.Error through Unwrap when the server sent one.
type SendError struct {
Phase string
Err error
}
func (e *SendError) Error() string { return "smtp " + e.Phase + ": " + e.Err.Error() }
func (e *SendError) Unwrap() error { return e.Err }
// Sender is a configured SMTP destination. It is a value, not a singleton:
// server/internal/notify builds one per notification channel from data in
// Mongo, while admin and sitesvc build one at boot.
@@ -74,6 +100,11 @@ type message struct {
Subject string
HTML string
Text string
// Headers are extra header lines for this one message, such as an
// announcement's List-Unsubscribe pair. Keys and values are CR/LF-stripped
// like every other header.
Headers map[string]string
}
// sendTemplate renders name against data and delivers the result.
@@ -96,17 +127,17 @@ func (s Sender) sendTemplate(to, replyTo, name string, data any) error {
// every admin email from being delivered once already.
func (s Sender) send(m message) error {
if !s.Enabled() {
return fmt.Errorf("smtp: not configured")
return &SendError{Phase: PhasePrepare, Err: fmt.Errorf("smtp: not configured")}
}
rcpts := recipients(m.To)
if len(rcpts) == 0 {
return fmt.Errorf("smtp: no recipient")
return &SendError{Phase: PhasePrepare, Err: fmt.Errorf("smtp: no recipient")}
}
addr := net.JoinHostPort(s.Host, s.Port)
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return fmt.Errorf("smtp: dial %s: %w", addr, err)
return &SendError{Phase: PhaseConnect, Err: fmt.Errorf("smtp: dial %s: %w", addr, err)}
}
_ = conn.SetDeadline(time.Now().Add(timeout))
@@ -117,49 +148,66 @@ func (s Sender) send(m message) error {
client, err := smtp.NewClient(conn, s.Host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp: client: %w", err)
return &SendError{Phase: PhaseConnect, Err: fmt.Errorf("smtp: client: %w", err)}
}
defer client.Close()
if s.Port != "465" {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: s.Host}); err != nil {
return fmt.Errorf("smtp: starttls: %w", err)
return &SendError{Phase: PhaseConnect, Err: fmt.Errorf("smtp: starttls: %w", err)}
}
}
}
if s.Username != "" {
if err := client.Auth(smtp.PlainAuth("", s.Username, s.Password, s.Host)); err != nil {
return fmt.Errorf("smtp: auth: %w", err)
return &SendError{Phase: PhaseConnect, Err: fmt.Errorf("smtp: auth: %w", err)}
}
}
if err := client.Mail(s.From); err != nil {
return fmt.Errorf("smtp: mail from: %w", err)
if err := client.Mail(addrSpec(s.From)); err != nil {
return &SendError{Phase: PhaseConnect, Err: fmt.Errorf("smtp: mail from: %w", err)}
}
for _, rcpt := range rcpts {
if err := client.Rcpt(rcpt); err != nil {
return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err)
if err := client.Rcpt(addrSpec(rcpt)); err != nil {
return &SendError{Phase: PhaseRecipient, Err: fmt.Errorf("smtp: rcpt %s: %w", rcpt, err)}
}
}
body, err := s.envelope(m)
if err != nil {
return fmt.Errorf("smtp: build message: %w", err)
return &SendError{Phase: PhasePrepare, Err: fmt.Errorf("smtp: build message: %w", err)}
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp: data: %w", err)
return &SendError{Phase: PhaseData, Err: fmt.Errorf("smtp: data: %w", err)}
}
if _, err := w.Write(body); err != nil {
return fmt.Errorf("smtp: write: %w", err)
return &SendError{Phase: PhaseData, Err: fmt.Errorf("smtp: write: %w", err)}
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp: close data: %w", err)
return &SendError{Phase: PhaseData, Err: fmt.Errorf("smtp: close data: %w", err)}
}
return client.Quit()
if err := client.Quit(); err != nil {
return &SendError{Phase: PhaseQuit, Err: fmt.Errorf("smtp: quit: %w", err)}
}
return nil
}
// addrSpec is the bare address for the SMTP envelope. SMTP_FROM is usually
// "Vantage <support@example.com>", which belongs in the From header only:
// sent as MAIL FROM it became "<Vantage <support@example.com>>". Postfix
// salvaged the address, so delivery and Return-Path looked fine, but rspamd
// saw no envelope sender and mailcow skipped DKIM signing, so Gmail filed
// every Vantage email as spam. Anything that does not parse is passed through
// for the server to judge.
func addrSpec(v string) string {
if a, err := netmail.ParseAddress(v); err == nil {
return a.Address
}
return strings.TrimSpace(v)
}
func recipients(to string) []string {
@@ -180,27 +228,19 @@ func recipients(to string) []string {
// text part is sent beside every HTML one for the same reason, and because a
// client that refuses HTML should not receive a blank message. Header values
// are stripped of CR and LF so a crafted instance name cannot inject headers.
//
// Both parts are quoted-printable. They were raw UTF-8 with no
// Content-Transfer-Encoding, which means 7bit, and every template carries
// non-ASCII (the middot in the masthead at least), which rspamd scored as
// R_BAD_CTE_7BIT.
func (s Sender) envelope(m message) ([]byte, error) {
var parts strings.Builder
w := multipart.NewWriter(&parts)
textPart, err := w.CreatePart(textproto.MIMEHeader{
"Content-Type": {"text/plain; charset=utf-8"},
})
if err != nil {
if err := writeQPPart(w, "text/plain; charset=utf-8", m.Text); err != nil {
return nil, err
}
if _, err := textPart.Write([]byte(m.Text)); err != nil {
return nil, err
}
htmlPart, err := w.CreatePart(textproto.MIMEHeader{
"Content-Type": {"text/html; charset=utf-8"},
})
if err != nil {
return nil, err
}
if _, err := htmlPart.Write([]byte(m.HTML)); err != nil {
if err := writeQPPart(w, "text/html; charset=utf-8", m.HTML); err != nil {
return nil, err
}
@@ -218,12 +258,37 @@ func (s Sender) envelope(m message) ([]byte, error) {
b.WriteString("Message-ID: " + messageID(s.From) + "\r\n")
b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", sanitizeHeader(m.Subject)) + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
keys := make([]string, 0, len(m.Headers))
for k := range m.Headers {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
b.WriteString(sanitizeHeader(k) + ": " + sanitizeHeader(m.Headers[k]) + "\r\n")
}
b.WriteString("Content-Type: multipart/alternative; boundary=" + w.Boundary() + "\r\n")
b.WriteString("\r\n")
b.WriteString(parts.String())
return []byte(b.String()), nil
}
// writeQPPart adds one quoted-printable part, so the wire stays 7-bit and
// lines stay under SMTP's 998-byte limit whatever the template produced.
func writeQPPart(w *multipart.Writer, contentType, body string) error {
part, err := w.CreatePart(textproto.MIMEHeader{
"Content-Type": {contentType},
"Content-Transfer-Encoding": {"quoted-printable"},
})
if err != nil {
return err
}
qp := quotedprintable.NewWriter(part)
if _, err := qp.Write([]byte(body)); err != nil {
return err
}
return qp.Close()
}
func messageID(from string) string {
domain := "vantage.local"
if at := strings.LastIndex(from, "@"); at >= 0 && at < len(from)-1 {
+330
View File
@@ -0,0 +1,330 @@
package mail
import (
"bufio"
"bytes"
"errors"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
"net/mail"
"net/textproto"
"strings"
"testing"
)
// fakeSMTP is a tiny scripted SMTP server. script maps an uppercased command
// verb (or "DATA_BODY" for the reply after the body's terminating dot, or
// "CLOSE_AFTER_BODY" to drop the connection with no reply at all) to the
// line(s) to write back. Anything not listed gets a plain 250 OK.
func fakeSMTP(t *testing.T, script map[string]string) string {
t.Helper()
port, _ := fakeSMTPData(t, script)
return port
}
// fakeSMTPData is fakeSMTP that also hands back the raw DATA the client sent,
// once the body's terminating dot arrives.
func fakeSMTPData(t *testing.T, script map[string]string) (string, <-chan []byte) {
t.Helper()
got := make(chan []byte, 1)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
w := bufio.NewWriter(conn)
r := bufio.NewReader(conn)
writeLine := func(s string) {
w.WriteString(s + "\r\n")
w.Flush()
}
writeLine("220 fake.example ESMTP")
inData := false
var data bytes.Buffer
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
raw := line
line = strings.TrimRight(line, "\r\n")
if inData {
if line != "." {
data.WriteString(raw)
}
if line == "." {
inData = false
got <- data.Bytes()
if reply, ok := script["DATA_BODY"]; ok {
if reply == "CLOSE" {
return
}
writeLine(reply)
} else {
writeLine("250 OK")
}
}
continue
}
verb := strings.ToUpper(strings.Fields(line)[0])
if verb == "EHLO" || verb == "HELO" {
writeLine("250 fake.example")
continue
}
if verb == "DATA" {
if reply, ok := script["DATA"]; ok {
if reply == "CLOSE" {
return
}
writeLine(reply)
continue
}
inData = true
writeLine("354 go ahead")
continue
}
if verb == "QUIT" {
if reply, ok := script["QUIT"]; ok {
if reply == "CLOSE" {
return
}
writeLine(reply)
} else {
writeLine("221 bye")
}
return
}
if reply, ok := script[verb]; ok {
if reply == "CLOSE" {
return
}
writeLine(reply)
continue
}
writeLine("250 OK")
}
}()
host, port, _ := net.SplitHostPort(ln.Addr().String())
_ = host
return port, got
}
func testMsg() message {
return message{To: "rcpt@example.com", Subject: "s", Text: "t", HTML: "<p>h</p>"}
}
func TestSendPhaseOnRecipientFailure(t *testing.T) {
port := fakeSMTP(t, map[string]string{"RCPT": "550 no such user"})
s := Sender{Host: "127.0.0.1", Port: port, From: "updates@example.com"}
err := s.send(testMsg())
var se *SendError
if !errors.As(err, &se) {
t.Fatalf("err = %v, want *SendError", err)
}
if se.Phase != PhaseRecipient {
t.Fatalf("phase = %q, want %q", se.Phase, PhaseRecipient)
}
var tp *textproto.Error
if !errors.As(err, &tp) || tp.Code != 550 {
t.Fatalf("textproto reply = %+v", tp)
}
}
func TestSendPhaseOnMailFromFailure(t *testing.T) {
// No AUTH configured, so a MAIL-stage 550 exercises the connect phase
// without needing to script a real AUTH challenge/response.
port := fakeSMTP(t, map[string]string{"MAIL": "550 relay denied"})
s := Sender{Host: "127.0.0.1", Port: port, From: "updates@example.com"}
err := s.send(testMsg())
var se *SendError
if !errors.As(err, &se) {
t.Fatalf("err = %v, want *SendError", err)
}
if se.Phase != PhaseConnect {
t.Fatalf("phase = %q, want %q", se.Phase, PhaseConnect)
}
var tp *textproto.Error
if !errors.As(err, &tp) || tp.Code != 550 {
t.Fatalf("textproto reply = %+v", tp)
}
}
func TestSendPhaseOnDataCommandFailure(t *testing.T) {
port := fakeSMTP(t, map[string]string{"DATA": "554 no thanks"})
s := Sender{Host: "127.0.0.1", Port: port, From: "updates@example.com"}
err := s.send(testMsg())
var se *SendError
if !errors.As(err, &se) {
t.Fatalf("err = %v, want *SendError", err)
}
if se.Phase != PhaseData {
t.Fatalf("phase = %q, want %q", se.Phase, PhaseData)
}
var tp *textproto.Error
if !errors.As(err, &tp) || tp.Code != 554 {
t.Fatalf("textproto reply = %+v", tp)
}
}
// The connection drops after the body's terminating dot but before any reply
// is read: the data phase, but with no textproto error, since the server
// never spoke back at all.
func TestSendPhaseOnDataNoReply(t *testing.T) {
port := fakeSMTP(t, map[string]string{"DATA_BODY": "CLOSE"})
s := Sender{Host: "127.0.0.1", Port: port, From: "updates@example.com"}
err := s.send(testMsg())
var se *SendError
if !errors.As(err, &se) {
t.Fatalf("err = %v, want *SendError", err)
}
if se.Phase != PhaseData {
t.Fatalf("phase = %q, want %q", se.Phase, PhaseData)
}
var tp *textproto.Error
if errors.As(err, &tp) {
t.Fatalf("expected no textproto reply, got %+v", tp)
}
}
// A 250 on DATA_BODY (the message is accepted) followed by a dropped
// connection on QUIT: the message was already delivered, so this must be the
// quit phase, not data or connect.
func TestSendPhaseOnQuitFailure(t *testing.T) {
port := fakeSMTP(t, map[string]string{"QUIT": "CLOSE"})
s := Sender{Host: "127.0.0.1", Port: port, From: "updates@example.com"}
err := s.send(testMsg())
var se *SendError
if !errors.As(err, &se) {
t.Fatalf("err = %v, want *SendError", err)
}
if se.Phase != PhaseQuit {
t.Fatalf("phase = %q, want %q", se.Phase, PhaseQuit)
}
}
func TestSendPrepareErrors(t *testing.T) {
s := Sender{}
err := s.send(testMsg())
var se *SendError
if !errors.As(err, &se) || se.Phase != PhasePrepare {
t.Fatalf("not configured: err = %v", err)
}
s2 := Sender{Host: "127.0.0.1", Port: "0", From: "updates@example.com"}
err2 := s2.send(message{Subject: "s", Text: "t", HTML: "<p>h</p>"})
var se2 *SendError
if !errors.As(err2, &se2) || se2.Phase != PhasePrepare {
t.Fatalf("no recipient: err = %v", err2)
}
}
// The envelope carries the bare address. "Vantage <x@y>" as MAIL FROM left
// rspamd with no envelope sender, and mailcow skipped DKIM signing.
func TestAddrSpecStripsDisplayName(t *testing.T) {
for in, want := range map[string]string{
"Vantage <support@example.com>": "support@example.com",
"support@example.com": "support@example.com",
" a@example.com ": "a@example.com",
"not an address": "not an address",
} {
if got := addrSpec(in); got != want {
t.Errorf("addrSpec(%q) = %q, want %q", in, got, want)
}
}
}
// Every part must declare quoted-printable and put only 7-bit bytes on the
// wire. Raw UTF-8 under an implied 7bit encoding scored R_BAD_CTE_7BIT in
// rspamd.
func TestEnvelopePartsAreQuotedPrintable(t *testing.T) {
s := Sender{From: "Vantage <support@example.com>"}
m := message{
To: "a@example.com",
Subject: "Smith & Sons · restored",
Text: "VANTAGE · Account\nBilling has resumed.",
HTML: "<p>VANTAGE · Account</p><p>" + strings.Repeat("x", 2000) + "</p>",
}
raw, err := s.envelope(m)
if err != nil {
t.Fatal(err)
}
for i, c := range raw {
if c > 0x7e && c != '\r' && c != '\n' {
t.Fatalf("byte %d is 0x%x: the wire must be 7-bit", i, c)
}
}
for _, line := range strings.Split(string(raw), "\r\n") {
if len(line) > 998 {
t.Fatalf("line of %d bytes exceeds SMTP's 998", len(line))
}
}
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
_, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
if err != nil {
t.Fatal(err)
}
r := multipart.NewReader(msg.Body, params["boundary"])
want := []string{m.Text, m.HTML}
for i := range want {
p, err := r.NextRawPart()
if err != nil {
t.Fatalf("part %d: %v", i, err)
}
if cte := p.Header.Get("Content-Transfer-Encoding"); cte != "quoted-printable" {
t.Fatalf("part %d Content-Transfer-Encoding = %q", i, cte)
}
// NextPart would decode for us; NextRawPart keeps the check honest by
// decoding here, so a missing header cannot pass by accident.
body, err := io.ReadAll(quotedprintable.NewReader(p))
if err != nil {
t.Fatal(err)
}
// Quoted-printable text mode writes line breaks as CRLF, which is the
// canonical form on the wire.
if strings.ReplaceAll(string(body), "\r\n", "\n") != want[i] {
t.Fatalf("part %d decodes to %q", i, body)
}
}
}
func TestEnvelopeWritesExtraHeadersSanitised(t *testing.T) {
s := Sender{From: "Vantage <updates@example.com>"}
raw, err := s.envelope(message{
To: "a@example.com", Subject: "s", Text: "t", HTML: "<p>h</p>",
Headers: map[string]string{
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
"List-Unsubscribe": "<https://x.example/u?t=1>\r\nBcc: evil@example.com",
},
})
if err != nil {
t.Fatal(err)
}
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
if got := msg.Header.Get("List-Unsubscribe-Post"); got != "List-Unsubscribe=One-Click" {
t.Fatalf("List-Unsubscribe-Post = %q", got)
}
if got := msg.Header.Get("Bcc"); got != "" {
t.Fatalf("header injection: Bcc = %q", got)
}
if !strings.HasPrefix(msg.Header.Get("List-Unsubscribe"), "<https://x.example/u?t=1>") {
t.Fatalf("List-Unsubscribe = %q", msg.Header.Get("List-Unsubscribe"))
}
}
+9
View File
@@ -0,0 +1,9 @@
{{define "pill"}}{{template "chip" (dict "label" "Account locked" "tone" "pend")}}{{end}}
{{define "title"}}Your account is locked{{end}}
{{define "body"}}
{{template "lead" (printf "We have locked the Vantage account %s. Reason: %s." .AccountName .Reason)}}
{{template "p" "Nothing has been deleted. While the account is locked, sign-in is paused, cloud instances are offline and billing is paused."}}
{{if .InstanceRows}}{{template "rows" .InstanceRows}}{{end}}
{{template "p" (printf "If you believe this is a mistake, reply to %s before %s, quoting dispute reference %s." .DisputeEmail (date .DisputeBy) .DisputeID)}}
{{template "p" "If the dispute is not upheld, the account will be terminated and its cloud instances deleted."}}
{{end}}
+10
View File
@@ -0,0 +1,10 @@
{{define "subject"}}Your Vantage account {{.AccountName}} is locked{{end}}
{{define "pill"}}{{template "chip" (dict "label" "Account locked" "tone" "pend")}}{{end}}
{{define "title"}}Your account is locked{{end}}
{{define "body"}}
{{template "lead" (printf "We have locked the Vantage account %s. Reason: %s." .AccountName .Reason)}}
{{template "p" "Nothing has been deleted. While the account is locked, sign-in is paused, cloud instances are offline and billing is paused."}}
{{if .InstanceRows}}{{template "rows" .InstanceRows}}{{end}}
{{template "p" (printf "If you believe this is a mistake, reply to %s before %s, quoting dispute reference %s." .DisputeEmail (date .DisputeBy) .DisputeID)}}
{{template "p" "If the dispute is not upheld, the account will be terminated and its cloud instances deleted."}}
{{end}}
+7
View File
@@ -0,0 +1,7 @@
{{define "pill"}}{{template "chip" (dict "label" "Restored" "tone" "up")}}{{end}}
{{define "title"}}Your account is restored{{end}}
{{define "body"}}
{{template "lead" (printf "The dispute for %s is resolved and your account is restored." .AccountName)}}
{{template "p" "Sign-in works again and cloud instances are back online. Billing has resumed on the period you already paid for, so there is no charge today."}}
{{template "p" "You will need to sign in again."}}
{{end}}
+8
View File
@@ -0,0 +1,8 @@
{{define "subject"}}Your Vantage account {{.AccountName}} is restored{{end}}
{{define "pill"}}{{template "chip" (dict "label" "Restored" "tone" "up")}}{{end}}
{{define "title"}}Your account is restored{{end}}
{{define "body"}}
{{template "lead" (printf "The dispute for %s is resolved and your account is restored." .AccountName)}}
{{template "p" "Sign-in works again and cloud instances are back online. Billing has resumed on the period you already paid for, so there is no charge today."}}
{{template "p" "You will need to sign in again."}}
{{end}}
@@ -0,0 +1,9 @@
{{define "pill"}}{{template "chip" (dict "label" "Terminated" "tone" "down")}}{{end}}
{{define "title"}}Your account is terminated{{end}}
{{define "body"}}
{{template "lead" (printf "The dispute for %s was not upheld, and the account is terminated. Reason: %s." .AccountName .Reason)}}
{{template "p" "Every subscription is cancelled and no refund is due. Sign-in is removed and cloud instances are being deleted. This cannot be undone."}}
{{if .InstanceRows}}{{template "rows" .InstanceRows}}{{end}}
{{if .SelfHosted}}{{template "p" "Self-hosted installs keep running until the licence date shown above. No new licence will be issued."}}{{end}}
{{template "p" (printf "Questions: %s, quoting dispute reference %s." .DisputeEmail .DisputeID)}}
{{end}}
+10
View File
@@ -0,0 +1,10 @@
{{define "subject"}}Your Vantage account {{.AccountName}} is terminated{{end}}
{{define "pill"}}{{template "chip" (dict "label" "Terminated" "tone" "down")}}{{end}}
{{define "title"}}Your account is terminated{{end}}
{{define "body"}}
{{template "lead" (printf "The dispute for %s was not upheld, and the account is terminated. Reason: %s." .AccountName .Reason)}}
{{template "p" "Every subscription is cancelled and no refund is due. Sign-in is removed and cloud instances are being deleted. This cannot be undone."}}
{{if .InstanceRows}}{{template "rows" .InstanceRows}}{{end}}
{{if .SelfHosted}}{{template "p" "Self-hosted installs keep running until the licence date shown above. No new licence will be issued."}}{{end}}
{{template "p" (printf "Questions: %s, quoting dispute reference %s." .DisputeEmail .DisputeID)}}
{{end}}
+9
View File
@@ -0,0 +1,9 @@
{{define "tone"}}accent{{end}}
{{define "category"}}{{.CategoryLabel}}{{end}}
{{define "pill"}}{{template "chip" (dict "label" .CategoryLabel "tone" "accent")}}{{end}}
{{define "title"}}{{.Subject}}{{end}}
{{define "body"}}
<div style="color:#e4ecf6;font-size:15px;line-height:1.6;">{{.BodyHTML}}</div>
<p style="margin:24px 0 0;color:#71879f;font-size:12px;line-height:1.6;"><a href="{{.UnsubscribeURL}}" style="color:#9fb3ca;">Unsubscribe from {{.CategoryLabel}}</a> &middot; <a href="{{.PreferencesURL}}" style="color:#9fb3ca;">Email preferences</a>{{if .PostalAddress}}<br>{{.PostalAddress}}{{end}}</p>
{{end}}
{{define "why"}}you have a Vantage account and are subscribed to {{.CategoryLabel}}. Account emails such as sign-in, licences and billing are sent whatever you choose here.{{end}}
+13
View File
@@ -0,0 +1,13 @@
{{define "subject"}}{{.Subject}}{{end}}
{{define "tone"}}accent{{end}}
{{define "category"}}{{.CategoryLabel}}{{end}}
{{define "pill"}}{{template "chip" (dict "label" .CategoryLabel "tone" "accent")}}{{end}}
{{define "title"}}{{.Subject}}{{end}}
{{define "body"}}
{{.BodyMarkdown}}
Unsubscribe from {{.CategoryLabel}}: {{.UnsubscribeURL}}
Email preferences: {{.PreferencesURL}}
{{if .PostalAddress}}{{.PostalAddress}}{{end}}
{{end}}
{{define "why"}}you have a Vantage account and are subscribed to {{.CategoryLabel}}. Account emails such as sign-in, licences and billing are sent whatever you choose here.{{end}}
+7
View File
@@ -0,0 +1,7 @@
{{define "pill"}}{{template "chip" (dict "label" "Dispute deadline" "tone" "pend")}}{{end}}
{{define "title"}}Your account is still locked{{end}}
{{define "body"}}
{{template "lead" (printf "The Vantage account %s is still locked. Reason: %s." .AccountName .Reason)}}
{{template "p" (printf "To dispute this, reply to %s before %s, quoting dispute reference %s." .DisputeEmail (date .DisputeBy) .DisputeID)}}
{{template "p" "After that date the dispute is decided. If it is not upheld, the account is terminated and its cloud instances deleted."}}
{{end}}
+8
View File
@@ -0,0 +1,8 @@
{{define "subject"}}{{.AccountName}}: dispute deadline {{shortDate .DisputeBy}}{{end}}
{{define "pill"}}{{template "chip" (dict "label" "Dispute deadline" "tone" "pend")}}{{end}}
{{define "title"}}Your account is still locked{{end}}
{{define "body"}}
{{template "lead" (printf "The Vantage account %s is still locked. Reason: %s." .AccountName .Reason)}}
{{template "p" (printf "To dispute this, reply to %s before %s, quoting dispute reference %s." .DisputeEmail (date .DisputeBy) .DisputeID)}}
{{template "p" "After that date the dispute is decided. If it is not upheld, the account is terminated and its cloud instances deleted."}}
{{end}}
+8
View File
@@ -31,4 +31,12 @@ type Instance struct {
LicenseBlob string `bson:"license_blob,omitempty" json:"-"`
LicenseTier string `bson:"license_tier,omitempty" json:"license_tier,omitempty"`
LicenseExpiry *time.Time `bson:"license_expiry,omitempty" json:"license_expiry,omitempty"`
// LockedAt and PurgeAfter are written by vantage-admin's cloudprov when the
// account that owns this instance is under a dispute. LockedAt makes the
// control plane refuse the instance everywhere; PurgeAfter, set only once a
// dispute has failed, authorises the reaper to delete it. Both absent is the
// normal state, and nothing but admin ever sets them.
LockedAt *time.Time `bson:"locked_at,omitempty" json:"locked_at,omitempty"`
PurgeAfter *time.Time `bson:"purge_after,omitempty" json:"purge_after,omitempty"`
}
+40
View File
@@ -0,0 +1,40 @@
package models
import (
"testing"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestInstanceLockFieldsBSON(t *testing.T) {
at := time.Date(2026, 9, 10, 10, 0, 0, 0, time.UTC)
raw, err := bson.Marshal(Instance{InstanceID: "i1", LockedAt: &at, PurgeAfter: &at})
if err != nil {
t.Fatalf("marshal: %v", err)
}
var set bson.M
if err := bson.Unmarshal(raw, &set); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, k := range []string{"locked_at", "purge_after"} {
if _, ok := set[k]; !ok {
t.Errorf("%s missing from %v", k, set)
}
}
raw, err = bson.Marshal(Instance{InstanceID: "i1"})
if err != nil {
t.Fatalf("marshal: %v", err)
}
var unset bson.M
if err := bson.Unmarshal(raw, &unset); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, k := range []string{"locked_at", "purge_after"} {
if _, ok := unset[k]; ok {
t.Errorf("%s written for an unlocked instance: %v", k, unset)
}
}
}
+15 -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;
}
}
@@ -168,9 +169,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 +221,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 {