This is a large squashed commit covering two batches of prior uncommitted work plus a full security-audit remediation pass, kept together because go.mod/go.sum and several shared files (main.go, handler.go) were touched by both and splitting risked non-building intermediate commits. Features (built earlier, previously uncommitted): - Local username/password login for single-tenant deployments with no SSO configured (api/localauth, alerting/internal/sessioncheck, sentryctl users, web/src/routes/login, metadata migrations 0040/0041). - Remotely-editable additional log file paths for agents, on top of their existing primary source (api/agents, agent/sentry-agent extra-file-path diffing, web agent config UI). - IPv4/IPv6 addresses reported alongside other host system metrics. Security audit remediation (this pass, all live-verified in production): - Critical: block ClickHouse SSRF table functions (url/remote/file/s3/...) in the raw-SQL query escape hatch. - High: deny sensitive paths and require Admin to add agent extra_file_paths (Editor could previously point an agent at /etc/shadow or an SSH key); alerting webhook targets now validate against internal/metadata/loopback addresses, both at creation and send time; alerting's session middleware now enforces an Editor+ floor on mutating requests instead of "any authenticated session"; bumped goxmldsig to close a SAML signature-verification bypass (GO-2026-4753). - Medium: per-IP login rate limiting; security response headers (HSTS/CSP/nosniff/X-Frame-Options/Referrer-Policy/Permissions-Policy) on web/nginx.conf; a DevCredentialWarnings check in every Go service's config loader, logging loudly at startup if a deployment is still on docker-compose.yml's literal dev-only credentials; dependency bumps (golang.org/x/text, grpc, x/net, quick-xml, h2) across every affected Go module and both Rust crates, including a previously-uncovered x/net vulnerability in deploy/operator; a new security-scan.yml CI workflow running cargo-deny/govulncheck/npm-audit, mirroring the existing license-compliance.yml matrix shape. - Low: removed sentryctl's plaintext --password flag (shell history/`ps` exposure) in favor of stdin and a --password-stdin flag for reset-password's optional specific-password path; a dummy bcrypt comparison closes a login response-time username-enumeration side-channel.
261 lines
9.4 KiB
Go
261 lines
9.4 KiB
Go
// Package agents is the web-facing half of agent inventory/remote
|
|
// config (see /docs/agent-management-design.md) -- reads/writes the
|
|
// same `agents` table ingest's internal/agentregistry writes on every
|
|
// CheckIn RPC, the same shared-schema-different-services shape
|
|
// alerting and api already use for dashboards/alert_rules.
|
|
package agents
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
var ErrNotFound = errors.New("not found")
|
|
|
|
// CommandRestart is the one supported lifecycle command -- see
|
|
// ingest/internal/grpcserver.AgentCommandRestart and
|
|
// agent_control.proto's AgentCommand enum comment for why STOP/
|
|
// UNINSTALL aren't here yet.
|
|
const CommandRestart = "restart"
|
|
|
|
func validCommand(c string) bool {
|
|
return c == CommandRestart
|
|
}
|
|
|
|
// ConfigOverride is the remotely-editable subset of an agent's config --
|
|
// a plain-Go mirror of ingest/internal/agentregistry's overrideFields
|
|
// and agent_control.proto's DesiredOverride. Deliberately duplicated
|
|
// rather than imported across the module boundary, same convention as
|
|
// every other cross-module shared shape in this codebase (see
|
|
// grpcserver.TenantIDHeaderKey, enterprise/internal/apiconfig.AIConfig).
|
|
// Keep the three in sync by hand.
|
|
type ConfigOverride struct {
|
|
BatchMaxSize *int64 `json:"batch_max_size,omitempty"`
|
|
BatchFlushIntervalMS *int64 `json:"batch_flush_interval_ms,omitempty"`
|
|
HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"`
|
|
HeartbeatIntervalMS *int64 `json:"heartbeat_interval_ms,omitempty"`
|
|
JournaldUnit *string `json:"journald_unit,omitempty"`
|
|
ExtraFilePaths []string `json:"extra_file_paths,omitempty"`
|
|
}
|
|
|
|
type Agent struct {
|
|
ID string `json:"id"`
|
|
TenantID string `json:"tenant_id"`
|
|
Host string `json:"host"`
|
|
Service string `json:"service"`
|
|
AgentVersion string `json:"agent_version"`
|
|
SourceKind string `json:"source_kind"`
|
|
SourceDetail string `json:"source_detail"`
|
|
BatchMaxSize int64 `json:"batch_max_size"`
|
|
BatchFlushIntervalMS int64 `json:"batch_flush_interval_ms"`
|
|
HeartbeatEnabled bool `json:"heartbeat_enabled"`
|
|
HeartbeatIntervalMS int64 `json:"heartbeat_interval_ms"`
|
|
FirstSeenAt time.Time `json:"first_seen_at"`
|
|
LastSeenAt time.Time `json:"last_seen_at"`
|
|
DesiredOverride *ConfigOverride `json:"desired_override,omitempty"`
|
|
DesiredOverrideVersion string `json:"desired_override_version,omitempty"`
|
|
AppliedOverrideVersion string `json:"applied_override_version"`
|
|
// Pending is computed, not stored: an override exists
|
|
// (DesiredOverrideVersion != "") that the agent hasn't reported
|
|
// applying yet (AppliedOverrideVersion doesn't match). This is what
|
|
// the web UI's "pending"/"applied" indicator (task selected:
|
|
// "+ Remote config editing") reads directly, rather than
|
|
// recomputing the same string comparison itself.
|
|
Pending bool `json:"pending"`
|
|
UpdatedBy string `json:"updated_by,omitempty"`
|
|
// PendingCommand is "" when nothing is queued, or CommandRestart
|
|
// while a restart hasn't yet been delivered to the agent. Unlike
|
|
// Pending (config), there's no way to observe "delivered" from this
|
|
// table alone -- ingest clears pending_command the instant it hands
|
|
// the command out (see ingest/internal/agentregistry.Registry.
|
|
// CheckIn), so PendingCommand flipping back to "" just as plausibly
|
|
// means "delivered a moment ago" as "never issued." CommandIssuedAt
|
|
// is what the web UI shows instead, as a last-issued record.
|
|
PendingCommand string `json:"pending_command,omitempty"`
|
|
CommandIssuedAt *time.Time `json:"command_issued_at,omitempty"`
|
|
CommandIssuedBy string `json:"command_issued_by,omitempty"`
|
|
}
|
|
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewStore(pool *pgxpool.Pool) *Store {
|
|
return &Store{pool: pool}
|
|
}
|
|
|
|
const selectColumns = `
|
|
id, tenant_id, host, service,
|
|
reported_agent_version, reported_source_kind, reported_source_detail,
|
|
reported_batch_max_size, reported_batch_flush_ms,
|
|
reported_heartbeat_on, reported_heartbeat_ms,
|
|
first_seen_at, last_seen_at,
|
|
desired_override, desired_override_version, applied_override_version, updated_by,
|
|
pending_command, command_issued_at, command_issued_by`
|
|
|
|
func (s *Store) List(ctx context.Context, tenantID string) ([]Agent, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT `+selectColumns+`
|
|
FROM agents WHERE tenant_id = $1 ORDER BY host`, tenantID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []Agent
|
|
for rows.Next() {
|
|
a, err := scanAgent(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, a)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) Get(ctx context.Context, tenantID, host string) (*Agent, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT `+selectColumns+`
|
|
FROM agents WHERE tenant_id = $1 AND host = $2`, tenantID, host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
if !rows.Next() {
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, ErrNotFound
|
|
}
|
|
a, err := scanAgent(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &a, nil
|
|
}
|
|
|
|
// SetOverride writes a new desired override for host, generating a
|
|
// fresh version stamp -- overwrites any previous override wholesale
|
|
// (this is "set the desired config," not "patch a few fields into
|
|
// whatever was there," so a caller building a partial edit must have
|
|
// already merged it against the current value, same as any other PUT
|
|
// endpoint in this codebase). Returns ErrNotFound if the agent has
|
|
// never checked in (nothing to target an override at yet -- an
|
|
// override for a host ingest has never seen would be silently
|
|
// unreachable).
|
|
func (s *Store) SetOverride(ctx context.Context, tenantID, host string, override ConfigOverride, updatedBy string) (*Agent, error) {
|
|
version := newVersion()
|
|
data, err := json.Marshal(override)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tag, err := s.pool.Exec(ctx, `
|
|
UPDATE agents SET desired_override = $1, desired_override_version = $2, updated_by = $3
|
|
WHERE tenant_id = $4 AND host = $5`,
|
|
data, version, updatedBy, tenantID, host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return nil, ErrNotFound
|
|
}
|
|
return s.Get(ctx, tenantID, host)
|
|
}
|
|
|
|
// IssueCommand queues a one-shot lifecycle command for host, delivered
|
|
// on its next CheckIn and cleared atomically by ingest the instant
|
|
// that happens (see ingest/internal/agentregistry.Registry.CheckIn) --
|
|
// unlike SetOverride, there's no "applied" confirmation to wait for,
|
|
// since a restarting agent's process is gone before it could send one.
|
|
// command_issued_at/by are overwritten on every call, forming a
|
|
// last-issued record even after pending_command itself clears.
|
|
func (s *Store) IssueCommand(ctx context.Context, tenantID, host, command, issuedBy string) (*Agent, error) {
|
|
tag, err := s.pool.Exec(ctx, `
|
|
UPDATE agents SET pending_command = $1, command_issued_at = now(), command_issued_by = $2
|
|
WHERE tenant_id = $3 AND host = $4`,
|
|
command, issuedBy, tenantID, host)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return nil, ErrNotFound
|
|
}
|
|
return s.Get(ctx, tenantID, host)
|
|
}
|
|
|
|
// ClearOverride reverts an agent to running its local agent.toml
|
|
// untouched -- the next CheckIn gets has_override=false.
|
|
func (s *Store) ClearOverride(ctx context.Context, tenantID, host string) error {
|
|
tag, err := s.pool.Exec(ctx, `
|
|
UPDATE agents SET desired_override = NULL, desired_override_version = NULL, updated_by = NULL
|
|
WHERE tenant_id = $1 AND host = $2`, tenantID, host)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type rowScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanAgent(row rowScanner) (Agent, error) {
|
|
var a Agent
|
|
var desiredOverride []byte
|
|
var desiredVersion, updatedBy, pendingCommand, commandIssuedBy *string
|
|
if err := row.Scan(
|
|
&a.ID, &a.TenantID, &a.Host, &a.Service,
|
|
&a.AgentVersion, &a.SourceKind, &a.SourceDetail,
|
|
&a.BatchMaxSize, &a.BatchFlushIntervalMS,
|
|
&a.HeartbeatEnabled, &a.HeartbeatIntervalMS,
|
|
&a.FirstSeenAt, &a.LastSeenAt,
|
|
&desiredOverride, &desiredVersion, &a.AppliedOverrideVersion, &updatedBy,
|
|
&pendingCommand, &a.CommandIssuedAt, &commandIssuedBy,
|
|
); err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Agent{}, ErrNotFound
|
|
}
|
|
return Agent{}, err
|
|
}
|
|
if updatedBy != nil {
|
|
a.UpdatedBy = *updatedBy
|
|
}
|
|
if pendingCommand != nil {
|
|
a.PendingCommand = *pendingCommand
|
|
}
|
|
if commandIssuedBy != nil {
|
|
a.CommandIssuedBy = *commandIssuedBy
|
|
}
|
|
if desiredVersion != nil {
|
|
a.DesiredOverrideVersion = *desiredVersion
|
|
a.Pending = *desiredVersion != a.AppliedOverrideVersion
|
|
if len(desiredOverride) > 0 {
|
|
var override ConfigOverride
|
|
if err := json.Unmarshal(desiredOverride, &override); err != nil {
|
|
return Agent{}, err
|
|
}
|
|
a.DesiredOverride = &override
|
|
}
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// newVersion is an opaque, monotonically-informative-enough stamp for
|
|
// DesiredOverride.version -- a timestamp, not a counter, since Store
|
|
// has no prior version to increment from without an extra read. Never
|
|
// interpreted as a real time value by the agent (see
|
|
// agent_control.proto's DesiredOverride.version comment) -- just needs
|
|
// to change on every edit.
|
|
func newVersion() string {
|
|
return time.Now().UTC().Format(time.RFC3339Nano)
|
|
}
|