Add local login, agent extra log paths, IPv4/IPv6 metrics; remediate security audit findings

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.
This commit is contained in:
2026-08-18 23:53:20 -07:00
parent d2bb9de245
commit 4b5dae5879
87 changed files with 5095 additions and 164 deletions
+81
View File
@@ -0,0 +1,81 @@
name: Security scan
# Closes a real gap the security audit found: license-compliance.yml
# (this repo's only other workflow) checks license text, never
# vulnerabilities -- and agent/deny.toml and search/deny.toml already
# ship an [advisories] policy that nothing in CI ever invoked. Same
# matrix-per-language shape as license-compliance.yml, extended to the
# equivalent vulnerability-scanning tool per ecosystem: cargo-deny's
# other command for Rust, govulncheck for Go, npm audit for the one
# npm package. A new dependency with a known vulnerability now fails
# the build here, not months later when someone happens to re-run this
# by hand.
on:
push:
branches: [master, main]
pull_request:
jobs:
rust-advisories:
name: Rust vulnerability check (cargo-deny)
runs-on: ubuntu-latest
strategy:
matrix:
crate_dir: [agent, search]
steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@v2
with:
manifest-path: ${{ matrix.crate_dir }}/Cargo.toml
command: check advisories
go-vulncheck:
name: Go vulnerability check (govulncheck)
runs-on: ubuntu-latest
strategy:
matrix:
# Same module list as license-compliance.yml's go-licenses job --
# see that job's own comment for why cli/hack-webhook-sink/
# hack-alert-load-test are excluded (no third-party dependencies
# at audit time).
module_dir:
- api
- ingest
- alerting
- enterprise
- deploy/operator
- terraform
- proto
- hack/benchmark-fixture
- hack/windows-fixture
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: ${{ matrix.module_dir }}/go.mod
- run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Check for known vulnerabilities
working-directory: ${{ matrix.module_dir }}
run: govulncheck ./...
npm-audit:
name: npm vulnerability check (npm audit)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- working-directory: web
run: npm ci
- name: Audit production dependencies
working-directory: web
# --omit=dev, not the deprecated --production: this deliberately
# only gates the runtime bundle a real deployment actually
# ships. The one known finding in web's full dependency tree
# today (a `cookie` advisory) lives entirely in the SvelteKit
# build toolchain, not the production bundle -- fixing it needs
# a deliberate, tested major-version bump, not an automated
# `audit fix --force`, so it's out of scope for this gate.
run: npm audit --omit=dev
+8 -8
View File
@@ -258,7 +258,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -348,9 +348,9 @@ dependencies = [
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.15" version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
@@ -477,7 +477,7 @@ dependencies = [
"hyper", "hyper",
"libc", "libc",
"pin-project-lite", "pin-project-lite",
"socket2 0.6.5", "socket2 0.5.10",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -737,9 +737,9 @@ dependencies = [
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.36.2" version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@@ -842,7 +842,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -1079,7 +1079,7 @@ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
+6 -1
View File
@@ -8,8 +8,13 @@ WORKDIR /src
COPY proto ./proto COPY proto ./proto
COPY agent ./agent COPY agent ./agent
WORKDIR /src/agent WORKDIR /src/agent
# file-tail is additive (no extra deps -- see Cargo.toml's [features]
# doc comment) and needed on any host whose logs aren't in journald
# (e.g. nginx's plain access/error log files), so it's built in
# alongside the journald default rather than left as an opt-in most
# deployments would otherwise have to remember to ask for.
RUN rustup target add x86_64-unknown-linux-musl \ RUN rustup target add x86_64-unknown-linux-musl \
&& cargo build --release --target x86_64-unknown-linux-musl -p sentry-agent && cargo build --release --target x86_64-unknown-linux-musl -p sentry-agent --features journald,file-tail
FROM scratch FROM scratch
COPY --from=builder /src/agent/target/x86_64-unknown-linux-musl/release/sentry-agent /sentry-agent COPY --from=builder /src/agent/target/x86_64-unknown-linux-musl/release/sentry-agent /sentry-agent
+24
View File
@@ -153,6 +153,30 @@ alerting engine already detects natively via an `absence`-condition
alert rule. See `/docs/agent-heartbeat-monitoring.md` for the exact rule alert rule. See `/docs/agent-heartbeat-monitoring.md` for the exact rule
to create. to create.
## Host CPU/memory/disk metrics
Same shape as heartbeat, same reasoning: `[metrics]` in the config
(`enabled = false` by default) sends a periodic record — CPU%, memory
used/total, disk used/total for `/` — tagged `sentry.metrics=true`, with
the individual numbers as their own attributes (`cpu_percent`,
`mem_used_bytes`, `mem_total_bytes`, `disk_used_bytes`,
`disk_total_bytes`), queryable directly (e.g. `cpu_percent > 80`) since
the query language transparently maps any non-standard field name to
`attributes['field']` with automatic numeric casting. Powers the web
UI's "Hosts" nav section. Linux-only for now (`src/metrics.rs`) — reads
`/proc/stat`/`/proc/meminfo` and shells out to `df`, no new
dependencies, same "shell out to a boring, ubiquitous tool" precedent
`journalctl` already sets.
**Enable this on only one agent process per physical host.** It's
common for one host to run several `sentry-agent` processes (one per
log source, each needing its own `[agent] host` value to work around
the `agents` table's `UNIQUE (tenant_id, host)` constraint — see
`/docs/agent-management-design.md`) — turning `[metrics]` on for more
than one of them reports the same physical machine as multiple
different "hosts" with conflicting metric series. Pick the one process
using that host's real, unoverridden hostname.
## Running as a Windows service ## Running as a Windows service
"A native Windows service, not a WSL wrapper" means implementing the Win32 "A native Windows service, not a WSL wrapper" means implementing the Win32
+1 -1
View File
@@ -47,7 +47,7 @@ windows = { version = "0.58", features = [
"Win32_Security", "Win32_Security",
] } ] }
windows-service = "0.7" windows-service = "0.7"
quick-xml = "0.36" quick-xml = "0.41"
[build-dependencies] [build-dependencies]
tonic-build = "0.12" tonic-build = "0.12"
@@ -51,6 +51,17 @@ interval = "60s"
# interval = "5m" # interval = "5m"
# interval = "1h" # interval = "1h"
[metrics]
# Host CPU/memory/disk usage, sent as its own periodic record the same
# way heartbeat is (see web/'s "Hosts" nav section). Off by default --
# unlike heartbeat, this is a deliberate per-host decision: if several
# sentry-agent processes run on the same physical host (e.g. one per log
# source), enable this on only ONE of them, or the same host will report
# multiple conflicting metric series. Linux-only for now. Root disk ("/")
# only -- not configurable in this release.
enabled = false
interval = "60s"
[ingest] [ingest]
endpoint = "https://ingest.internal:4317" endpoint = "https://ingest.internal:4317"
+50
View File
@@ -15,6 +15,7 @@ pub struct Config {
pub source: SourceConfig, pub source: SourceConfig,
pub batch: BatchConfig, pub batch: BatchConfig,
pub heartbeat: HeartbeatConfig, pub heartbeat: HeartbeatConfig,
pub metrics: MetricsConfig,
pub ingest: IngestConfig, pub ingest: IngestConfig,
pub tls: TlsConfig, pub tls: TlsConfig,
} }
@@ -168,6 +169,31 @@ impl Default for HeartbeatConfig {
} }
} }
/// Off by default -- same "off unless configured" posture as every other
/// optional feature in this codebase -- since collecting host metrics is
/// a deliberate per-host decision (see /agent/README.md's Hosts-feature
/// notes: only one agent process per physical host should have this on,
/// to avoid duplicate/fragmented metric series when several agent
/// processes share a host under different `[agent] host` overrides).
/// Sent independently of `batch`, same reasoning and mechanism as
/// `HeartbeatConfig` above (see main.rs's `send_metrics`).
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct MetricsConfig {
pub enabled: bool,
#[serde(deserialize_with = "deserialize_duration")]
pub interval: Duration,
}
impl Default for MetricsConfig {
fn default() -> Self {
Self {
enabled: false,
interval: Duration::from_secs(60),
}
}
}
/// Parses a human-friendly duration string with an explicit unit suffix /// Parses a human-friendly duration string with an explicit unit suffix
/// -- "30s", "5m", "1h" -- deliberately the same s/m/h vocabulary /// -- "30s", "5m", "1h" -- deliberately the same s/m/h vocabulary
/// `earliest=`/`latest=` use in the query language /// `earliest=`/`latest=` use in the query language
@@ -237,6 +263,30 @@ mod heartbeat_config_tests {
} }
} }
#[cfg(test)]
mod metrics_config_tests {
use super::*;
#[test]
fn default_is_60_seconds_and_disabled() {
let cfg = MetricsConfig::default();
assert!(!cfg.enabled);
assert_eq!(cfg.interval, Duration::from_secs(60));
}
#[test]
fn toml_field_parses_via_deserialize() {
#[derive(Deserialize)]
struct Wrapper {
#[serde(default)]
metrics: MetricsConfig,
}
let w: Wrapper = toml::from_str("[metrics]\nenabled = true\ninterval = \"30s\"\n").unwrap();
assert!(w.metrics.enabled);
assert_eq!(w.metrics.interval, Duration::from_secs(30));
}
}
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct IngestConfig { pub struct IngestConfig {
+149 -11
View File
@@ -1,6 +1,7 @@
mod batch; mod batch;
mod config; mod config;
mod grpc; mod grpc;
mod metrics;
mod source; mod source;
#[cfg(windows)] #[cfg(windows)]
@@ -22,6 +23,8 @@ use clap::Parser;
use config::Config; use config::Config;
use pb::agent::v1::{agent_control_client::AgentControlClient, AgentCommand, CheckInRequest, DesiredOverride, ReportedConfig}; use pb::agent::v1::{agent_control_client::AgentControlClient, AgentCommand, CheckInRequest, DesiredOverride, ReportedConfig};
use pb::{log_ingest_client::LogIngestClient, LogRecord, Severity}; use pb::{log_ingest_client::LogIngestClient, LogRecord, Severity};
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
@@ -92,12 +95,33 @@ pub async fn run_agent(config_path: Option<PathBuf>) -> Result<()> {
let host = cfg.agent.host.clone().unwrap_or_else(default_hostname); let host = cfg.agent.host.clone().unwrap_or_else(default_hostname);
let service = cfg.agent.service.clone(); let service = cfg.agent.service.clone();
// One long-lived channel for the whole process, not one per source
// task -- both the primary source and every extra-file-path task
// (see apply_override's extra_file_paths handling) send into clones
// of the same `tx`. This matters specifically because the primary
// source can be aborted and respawned at runtime (a journald_unit
// override): if each respawn created a *new* channel the way this
// used to work, any extra-file-path task still holding a clone of
// the old `tx` would be silently orphaned (sending into a channel
// whose `rx` had just been replaced/dropped). Creating the channel
// once and only ever swapping the *task* (never the channel) avoids
// that entirely.
let (tx, mut rx) = mpsc::channel(1024);
// source_cfg is mutable: a remote DesiredOverride's journald_unit // source_cfg is mutable: a remote DesiredOverride's journald_unit
// field (see apply_override below) can change it at runtime, which // field (see apply_override below) can change it at runtime, which
// means aborting and respawning the source task with the new // means aborting and respawning the source task with the new
// filter -- source_handle/rx are mutable for the same reason. // filter -- source_handle is mutable for the same reason.
let mut source_cfg = cfg.source.clone(); let mut source_cfg = cfg.source.clone();
let (mut source_handle, mut rx) = spawn_source_task(source_cfg.clone()); let mut source_handle = spawn_source_task(source_cfg.clone(), tx.clone());
// Extra file paths an operator has remotely added via the web UI
// (see apply_override's extra_file_paths handling) -- empty until
// the first such override arrives. Keyed by path so a later
// override with a different path list can be diffed against what's
// already running: abort tasks for paths that were removed, spawn
// tasks for paths that are new, leave everything else untouched.
let mut extra_file_tasks: HashMap<PathBuf, tokio::task::JoinHandle<()>> = HashMap::new();
let channel = grpc::connect(&cfg.ingest, &cfg.tls) let channel = grpc::connect(&cfg.ingest, &cfg.tls)
.await .await
@@ -116,6 +140,12 @@ pub async fn run_agent(config_path: Option<PathBuf>) -> Result<()> {
let mut flush_interval = Duration::from_millis(cfg.batch.flush_interval_ms); let mut flush_interval = Duration::from_millis(cfg.batch.flush_interval_ms);
let mut heartbeat_enabled = cfg.heartbeat.enabled; let mut heartbeat_enabled = cfg.heartbeat.enabled;
let mut heartbeat_interval = cfg.heartbeat.interval; let mut heartbeat_interval = cfg.heartbeat.interval;
// Not remotely overridable (unlike batch/heartbeat above) -- see
// /agent/README.md's Hosts-feature notes on why this is a
// deliberate, per-host, config-file-only decision, not something
// the web UI can flip on for an arbitrary agent.
let metrics_enabled = cfg.metrics.enabled;
let metrics_interval = cfg.metrics.interval;
// Empty until the first override is ever applied -- echoed back on // Empty until the first override is ever applied -- echoed back on
// every CheckIn as-is so the server can tell "pending" (an edit // every CheckIn as-is so the server can tell "pending" (an edit
// exists this agent hasn't picked up) from "applied." // exists this agent hasn't picked up) from "applied."
@@ -134,8 +164,17 @@ pub async fn run_agent(config_path: Option<PathBuf>) -> Result<()> {
// heartbeat_enabled. // heartbeat_enabled.
let mut heartbeat_ticker = tokio::time::interval(heartbeat_interval.max(Duration::from_millis(50))); let mut heartbeat_ticker = tokio::time::interval(heartbeat_interval.max(Duration::from_millis(50)));
// Unlike heartbeat_ticker, metrics has no secondary purpose (no
// CheckIn-equivalent side effect) to keep it running when disabled,
// so the select! arm below skips it entirely via `if metrics_enabled`
// rather than always firing and conditionally acting.
let mut metrics_ticker = tokio::time::interval(metrics_interval.max(Duration::from_millis(50)));
loop { loop {
tokio::select! { tokio::select! {
_ = metrics_ticker.tick(), if metrics_enabled => {
send_metrics(&mut client, &host, &service).await;
}
_ = heartbeat_ticker.tick() => { _ = heartbeat_ticker.tick() => {
if heartbeat_enabled { if heartbeat_enabled {
send_heartbeat(&mut client, &host, &service).await; send_heartbeat(&mut client, &host, &service).await;
@@ -164,7 +203,8 @@ pub async fn run_agent(config_path: Option<PathBuf>) -> Result<()> {
&mut batch_max_size, &mut flush_interval, &mut batch_max_size, &mut flush_interval,
&mut heartbeat_enabled, &mut heartbeat_interval, &mut heartbeat_enabled, &mut heartbeat_interval,
&mut batcher, &mut ticker, &mut heartbeat_ticker, &mut batcher, &mut ticker, &mut heartbeat_ticker,
&mut source_cfg, &mut source_handle, &mut rx, &mut source_cfg, &mut source_handle,
&mut extra_file_tasks, &tx,
&mut client, &mut client,
).await; ).await;
applied_override_version = ov.version.clone(); applied_override_version = ov.version.clone();
@@ -272,7 +312,8 @@ async fn apply_override(
heartbeat_ticker: &mut tokio::time::Interval, heartbeat_ticker: &mut tokio::time::Interval,
source_cfg: &mut config::SourceConfig, source_cfg: &mut config::SourceConfig,
source_handle: &mut tokio::task::JoinHandle<()>, source_handle: &mut tokio::task::JoinHandle<()>,
rx: &mut mpsc::Receiver<source::RawLine>, extra_file_tasks: &mut HashMap<PathBuf, tokio::task::JoinHandle<()>>,
tx: &source::LineSender,
client: &mut LogIngestClient<Channel>, client: &mut LogIngestClient<Channel>,
) { ) {
if let Some(v) = ov.batch_max_size { if let Some(v) = ov.batch_max_size {
@@ -310,19 +351,57 @@ async fn apply_override(
if *current_unit != new_unit { if *current_unit != new_unit {
*source_cfg = config::SourceConfig::Journald { unit: new_unit }; *source_cfg = config::SourceConfig::Journald { unit: new_unit };
source_handle.abort(); source_handle.abort();
let (new_handle, new_rx) = spawn_source_task(source_cfg.clone()); *source_handle = spawn_source_task(source_cfg.clone(), tx.clone());
*source_handle = new_handle;
*rx = new_rx;
tracing::info!(unit = ?unit, "applied remote journald unit override, restarted source"); tracing::info!(unit = ?unit, "applied remote journald unit override, restarted source");
} }
} }
} }
// Extra file paths to tail alongside whatever the primary source
// above is -- see agent_control.proto's DesiredOverride.
// extra_file_paths comment. Diffed against what's already running
// (extra_file_tasks' keys) rather than blindly tearing everything
// down and respawning: an edit to, say, heartbeat_interval_ms must
// never interrupt a file that's already being tailed and hasn't
// changed. `ov.extra_file_paths` is the complete desired list every
// time (never a partial patch -- see the proto field's own doc
// comment), so anything not in it gets removed.
let desired: HashSet<PathBuf> = ov.extra_file_paths.iter().map(PathBuf::from).collect();
let current: HashSet<PathBuf> = extra_file_tasks.keys().cloned().collect();
for removed in current.difference(&desired) {
if let Some(handle) = extra_file_tasks.remove(removed) {
handle.abort();
tracing::info!(path = %removed.display(), "stopped tailing removed extra file path");
}
}
for added in desired.difference(&current) {
extra_file_tasks.insert(added.clone(), spawn_extra_file_task(added.clone(), tx.clone()));
tracing::info!(path = %added.display(), "started tailing new extra file path");
}
} }
fn spawn_source_task(source_cfg: config::SourceConfig) -> (tokio::task::JoinHandle<()>, mpsc::Receiver<source::RawLine>) { fn spawn_source_task(source_cfg: config::SourceConfig, tx: source::LineSender) -> tokio::task::JoinHandle<()> {
let (tx, rx) = mpsc::channel(1024); tokio::spawn(spawn_source(source_cfg, tx))
let handle = tokio::spawn(spawn_source(source_cfg, tx)); }
(handle, rx)
/// Same shape as `spawn_source`'s own per-source-kind feature gating --
/// an extra file path is really just another `File` source, tailed
/// from the end (no `from_beginning` knob for these; matches the
/// primary `file` source's own sensible default), feeding into the
/// same shared channel every other source in this process uses.
#[cfg_attr(not(feature = "file-tail"), allow(unused_variables))]
fn spawn_extra_file_task(path: PathBuf, tx: source::LineSender) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
#[cfg(feature = "file-tail")]
let result = source::file_tail::run(&path, false, tx).await;
#[cfg(not(feature = "file-tail"))]
let result: Result<(), anyhow::Error> =
Err(anyhow::anyhow!("this build was compiled without the `file-tail` feature"));
if let Err(e) = result {
tracing::error!(error = %e, path = %path.display(), "extra file path exited with error");
}
})
} }
fn source_kind_name(cfg: &config::SourceConfig) -> String { fn source_kind_name(cfg: &config::SourceConfig) -> String {
@@ -418,6 +497,65 @@ async fn send_heartbeat(client: &mut LogIngestClient<Channel>, host: &str, servi
} }
} }
/// Same "no new proto, no new ingest code, no new ClickHouse schema"
/// shape as `send_heartbeat` above -- a metrics sample is just another
/// tagged `LogRecord`, distinguished by the `sentry.metrics` attribute.
/// Unlike heartbeat, the numeric fields themselves are real query-language
/// attributes too (`cpu_percent`, `mem_used_bytes`, etc.) rather than
/// being folded into `message` -- confirmed before building this that
/// arbitrary attribute names are transparently queryable/comparable as
/// numbers (`api/querylang/executor/sql.go`'s `topLevelFields` fallback
/// to `attributes['field']` with automatic numeric casting), so there's
/// no need to encode them as a JSON blob in `message` and parse it
/// client-side instead.
async fn send_metrics(client: &mut LogIngestClient<Channel>, host: &str, service: &str) {
let m = match metrics::collect("/").await {
Ok(m) => m,
Err(e) => {
tracing::warn!(error = %e, host, "collecting host metrics failed");
return;
}
};
let record = LogRecord {
timestamp_unix_nano: now_unix_nanos(),
host: host.to_string(),
service: service.to_string(),
severity: Severity::Info as i32,
message: "host metrics".to_string(),
attributes: std::collections::HashMap::from([
("sentry.metrics".to_string(), "true".to_string()),
("cpu_percent".to_string(), format!("{:.2}", m.cpu_percent)),
("mem_used_bytes".to_string(), m.mem_used_bytes.to_string()),
("mem_total_bytes".to_string(), m.mem_total_bytes.to_string()),
("disk_used_bytes".to_string(), m.disk_used_bytes.to_string()),
("disk_total_bytes".to_string(), m.disk_total_bytes.to_string()),
// Static-or-slow-changing context, not utilization numbers --
// sent on the same record so a viewer never has to correlate
// two different samples to make sense of the numbers above
// (see metrics::Metrics's doc comment).
("cpu_cores".to_string(), m.cpu_cores.to_string()),
("os_name".to_string(), m.os_name.clone()),
("kernel_version".to_string(), m.kernel_version.clone()),
("arch".to_string(), m.arch.to_string()),
("uptime_seconds".to_string(), m.uptime_seconds.to_string()),
// Comma-joined -- LogRecord attributes are string-valued,
// and a host can have more than one address per family
// (multi-NIC, or a v6 privacy/temporary address alongside
// the stable one). Empty string, not an omitted key, when
// a host genuinely has none of a given family -- matches
// every other soft-failed context field here (see
// metrics::collect's unwrap_or_default for this one).
("ipv4_addresses".to_string(), m.ipv4_addresses.join(",")),
("ipv6_addresses".to_string(), m.ipv6_addresses.join(",")),
]),
record_id: String::new(),
};
match grpc::send_batch(client, format!("metrics-{}", batch_id()), vec![record]).await {
Ok(_) => tracing::debug!(host, "metrics sent"),
Err(e) => tracing::warn!(error = %e, host, "metrics send failed"),
}
}
fn now_unix_nanos() -> i64 { fn now_unix_nanos() -> i64 {
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
+394
View File
@@ -0,0 +1,394 @@
use anyhow::{Context, Result};
use std::fs;
use std::process::Command;
use std::time::Duration;
pub struct Metrics {
pub cpu_percent: f64,
pub mem_used_bytes: u64,
pub mem_total_bytes: u64,
pub disk_used_bytes: u64,
pub disk_total_bytes: u64,
/// The rest of these are static-or-slow-changing context, not
/// utilization numbers -- sent alongside the utilization fields on
/// the same record (rather than as a separate one-off record) so a
/// viewer never has to correlate two different samples to answer
/// "is 21% CPU busy or idle for this box" (needs core count) or
/// "is this usage normal" (needs how long it's been running).
pub cpu_cores: u32,
pub os_name: String,
pub kernel_version: String,
pub arch: &'static str,
pub uptime_seconds: u64,
/// Non-loopback, non-link-local addresses only -- a host's `fe80::/10`
/// and `127.0.0.1`/`::1` are never what a viewer means by "this
/// host's IP", and would just add noise. Sorted and deduplicated,
/// but otherwise unfiltered: a multi-NIC host reports every address
/// it has, not just one "primary" guess (there's no reliable way to
/// pick a single "the" address from userspace without also knowing
/// which interface actually carries this host's traffic).
pub ipv4_addresses: Vec<String>,
pub ipv6_addresses: Vec<String>,
}
/// Collects a point-in-time snapshot of host resource usage plus the
/// system context needed to make it legible. Linux-only for now (a
/// disclosed gap, not a silent assumption -- see /agent/README.md):
/// every host this has been deployed to so far is Linux, and a Windows
/// implementation (perf counters/WMI) is real future work, not
/// attempted here.
pub async fn collect(disk_path: &str) -> Result<Metrics> {
let cpu_percent = cpu_percent().await.context("reading CPU usage")?;
let (mem_used_bytes, mem_total_bytes) = memory().context("reading memory usage")?;
let (disk_used_bytes, disk_total_bytes) = disk(disk_path).context("reading disk usage")?;
// Soft-fail on all four: none of these should ever cost a whole
// sample (losing real cpu_percent/mem/disk numbers) just because
// e.g. /etc/os-release is missing on some minimal distro --
// consistent with this codebase's existing "an optional feature's
// failure must never take down the thing it's supplementing"
// posture (see send_heartbeat/CheckIn's own graceful degradation).
let cpu_cores = cpu_cores().unwrap_or(0);
let os_name = os_name().unwrap_or_else(|_| "unknown".to_string());
let kernel_version = kernel_version().unwrap_or_else(|_| "unknown".to_string());
let uptime_seconds = uptime_seconds().unwrap_or(0);
let (ipv4_addresses, ipv6_addresses) = ip_addresses().unwrap_or_default();
Ok(Metrics {
cpu_percent,
mem_used_bytes,
mem_total_bytes,
disk_used_bytes,
disk_total_bytes,
cpu_cores,
os_name,
kernel_version,
arch: std::env::consts::ARCH,
uptime_seconds,
ipv4_addresses,
ipv6_addresses,
})
}
/// Two `/proc/stat` samples ~200ms apart, delta-based -- the standard
/// technique every `top`-like tool uses, since a single snapshot of
/// cumulative jiffies-since-boot can't express a percentage on its own.
/// Self-contained (no state threaded through main.rs's `select!` loop)
/// at the cost of blocking this one `collect()` call for ~200ms once
/// per `metrics.interval` tick -- an acceptable trade against the
/// complexity of holding a previous-sample struct across ticks in an
/// already-busy loop, for a feature that only runs once a minute by
/// default.
async fn cpu_percent() -> Result<f64> {
let (total1, idle1) = read_proc_stat()?;
tokio::time::sleep(Duration::from_millis(200)).await;
let (total2, idle2) = read_proc_stat()?;
let total_delta = total2.saturating_sub(total1);
let idle_delta = idle2.saturating_sub(idle1);
if total_delta == 0 {
return Ok(0.0);
}
Ok((1.0 - (idle_delta as f64 / total_delta as f64)) * 100.0)
}
fn read_proc_stat() -> Result<(u64, u64)> {
let contents = fs::read_to_string("/proc/stat").context("reading /proc/stat")?;
parse_proc_stat(&contents)
}
/// Parses `/proc/stat`'s leading "cpu " line: user nice system idle
/// iowait irq softirq steal guest guest_nice, all in USER_HZ jiffies
/// since boot. Returns (total, idle) -- idle here is idle+iowait,
/// matching what every standard CPU%-from-/proc/stat implementation
/// treats as "not busy" (iowait is a CPU waiting on I/O, not doing
/// work, even though the kernel's own `idle` field alone doesn't
/// include it).
fn parse_proc_stat(contents: &str) -> Result<(u64, u64)> {
let line = contents
.lines()
.find(|l| l.starts_with("cpu "))
.context("/proc/stat has no leading \"cpu \" line")?;
let fields: Vec<u64> = line.split_whitespace().skip(1).filter_map(|f| f.parse().ok()).collect();
if fields.len() < 4 {
anyhow::bail!("unexpected /proc/stat format: {line:?}");
}
let idle = fields[3] + fields.get(4).copied().unwrap_or(0);
let total: u64 = fields.iter().sum();
Ok((total, idle))
}
fn memory() -> Result<(u64, u64)> {
let contents = fs::read_to_string("/proc/meminfo").context("reading /proc/meminfo")?;
parse_meminfo(&contents)
}
/// Parses `/proc/meminfo`'s MemTotal/MemAvailable (kB). MemAvailable
/// (not MemFree) is the kernel's own "how much could a new process
/// actually get" estimate, accounting for reclaimable caches/buffers --
/// what a human means by "memory used" far better than MemFree alone
/// (a system with most of RAM in disk cache but MemFree near zero is
/// not actually under memory pressure).
fn parse_meminfo(contents: &str) -> Result<(u64, u64)> {
let mut total_kb = None;
let mut available_kb = None;
for line in contents.lines() {
if let Some(v) = line.strip_prefix("MemTotal:") {
total_kb = parse_meminfo_kb(v);
} else if let Some(v) = line.strip_prefix("MemAvailable:") {
available_kb = parse_meminfo_kb(v);
}
}
let total_kb = total_kb.context("MemTotal not found in /proc/meminfo")?;
let available_kb = available_kb.context("MemAvailable not found in /proc/meminfo")?;
let used_kb = total_kb.saturating_sub(available_kb);
Ok((used_kb * 1024, total_kb * 1024))
}
fn parse_meminfo_kb(s: &str) -> Option<u64> {
s.trim().trim_end_matches("kB").trim().parse().ok()
}
fn disk(path: &str) -> Result<(u64, u64)> {
let output = Command::new("df").arg("-B1").arg(path).output().context("running df")?;
if !output.status.success() {
anyhow::bail!("df exited with status {}: {}", output.status, String::from_utf8_lossy(&output.stderr));
}
parse_df_output(&String::from_utf8_lossy(&output.stdout))
}
/// Shells out to `df` rather than linking a statvfs binding -- same
/// "shell out to a boring, ubiquitous tool rather than add a dependency
/// or FFI binding" precedent `source/journald.rs` already sets for
/// `journalctl` (see /agent/README.md's "Why journalctl, not
/// libsystemd"). `-B1` requests byte-granularity output instead of the
/// default 1K-block units, so no unit conversion is needed here. Total
/// is `used + available`, not the raw block count `df` also reports --
/// some filesystems (ext4's default ~5% root reservation) hold back
/// blocks a normal process can never use, which would make a "percent
/// full" computed against the raw total look artificially low; `used +
/// available` matches what `df`'s own `Use%` column is computed
/// against.
fn parse_df_output(stdout: &str) -> Result<(u64, u64)> {
let data_line = stdout.lines().nth(1).context("df produced no data line")?;
let fields: Vec<&str> = data_line.split_whitespace().collect();
// Filesystem, 1B-blocks, Used, Available, Use%, Mounted on
if fields.len() < 4 {
anyhow::bail!("unexpected df output: {data_line:?}");
}
let used: u64 = fields[2].parse().context("parsing df's Used column")?;
let available: u64 = fields[3].parse().context("parsing df's Available column")?;
Ok((used, used + available))
}
fn cpu_cores() -> Result<u32> {
let contents = fs::read_to_string("/proc/cpuinfo").context("reading /proc/cpuinfo")?;
parse_cpuinfo_core_count(&contents)
}
/// Counts `processor\t: N` lines in `/proc/cpuinfo` -- one per logical
/// CPU (a hyperthreaded core counts as two, same as what `nproc`/every
/// scheduler-facing tool means by "CPU count"), which is what
/// `cpu_percent`'s 0-100 scale is an average across.
fn parse_cpuinfo_core_count(contents: &str) -> Result<u32> {
let n = contents.lines().filter(|l| l.starts_with("processor")).count() as u32;
if n == 0 {
anyhow::bail!("no \"processor\" lines found in /proc/cpuinfo");
}
Ok(n)
}
fn os_name() -> Result<String> {
let contents = fs::read_to_string("/etc/os-release").context("reading /etc/os-release")?;
parse_os_release_pretty_name(&contents)
}
/// Parses `/etc/os-release`'s `PRETTY_NAME="..."` line (e.g. "Debian
/// GNU/Linux 13 (trixie)") -- the one field every distro's os-release
/// is guaranteed to carry for exactly this "show a human a readable OS
/// name" purpose (see os-release(5)).
fn parse_os_release_pretty_name(contents: &str) -> Result<String> {
contents
.lines()
.find_map(|l| l.strip_prefix("PRETTY_NAME="))
.map(|v| v.trim().trim_matches('"').to_string())
.context("PRETTY_NAME not found in /etc/os-release")
}
/// `/proc/sys/kernel/osrelease` is just the bare version string (e.g.
/// "6.12.90+deb13.1-amd64") with no parsing needed -- simpler and more
/// robust than picking the version back out of `/proc/version`'s
/// free-form `uname -a`-style sentence.
fn kernel_version() -> Result<String> {
Ok(fs::read_to_string("/proc/sys/kernel/osrelease")
.context("reading /proc/sys/kernel/osrelease")?
.trim()
.to_string())
}
fn uptime_seconds() -> Result<u64> {
let contents = fs::read_to_string("/proc/uptime").context("reading /proc/uptime")?;
parse_uptime(&contents)
}
/// `/proc/uptime`'s first field is seconds since boot (as a float, to
/// centisecond precision) -- the second field (total idle time summed
/// across all cores) isn't relevant here.
fn parse_uptime(contents: &str) -> Result<u64> {
let first = contents.split_whitespace().next().context("/proc/uptime is empty")?;
let seconds: f64 = first.parse().context("parsing /proc/uptime's first field")?;
Ok(seconds as u64)
}
fn ip_addresses() -> Result<(Vec<String>, Vec<String>)> {
let output = Command::new("ip").arg("-o").arg("addr").arg("show").output().context("running ip addr show")?;
if !output.status.success() {
anyhow::bail!("ip exited with status {}: {}", output.status, String::from_utf8_lossy(&output.stderr));
}
Ok(parse_ip_addr_output(&String::from_utf8_lossy(&output.stdout)))
}
/// Shells out to `ip -o addr show` -- same "boring, ubiquitous tool"
/// precedent `disk`'s `df` call and `source/journald.rs`'s `journalctl`
/// call already set, over an FFI binding to `getifaddrs(3)`. `-o`
/// (oneline) puts each address on its own line, e.g.:
/// 2: eth0 inet 172.239.44.244/24 brd ... scope global eth0\ ...
/// 2: eth0 inet6 fe80::1/64 scope link \ ...
/// Skips the loopback interface by name (`lo`) and any address whose
/// line mentions `scope link` (IPv6 link-local, `fe80::/10`) or
/// `scope host` (loopback addresses `ip` sometimes reports even on a
/// non-`lo` line) -- neither is what a viewer means by "this host's
/// IP". Field 1 is the interface name, field 2 is the address family
/// (`inet`/`inet6`), field 3 is `address/prefix-length`.
fn parse_ip_addr_output(stdout: &str) -> (Vec<String>, Vec<String>) {
let mut v4 = Vec::new();
let mut v6 = Vec::new();
for line in stdout.lines() {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 4 {
continue;
}
let iface = fields[1];
if iface == "lo" || line.contains("scope link") || line.contains("scope host") {
continue;
}
let addr = fields[3].split('/').next().unwrap_or(fields[3]);
match fields[2] {
"inet" => v4.push(addr.to_string()),
"inet6" => v6.push(addr.to_string()),
_ => {}
}
}
v4.sort();
v4.dedup();
v6.sort();
v6.dedup();
(v4, v6)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_proc_stat() {
let contents = "cpu 100 0 50 800 20 0 0 0 0 0\ncpu0 100 0 50 800 20 0 0 0 0 0\n";
let (total, idle) = parse_proc_stat(contents).unwrap();
// total = 100+0+50+800+20 = 970; idle = 800 (idle) + 20 (iowait) = 820
assert_eq!(total, 970);
assert_eq!(idle, 820);
}
#[test]
fn rejects_proc_stat_with_no_cpu_line() {
assert!(parse_proc_stat("not cpu data\n").is_err());
}
#[test]
fn parses_meminfo() {
let contents = "MemTotal: 16384000 kB\nMemFree: 1000000 kB\nMemAvailable: 8192000 kB\n";
let (used, total) = parse_meminfo(contents).unwrap();
assert_eq!(total, 16384000 * 1024);
assert_eq!(used, (16384000 - 8192000) * 1024);
}
#[test]
fn rejects_meminfo_missing_fields() {
assert!(parse_meminfo("MemTotal: 16384000 kB\n").is_err());
}
#[test]
fn parses_df_output() {
let stdout = "Filesystem 1B-blocks Used Available Use% Mounted on\n/dev/sda1 80000000000 20000000000 60000000000 25% /\n";
let (used, total) = parse_df_output(stdout).unwrap();
assert_eq!(used, 20000000000);
assert_eq!(total, 20000000000 + 60000000000);
}
#[test]
fn rejects_df_output_with_no_data_line() {
assert!(parse_df_output("Filesystem 1B-blocks Used Available Use% Mounted on\n").is_err());
}
#[test]
fn counts_cpuinfo_processors() {
let contents = "processor\t: 0\nmodel name\t: x\n\nprocessor\t: 1\nmodel name\t: x\n";
assert_eq!(parse_cpuinfo_core_count(contents).unwrap(), 2);
}
#[test]
fn rejects_cpuinfo_with_no_processor_lines() {
assert!(parse_cpuinfo_core_count("model name: x\n").is_err());
}
#[test]
fn parses_os_release_pretty_name() {
let contents = "NAME=\"Debian GNU/Linux\"\nPRETTY_NAME=\"Debian GNU/Linux 13 (trixie)\"\nVERSION_ID=\"13\"\n";
assert_eq!(parse_os_release_pretty_name(contents).unwrap(), "Debian GNU/Linux 13 (trixie)");
}
#[test]
fn rejects_os_release_missing_pretty_name() {
assert!(parse_os_release_pretty_name("NAME=\"Debian\"\n").is_err());
}
#[test]
fn parses_uptime() {
assert_eq!(parse_uptime("12345.67 98765.43\n").unwrap(), 12345);
}
#[test]
fn rejects_empty_uptime() {
assert!(parse_uptime("").is_err());
}
#[test]
fn parses_ip_addr_output_excluding_loopback_and_link_local() {
let stdout = concat!(
"1: lo inet 127.0.0.1/8 scope host lo\\ valid_lft forever preferred_lft forever\n",
"1: lo inet6 ::1/128 scope host \\ valid_lft forever preferred_lft forever\n",
"2: eth0 inet 172.239.44.244/24 brd 172.239.44.255 scope global eth0\\ valid_lft forever preferred_lft forever\n",
"2: eth0 inet6 2600:3c06::1/64 scope global dynamic mngtmpaddr noprefixroute \\ valid_lft forever preferred_lft forever\n",
"2: eth0 inet6 fe80::abcd/64 scope link \\ valid_lft forever preferred_lft forever\n",
);
let (v4, v6) = parse_ip_addr_output(stdout);
assert_eq!(v4, vec!["172.239.44.244".to_string()]);
assert_eq!(v6, vec!["2600:3c06::1".to_string()]);
}
#[test]
fn parses_ip_addr_output_dedupes_and_sorts_multiple_interfaces() {
let stdout = concat!(
"2: eth0 inet 10.0.0.5/24 scope global eth0\\ valid_lft forever preferred_lft forever\n",
"3: eth1 inet 10.0.0.2/24 scope global eth1\\ valid_lft forever preferred_lft forever\n",
"3: eth1 inet 10.0.0.5/24 scope global secondary eth1\\ valid_lft forever preferred_lft forever\n",
);
let (v4, _v6) = parse_ip_addr_output(stdout);
assert_eq!(v4, vec!["10.0.0.2".to_string(), "10.0.0.5".to_string()]);
}
#[test]
fn parses_ip_addr_output_with_no_addresses() {
let (v4, v6) = parse_ip_addr_output("1: lo inet 127.0.0.1/8 scope host lo\\ valid_lft forever preferred_lft forever\n");
assert!(v4.is_empty());
assert!(v6.is_empty());
}
}
+19 -1
View File
@@ -29,6 +29,7 @@ import (
"github.com/sentry/sentry/alerting/internal/notifystore" "github.com/sentry/sentry/alerting/internal/notifystore"
"github.com/sentry/sentry/alerting/internal/queryclient" "github.com/sentry/sentry/alerting/internal/queryclient"
"github.com/sentry/sentry/alerting/internal/rulestore" "github.com/sentry/sentry/alerting/internal/rulestore"
"github.com/sentry/sentry/alerting/internal/sessioncheck"
) )
func main() { func main() {
@@ -39,6 +40,9 @@ func main() {
logger.Error("loading config", "error", err) logger.Error("loading config", "error", err)
os.Exit(1) os.Exit(1)
} }
for _, w := range cfg.DevCredentialWarnings() {
logger.Warn(w)
}
// -healthcheck: self-check mode for Docker's HEALTHCHECK, mirrors // -healthcheck: self-check mode for Docker's HEALTHCHECK, mirrors
// api/cmd/api/main.go's runHealthcheck -- this image is distroless too // api/cmd/api/main.go's runHealthcheck -- this image is distroless too
@@ -71,9 +75,23 @@ func main() {
handler := httpapi.NewHandler(logger, rules, targets, rules) handler := httpapi.NewHandler(logger, rules, targets, rules)
mux := http.NewServeMux() mux := http.NewServeMux()
handler.RegisterRoutes(mux) handler.RegisterRoutes(mux)
// Local login (see /docs -- deployment runbook, and
// api/localauth's package doc comment for the full feature):
// alerting has no per-route role plumbing of its own, so this is one
// blanket "must have a valid session" gate in front of the whole
// mux, same shape CORS already wraps it in below. /healthz stays
// reachable regardless -- see sessioncheck.RequireSession's doc
// comment.
var gatedMux http.Handler = mux
corsFn := httpserver.WithCORS
if cfg.LocalAuthEnabled {
gatedMux = sessioncheck.RequireSession(sessioncheck.NewChecker(pgPool), mux)
corsFn = httpserver.WithCredentialedCORS
}
srv := &http.Server{ srv := &http.Server{
Addr: cfg.HTTPListenAddr, Addr: cfg.HTTPListenAddr,
Handler: httpserver.WithCORS(mux, cfg.CORSAllowedOrigin), Handler: corsFn(gatedMux, cfg.CORSAllowedOrigin),
} }
eval := evaluator.New(rules, targets, qc, cfg.Evaluator.QueryTimeout, cfg.Evaluator.ClaimBatchSize, cfg.Evaluator.WorkerPoolSize, logger) eval := evaluator.New(rules, targets, qc, cfg.Evaluator.QueryTimeout, cfg.Evaluator.ClaimBatchSize, cfg.Evaluator.WorkerPoolSize, logger)
+1 -1
View File
@@ -12,5 +12,5 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/text v0.29.0 // indirect golang.org/x/text v0.39.0 // indirect
) )
+2 -2
View File
@@ -20,8 +20,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+30
View File
@@ -16,6 +16,11 @@ type Config struct {
APIServiceToken string // RoleService credential presented to /api's POST /query -- see queryclient.New's doc comment APIServiceToken string // RoleService credential presented to /api's POST /query -- see queryclient.New's doc comment
CORSAllowedOrigin string CORSAllowedOrigin string
Evaluator EvaluatorConfig Evaluator EvaluatorConfig
// LocalAuthEnabled gates alerting's own session-required middleware
// (see internal/sessioncheck) -- same env var name as api's
// LOCAL_AUTH_ENABLED, one consistent on/off switch across both
// services for a single-tenant deployment turning local login on.
LocalAuthEnabled bool
} }
type PostgresConfig struct { type PostgresConfig struct {
@@ -43,6 +48,25 @@ type EvaluatorConfig struct {
QueryTimeout time.Duration // per-evaluation POST /query timeout QueryTimeout time.Duration // per-evaluation POST /query timeout
} }
// devOnlyCredential is docker-compose.yml's zero-config default for
// every Postgres/ClickHouse password in this repo -- see
// api/internal/config.Config.DevCredentialWarnings for the full
// reasoning (duplicated here per this repo's no-shared-code-between-
// services convention).
const devOnlyCredential = "sentry-dev-only"
// DevCredentialWarnings reports whether the configured Postgres
// credential still equals the literal dev-only default --
// cmd/alerting/main.go logs it loudly at startup. A warning, not a
// startup-refusing error: local dev's zero-config docker-compose.yml
// path legitimately leaves it at this value.
func (c Config) DevCredentialWarnings() []string {
if c.Postgres.Password == devOnlyCredential {
return []string{"POSTGRES_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev"}
}
return nil
}
func Load() (Config, error) { func Load() (Config, error) {
cfg := Config{ cfg := Config{
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8081"), HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8081"),
@@ -90,6 +114,12 @@ func Load() (Config, error) {
} }
cfg.Evaluator.QueryTimeout = time.Duration(queryTimeoutSec) * time.Second cfg.Evaluator.QueryTimeout = time.Duration(queryTimeoutSec) * time.Second
localAuthEnabled, err := strconv.ParseBool(getenv("LOCAL_AUTH_ENABLED", "false"))
if err != nil {
return Config{}, fmt.Errorf("LOCAL_AUTH_ENABLED: %w", err)
}
cfg.LocalAuthEnabled = localAuthEnabled
return cfg, nil return cfg, nil
} }
+11
View File
@@ -162,6 +162,17 @@ func (w *Worker) attempt(ctx context.Context, c claimedDelivery) {
return return
} }
// Re-validated here, not just at target-creation time
// (httpapi.handleCreateTarget already checks this too): a hostname
// that resolved to a public IP when the target was created can be
// repointed at an internal/metadata address later via DNS rebinding,
// and this is the point that actually issues the outbound request --
// see notifystore.ValidateWebhookURL's doc comment.
if err := notifystore.ValidateWebhookURL(target.WebhookURL); err != nil {
w.fail(ctx, c, 0, fmt.Sprintf("webhook_url no longer valid: %v", err))
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.WebhookURL, bytes.NewReader(c.payload)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.WebhookURL, bytes.NewReader(c.payload))
if err != nil { if err != nil {
w.fail(ctx, c, 0, fmt.Sprintf("building request: %v", err)) w.fail(ctx, c, 0, fmt.Sprintf("building request: %v", err))
+4
View File
@@ -151,6 +151,10 @@ func (h *Handler) handleCreateTarget(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "webhook_url must not be empty") writeError(w, http.StatusBadRequest, "webhook_url must not be empty")
return return
} }
if err := notifystore.ValidateWebhookURL(target.WebhookURL); err != nil {
writeError(w, http.StatusBadRequest, "webhook_url: "+err.Error())
return
}
if err := h.targets.Create(r.Context(), &target); err != nil { if err := h.targets.Create(r.Context(), &target); err != nil {
h.logger.Error("creating notification target", "error", err) h.logger.Error("creating notification target", "error", err)
writeError(w, http.StatusInternalServerError, "creating notification target failed") writeError(w, http.StatusInternalServerError, "creating notification target failed")
+18 -1
View File
@@ -231,12 +231,29 @@ func TestCreateTargetRejectsInvalidKind(t *testing.T) {
func TestCreateSlackTarget(t *testing.T) { func TestCreateSlackTarget(t *testing.T) {
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{}) mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
rec := doRequest(t, mux, http.MethodPost, "/targets", `{"name": "oncall", "kind": "slack", "webhook_url": "https://hooks.slack.com/services/x"}`) // A literal public IP, not a real hostname like hooks.slack.com --
// ValidateWebhookURL (see notifystore/ssrf.go) now resolves the
// target host and rejects internal/metadata addresses, so this test
// stays deterministic without depending on live DNS; ssrf_test.go
// covers the validation logic itself in depth.
rec := doRequest(t, mux, http.MethodPost, "/targets", `{"name": "oncall", "kind": "slack", "webhook_url": "https://8.8.8.8/services/x"}`)
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
} }
} }
// TestCreateTargetRejectsSSRFWebhookURL is the regression test for the
// security-audit finding that target creation performed no URL
// validation at all -- any authenticated user could point a webhook at
// an internal or cloud-metadata address.
func TestCreateTargetRejectsSSRFWebhookURL(t *testing.T) {
mux := newTestMux(newFakeRuleStore(), newFakeTargetStore(), &fakeDeliveryReader{})
rec := doRequest(t, mux, http.MethodPost, "/targets", `{"name": "x", "kind": "webhook", "webhook_url": "http://169.254.169.254/latest/meta-data/"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestListDeliveriesForRule(t *testing.T) { func TestListDeliveriesForRule(t *testing.T) {
deliveries := &fakeDeliveryReader{entries: []rulestore.DeliveryLogEntry{ deliveries := &fakeDeliveryReader{entries: []rulestore.DeliveryLogEntry{
{ID: 1, RuleID: "rule-1", EventType: "firing", Status: "sent"}, {ID: 1, RuleID: "rule-1", EventType: "firing", Status: "sent"},
+24
View File
@@ -22,3 +22,27 @@ func WithCORS(next http.Handler, allowedOrigin string) http.Handler {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
// WithCredentialedCORS is WithCORS's sibling for local login (see
// /docs -- deployment runbook): once alerting requires a session
// cookie/bearer token (sessioncheck.RequireSession), a browser calling
// it cross-origin must send credentials, and browsers categorically
// refuse to combine a credentialed request with
// Access-Control-Allow-Origin: "*" -- allowedOrigin must be a real,
// literal origin, not the wildcard WithCORS's own zero-config default
// relies on. Deliberately duplicated from api/httpserver's identical
// function rather than shared, same convention as WithCORS's own doc
// comment above.
func WithCredentialedCORS(next http.Handler, allowedOrigin string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
+64
View File
@@ -0,0 +1,64 @@
package notifystore
import (
"fmt"
"net"
"net/url"
)
// ValidateWebhookURL rejects a notification target URL that resolves to
// an internal, loopback, link-local, or cloud-metadata address --
// closes an SSRF path a security audit found: without this, any user
// who could create a notification target could point
// delivery.Worker.attempt's outbound POST at
// http://169.254.169.254/... or an internal service address, and read
// back what happened via delivery_log's recorded status code -- a
// semi-blind SSRF oracle. Applies to all three Kind values (webhook,
// slack, pagerduty), since all three deliver through the same
// WebhookURL-addressed POST -- see webhook.go's package doc comment.
//
// Callers should invoke this both at target-creation time
// (httpapi.handleCreateTarget) and again immediately before every
// delivery attempt (delivery.Worker.attempt): a hostname that resolved
// to a public IP at creation time can be repointed at an internal one
// later (DNS rebinding), so checking only once would leave that gap
// open.
func ValidateWebhookURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("URL scheme must be http or https, got %q", u.Scheme)
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("URL must have a host")
}
ips, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("resolving host %q: %w", host, err)
}
if len(ips) == 0 {
return fmt.Errorf("host %q did not resolve to any address", host)
}
for _, ip := range ips {
if isDisallowedWebhookTarget(ip) {
return fmt.Errorf("host %q resolves to %s, a disallowed address -- internal, loopback, link-local, and cloud-metadata addresses are not allowed as notification target URLs", host, ip)
}
}
return nil
}
// isDisallowedWebhookTarget covers RFC1918/RFC4193 private ranges,
// loopback, link-local (which also covers 169.254.169.254, the AWS/GCP/
// Azure instance-metadata address), unspecified, and multicast.
func isDisallowedWebhookTarget(ip net.IP) bool {
return ip.IsLoopback() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsPrivate() ||
ip.IsUnspecified() ||
ip.IsMulticast()
}
@@ -0,0 +1,48 @@
package notifystore
import "testing"
// Uses literal IP addresses throughout, not real hostnames -- net.LookupIP
// resolves a literal IP without a network round trip, so these tests stay
// deterministic and fast in any environment, including one with no DNS/
// network access.
func TestValidateWebhookURLRejectsInternalAndMetadataAddresses(t *testing.T) {
disallowed := []string{
"http://169.254.169.254/latest/meta-data/", // cloud instance metadata
"http://127.0.0.1:8123/", // loopback -- e.g. ClickHouse
"http://10.0.0.5:5432/", // RFC1918
"http://172.17.0.2:9092/", // RFC1918 (default docker bridge range)
"http://192.168.1.1/", // RFC1918
"http://[::1]/", // IPv6 loopback
"http://[fe80::1]/", // IPv6 link-local
"http://[fc00::1]/", // IPv6 unique local (RFC4193)
"http://0.0.0.0/", // unspecified
}
for _, u := range disallowed {
if err := ValidateWebhookURL(u); err == nil {
t.Errorf("ValidateWebhookURL(%q): want error, got nil", u)
}
}
}
func TestValidateWebhookURLAllowsPublicAddress(t *testing.T) {
// A real-looking public IP literal, not a hostname needing DNS.
if err := ValidateWebhookURL("https://8.8.8.8/webhook"); err != nil {
t.Errorf("ValidateWebhookURL on a public IP: err = %v, want nil", err)
}
}
func TestValidateWebhookURLRejectsBadScheme(t *testing.T) {
cases := []string{"ftp://8.8.8.8/", "file:///etc/passwd", "not-a-url"}
for _, u := range cases {
if err := ValidateWebhookURL(u); err == nil {
t.Errorf("ValidateWebhookURL(%q): want error, got nil", u)
}
}
}
func TestValidateWebhookURLRejectsEmptyHost(t *testing.T) {
if err := ValidateWebhookURL("http:///path"); err == nil {
t.Error("ValidateWebhookURL with no host: want error, got nil")
}
}
@@ -0,0 +1,91 @@
package sessioncheck
import (
"encoding/json"
"net/http"
)
// sessionCookieName must match api/localauth's sessionCookieName
// exactly (unexported there too, deliberately duplicated rather than
// imported -- see this package's doc comment) -- the same cookie
// api/localauth.Handler.setCookie writes, scoped (via SESSION_COOKIE_
// DOMAIN) to cover both api's and alerting's subdomains in production.
const sessionCookieName = "sentry_local_session"
type errorResponse struct {
Error string `json:"error"`
}
func writeUnauthorized(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(errorResponse{Error: "unauthorized"})
}
func writeForbidden(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(errorResponse{Error: "forbidden"})
}
// mutatingRoleFloor is the minimum role RequireSession enforces for any
// non-read request -- closes a real gap the security audit found: this
// package used to be a pure "logged in or not" gate with no role check
// at all, meaning a Viewer-role session could create/delete alert rules
// and notification targets exactly like an Editor. GET/HEAD (read-only)
// stay at "any valid session," matching every role floor in this
// codebase's other RBAC-gated resources (queries, dashboards) using
// Viewer as their read bar.
const mutatingRoleFloor = "editor"
func isReadOnly(method string) bool {
return method == http.MethodGet || method == http.MethodHead
}
func credentialFromRequest(r *http.Request) string {
if auth := r.Header.Get("Authorization"); auth != "" {
const prefix = "Bearer "
if len(auth) > len(prefix) && auth[:len(prefix)] == prefix {
return auth[len(prefix):]
}
}
if cookie, err := r.Cookie(sessionCookieName); err == nil {
return cookie.Value
}
return ""
}
// RequireSession wraps next so every request needs a valid local-login
// session -- a blanket gate, not per-route roles: alerting has no
// role-check plumbing at all today (unlike api/authz's per-route
// RequireRole), and building a full parallel system just for this
// feature is out of scope (see /docs/agent-management-design.md-style
// "resist scope creep" discipline this codebase applies everywhere).
// GET /healthz is deliberately exempt -- Docker's HEALTHCHECK execs
// this same binary against itself over loopback (cmd/alerting/main.go's
// runHealthcheck), pre-auth, and must keep working regardless of
// whether local auth is enabled.
func RequireSession(checker *Checker, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
}
raw := credentialFromRequest(r)
if raw == "" {
writeUnauthorized(w)
return
}
role, err := checker.Validate(r.Context(), raw)
if err != nil {
writeUnauthorized(w)
return
}
if !isReadOnly(r.Method) && !roleSatisfies(role, mutatingRoleFloor) {
writeForbidden(w)
return
}
next.ServeHTTP(w, r)
})
}
@@ -0,0 +1,73 @@
// Package sessioncheck is alerting's half of local login (see
// api/localauth's package doc comment for the full feature). It only
// ever validates an already-issued session against the shared
// local_sessions table api/localauth writes to (same Postgres, no Go
// import) -- it never handles a raw password, never creates a session,
// and has no user-management surface at all; that stays exclusively in
// api. Deliberately its own small package rather than an import of
// api/localauth: this repo's hard, documented convention is no shared
// Go store/HTTP code between api and alerting, only /proto (see
// alerting/internal/httpserver/cors.go's WithCORS doc comment) --
// duplicating this one hash-and-look-up check is a small, low-risk
// price for keeping that boundary real.
package sessioncheck
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrInvalidSession = errors.New("sessioncheck: invalid or expired session")
type Checker struct {
pool *pgxpool.Pool
}
func NewChecker(pool *pgxpool.Pool) *Checker {
return &Checker{pool: pool}
}
// roleRank duplicates api/authz.Role's rank table -- same "no shared Go
// code between api and alerting" boundary this package's doc comment
// already explains for hashToken, applied to the one extra column
// (role) middleware.go now needs to enforce a floor on mutating
// requests (see RequireSession).
var roleRank = map[string]int{"viewer": 1, "editor": 2, "admin": 3, "owner": 4}
// roleSatisfies reports whether role meets minRole on the same
// Viewer<Editor<Admin<Owner scale api/authz.Role.Satisfies uses.
func roleSatisfies(role, minRole string) bool {
return roleRank[role] >= roleRank[minRole]
}
// Validate hashes raw (plain SHA-256, no bcrypt -- see
// api/localauth/token.go's hashToken doc comment for why a session
// token doesn't need bcrypt's deliberate slowness) and checks it
// against local_sessions, returning the session's role snapshot
// alongside. Returns ErrInvalidSession for both "no such session" and
// "expired" -- middleware.go's caller doesn't distinguish them either,
// same posture api/localauth.Store.GetSession already takes for the
// same two cases.
func (c *Checker) Validate(ctx context.Context, raw string) (role string, err error) {
sum := sha256.Sum256([]byte(raw))
hash := hex.EncodeToString(sum[:])
var expiresAt time.Time
err = c.pool.QueryRow(ctx, `SELECT role, expires_at FROM local_sessions WHERE token_hash = $1`, hash).Scan(&role, &expiresAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrInvalidSession
}
return "", err
}
if expiresAt.Before(time.Now()) {
return "", ErrInvalidSession
}
return role, nil
}
@@ -0,0 +1,182 @@
// Exercises Checker.Validate against a real local_sessions row --
// unlike a fake, this confirms alerting can actually read the rows
// api/localauth (a separate Go module/service) writes into the shared
// Postgres, including the exact hash function agreeing on both sides.
// Same "skip unless a live-Postgres env var is set" convention as
// api/dashboards/store_integration_test.go.
//
// Skipped unless SESSIONCHECK_TEST_POSTGRES_ADDR is set; run via:
//
// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/alerting \
// -e SESSIONCHECK_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
// -e SESSIONCHECK_TEST_POSTGRES_PASSWORD=sentry-dev-only \
// golang:1.25-alpine go test ./internal/sessioncheck/... -run Integration -v
package sessioncheck
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func integrationPool(t *testing.T) *pgxpool.Pool {
t.Helper()
addr := os.Getenv("SESSIONCHECK_TEST_POSTGRES_ADDR")
if addr == "" {
t.Skip("SESSIONCHECK_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test")
}
password := os.Getenv("SESSIONCHECK_TEST_POSTGRES_PASSWORD")
dsn := fmt.Sprintf("postgres://sentry:%s@%s/sentry_metadata", password, addr)
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("opening pool: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
// insertTestSession writes directly into local_sessions and users --
// this package has no Store type of its own (see package doc comment:
// creating a session is api/localauth's job, this only ever validates
// one), so a real row has to come from somewhere for the test to check
// against.
func insertTestSession(t *testing.T, pool *pgxpool.Pool, ttl time.Duration) (raw string) {
t.Helper()
return insertTestSessionWithRole(t, pool, ttl, "viewer")
}
func insertTestSessionWithRole(t *testing.T, pool *pgxpool.Pool, ttl time.Duration, role string) (raw string) {
t.Helper()
ctx := context.Background()
userID := uuid.NewString()
if _, err := pool.Exec(ctx, `
INSERT INTO users (id, username, password_hash, display_name, created_at, updated_at)
VALUES ($1, $2, 'unused', $2, now(), now())`,
userID, "test-"+userID[:8]); err != nil {
t.Fatalf("inserting test user: %v", err)
}
t.Cleanup(func() { _, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) })
buf := make([]byte, 32)
sum := sha256.Sum256([]byte(userID + role)) // deterministic-enough per-test randomness without crypto/rand here
copy(buf, sum[:])
raw = base64.RawURLEncoding.EncodeToString(buf)
hashSum := sha256.Sum256([]byte(raw))
hash := hex.EncodeToString(hashSum[:])
if _, err := pool.Exec(ctx, `
INSERT INTO local_sessions (id, user_id, tenant_id, role, token_hash, expires_at)
VALUES ($1, $2, 'default', $3, $4, $5)`,
uuid.NewString(), userID, role, hash, time.Now().Add(ttl)); err != nil {
t.Fatalf("inserting test session: %v", err)
}
return raw
}
func TestIntegrationValidateAcceptsRealSession(t *testing.T) {
pool := integrationPool(t)
raw := insertTestSession(t, pool, time.Hour)
role, err := NewChecker(pool).Validate(context.Background(), raw)
if err != nil {
t.Errorf("Validate on a real, unexpired session: err = %v, want nil", err)
}
if role != "viewer" {
t.Errorf("role = %q, want %q (matches insertTestSession's role column)", role, "viewer")
}
}
func TestIntegrationValidateRejectsExpiredSession(t *testing.T) {
pool := integrationPool(t)
raw := insertTestSession(t, pool, -time.Hour)
if _, err := NewChecker(pool).Validate(context.Background(), raw); !errors.Is(err, ErrInvalidSession) {
t.Errorf("Validate on an expired session: err = %v, want ErrInvalidSession", err)
}
}
func TestIntegrationValidateRejectsUnknownToken(t *testing.T) {
pool := integrationPool(t)
if _, err := NewChecker(pool).Validate(context.Background(), "not-a-real-token"); !errors.Is(err, ErrInvalidSession) {
t.Errorf("Validate on an unknown token: err = %v, want ErrInvalidSession", err)
}
}
// TestIntegrationRequireSessionForbidsMutatingRequestFromViewer is the
// regression test for the security-audit finding that this middleware
// used to be a pure "logged in or not" gate: a Viewer-role session
// could create/delete alert rules and notification targets exactly like
// an Editor. A POST from a Viewer session must now be 403, not passed
// through to the handler.
func TestIntegrationRequireSessionForbidsMutatingRequestFromViewer(t *testing.T) {
pool := integrationPool(t)
raw := insertTestSessionWithRole(t, pool, time.Hour, "viewer")
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true; w.WriteHeader(http.StatusOK) })
handler := RequireSession(NewChecker(pool), next)
req := httptest.NewRequest(http.MethodPost, "/targets", nil)
req.Header.Set("Authorization", "Bearer "+raw)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", rec.Code)
}
if called {
t.Error("handler must not run for a Viewer's mutating request")
}
}
// TestIntegrationRequireSessionAllowsMutatingRequestFromEditor is the
// positive counterpart: an Editor-role session (the new floor) must
// still be able to reach mutating routes.
func TestIntegrationRequireSessionAllowsMutatingRequestFromEditor(t *testing.T) {
pool := integrationPool(t)
raw := insertTestSessionWithRole(t, pool, time.Hour, "editor")
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
handler := RequireSession(NewChecker(pool), next)
req := httptest.NewRequest(http.MethodPost, "/targets", nil)
req.Header.Set("Authorization", "Bearer "+raw)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
}
// TestIntegrationRequireSessionAllowsReadFromViewer confirms the read
// path is untouched: GET still only needs a valid session, any role.
func TestIntegrationRequireSessionAllowsReadFromViewer(t *testing.T) {
pool := integrationPool(t)
raw := insertTestSessionWithRole(t, pool, time.Hour, "viewer")
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
handler := RequireSession(NewChecker(pool), next)
req := httptest.NewRequest(http.MethodGet, "/targets", nil)
req.Header.Set("Authorization", "Bearer "+raw)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
}
@@ -0,0 +1,32 @@
package sessioncheck
import "testing"
func TestRoleSatisfies(t *testing.T) {
cases := []struct {
role, min string
want bool
}{
{"viewer", "editor", false},
{"editor", "editor", true},
{"admin", "editor", true},
{"owner", "editor", true},
{"", "editor", false}, // unknown/empty role never satisfies a real floor
}
for _, c := range cases {
if got := roleSatisfies(c.role, c.min); got != c.want {
t.Errorf("roleSatisfies(%q, %q) = %v, want %v", c.role, c.min, got, c.want)
}
}
}
func TestIsReadOnly(t *testing.T) {
if !isReadOnly("GET") || !isReadOnly("HEAD") {
t.Error("GET/HEAD should be read-only")
}
for _, m := range []string{"POST", "PUT", "DELETE", "PATCH"} {
if isReadOnly(m) {
t.Errorf("%s should not be read-only", m)
}
}
}
+115
View File
@@ -4,8 +4,11 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"path"
"strings"
"github.com/sentry/sentry/api/authz" "github.com/sentry/sentry/api/authz"
) )
@@ -128,6 +131,30 @@ func (h *Handler) handleSetConfig(w http.ResponseWriter, r *http.Request) {
return return
} }
// extra_file_paths is a materially different capability than the
// rest of this override: every other field tunes an already-running
// source, but this one tells the agent (which runs as root, with no
// filesystem sandboxing today -- see the security audit) to read and
// ship an arbitrary local file. RoleEditor is the right bar for
// "adjust batch size," not for "grant read access to any file on the
// host" -- so a request that actually *changes* the set of extra
// paths (adds or edits one -- shrinking or clearing never needs
// this, since that only removes capability) requires RoleAdmin,
// checked here rather than by splitting /agents/{host}/config into
// two routes with two RegisterRoutes role floors, which would break
// the "PUT replaces the whole override" contract every field here
// otherwise shares.
// A nil authorizer means no RBAC is configured at all (Phase 0-3
// default-open behavior) -- consistent with RequireRole's own
// no-op-when-nil posture, this extra gate only applies once an
// authorizer resolves a real Identity to check.
if identity, ok := authz.IdentityFromContext(r.Context()); ok && !identity.Role.Satisfies(authz.RoleAdmin) {
if changesExtraFilePaths(h.currentExtraFilePaths(r.Context(), h.tenantID(r), r.PathValue("host")), override.ExtraFilePaths) {
writeError(w, http.StatusForbidden, "extra_file_paths requires the admin role")
return
}
}
a, err := h.store.SetOverride(r.Context(), h.tenantID(r), r.PathValue("host"), override, h.updatedBy(r)) a, err := h.store.SetOverride(r.Context(), h.tenantID(r), r.PathValue("host"), override, h.updatedBy(r))
if err != nil { if err != nil {
h.writeStoreErr(w, err, "setting agent config") h.writeStoreErr(w, err, "setting agent config")
@@ -202,9 +229,97 @@ func validateOverride(o ConfigOverride) error {
if o.HeartbeatIntervalMS != nil && *o.HeartbeatIntervalMS < 5000 { if o.HeartbeatIntervalMS != nil && *o.HeartbeatIntervalMS < 5000 {
return errors.New("heartbeat_interval_ms must be at least 5000 (5s)") return errors.New("heartbeat_interval_ms must be at least 5000 (5s)")
} }
if len(o.ExtraFilePaths) > 20 {
return errors.New("extra_file_paths: at most 20 paths")
}
for _, p := range o.ExtraFilePaths {
if err := validateExtraFilePath(p); err != nil {
return err
}
}
return nil return nil
} }
// extraFilePathDenylistPrefixes blocks whole directory trees that are
// never legitimate log-file locations but very commonly hold sensitive
// material an agent (which runs as root, unsandboxed, on every host
// this deployment has been checked against -- see the security audit)
// can otherwise read: OS credential/config storage, home directories,
// and kernel/process pseudo-filesystems.
var extraFilePathDenylistPrefixes = []string{"/etc/", "/root/", "/home/", "/proc/", "/sys/", "/boot/"}
// extraFilePathDenylistSubstrings catches credential material that can
// live outside the directories above too (e.g. a service account's
// SSH/cloud-credential directory under an app's own working directory,
// not necessarily /home or /root).
var extraFilePathDenylistSubstrings = []string{"/.ssh/", "/.gnupg/", "/.aws/", "/.kube/"}
// extraFilePathDenylistSuffixes catches specific high-value filenames by
// name, regardless of directory -- named here because the audit that
// motivated this check demonstrated /etc/shadow and an SSH private key
// specifically, and this covers both even outside the prefix-denylisted
// directories above (e.g. a private key accidentally copied to /opt).
var extraFilePathDenylistSuffixes = []string{"-key.pem", "id_rsa", "id_ecdsa", "id_ed25519", "id_dsa", "/shadow", "/gshadow"}
func validateExtraFilePath(p string) error {
if p == "" || !strings.HasPrefix(p, "/") {
return errors.New("extra_file_paths: each path must be a non-empty absolute path")
}
if strings.Contains(p, "..") {
return errors.New(`extra_file_paths: path must not contain ".."`)
}
if cleaned := path.Clean(p); cleaned != p {
return fmt.Errorf("extra_file_paths: %q must be in canonical form (e.g. %q)", p, cleaned)
}
for _, prefix := range extraFilePathDenylistPrefixes {
if p == strings.TrimSuffix(prefix, "/") || strings.HasPrefix(p, prefix) {
return fmt.Errorf("extra_file_paths: %q is not an allowed path (under denylisted %s)", p, prefix)
}
}
for _, substr := range extraFilePathDenylistSubstrings {
if strings.Contains(p, substr) {
return fmt.Errorf("extra_file_paths: %q is not an allowed path", p)
}
}
for _, suffix := range extraFilePathDenylistSuffixes {
if strings.HasSuffix(p, suffix) {
return fmt.Errorf("extra_file_paths: %q is not an allowed path", p)
}
}
return nil
}
// currentExtraFilePaths reads back the agent's already-stored override
// (empty/nil if the agent or override doesn't exist yet) so
// handleSetConfig can tell an addition/change apart from a pure
// shrink-or-clear -- see changesExtraFilePaths.
func (h *Handler) currentExtraFilePaths(ctx context.Context, tenantID, host string) []string {
a, err := h.store.Get(ctx, tenantID, host)
if err != nil || a.DesiredOverride == nil {
return nil
}
return a.DesiredOverride.ExtraFilePaths
}
// changesExtraFilePaths reports whether desired introduces any path not
// already present in current -- an addition or an edit, either of which
// grants the agent read access to something it couldn't read before.
// Removing paths (desired is a subset of current) is never a capability
// grant, so that alone never requires the stricter role handleSetConfig
// applies around this.
func changesExtraFilePaths(current, desired []string) bool {
existing := make(map[string]struct{}, len(current))
for _, p := range current {
existing[p] = struct{}{}
}
for _, p := range desired {
if _, ok := existing[p]; !ok {
return true
}
}
return false
}
func (h *Handler) writeStoreErr(w http.ResponseWriter, err error, action string) { func (h *Handler) writeStoreErr(w http.ResponseWriter, err error, action string) {
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "agent not found") writeError(w, http.StatusNotFound, "agent not found")
+110
View File
@@ -199,6 +199,116 @@ func TestHandleSetConfigRejectsTooSmallHeartbeatInterval(t *testing.T) {
} }
} }
func TestHandleSetConfigExtraFilePathsRoundTrips(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
h := newTestHandler(s)
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{
ExtraFilePaths: []string{"/var/log/nginx/access.log", "/var/log/nginx/error.log"},
})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var got Agent
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decoding response: %v", err)
}
if got.DesiredOverride == nil || len(got.DesiredOverride.ExtraFilePaths) != 2 {
t.Fatalf("unexpected override: %+v", got.DesiredOverride)
}
}
func TestHandleSetConfigRejectsRelativeExtraFilePath(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
h := newTestHandler(s)
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{
ExtraFilePaths: []string{"relative/path.log"},
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestHandleSetConfigRejectsTooManyExtraFilePaths(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
h := newTestHandler(s)
paths := make([]string, 21)
for i := range paths {
paths[i] = "/var/log/x.log"
}
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{ExtraFilePaths: paths})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
// TestHandleSetConfigDenylistsSensitivePaths is the regression test for
// the security-audit finding that a root, unsandboxed agent plus an
// unrestricted extra_file_paths let any Editor read arbitrary files
// (e.g. /etc/shadow, SSH keys) and have them shipped into ClickHouse.
func TestHandleSetConfigDenylistsSensitivePaths(t *testing.T) {
denied := []string{
"/etc/shadow",
"/etc/passwd",
"/root/.bash_history",
"/home/alice/.ssh/id_rsa",
"/home/alice/.ssh/authorized_keys",
"/proc/1/environ",
"/etc/sentry-agent/client-key.pem",
"/opt/app/../../etc/shadow",
"/opt/app/id_ed25519",
}
for _, p := range denied {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
h := newTestHandler(s)
rec := doRequest(t, h, "PUT", "/agents/web-01/config", ConfigOverride{ExtraFilePaths: []string{p}})
if rec.Code != http.StatusBadRequest {
t.Errorf("path %q: status = %d, want 400 (should be denylisted), body=%s", p, rec.Code, rec.Body.String())
}
}
}
// TestHandleSetConfigExtraFilePathsRequiresAdminToAdd is the regression
// test for the audit's role-floor fix: adding/changing extra_file_paths
// needs Admin, not just Editor, since it grants the agent read access to
// a new file. Purely shrinking or clearing an existing set stays at the
// Editor floor everything else in this override uses.
func TestHandleSetConfigExtraFilePathsRequiresAdminToAdd(t *testing.T) {
s := newFakeStore()
s.put(Agent{TenantID: "default", Host: "web-01"})
editor := NewHandler(discardLogger(), s, fakeAuthorizer{role: authz.RoleEditor}, nil)
admin := NewHandler(discardLogger(), s, fakeAuthorizer{role: authz.RoleAdmin}, nil)
rec := doRequest(t, editor, "PUT", "/agents/web-01/config", ConfigOverride{
ExtraFilePaths: []string{"/var/log/nginx/access.log"},
})
if rec.Code != http.StatusForbidden {
t.Fatalf("editor adding a path: status = %d, want 403", rec.Code)
}
rec = doRequest(t, admin, "PUT", "/agents/web-01/config", ConfigOverride{
ExtraFilePaths: []string{"/var/log/nginx/access.log", "/var/log/nginx/error.log"},
})
if rec.Code != http.StatusOK {
t.Fatalf("admin adding paths: status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
// Shrinking back down to one path is a pure removal -- Editor should
// be allowed to do this even though they couldn't have added it.
rec = doRequest(t, editor, "PUT", "/agents/web-01/config", ConfigOverride{
ExtraFilePaths: []string{"/var/log/nginx/access.log"},
})
if rec.Code != http.StatusOK {
t.Fatalf("editor removing a path: status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
}
func TestHandleSetConfigUnknownHostIsNotFound(t *testing.T) { func TestHandleSetConfigUnknownHostIsNotFound(t *testing.T) {
h := newTestHandler(newFakeStore()) h := newTestHandler(newFakeStore())
interval := int64(30000) interval := int64(30000)
+1
View File
@@ -40,6 +40,7 @@ type ConfigOverride struct {
HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"` HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"`
HeartbeatIntervalMS *int64 `json:"heartbeat_interval_ms,omitempty"` HeartbeatIntervalMS *int64 `json:"heartbeat_interval_ms,omitempty"`
JournaldUnit *string `json:"journald_unit,omitempty"` JournaldUnit *string `json:"journald_unit,omitempty"`
ExtraFilePaths []string `json:"extra_file_paths,omitempty"`
} }
type Agent struct { type Agent struct {
+96 -4
View File
@@ -7,7 +7,11 @@ package main
import ( import (
"context" "context"
"crypto/rand"
"encoding/base64"
"flag"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
@@ -28,6 +32,7 @@ import (
"github.com/sentry/sentry/api/dashboards" "github.com/sentry/sentry/api/dashboards"
"github.com/sentry/sentry/api/httpserver" "github.com/sentry/sentry/api/httpserver"
"github.com/sentry/sentry/api/internal/config" "github.com/sentry/sentry/api/internal/config"
"github.com/sentry/sentry/api/localauth"
"github.com/sentry/sentry/api/queryapi" "github.com/sentry/sentry/api/queryapi"
"github.com/sentry/sentry/api/querylang/executor" "github.com/sentry/sentry/api/querylang/executor"
"github.com/sentry/sentry/api/searchclient" "github.com/sentry/sentry/api/searchclient"
@@ -48,6 +53,9 @@ func main() {
logger.Error("loading config", "error", err) logger.Error("loading config", "error", err)
os.Exit(1) os.Exit(1)
} }
for _, w := range cfg.DevCredentialWarnings() {
logger.Warn(w)
}
// -healthcheck: a self-check mode for Docker's HEALTHCHECK, not a // -healthcheck: a self-check mode for Docker's HEALTHCHECK, not a
// flag anyone runs by hand. The api image is distroless (no shell, // flag anyone runs by hand. The api image is distroless (no shell,
@@ -59,6 +67,13 @@ func main() {
os.Exit(runHealthcheck(cfg.HTTPListenAddr)) os.Exit(runHealthcheck(cfg.HTTPListenAddr))
} }
// -seed-admin: a one-shot action, not part of the normal server
// startup path -- mirrors enterprise-api's -provision-tenant shape
// (declare, flag.Parse(), short-circuit before the rest of main's
// dependencies matter to it). See runSeedAdmin's doc comment.
seedAdmin := flag.Bool("seed-admin", false, "create the default local-auth admin user with a random password if none exists, print it once, and exit")
flag.Parse()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
@@ -101,12 +116,25 @@ func main() {
os.Exit(1) os.Exit(1)
} }
if *seedAdmin {
os.Exit(runSeedAdmin(ctx, logger, os.Stdout, localauth.NewStore(pgPool)))
}
// authorizer is nil (RequireRole* becomes a no-op) unless // authorizer is nil (RequireRole* becomes a no-op) unless
// ENTERPRISE_AUTH_URL is configured -- matches Phase 0-3 behavior // ENTERPRISE_AUTH_URL or LOCAL_AUTH_ENABLED is configured -- matches
// for a single-tenant deployment with no enterprise/ deployed. // Phase 0-3 behavior for a single-tenant deployment with neither
// enterprise/ nor local login turned on. EnterpriseAuthURL wins if
// both were somehow set -- a deployment with real SSO configured has
// no use for a second, local auth mechanism (see LocalAuthConfig's
// doc comment).
var authorizer authz.Authorizer var authorizer authz.Authorizer
if cfg.EnterpriseAuthURL != "" { var localAuthStore *localauth.Store
switch {
case cfg.EnterpriseAuthURL != "":
authorizer = authz.NewHTTPAuthorizer(cfg.EnterpriseAuthURL) authorizer = authz.NewHTTPAuthorizer(cfg.EnterpriseAuthURL)
case cfg.LocalAuth.Enabled:
localAuthStore = localauth.NewStore(pgPool)
authorizer = localauth.NewAuthorizer(localAuthStore)
} }
sqlRunner := executor.NewChRunner(conn) sqlRunner := executor.NewChRunner(conn)
@@ -139,6 +167,18 @@ func main() {
dashboardsHandler.RegisterRoutes(mux) dashboardsHandler.RegisterRoutes(mux)
agentsHandler.RegisterRoutes(mux) agentsHandler.RegisterRoutes(mux)
// Only registered when local auth is actually enabled -- see
// localauth.Handler.RegisterRoutes' doc comment for why a disabled
// deployment gets a plain 404 on /auth/* rather than a dedicated
// "feature off" response.
if localAuthStore != nil {
localauthHandler := localauth.NewHandler(logger, localAuthStore, authorizer, cfg.LocalAuth.SessionTTL, localauth.CookieConfig{
Domain: cfg.LocalAuth.CookieDomain,
Secure: cfg.LocalAuth.CookieSecure,
})
localauthHandler.RegisterRoutes(mux)
}
// AI routes (Phase 7) are only registered at all when OLLAMA_BASE_URL // AI routes (Phase 7) are only registered at all when OLLAMA_BASE_URL
// is set -- an unconfigured deployment gets a plain 404 on /ai/* // is set -- an unconfigured deployment gets a plain 404 on /ai/*
// rather than every request failing against an unreachable // rather than every request failing against an unreachable
@@ -165,9 +205,19 @@ func main() {
logger.Info("ai routes enabled", "ollama_base_url", cfg.AI.OllamaBaseURL, "model", cfg.AI.OllamaModel) logger.Info("ai routes enabled", "ollama_base_url", cfg.AI.OllamaBaseURL, "model", cfg.AI.OllamaModel)
} }
// Once an authorizer is live, requests carry a session cookie/bearer
// token that must survive a cross-origin browser fetch --
// WithCredentialedCORS is WithCORS's sibling for exactly that (see
// httpserver/cors.go). This also fixes a latent gap: previously,
// enterprise mode applied plain WithCORS here despite needing
// cookies too.
corsHandler := httpserver.WithCORS(mux, cfg.CORSAllowedOrigin)
if authorizer != nil {
corsHandler = httpserver.WithCredentialedCORS(mux, cfg.CORSAllowedOrigin)
}
srv := &http.Server{ srv := &http.Server{
Addr: cfg.HTTPListenAddr, Addr: cfg.HTTPListenAddr,
Handler: httpserver.WithCORS(mux, cfg.CORSAllowedOrigin), Handler: corsHandler,
} }
errCh := make(chan error, 1) errCh := make(chan error, 1)
@@ -191,6 +241,48 @@ func main() {
} }
} }
// runSeedAdmin is the operator action that bootstraps local login on a
// fresh deployment: idempotent (a no-op if any local user already
// exists, safe to run on every deploy per the runbook), so there's no
// separate "has this already run" flag to track. The generated
// password is printed to stdout exactly once and never stored in
// plaintext anywhere -- losing it means resetting it
// (POST /auth/users/{id}/reset-password), not recovering it.
func runSeedAdmin(ctx context.Context, logger *slog.Logger, stdout io.Writer, store *localauth.Store) int {
n, err := store.CountLocalUsers(ctx)
if err != nil {
logger.Error("counting local users", "error", err)
return 1
}
if n > 0 {
fmt.Fprintln(stdout, "admin already provisioned, skipping")
return 0
}
buf := make([]byte, 20)
if _, err := rand.Read(buf); err != nil {
logger.Error("generating random password", "error", err)
return 1
}
password := base64.RawURLEncoding.EncodeToString(buf)
hash, err := localauth.HashPassword(password)
if err != nil {
logger.Error("hashing password", "error", err)
return 1
}
if _, err := store.CreateUser(ctx, "admin", hash, authz.RoleOwner); err != nil {
logger.Error("creating admin user", "error", err)
return 1
}
fmt.Fprintln(stdout, "created default admin user:")
fmt.Fprintln(stdout, " username: admin")
fmt.Fprintf(stdout, " password: %s\n", password)
fmt.Fprintln(stdout, "this password will not be shown again -- save it now.")
return 0
}
// runHealthcheck GETs its own /healthz and returns an exit code, for // runHealthcheck GETs its own /healthz and returns an exit code, for
// Docker's HEALTHCHECK to exec directly (see the -healthcheck flag // Docker's HEALTHCHECK to exec directly (see the -healthcheck flag
// above). listenAddr is HTTP_LISTEN_ADDR-shaped (e.g. ":8080") -- // above). listenAddr is HTTP_LISTEN_ADDR-shaped (e.g. ":8080") --
+3 -2
View File
@@ -5,7 +5,9 @@ go 1.25.0
require ( require (
github.com/ClickHouse/clickhouse-go/v2 v2.48.0 github.com/ClickHouse/clickhouse-go/v2 v2.48.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/sentry/sentry/proto v0.0.0-00010101000000-000000000000 github.com/sentry/sentry/proto v0.0.0-00010101000000-000000000000
golang.org/x/crypto v0.55.0
google.golang.org/grpc v1.83.0 google.golang.org/grpc v1.83.0
) )
@@ -19,7 +21,6 @@ require (
github.com/go-faster/errors v0.7.1 // indirect github.com/go-faster/errors v0.7.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.10.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.19.1 // indirect github.com/klauspost/compress v1.19.1 // indirect
github.com/paulmach/orb v0.13.0 // indirect github.com/paulmach/orb v0.13.0 // indirect
@@ -31,7 +32,7 @@ require (
golang.org/x/net v0.57.0 // indirect golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/protobuf v1.36.12 // indirect google.golang.org/protobuf v1.36.12 // indirect
) )
+4 -2
View File
@@ -63,14 +63,16 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+72
View File
@@ -18,6 +18,32 @@ type Config struct {
CORSAllowedOrigin string CORSAllowedOrigin string
EnterpriseAuthURL string EnterpriseAuthURL string
AI AIConfig AI AIConfig
LocalAuth LocalAuthConfig
}
// LocalAuthConfig gates single-tenant mode's local username/password
// login (see api/localauth) -- off unless Enabled, same "off unless
// configured" convention as EnterpriseAuthURL/AI.OllamaBaseURL. Only
// meaningful when EnterpriseAuthURL is empty -- a deployment with real
// SSO configured has no use for a second, local auth mechanism, and
// main.go's authorizer selection treats EnterpriseAuthURL as taking
// priority if both were somehow set.
type LocalAuthConfig struct {
Enabled bool
// SessionTTL is deliberately long (30 days default) compared to
// enterprise/'s session TTL -- there's no SSO round-trip here to
// silently refresh a session against, so a short TTL would just mean
// re-entering a password often on a self-hosted single-operator tool.
SessionTTL time.Duration
// CookieDomain empty means a host-only cookie (fine for local dev,
// where web/api are both localhost:<port>). Set to e.g.
// ".sentry.example.com" in production so the cookie is also sent to
// api.sentry.example.com/alerting.sentry.example.com.
CookieDomain string
// CookieSecure defaults true (never sent over plain HTTP) --
// deliberately opt-out via LOCAL_AUTH_COOKIE_SECURE=false, only
// useful to test the login flow locally over http://localhost.
CookieSecure bool
} }
// AIConfig gates Phase 7's AI-assisted query features (Track A/B) -- // AIConfig gates Phase 7's AI-assisted query features (Track A/B) --
@@ -50,6 +76,33 @@ type PostgresConfig struct {
Password string Password string
} }
// devOnlyCredential is docker-compose.yml's zero-config default for
// every Postgres/ClickHouse password in this repo -- genuinely fine for
// local dev (that's the whole point of a zero-config default), but a
// real deployment that skips docker-compose.override.yml would
// otherwise go live with a password anyone can read straight off
// GitHub. See DevCredentialWarnings.
const devOnlyCredential = "sentry-dev-only"
// DevCredentialWarnings reports which configured credentials still
// equal docker-compose.yml's literal dev-only default -- cmd/api/main.go
// logs each one loudly at startup. Deliberately a warning, not a
// startup-refusing error: local dev's documented zero-config path is
// exactly "run docker-compose.yml with no override," which legitimately
// leaves every password at this literal value, so hard-failing here
// would break that path rather than only catching real deployments that
// forgot to override it.
func (c Config) DevCredentialWarnings() []string {
var warnings []string
if c.ClickHouse.Password == devOnlyCredential {
warnings = append(warnings, "CLICKHOUSE_PASSWORD is still the default dev-only value -- set a real password via docker-compose.override.yml (or your deployment's equivalent) before this is reachable outside local dev")
}
if c.Postgres.Password == devOnlyCredential {
warnings = append(warnings, "POSTGRES_PASSWORD is still the default dev-only value -- set a real password via docker-compose.override.yml (or your deployment's equivalent) before this is reachable outside local dev")
}
return warnings
}
func Load() (Config, error) { func Load() (Config, error) {
cfg := Config{ cfg := Config{
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8080"), HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8080"),
@@ -98,6 +151,25 @@ func Load() (Config, error) {
} }
cfg.QueryTimeout = time.Duration(timeoutSec) * time.Second cfg.QueryTimeout = time.Duration(timeoutSec) * time.Second
localAuthEnabled, err := strconv.ParseBool(getenv("LOCAL_AUTH_ENABLED", "false"))
if err != nil {
return Config{}, fmt.Errorf("LOCAL_AUTH_ENABLED: %w", err)
}
sessionTTLHours, err := strconv.Atoi(getenv("LOCAL_SESSION_TTL_HOURS", "720")) // 30 days
if err != nil {
return Config{}, fmt.Errorf("LOCAL_SESSION_TTL_HOURS: %w", err)
}
cookieSecure, err := strconv.ParseBool(getenv("LOCAL_AUTH_COOKIE_SECURE", "true"))
if err != nil {
return Config{}, fmt.Errorf("LOCAL_AUTH_COOKIE_SECURE: %w", err)
}
cfg.LocalAuth = LocalAuthConfig{
Enabled: localAuthEnabled,
SessionTTL: time.Duration(sessionTTLHours) * time.Hour,
CookieDomain: getenv("SESSION_COOKIE_DOMAIN", ""),
CookieSecure: cookieSecure,
}
return cfg, nil return cfg, nil
} }
+20
View File
@@ -30,3 +30,23 @@ func TestLoadInvalidTimeoutErrors(t *testing.T) {
t.Fatal("expected error for non-numeric QUERY_TIMEOUT_SECONDS, got nil") t.Fatal("expected error for non-numeric QUERY_TIMEOUT_SECONDS, got nil")
} }
} }
// TestDevCredentialWarnings is the regression test for the
// security-audit finding that docker-compose.yml's hardcoded
// "sentry-dev-only" password has no runtime fail-safe if an operator
// forgets to override it for a real deployment.
func TestDevCredentialWarnings(t *testing.T) {
if got := (Config{}).DevCredentialWarnings(); len(got) != 0 {
t.Errorf("empty passwords: warnings = %v, want none", got)
}
real := Config{ClickHouse: ClickHouseConfig{Password: "a-real-password"}, Postgres: PostgresConfig{Password: "another-real-one"}}
if got := real.DevCredentialWarnings(); len(got) != 0 {
t.Errorf("real passwords: warnings = %v, want none", got)
}
devOnly := Config{ClickHouse: ClickHouseConfig{Password: devOnlyCredential}, Postgres: PostgresConfig{Password: devOnlyCredential}}
if got := devOnly.DevCredentialWarnings(); len(got) != 2 {
t.Errorf("both dev-default passwords: warnings = %v, want 2 entries", got)
}
}
+21
View File
@@ -259,6 +259,24 @@ func defaultAggAlias(a ast.AggCall) string {
// SQL parser -- same tradeoffs as the Phase 0/1 version this replaces. // SQL parser -- same tradeoffs as the Phase 0/1 version this replaces.
var disallowedKeyword = regexp.MustCompile(`(?i)\b(insert|update|delete|alter|drop|truncate|create|grant|revoke|attach|detach|rename|kill|optimize|system|set|exchange|watch)\b`) var disallowedKeyword = regexp.MustCompile(`(?i)\b(insert|update|delete|alter|drop|truncate|create|grant|revoke|attach|detach|rename|kill|optimize|system|set|exchange|watch)\b`)
// disallowedTableFunction blocks ClickHouse's built-in table functions
// that reach outside ClickHouse itself -- a keyword blocklist for
// mutating statements (above) doesn't touch these at all, since
// `SELECT * FROM url(...)` is a perfectly ordinary read-only SELECT as
// far as validateSelectOnly's other checks are concerned. Every
// function here lets a SELECT-only, RoleViewer-gated query make
// ClickHouse itself issue an outbound request or read a local file on
// the caller's behalf -- cloud-metadata SSRF via url(), a proxy into
// other internal ClickHouse/MySQL/Postgres instances via
// remote()/remoteSecure()/mysql()/postgresql(), and local/object-storage
// file reads via file()/hdfs()/s3()/azureBlobStorage()/deltaLake()/
// iceberg()/hudi(). Same word-boundary-regex tradeoff as
// disallowedKeyword above: this is a blocklist, not a real SQL parser,
// so it can't be the only control -- see the ClickHouse-grant-level
// hardening this should be paired with (table-function usage revoked
// for the role api's raw-SQL path connects as).
var disallowedTableFunction = regexp.MustCompile(`(?i)\b(url|remote|remoteSecure|mysql|postgresql|s3|s3Cluster|hdfs|hdfsCluster|file|odbc|jdbc|executable|cluster|clusterAllReplicas|azureBlobStorage|deltaLake|iceberg|hudi|redis|mongodb)\s*\(`)
func validateSelectOnly(sql string) error { func validateSelectOnly(sql string) error {
trimmed := strings.TrimSpace(sql) trimmed := strings.TrimSpace(sql)
if trimmed == "" { if trimmed == "" {
@@ -281,6 +299,9 @@ func validateSelectOnly(sql string) error {
if disallowedKeyword.MatchString(trimmed) { if disallowedKeyword.MatchString(trimmed) {
return fmt.Errorf("query contains a disallowed keyword") return fmt.Errorf("query contains a disallowed keyword")
} }
if disallowedTableFunction.MatchString(trimmed) {
return fmt.Errorf("query contains a disallowed table function")
}
return nil return nil
} }
@@ -40,6 +40,48 @@ func TestCompileRejectsNonSelectSQLKeyword(t *testing.T) {
} }
} }
// TestCompileRejectsSSRFTableFunctions guards against a real finding: a
// SELECT-only, keyword-blocklist check alone doesn't stop ClickHouse's
// built-in table functions, which let an otherwise-ordinary read-only
// SELECT make ClickHouse itself issue an outbound request (url,
// remote/remoteSecure, mysql, postgresql, s3, hdfs, ...) or read a local
// file (file) on the caller's behalf -- an SSRF/file-read primitive
// reachable by RoleViewer, the platform's lowest role.
func TestCompileRejectsSSRFTableFunctions(t *testing.T) {
queries := []string{
`SELECT * FROM url('http://169.254.169.254/latest/meta-data/', 'LineAsString', 's String')`,
`select * from remote('internal-host:9000', system, tables)`,
`SELECT * FROM remoteSecure('attacker.example:9440', db, tbl, 'user', 'pass')`,
`select * from mysql('host:3306', 'db', 'table', 'user', 'pass')`,
`SELECT * FROM postgresql('host:5432', 'db', 'table', 'user', 'pass')`,
`select * from s3('https://bucket.s3.amazonaws.com/key', 'CSV')`,
`SELECT * FROM hdfs('hdfs://host:9000/path', 'CSV')`,
`select * from file('/etc/passwd', 'LineAsString')`,
`SELECT * FROM odbc('DSN=foo', 'db', 'table')`,
`select * from executable('id', 'TSV', 'x String')`,
`SELECT * FROM cluster('some_cluster', system, tables)`,
}
for _, q := range queries {
if _, err := Compile(q, SQL, fixedNow); err == nil {
t.Errorf("expected Compile(%q) to reject a table-function SSRF vector, got no error", q)
}
}
}
// TestCompileAllowsOrdinaryColumnNamesResemblingTableFunctions makes
// sure the table-function blocklist only fires on actual function-call
// syntax (name immediately followed by "(") and not merely a column or
// identifier that happens to share a name with a blocked function.
func TestCompileAllowsOrdinaryColumnNamesResemblingTableFunctions(t *testing.T) {
plan, err := Compile(`SELECT cluster_id, file_name FROM logs WHERE cluster_id = 1`, SQL, fixedNow)
if err != nil {
t.Fatalf("Compile() error = %v", err)
}
if plan.RawSQL == "" {
t.Fatal("expected RawSQL to be set")
}
}
func TestCompileExplicitLanguageOverridesAutoDetect(t *testing.T) { func TestCompileExplicitLanguageOverridesAutoDetect(t *testing.T) {
// "select" as a bare free-text search term -- would be misdetected // "select" as a bare free-text search term -- would be misdetected
// as SQL by the heuristic alone, hence the override. // as SQL by the heuristic alone, hence the override.
+70
View File
@@ -0,0 +1,70 @@
package localauth
import (
"context"
"errors"
"net/http"
"github.com/sentry/sentry/api/authz"
)
// sessionStore is the narrow interface Authorizer depends on -- *Store
// is the production implementation; tests use a fake.
type sessionStore interface {
GetSession(ctx context.Context, tokenHash string) (*Session, error)
}
// sessionCookieName is also read directly by handler.go (Set-Cookie on
// login/logout) and is the one piece of this package's shape web/
// needs to know about implicitly (via credentials: 'include', not by
// name -- the browser handles the cookie, JS never reads it since it's
// HttpOnly).
const sessionCookieName = "sentry_local_session"
// Authorizer implements api/authz.Authorizer against local_sessions --
// wiring a non-nil *Authorizer into api/cmd/api/main.go's authorizer
// variable is what turns every existing RequireRole-wrapped route in
// dashboards/agents/queryapi/aiapi from a no-op into real enforcement,
// with no changes needed to any of those handler files (see this
// package's doc comment).
type Authorizer struct {
store sessionStore
}
func NewAuthorizer(store sessionStore) *Authorizer {
return &Authorizer{store: store}
}
var errNoCredential = errors.New("localauth: no session credential presented")
// Authorize checks Authorization: Bearer first (sentryctl and other
// non-browser callers), then the session cookie (the web UI) -- same
// precedence authz.HTTPAuthorizer's caller-side forwarding implies,
// and the same reason POST /auth/login's response body returns the raw
// token alongside setting the cookie (see handler.go): one opaque
// value works both ways.
func (a *Authorizer) Authorize(r *http.Request) (authz.Identity, error) {
raw, err := credentialFromRequest(r)
if err != nil {
return authz.Identity{}, err
}
sess, err := a.store.GetSession(r.Context(), hashToken(raw))
if err != nil {
return authz.Identity{}, err
}
return authz.Identity{TenantID: sess.TenantID, UserID: sess.UserID, Role: sess.Role}, nil
}
func credentialFromRequest(r *http.Request) (string, error) {
if auth := r.Header.Get("Authorization"); auth != "" {
const prefix = "Bearer "
if len(auth) > len(prefix) && auth[:len(prefix)] == prefix {
return auth[len(prefix):], nil
}
}
if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" {
return cookie.Value, nil
}
return "", errNoCredential
}
+127
View File
@@ -0,0 +1,127 @@
package localauth
import (
"context"
"strconv"
"time"
"github.com/sentry/sentry/api/authz"
)
// fakeStore implements both store (handler.go) and sessionStore
// (authorizer.go) -- a real *Store satisfies both too, this is just the
// in-memory test double, same "fake enforces the same invariants the
// real pgx-backed Store does" posture dashboards/handler_test.go's
// fakeStore documents.
type fakeStore struct {
users map[string]*User // by id
hashes map[string]string
byUsername map[string]string // username -> id
sessions map[string]Session
nextID int
createErr error
}
func newFakeStore() *fakeStore {
return &fakeStore{
users: map[string]*User{},
hashes: map[string]string{},
byUsername: map[string]string{},
sessions: map[string]Session{},
}
}
func (f *fakeStore) CreateUser(_ context.Context, username, passwordHash string, role authz.Role) (*User, error) {
if f.createErr != nil {
return nil, f.createErr
}
if _, ok := f.byUsername[username]; ok {
return nil, ErrUsernameTaken
}
f.nextID++
id := "user-" + strconv.Itoa(f.nextID)
u := &User{ID: id, Username: username, Role: role, CreatedAt: time.Now()}
f.users[id] = u
f.hashes[id] = passwordHash
f.byUsername[username] = id
return u, nil
}
func (f *fakeStore) ListUsers(_ context.Context) ([]User, error) {
var out []User
for _, u := range f.users {
out = append(out, *u)
}
return out, nil
}
func (f *fakeStore) GetUserForLogin(_ context.Context, username string) (*User, string, error) {
id, ok := f.byUsername[username]
if !ok {
return nil, "", ErrNotFound
}
return f.users[id], f.hashes[id], nil
}
func (f *fakeStore) GetUserByID(_ context.Context, id string) (*User, error) {
u, ok := f.users[id]
if !ok {
return nil, ErrNotFound
}
return u, nil
}
func (f *fakeStore) DeleteUser(_ context.Context, id string) error {
u, ok := f.users[id]
if !ok {
return ErrNotFound
}
delete(f.users, id)
delete(f.hashes, id)
delete(f.byUsername, u.Username)
for hash, sess := range f.sessions {
if sess.UserID == id {
delete(f.sessions, hash)
}
}
return nil
}
func (f *fakeStore) SetPasswordHash(_ context.Context, userID, hash string) error {
if _, ok := f.users[userID]; !ok {
return ErrNotFound
}
f.hashes[userID] = hash
for h, sess := range f.sessions {
if sess.UserID == userID {
delete(f.sessions, h)
}
}
return nil
}
func (f *fakeStore) CountLocalUsers(_ context.Context) (int, error) {
return len(f.users), nil
}
func (f *fakeStore) CreateSession(_ context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error) {
raw, hash, err := newOpaqueToken()
if err != nil {
return "", err
}
f.sessions[hash] = Session{UserID: userID, TenantID: tenantID, Role: role, ExpiresAt: time.Now().Add(ttl)}
return raw, nil
}
func (f *fakeStore) GetSession(_ context.Context, tokenHash string) (*Session, error) {
sess, ok := f.sessions[tokenHash]
if !ok || sess.ExpiresAt.Before(time.Now()) {
return nil, ErrNotFound
}
return &sess, nil
}
func (f *fakeStore) DeleteSessionByHash(_ context.Context, tokenHash string) error {
delete(f.sessions, tokenHash)
return nil
}
+407
View File
@@ -0,0 +1,407 @@
package localauth
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"github.com/sentry/sentry/api/authz"
)
const maxBodyBytes = 1 << 20 // 1 MiB, same cap as queryapi/dashboards/agents
// store is the narrow interface Handler depends on -- *Store (store.go)
// is the production implementation; tests use a fake, same pattern as
// dashboards.store/agents.store.
type store interface {
CreateUser(ctx context.Context, username, passwordHash string, role authz.Role) (*User, error)
ListUsers(ctx context.Context) ([]User, error)
GetUserForLogin(ctx context.Context, username string) (*User, string, error)
GetUserByID(ctx context.Context, id string) (*User, error)
DeleteUser(ctx context.Context, id string) error
SetPasswordHash(ctx context.Context, userID, hash string) error
CreateSession(ctx context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error)
DeleteSessionByHash(ctx context.Context, tokenHash string) error
}
// CookieConfig is the deployment-specific half of how the session
// cookie is set -- everything else about it (name, HttpOnly, SameSite)
// is fixed by this package, not configurable per deployment.
type CookieConfig struct {
// Domain is typically empty for local dev (host-only cookie, works
// fine when web/api are both localhost:<port>) and something like
// ".sentry.example.com" in production, so the same cookie is sent to
// api.sentry.example.com and alerting.sentry.example.com too -- see
// /docs (deployment runbook) for the subdomain scheme this assumes.
Domain string
// Secure defaults to true (the cookie is never sent over plain
// HTTP) -- deliberately opt-out, not opt-in, since the real
// deployment this feature exists for is always behind HTTPS. Only
// worth setting false to test the login flow locally over plain
// http://localhost.
Secure bool
}
// loginRateLimitMax/Window bound how many login attempts one client IP
// may make -- see loginLimiter's doc comment for why this is per-IP,
// in-memory, and counts both successful and failed attempts. 10 per 5
// minutes is generous enough that a real user mistyping a password a
// few times never notices, while still bounding an online brute-force
// attempt to a few attempts per minute.
const (
loginRateLimitMax = 10
loginRateLimitWindow = 5 * time.Minute
)
type Handler struct {
logger *slog.Logger
store store
authorizer authz.Authorizer
sessionTTL time.Duration
cookies CookieConfig
loginLimits *loginLimiter
}
func NewHandler(logger *slog.Logger, store store, authorizer authz.Authorizer, sessionTTL time.Duration, cookies CookieConfig) *Handler {
return &Handler{
logger: logger,
store: store,
authorizer: authorizer,
sessionTTL: sessionTTL,
cookies: cookies,
loginLimits: newLoginLimiter(loginRateLimitMax, loginRateLimitWindow),
}
}
// RegisterRoutes is only ever called when local auth is enabled (see
// cmd/api/main.go) -- a deployment that doesn't enable it simply never
// registers these routes at all, so GET /auth/session (etc.) 404s
// rather than needing its own "is this feature even on" response
// shape. Login/logout/session are deliberately NOT RequireRole-wrapped
// with anything above RoleViewer's floor: login is how you become
// authenticated in the first place, logout/session must work for any
// already-authenticated user regardless of role.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /auth/login", h.handleLogin)
mux.HandleFunc("POST /auth/logout", h.handleLogout)
mux.HandleFunc("GET /auth/session", authz.RequireRole(h.authorizer, authz.RoleViewer, h.handleGetSession))
mux.HandleFunc("GET /auth/users", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleListUsers))
mux.HandleFunc("POST /auth/users", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleCreateUser))
mux.HandleFunc("DELETE /auth/users/{id}", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleDeleteUser))
mux.HandleFunc("POST /auth/users/{id}/reset-password", authz.RequireRole(h.authorizer, authz.RoleOwner, h.handleResetPassword))
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type sessionResponse struct {
// Token duplicates what the Set-Cookie header already carries,
// specifically for non-browser callers with no cookie jar --
// sentryctl captures this into SENTRYCTL_TOKEN and sends it back as
// Authorization: Bearer (see authorizer.go's credentialFromRequest,
// which accepts either). The web UI ignores this field entirely and
// relies on the cookie.
Token string `json:"token"`
UserID string `json:"user_id"`
TenantID string `json:"tenant_id"`
Username string `json:"username"`
Role string `json:"role"`
}
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
if !h.loginLimits.allow(clientIP(r)) {
writeError(w, http.StatusTooManyRequests, "too many login attempts, try again later")
return
}
var req loginRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "username and password are required")
return
}
user, hash, err := h.store.GetUserForLogin(r.Context(), req.Username)
if err != nil {
if errors.Is(err, ErrNotFound) {
// Run a dummy bcrypt comparison even though there's no real
// hash to check -- otherwise this branch returns immediately
// while a known-username branch always pays bcrypt's cost
// below, and that timing gap lets a patient caller enumerate
// valid usernames by response latency alone even though the
// error message text is identical either way.
ComparePassword(dummyPasswordHash, req.Password)
writeError(w, http.StatusUnauthorized, "invalid username or password")
return
}
h.logger.Error("looking up user for login", "error", err)
writeError(w, http.StatusInternalServerError, "login failed")
return
}
if !ComparePassword(hash, req.Password) {
writeError(w, http.StatusUnauthorized, "invalid username or password")
return
}
raw, err := h.store.CreateSession(r.Context(), user.ID, defaultTenantID, user.Role, h.sessionTTL)
if err != nil {
h.logger.Error("creating session", "error", err)
writeError(w, http.StatusInternalServerError, "login failed")
return
}
h.setCookie(w, raw, h.sessionTTL)
writeJSON(w, http.StatusOK, sessionResponse{
Token: raw, UserID: user.ID, TenantID: defaultTenantID,
Username: user.Username, Role: string(user.Role),
})
}
// handleLogout always responds 204, whether or not a valid session was
// presented -- "log me out" is idempotent from the caller's point of
// view either way.
func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
if raw, err := credentialFromRequest(r); err == nil {
if err := h.store.DeleteSessionByHash(r.Context(), hashToken(raw)); err != nil {
h.logger.Error("deleting session", "error", err)
}
}
h.clearCookie(w)
w.WriteHeader(http.StatusNoContent)
}
// handleGetSession is what web's route guard (+layout.ts) polls on
// every navigation -- RequireRole(RoleViewer) above already turns "no
// valid session" into a 401 before this ever runs, so by the time
// we're here the identity is real.
func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
identity, _ := authz.IdentityFromContext(r.Context())
user, err := h.store.GetUserByID(r.Context(), identity.UserID)
if err != nil {
h.writeStoreErr(w, err, "fetching session user")
return
}
writeJSON(w, http.StatusOK, sessionResponse{
UserID: user.ID, TenantID: identity.TenantID, Username: user.Username, Role: string(user.Role),
})
}
type userResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
}
func (h *Handler) handleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.store.ListUsers(r.Context())
if err != nil {
h.logger.Error("listing users", "error", err)
writeError(w, http.StatusInternalServerError, "listing users failed")
return
}
out := make([]userResponse, len(users))
for i, u := range users {
out[i] = userResponse{ID: u.ID, Username: u.Username, Role: string(u.Role), CreatedAt: u.CreatedAt}
}
writeJSON(w, http.StatusOK, out)
}
type createUserRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}
func validRole(r authz.Role) bool {
switch r {
case authz.RoleViewer, authz.RoleEditor, authz.RoleAdmin, authz.RoleOwner:
return true
default:
return false
}
}
func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Username == "" {
writeError(w, http.StatusBadRequest, "username must not be empty")
return
}
if len(req.Password) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
role := authz.Role(req.Role)
if role == "" {
role = authz.RoleEditor
}
if !validRole(role) {
writeError(w, http.StatusBadRequest, `role must be "viewer", "editor", "admin", or "owner"`)
return
}
hash, err := HashPassword(req.Password)
if err != nil {
h.logger.Error("hashing password", "error", err)
writeError(w, http.StatusInternalServerError, "creating user failed")
return
}
user, err := h.store.CreateUser(r.Context(), req.Username, hash, role)
if err != nil {
if errors.Is(err, ErrUsernameTaken) {
writeError(w, http.StatusConflict, "username already taken")
return
}
h.logger.Error("creating user", "error", err)
writeError(w, http.StatusInternalServerError, "creating user failed")
return
}
writeJSON(w, http.StatusCreated, userResponse{ID: user.ID, Username: user.Username, Role: string(user.Role), CreatedAt: user.CreatedAt})
}
// handleDeleteUser deliberately does not stop an admin from deleting
// their own account -- this package has no separate "you can't remove
// the last admin" guard; a single-operator prototype deployment is
// expected to know what it's doing here, same trust level the rest of
// this codebase's admin-only endpoints assume.
func (h *Handler) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
if err := h.store.DeleteUser(r.Context(), r.PathValue("id")); err != nil {
h.writeStoreErr(w, err, "deleting user")
return
}
w.WriteHeader(http.StatusNoContent)
}
type resetPasswordRequest struct {
// Password is optional -- omitted, a random one is generated and
// returned in the response body exactly once, same "shown once,
// never stored, never recoverable" posture as -seed-admin's initial
// password (see cmd/api/main.go's runSeedAdmin).
Password string `json:"password,omitempty"`
}
type resetPasswordResponse struct {
// Password is only set when the request didn't supply one --
// omitempty so an admin-supplied reset doesn't echo it back.
Password string `json:"password,omitempty"`
}
func (h *Handler) handleResetPassword(w http.ResponseWriter, r *http.Request) {
var req resetPasswordRequest
// An empty body is valid here (generate a random password) --
// decodeJSON's json.Decode on an empty io.Reader would error, so
// this endpoint reads the body directly instead of reusing
// decodeJSON, tolerating "no body at all" as "use defaults."
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
if r.ContentLength != 0 {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
}
plaintext := req.Password
generated := false
if plaintext == "" {
raw, _, err := newOpaqueToken()
if err != nil {
h.logger.Error("generating random password", "error", err)
writeError(w, http.StatusInternalServerError, "resetting password failed")
return
}
plaintext = raw
generated = true
} else if len(plaintext) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
hash, err := HashPassword(plaintext)
if err != nil {
h.logger.Error("hashing password", "error", err)
writeError(w, http.StatusInternalServerError, "resetting password failed")
return
}
if err := h.store.SetPasswordHash(r.Context(), r.PathValue("id"), hash); err != nil {
h.writeStoreErr(w, err, "resetting password")
return
}
resp := resetPasswordResponse{}
if generated {
resp.Password = plaintext
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) setCookie(w http.ResponseWriter, raw string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: raw,
Domain: h.cookies.Domain,
Path: "/",
MaxAge: int(ttl.Seconds()),
HttpOnly: true,
Secure: h.cookies.Secure,
SameSite: http.SameSiteLaxMode,
})
}
func (h *Handler) clearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Domain: h.cookies.Domain,
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: h.cookies.Secure,
SameSite: http.SameSiteLaxMode,
})
}
func (h *Handler) writeStoreErr(w http.ResponseWriter, err error, action string) {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "not found")
return
}
h.logger.Error(action, "error", err)
writeError(w, http.StatusInternalServerError, action+" failed")
}
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
type errorResponse struct {
Error string `json:"error"`
}
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
}
+261
View File
@@ -0,0 +1,261 @@
package localauth
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/sentry/sentry/api/authz"
)
func newTestHandler(t *testing.T, fs *fakeStore) (*Handler, *http.ServeMux) {
t.Helper()
authorizer := NewAuthorizer(fs)
h := NewHandler(slog.New(slog.NewTextHandler(io.Discard, nil)), fs, authorizer, time.Hour, CookieConfig{})
mux := http.NewServeMux()
h.RegisterRoutes(mux)
return h, mux
}
func doRequest(t *testing.T, mux *http.ServeMux, method, path, body string, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, r)
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
func mustCreateUser(t *testing.T, fs *fakeStore, username, password string, role authz.Role) *User {
t.Helper()
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("hashing password: %v", err)
}
u, err := fs.CreateUser(t.Context(), username, hash, role)
if err != nil {
t.Fatalf("creating user: %v", err)
}
return u
}
func sessionCookieFrom(rec *httptest.ResponseRecorder) *http.Cookie {
for _, c := range rec.Result().Cookies() {
if c.Name == sessionCookieName {
return c
}
}
return nil
}
func TestLoginSuccess(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
rec := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
cookie := sessionCookieFrom(rec)
if cookie == nil || cookie.Value == "" {
t.Fatalf("expected a session cookie to be set, got none")
}
if !cookie.HttpOnly {
t.Errorf("session cookie must be HttpOnly")
}
var resp sessionResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.Token == "" {
t.Errorf("expected the response body to also carry the raw token for non-browser callers")
}
if resp.Role != "editor" {
t.Errorf("role = %q, want editor", resp.Role)
}
}
func TestLoginWrongPassword(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
rec := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"wrong"}`, nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
func TestLoginUnknownUserSameErrorAsWrongPassword(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
unknown := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"bob","password":"whatever"}`, nil)
wrongPass := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"wrong"}`, nil)
if unknown.Code != http.StatusUnauthorized || wrongPass.Code != http.StatusUnauthorized {
t.Fatalf("both must be 401, got unknown=%d wrongPass=%d", unknown.Code, wrongPass.Code)
}
if unknown.Body.String() != wrongPass.Body.String() {
t.Errorf("responses must be identical (no username enumeration): unknown=%q wrongPass=%q", unknown.Body.String(), wrongPass.Body.String())
}
}
func TestSessionRequiresAuth(t *testing.T) {
fs := newFakeStore()
_, mux := newTestHandler(t, fs)
rec := doRequest(t, mux, http.MethodGet, "/auth/session", "", nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 with no session cookie", rec.Code)
}
}
func TestLoginThenSessionRoundTrip(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil)
cookie := sessionCookieFrom(login)
sess := doRequest(t, mux, http.MethodGet, "/auth/session", "", cookie)
if sess.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", sess.Code, sess.Body.String())
}
var resp sessionResponse
if err := json.Unmarshal(sess.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.Username != "alice" {
t.Errorf("username = %q, want alice", resp.Username)
}
}
func TestLogoutInvalidatesSession(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil)
cookie := sessionCookieFrom(login)
logout := doRequest(t, mux, http.MethodPost, "/auth/logout", "", cookie)
if logout.Code != http.StatusNoContent {
t.Fatalf("logout status = %d, want 204", logout.Code)
}
sess := doRequest(t, mux, http.MethodGet, "/auth/session", "", cookie)
if sess.Code != http.StatusUnauthorized {
t.Fatalf("status after logout = %d, want 401 (session must be revoked)", sess.Code)
}
}
func TestNonOwnerCannotManageUsers(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"hunter22"}`, nil)
cookie := sessionCookieFrom(login)
rec := doRequest(t, mux, http.MethodGet, "/auth/users", "", cookie)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 for a non-owner listing users", rec.Code)
}
}
func TestOwnerCanCreateAndDeleteUsers(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
cookie := sessionCookieFrom(login)
create := doRequest(t, mux, http.MethodPost, "/auth/users", `{"username":"bob","password":"bobspassword","role":"viewer"}`, cookie)
if create.Code != http.StatusCreated {
t.Fatalf("create status = %d, want 201; body=%s", create.Code, create.Body.String())
}
var created userResponse
if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil {
t.Fatalf("decoding response: %v", err)
}
if created.Role != "viewer" {
t.Errorf("role = %q, want viewer", created.Role)
}
if created.CreatedAt.IsZero() {
t.Errorf("created_at was not populated in the create response")
}
list := doRequest(t, mux, http.MethodGet, "/auth/users", "", cookie)
var users []userResponse
if err := json.Unmarshal(list.Body.Bytes(), &users); err != nil {
t.Fatalf("decoding response: %v", err)
}
if len(users) != 2 {
t.Fatalf("len(users) = %d, want 2 (admin + bob)", len(users))
}
del := doRequest(t, mux, http.MethodDelete, "/auth/users/"+created.ID, "", cookie)
if del.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, want 204", del.Code)
}
}
func TestCreateUserRejectsShortPassword(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
_, mux := newTestHandler(t, fs)
login := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
cookie := sessionCookieFrom(login)
rec := doRequest(t, mux, http.MethodPost, "/auth/users", `{"username":"bob","password":"short"}`, cookie)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for a too-short password", rec.Code)
}
}
func TestResetPasswordRevokesExistingSessions(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "admin", "adminpass1", authz.RoleOwner)
bob := mustCreateUser(t, fs, "bob", "bobspassword", authz.RoleViewer)
_, mux := newTestHandler(t, fs)
adminLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"admin","password":"adminpass1"}`, nil)
adminCookie := sessionCookieFrom(adminLogin)
bobLogin := doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"bob","password":"bobspassword"}`, nil)
bobCookie := sessionCookieFrom(bobLogin)
reset := doRequest(t, mux, http.MethodPost, "/auth/users/"+bob.ID+"/reset-password", "", adminCookie)
if reset.Code != http.StatusOK {
t.Fatalf("reset status = %d, want 200; body=%s", reset.Code, reset.Body.String())
}
var resp resetPasswordResponse
if err := json.Unmarshal(reset.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.Password == "" {
t.Fatalf("expected a generated password in the response when none was supplied")
}
stale := doRequest(t, mux, http.MethodGet, "/auth/session", "", bobCookie)
if stale.Code != http.StatusUnauthorized {
t.Fatalf("bob's pre-reset session status = %d, want 401 (reset must revoke existing sessions)", stale.Code)
}
}
+29
View File
@@ -0,0 +1,29 @@
package localauth
import "golang.org/x/crypto/bcrypt"
// dummyPasswordHash is a precomputed bcrypt hash of an arbitrary,
// never-used-as-a-real-password string -- handleLogin runs
// ComparePassword against this on the "no such user" path purely to pay
// the same bcrypt cost the "wrong password" path already pays, closing
// a response-time side channel that would otherwise let a caller
// distinguish the two despite their identical error message. There is
// no real password behind this hash; it exists only to burn comparable
// CPU time.
const dummyPasswordHash = "$2a$10$fH9R3O6ViQ6c7bq0N7yyBO1JP2TOw/bZEopMyZKBYrBjgYBZO9rCa"
// HashPassword and ComparePassword are the only two places this package
// touches a raw password -- everywhere else, a user is identified by an
// already-issued session token (see token.go), never by re-checking a
// password on every request.
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func ComparePassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
+91
View File
@@ -0,0 +1,91 @@
package localauth
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
// loginLimiter is a simple in-memory sliding-window rate limiter for
// POST /auth/login, keyed by client IP -- closes a real gap the
// security audit found: nothing in the application, nginx, or the host
// (no fail2ban either) throttled repeated login attempts, making
// sustained online brute-forcing of a weaker, human-chosen password
// possible. (The auto-generated admin password is high-entropy, but
// every user created afterward only needs 8 characters with no
// complexity check -- see handleCreateUser.)
//
// Per-IP rather than per-username: a per-username-only limiter is
// itself a denial-of-service vector (deliberately fail a real
// username's login repeatedly, from anywhere, to lock them out), and
// wouldn't bound an attacker guessing across many usernames from one
// source. Both successful and failed attempts count against the
// window, not just failures -- simpler, and it means a low-and-slow
// guesser can't reset their budget by occasionally succeeding against
// an unrelated account.
//
// Deliberately in-memory, not Postgres-backed: login rate limiting is
// inherently best-effort per-process state (a restart clearing it is
// fine, unlike a session or password), and adding a database
// round-trip to every login attempt is the wrong tradeoff for a check
// whose only job is bounding attempt *rate*. Memory for IPs that stop
// attempting entirely is only reclaimed the next time that exact key is
// looked up -- a deliberate, bounded-in-practice simplicity tradeoff
// (real attacker/user IP cardinality against one deployment is small
// relative to a process's lifetime between deploys), not an oversight.
type loginLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
max int
window time.Duration
}
func newLoginLimiter(max int, window time.Duration) *loginLimiter {
return &loginLimiter{attempts: map[string][]time.Time{}, max: max, window: window}
}
// allow reports whether key may attempt another login right now, and
// records this attempt if so (a denied call does not itself count as a
// new attempt -- it just reports the existing window is full).
func (l *loginLimiter) allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
cutoff := now.Add(-l.window)
var kept []time.Time
for _, t := range l.attempts[key] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) >= l.max {
l.attempts[key] = kept
return false
}
l.attempts[key] = append(kept, now)
return true
}
// clientIP extracts the caller's address for rate-limiting purposes.
// Trusts the first hop of X-Forwarded-For when present -- correct for
// this deployment's actual topology (always behind nginx, which sets
// it), but note this is spoofable by any caller that reaches the
// application directly rather than through the trusted proxy; a
// deployment that exposes api's port directly to untrusted clients
// should not rely on this header. Falls back to r.RemoteAddr, which is
// always accurate for whoever the TCP connection is actually with.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if first, _, ok := strings.Cut(xff, ","); ok {
return strings.TrimSpace(first)
}
return strings.TrimSpace(xff)
}
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
+84
View File
@@ -0,0 +1,84 @@
package localauth
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/sentry/sentry/api/authz"
)
func TestLoginLimiterAllowsUpToMax(t *testing.T) {
l := newLoginLimiter(3, time.Minute)
for i := 0; i < 3; i++ {
if !l.allow("1.2.3.4") {
t.Fatalf("attempt %d: want allowed", i+1)
}
}
if l.allow("1.2.3.4") {
t.Fatal("4th attempt within the window: want denied")
}
}
func TestLoginLimiterIsPerKey(t *testing.T) {
l := newLoginLimiter(1, time.Minute)
if !l.allow("1.2.3.4") {
t.Fatal("first attempt from 1.2.3.4: want allowed")
}
if !l.allow("5.6.7.8") {
t.Fatal("a different IP must have its own budget")
}
if l.allow("1.2.3.4") {
t.Fatal("second attempt from 1.2.3.4: want denied")
}
}
func TestLoginLimiterResetsAfterWindow(t *testing.T) {
l := newLoginLimiter(1, 10*time.Millisecond)
if !l.allow("1.2.3.4") {
t.Fatal("first attempt: want allowed")
}
if l.allow("1.2.3.4") {
t.Fatal("second attempt within the window: want denied")
}
time.Sleep(20 * time.Millisecond)
if !l.allow("1.2.3.4") {
t.Fatal("attempt after the window elapsed: want allowed")
}
}
func TestClientIPPrefersForwardedFor(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/auth/login", nil)
r.RemoteAddr = "10.0.0.1:5555"
r.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
if got := clientIP(r); got != "203.0.113.9" {
t.Errorf("clientIP() = %q, want %q", got, "203.0.113.9")
}
}
func TestClientIPFallsBackToRemoteAddr(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/auth/login", nil)
r.RemoteAddr = "198.51.100.7:5555"
if got := clientIP(r); got != "198.51.100.7" {
t.Errorf("clientIP() = %q, want %q", got, "198.51.100.7")
}
}
// TestHandleLoginRateLimited is the regression test for the
// security-audit finding that POST /auth/login had no rate limiting at
// all -- repeated attempts from the same client must eventually get a
// 429, not another 401.
func TestHandleLoginRateLimited(t *testing.T) {
fs := newFakeStore()
mustCreateUser(t, fs, "alice", "hunter22", authz.RoleEditor)
_, mux := newTestHandler(t, fs)
var last *httptest.ResponseRecorder
for i := 0; i < loginRateLimitMax+1; i++ {
last = doRequest(t, mux, http.MethodPost, "/auth/login", `{"username":"alice","password":"wrong-password"}`, nil)
}
if last.Code != http.StatusTooManyRequests {
t.Fatalf("status after exceeding the limit = %d, want 429", last.Code)
}
}
+302
View File
@@ -0,0 +1,302 @@
// Package localauth is single-tenant mode's local username/password
// login: a real login page and session-based auth covering both /api
// and /alerting, plus a simple admin-managed user list, for deployments
// reachable over the internet that can no longer rely on Phase 0-3's
// "no auth yet" default (see /docs/architecture.md and CLAUDE.md's
// Phase 4 section for the enterprise/ SSO alternative this is not --
// this package has no tenant/RBAC-service concept, just "is this a
// valid logged-in user").
//
// Deliberately extends the existing users/tenants/tenant_memberships
// schema (0017/0018/0020_*.sql, built for Phase 4 SSO) rather than a
// parallel local_users table: tenant_memberships.role is already
// constrained to exactly authz.Role's four human values, so a local
// login gets real 4-tier roles for free, and a deployment that later
// turns on enterprise SSO has one identity graph to reconcile, not two.
// Every local user is a member of the "default" tenant only -- this
// package has no notion of provisioning additional tenants.
//
// Authorizer (authorizer.go) is what api/cmd/api/main.go wires into
// api/authz's Authorizer slot for a single-tenant deployment that wants
// real auth -- once that's non-nil, every existing RequireRole-wrapped
// route in dashboards/agents/queryapi/aiapi starts enforcing roles for
// free, no other handler file needs to change. alerting has no such
// per-route plumbing at all, so it gets its own, much smaller,
// deliberately-duplicated package (alerting/internal/sessioncheck) that
// only ever validates an already-issued session -- see that package's
// doc comment for why this isn't imported from here instead.
package localauth
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/sentry/sentry/api/authz"
)
var (
ErrNotFound = errors.New("not found")
ErrUsernameTaken = errors.New("username already taken")
)
// defaultTenantID is the only tenant a local user can ever belong to --
// see the package doc comment. Matches every other single-tenant
// deployment's "default" tenant_id convention (dashboards, agents,
// alert_rules).
const defaultTenantID = "default"
type User struct {
ID string
Username string
Role authz.Role
CreatedAt time.Time
}
type Session struct {
UserID string
TenantID string
Role authz.Role
ExpiresAt time.Time
}
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// CreateUser inserts a new local user and, in the same transaction, the
// tenant_memberships row that gives them role in the default tenant --
// a local user with no membership row would authenticate successfully
// (CreateSession has nothing that requires one) but satisfy no
// RequireRole check at all, so the two rows are never created
// separately.
func (s *Store) CreateUser(ctx context.Context, username, passwordHash string, role authz.Role) (*User, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
id := uuid.NewString()
var createdAt time.Time
err = tx.QueryRow(ctx, `
INSERT INTO users (id, username, password_hash, display_name, created_at, updated_at)
VALUES ($1, $2, $3, $2, now(), now())
RETURNING created_at`,
id, username, passwordHash).Scan(&createdAt)
if err != nil {
if isUniqueViolation(err) {
return nil, ErrUsernameTaken
}
return nil, err
}
if _, err := tx.Exec(ctx, `
INSERT INTO tenant_memberships (id, tenant_id, user_id, role)
VALUES ($1, $2, $3, $4)`,
uuid.NewString(), defaultTenantID, id, string(role)); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return &User{ID: id, Username: username, Role: role, CreatedAt: createdAt}, nil
}
const listColumns = `
u.id, u.username, tm.role, u.created_at`
// ListUsers only ever returns local users (username IS NOT NULL) --
// an SSO-provisioned user with no password_hash/username set never
// appears here, since there's nothing for this package's user manager
// to do with one.
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
rows, err := s.pool.Query(ctx, `
SELECT `+listColumns+`
FROM users u
JOIN tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = $1
WHERE u.username IS NOT NULL
ORDER BY u.username`, defaultTenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
var role string
if err := rows.Scan(&u.ID, &u.Username, &role, &u.CreatedAt); err != nil {
return nil, err
}
u.Role = authz.Role(role)
out = append(out, u)
}
return out, rows.Err()
}
// GetUserForLogin returns the user and their password hash together --
// the only place this package ever reads a password_hash back out, and
// only to feed ComparePassword. Everywhere else uses User, which never
// carries the hash.
func (s *Store) GetUserForLogin(ctx context.Context, username string) (*User, string, error) {
var u User
var role, hash string
err := s.pool.QueryRow(ctx, `
SELECT u.id, u.username, u.password_hash, tm.role, u.created_at
FROM users u
JOIN tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = $1
WHERE u.username = $2`, defaultTenantID, username).
Scan(&u.ID, &u.Username, &hash, &role, &u.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", ErrNotFound
}
return nil, "", err
}
u.Role = authz.Role(role)
return &u, hash, nil
}
// GetUserByID backs GET /auth/session -- looking up the identity
// RequireRole already resolved and attached to the request context, to
// return its username (Session/Identity carry no username, only IDs).
func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
var u User
var role string
err := s.pool.QueryRow(ctx, `
SELECT u.id, u.username, tm.role, u.created_at
FROM users u
JOIN tenant_memberships tm ON tm.user_id = u.id AND tm.tenant_id = $1
WHERE u.id = $2 AND u.username IS NOT NULL`, defaultTenantID, id).
Scan(&u.ID, &u.Username, &role, &u.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
u.Role = authz.Role(role)
return &u, nil
}
// DeleteUser cascades to the user's tenant_memberships and
// local_sessions rows (both ON DELETE CASCADE) -- a deleted user's
// existing sessions stop validating immediately, not just their next
// login.
func (s *Store) DeleteUser(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM users WHERE id = $1 AND username IS NOT NULL`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// SetPasswordHash also revokes every existing session for userID, in
// the same transaction -- Session.Role/TenantID are a snapshot taken at
// login (see 0041_create_local_sessions.sql's doc comment), so without
// this an account whose password was just reset for security reasons
// would keep any already-issued session working regardless.
func (s *Store) SetPasswordHash(ctx context.Context, userID, hash string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
tag, err := tx.Exec(ctx, `UPDATE users SET password_hash = $1, updated_at = now() WHERE id = $2 AND username IS NOT NULL`, hash, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
if _, err := tx.Exec(ctx, `DELETE FROM local_sessions WHERE user_id = $1`, userID); err != nil {
return err
}
return tx.Commit(ctx)
}
// CountLocalUsers backs -seed-admin's idempotency check (see
// cmd/api/main.go's runSeedAdmin): a deployment that already has at
// least one local user never gets a second auto-created admin account.
func (s *Store) CountLocalUsers(ctx context.Context) (int, error) {
var n int
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE username IS NOT NULL`).Scan(&n)
return n, err
}
// CreateSession mints a fresh opaque token for an already-authenticated
// user (login has already verified their password by the time this is
// called) and stores its hash plus a role/tenant snapshot. Returns the
// raw token -- the only time it's ever available in plaintext again
// after this call.
func (s *Store) CreateSession(ctx context.Context, userID, tenantID string, role authz.Role, ttl time.Duration) (string, error) {
raw, hash, err := newOpaqueToken()
if err != nil {
return "", err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO local_sessions (id, user_id, tenant_id, role, token_hash, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
uuid.NewString(), userID, tenantID, string(role), hash, time.Now().Add(ttl))
if err != nil {
return "", err
}
return raw, nil
}
// GetSession looks up an already-hashed lookup key rather than a raw
// token -- see authorizer.go, the only caller, which re-derives the
// hash from whatever the request presented before calling this.
// Deliberately does not delete an expired row itself (that's a plain
// SELECT with no side effect); the goal here is a fast, obviously-
// correct read path, not a lookup that also mutates state, so
// expired-session cleanup is a separate, simpler concern.
func (s *Store) GetSession(ctx context.Context, tokenHash string) (*Session, error) {
var sess Session
var role string
err := s.pool.QueryRow(ctx, `
SELECT user_id, tenant_id, role, expires_at
FROM local_sessions WHERE token_hash = $1`, tokenHash).
Scan(&sess.UserID, &sess.TenantID, &role, &sess.ExpiresAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
sess.Role = authz.Role(role)
if sess.ExpiresAt.Before(time.Now()) {
return nil, ErrNotFound
}
return &sess, nil
}
// DeleteSessionByHash backs logout -- a no-op (not an error) if the
// session is already gone, matching logout's own "always succeeds"
// posture (handler.go's handleLogout).
func (s *Store) DeleteSessionByHash(ctx context.Context, tokenHash string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM local_sessions WHERE token_hash = $1`, tokenHash)
return err
}
// isUniqueViolation checks for Postgres error code 23505 (unique_violation),
// same pgconn.PgError.Code pattern rbacstore.go's SetDataSourceCredentials
// already uses for 22P02.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
+157
View File
@@ -0,0 +1,157 @@
// Exercises the actual parameterized SQL in store.go against a real
// Postgres -- handler_test.go's fakeStore is hand-written to mimic this
// SQL's behavior, but can't catch a real gap like a typo in a WHERE
// clause, a wrong column name, or (the specific thing worth testing
// here) whether 0040/0041's schema/FK/CHECK constraints actually hold
// the shape this package assumes. Same "skip unless a live-Postgres env
// var is set" convention as api/dashboards/store_integration_test.go.
//
// Skipped unless LOCALAUTH_TEST_POSTGRES_ADDR is set; run via:
//
// docker run --rm --network sentry_default -v $(pwd)/../../..:/src -w /src/api \
// -e LOCALAUTH_TEST_POSTGRES_ADDR=metadata-postgres:5432 \
// -e LOCALAUTH_TEST_POSTGRES_PASSWORD=sentry-dev-only \
// golang:1.25-alpine go test ./localauth/... -run Integration -v
package localauth
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/sentry/sentry/api/authz"
)
func integrationStore(t *testing.T) *Store {
t.Helper()
addr := os.Getenv("LOCALAUTH_TEST_POSTGRES_ADDR")
if addr == "" {
t.Skip("LOCALAUTH_TEST_POSTGRES_ADDR not set -- skipping live-Postgres integration test")
}
password := os.Getenv("LOCALAUTH_TEST_POSTGRES_PASSWORD")
dsn := fmt.Sprintf("postgres://sentry:%s@%s/sentry_metadata", password, addr)
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("opening pool: %v", err)
}
t.Cleanup(pool.Close)
return NewStore(pool)
}
func testUsername(t *testing.T) string {
t.Helper()
return "test-" + uuid.NewString()[:8]
}
func TestIntegrationCreateAndLoginUser(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatalf("hashing password: %v", err)
}
created, err := store.CreateUser(ctx, username, hash, authz.RoleEditor)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, created.ID) })
got, gotHash, err := store.GetUserForLogin(ctx, username)
if err != nil {
t.Fatalf("GetUserForLogin: %v", err)
}
if got.ID != created.ID || got.Role != authz.RoleEditor {
t.Errorf("GetUserForLogin = %+v, want id=%s role=editor", got, created.ID)
}
if !ComparePassword(gotHash, "correct horse battery staple") {
t.Errorf("stored hash does not verify against the original password")
}
}
func TestIntegrationDuplicateUsernameRejected(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, _ := HashPassword("password1")
created, err := store.CreateUser(ctx, username, hash, authz.RoleViewer)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, created.ID) })
if _, err := store.CreateUser(ctx, username, hash, authz.RoleViewer); !errors.Is(err, ErrUsernameTaken) {
t.Fatalf("second CreateUser with the same username: err = %v, want ErrUsernameTaken", err)
}
}
func TestIntegrationSessionRoundTripAndExpiry(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, _ := HashPassword("password1")
user, err := store.CreateUser(ctx, username, hash, authz.RoleAdmin)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, user.ID) })
raw, err := store.CreateSession(ctx, user.ID, "default", authz.RoleAdmin, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
sess, err := store.GetSession(ctx, hashToken(raw))
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if sess.UserID != user.ID || sess.Role != authz.RoleAdmin {
t.Errorf("GetSession = %+v, want user_id=%s role=admin", sess, user.ID)
}
// An already-expired session (negative TTL) must not validate --
// exercises the real expires_at comparison against Postgres's own
// now(), not just Go's clock.
expiredRaw, err := store.CreateSession(ctx, user.ID, "default", authz.RoleAdmin, -time.Hour)
if err != nil {
t.Fatalf("CreateSession (expired): %v", err)
}
if _, err := store.GetSession(ctx, hashToken(expiredRaw)); !errors.Is(err, ErrNotFound) {
t.Errorf("GetSession on an expired session: err = %v, want ErrNotFound", err)
}
}
func TestIntegrationSetPasswordHashRevokesSessions(t *testing.T) {
store := integrationStore(t)
ctx := context.Background()
username := testUsername(t)
hash, _ := HashPassword("password1")
user, err := store.CreateUser(ctx, username, hash, authz.RoleViewer)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _ = store.DeleteUser(ctx, user.ID) })
raw, err := store.CreateSession(ctx, user.ID, "default", authz.RoleViewer, time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
newHash, _ := HashPassword("a-new-password")
if err := store.SetPasswordHash(ctx, user.ID, newHash); err != nil {
t.Fatalf("SetPasswordHash: %v", err)
}
if _, err := store.GetSession(ctx, hashToken(raw)); !errors.Is(err, ErrNotFound) {
t.Errorf("GetSession after password reset: err = %v, want ErrNotFound (reset must revoke existing sessions)", err)
}
}
+37
View File
@@ -0,0 +1,37 @@
package localauth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
)
// newOpaqueToken returns a fresh session credential: raw is what's set
// in the cookie/returned to the caller (base64url, URL/cookie-safe),
// hash is what's stored in local_sessions.token_hash. Only the hash is
// ever persisted -- same reasoning 0034_create_ingest_credentials.sql
// gives for hashing its own bearer tokens: the server only ever needs
// to check "does the presented value match," never recover the raw
// value.
func newOpaqueToken() (raw, hash string, err error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", "", err
}
raw = base64.RawURLEncoding.EncodeToString(buf)
return raw, hashToken(raw), nil
}
// hashToken re-derives a token's hash from a raw value a caller
// presents (Authorization header or cookie), for lookup against
// local_sessions.token_hash. Plain SHA-256, not bcrypt: unlike a
// password, a session token is already high-entropy random data, not
// something an attacker could feasibly brute-force offline even from a
// leaked hash, so there's no need for bcrypt's deliberate slowness here
// -- alerting/internal/sessioncheck validates sessions on every request
// and does the same plain hash, with no bcrypt dependency at all.
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
+205
View File
@@ -0,0 +1,205 @@
// Command surface for api/localauth -- single-tenant mode's local
// username/password login and user manager (see /docs -- deployment
// runbook, and api/localauth's package doc comment for the full
// feature). Same list/create/delete shape as agents/dashboards, plus a
// "login" subcommand: unlike every other resource this CLI manages,
// there's no way to get a first SENTRYCTL_TOKEN without one.
package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
)
func cmdUsers(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "sentryctl users: expected a subcommand (login, list, create, delete, reset-password)")
return 1
}
apiURL, rest := extractAPIFlag(args[1:], os.Getenv)
token := resolveToken(os.Getenv)
switch args[0] {
case "login":
if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl users login: missing username")
return 1
}
return cmdUsersLogin(rest[0], rest[1:], apiURL, stdin, stdout, stderr)
case "list":
return httpGetJSON(apiURL, "/auth/users", token, stdout, stderr)
case "create":
if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl users create: missing username")
return 1
}
return cmdUsersCreate(rest[0], rest[1:], apiURL, token, stdin, stdout, stderr)
case "delete":
if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl users delete: missing user id")
return 1
}
return httpMutateNoBody(http.MethodDelete, apiURL, "/auth/users/"+rest[0], token, "", "user deleted", stdout, stderr)
case "reset-password":
if len(rest) == 0 {
fmt.Fprintln(stderr, "sentryctl users reset-password: missing user id")
return 1
}
return cmdUsersResetPassword(rest[0], rest[1:], apiURL, token, stdin, stdout, stderr)
default:
fmt.Fprintf(stderr, "sentryctl users: unknown subcommand %q (want login, list, create, delete, reset-password)\n", args[0])
return 1
}
}
// extractPasswordStdinFlag pulls the boolean --password-stdin flag out
// of args if present -- same "walk args, splice out the one flag this
// caller cares about" shape extractAPIFlag already uses at the
// top-level dispatch layer. Unlike the --password <value> flag this
// replaced (security-audit finding L-4), this flag never carries the
// secret itself -- only readPasswordFromStdin's caller decides to
// actually read one, same "docker login --password-stdin" convention,
// chosen over inventing a new one: a plaintext password passed as a CLI
// argument is visible to any other local user via `ps`/
// `/proc/<pid>/cmdline` and typically lands in shell history too.
func extractPasswordStdinFlag(args []string) (useStdin bool, rest []string) {
for _, a := range args {
if a == "--password-stdin" {
useStdin = true
continue
}
rest = append(rest, a)
}
return useStdin, rest
}
// readPasswordFromStdin reads a single line from stdin. Not masked
// (this codebase has no terminal/raw-mode dependency to draw on -- see
// resolveToken's doc comment for the same tradeoff already accepted for
// SENTRYCTL_TOKEN); pipe the value in (`echo "$PW" | sentryctl users
// login admin`) rather than typing it at an interactive terminal where
// that matters.
func readPasswordFromStdin(stdin io.Reader) (string, error) {
line, err := bufio.NewReader(stdin).ReadString('\n')
if err != nil && line == "" {
return "", err
}
return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"), nil
}
type loginRequestBody struct {
Username string `json:"username"`
Password string `json:"password"`
}
type loginResponseBody struct {
Token string `json:"token"`
Error string `json:"error"`
}
// cmdUsersLogin prints only the raw token to stdout on success (nothing
// else) -- deliberately pipeable: `export SENTRYCTL_TOKEN=$(sentryctl
// users login admin)`.
func cmdUsersLogin(username string, _ []string, apiURL string, stdin io.Reader, stdout, stderr io.Writer) int {
password, err := readPasswordFromStdin(stdin)
if err != nil {
fmt.Fprintf(stderr, "reading password: %v\n", err)
return 1
}
body, err := json.Marshal(loginRequestBody{Username: username, Password: password})
if err != nil {
fmt.Fprintf(stderr, "encoding request: %v\n", err)
return 1
}
req, err := http.NewRequest(http.MethodPost, apiURL+"/auth/login", strings.NewReader(string(body)))
if err != nil {
fmt.Fprintf(stderr, "building request: %v\n", err)
return 1
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
fmt.Fprintf(stderr, "request failed: %v\n", err)
return 1
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Fprintf(stderr, "reading response: %v\n", err)
return 1
}
var login loginResponseBody
_ = json.Unmarshal(respBody, &login)
if resp.StatusCode != http.StatusOK {
if login.Error != "" {
fmt.Fprintf(stderr, "login failed: %s\n", login.Error)
} else {
fmt.Fprintf(stderr, "login failed: status %d\n", resp.StatusCode)
}
return 1
}
fmt.Fprintln(stdout, login.Token)
return 0
}
func cmdUsersCreate(username string, flagArgs []string, apiURL, token string, stdin io.Reader, stdout, stderr io.Writer) int {
role := "editor"
for i := 0; i < len(flagArgs); i++ {
if flagArgs[i] == "--role" && i+1 < len(flagArgs) {
role = flagArgs[i+1]
i++
continue
}
}
password, err := readPasswordFromStdin(stdin)
if err != nil {
fmt.Fprintf(stderr, "reading password: %v\n", err)
return 1
}
body, err := json.Marshal(struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}{Username: username, Password: password, Role: role})
if err != nil {
fmt.Fprintf(stderr, "encoding request: %v\n", err)
return 1
}
return httpPostJSON(apiURL, "/auth/users", token, string(body), stdout, stderr)
}
// cmdUsersResetPassword defaults to requesting a server-generated
// random password (empty body -- see api/localauth's handleResetPassword
// doc comment): pass --password-stdin to instead set a specific password
// read from stdin. There is deliberately no --password <value> flag (see
// extractPasswordStdinFlag's doc comment) -- a specific password chosen
// this way must be piped in, never typed as a bare CLI argument.
func cmdUsersResetPassword(id string, flagArgs []string, apiURL, token string, stdin io.Reader, stdout, stderr io.Writer) int {
useStdin, _ := extractPasswordStdinFlag(flagArgs)
body := "{}"
if useStdin {
password, err := readPasswordFromStdin(stdin)
if err != nil {
fmt.Fprintf(stderr, "reading password: %v\n", err)
return 1
}
encoded, err := json.Marshal(struct {
Password string `json:"password"`
}{Password: password})
if err != nil {
fmt.Fprintf(stderr, "encoding request: %v\n", err)
return 1
}
body = string(encoded)
}
return httpPostJSON(apiURL, "/auth/users/"+id+"/reset-password", token, body, stdout, stderr)
}
+190
View File
@@ -0,0 +1,190 @@
package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCmdUsersMissingSubcommand(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdUsers(nil, strings.NewReader(""), &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdUsersLoginPrintsOnlyTheToken(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/auth/login" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var body loginRequestBody
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Username != "admin" || body.Password != "s3cret!!" {
t.Errorf("unexpected credentials: %+v", body)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"token":"abc123","user_id":"u1","username":"admin","role":"owner"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"login", "admin", "--api", srv.URL}, strings.NewReader("s3cret!!\n"), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
if got := strings.TrimSpace(stdout.String()); got != "abc123" {
t.Fatalf("stdout = %q, want exactly the raw token (pipeable into SENTRYCTL_TOKEN)", got)
}
}
func TestCmdUsersLoginFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"invalid username or password"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"login", "admin", "--api", srv.URL}, strings.NewReader("wrong\n"), &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
if !strings.Contains(stderr.String(), "invalid username or password") {
t.Fatalf("stderr = %q, want it to surface the server's error message", stderr.String())
}
if stdout.String() != "" {
t.Fatalf("stdout = %q, want empty on failure (nothing pipeable into SENTRYCTL_TOKEN)", stdout.String())
}
}
func TestCmdUsersCreateSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/auth/users" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Role != "viewer" {
t.Errorf("role = %q, want viewer (from --role)", body.Role)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id":"u2","username":"bob","role":"viewer"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"create", "bob", "--role", "viewer", "--api", srv.URL}, strings.NewReader("bobspassword\n"), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "bob") {
t.Fatalf("stdout = %q, want it to contain the created user", stdout.String())
}
}
func TestCmdUsersCreateDefaultsRoleToEditor(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Role string `json:"role"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Role != "editor" {
t.Errorf("role = %q, want editor (the default when --role is omitted)", body.Role)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id":"u2","username":"bob","role":"editor"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"create", "bob", "--api", srv.URL}, strings.NewReader("bobspassword\n"), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
}
func TestCmdUsersDeleteMissingID(t *testing.T) {
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"delete"}, strings.NewReader(""), &stdout, &stderr)
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
}
func TestCmdUsersDeleteSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete || r.URL.Path != "/auth/users/u2" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"delete", "u2", "--api", srv.URL}, strings.NewReader(""), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
}
func TestCmdUsersResetPasswordWithGeneratedPassword(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/auth/users/u2/reset-password" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
if strings.TrimSpace(string(body)) != "{}" {
t.Errorf("body = %q, want {} (no --password-stdin supplied)", body)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"password":"generated-abc"}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"reset-password", "u2", "--api", srv.URL}, strings.NewReader(""), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "generated-abc") {
t.Fatalf("stdout = %q, want it to contain the generated password", stdout.String())
}
}
// TestCmdUsersResetPasswordWithStdinPassword is the regression test for
// the security-audit finding that this CLI accepted a plaintext
// --password <value> flag (visible via `ps`/shell history). Setting a
// specific password must go through --password-stdin plus piped input
// instead, never a bare argument.
func TestCmdUsersResetPasswordWithStdinPassword(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Password string `json:"password"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Password != "a-specific-password" {
t.Errorf("password = %q, want the value piped via stdin", body.Password)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{}`))
}))
defer srv.Close()
var stdout, stderr bytes.Buffer
code := cmdUsers([]string{"reset-password", "u2", "--password-stdin", "--api", srv.URL}, strings.NewReader("a-specific-password\n"), &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, want 0; stderr=%s", code, stderr.String())
}
}
+12 -1
View File
@@ -115,7 +115,18 @@ func httpMutateNoBody(method, baseURL, path, token, body, successMsg string, std
// for callers that construct the body themselves rather than reading it // for callers that construct the body themselves rather than reading it
// from a file (agents config set, agents restart). // from a file (agents config set, agents restart).
func httpPutJSON(baseURL, path, token, body string, stdout, stderr io.Writer) int { func httpPutJSON(baseURL, path, token, body string, stdout, stderr io.Writer) int {
req, err := http.NewRequest(http.MethodPut, baseURL+path, strings.NewReader(body)) return httpSendJSON(http.MethodPut, baseURL, path, token, body, stdout, stderr)
}
// httpPostJSON is httpPutJSON's POST sibling -- for callers creating a
// resource from a body they built themselves rather than reading it
// from a file (users create, users reset-password).
func httpPostJSON(baseURL, path, token, body string, stdout, stderr io.Writer) int {
return httpSendJSON(http.MethodPost, baseURL, path, token, body, stdout, stderr)
}
func httpSendJSON(method, baseURL, path, token, body string, stdout, stderr io.Writer) int {
req, err := http.NewRequest(method, baseURL+path, strings.NewReader(body))
if err != nil { if err != nil {
fmt.Fprintf(stderr, "building request: %v\n", err) fmt.Fprintf(stderr, "building request: %v\n", err)
return 1 return 1
+18
View File
@@ -40,6 +40,8 @@ func run(args []string, stdout, stderr io.Writer) int {
return cmdAlerts(args[1:], stdout, stderr) return cmdAlerts(args[1:], stdout, stderr)
case "agents": case "agents":
return cmdAgents(args[1:], stdout, stderr) return cmdAgents(args[1:], stdout, stderr)
case "users":
return cmdUsers(args[1:], os.Stdin, stdout, stderr)
case "-h", "--help", "help": case "-h", "--help", "help":
usage(stdout) usage(stdout)
return 0 return 0
@@ -67,6 +69,11 @@ Usage:
[--heartbeat-enabled true|false] [--heartbeat-interval-ms N] [--heartbeat-enabled true|false] [--heartbeat-interval-ms N]
[--journald-unit UNIT] [--api <url>] [--journald-unit UNIT] [--api <url>]
sentryctl agents restart <host> [--yes] [--api <url>] sentryctl agents restart <host> [--yes] [--api <url>]
sentryctl users login <username> [--password <pw>] [--api <url>]
sentryctl users list [--api <url>]
sentryctl users create <username> [--password <pw>] [--role viewer|editor|admin|owner] [--api <url>]
sentryctl users delete <id> [--api <url>]
sentryctl users reset-password <id> [--password <pw>] [--api <url>]
Commands: Commands:
ping Checks that the api service is reachable via GET /healthz. ping Checks that the api service is reachable via GET /healthz.
@@ -95,6 +102,17 @@ Commands:
as the web UI's edit form. "restart" briefly interrupts as the web UI's edit form. "restart" briefly interrupts
log collection on that host and prompts for confirmation log collection on that host and prompts for confirmation
unless --yes is given. unless --yes is given.
users Local username/password login and user management (see
api/localauth -- only meaningful on a deployment with
LOCAL_AUTH_ENABLED set; a 404 on any of these means it
isn't). "login" is the only command that works with no
$SENTRYCTL_TOKEN set yet -- it prints just the raw token
to stdout: `+"`export SENTRYCTL_TOKEN=$(sentryctl users login admin)`"+`.
--password (on any users subcommand) is read from stdin
if omitted -- same shell-history/ps caveat as typing a
credential in any flag, prefer piping it in.
"create"/"list"/"delete"/"reset-password" require an
owner-role token (see RegisterRoutes in api/localauth).
--api defaults to $SENTRYCTL_API_URL, or `+defaultAPIURL+` if unset. --api defaults to $SENTRYCTL_API_URL, or `+defaultAPIURL+` if unset.
--alerting-api defaults to $SENTRYCTL_ALERTING_API_URL, or `+defaultAlertingURL+` if unset. --alerting-api defaults to $SENTRYCTL_ALERTING_API_URL, or `+defaultAlertingURL+` if unset.
+4 -4
View File
@@ -45,11 +45,11 @@ require (
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect go.uber.org/zap v1.26.0 // indirect
golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
golang.org/x/net v0.26.0 // indirect golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sys v0.21.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.21.0 // indirect golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.16.0 // indirect golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.3.0 // indirect golang.org/x/time v0.3.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.34.2 // indirect
+10 -10
View File
@@ -121,8 +121,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -131,22 +131,22 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+3
View File
@@ -73,6 +73,9 @@ func main() {
logger.Error("loading config", "error", err) logger.Error("loading config", "error", err)
os.Exit(1) os.Exit(1)
} }
for _, w := range cfg.DevCredentialWarnings() {
logger.Warn(w)
}
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" { if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
os.Exit(runHealthcheck(cfg.HTTPListenAddr)) os.Exit(runHealthcheck(cfg.HTTPListenAddr))
+3
View File
@@ -56,6 +56,9 @@ func main() {
logger.Error("loading config", "error", err) logger.Error("loading config", "error", err)
os.Exit(1) os.Exit(1)
} }
for _, w := range cfg.DevCredentialWarnings() {
logger.Warn(w)
}
// -mint-service-token issues a RoleService credential and prints it // -mint-service-token issues a RoleService credential and prints it
// to stdout, then exits -- an operator bootstrap step (run once, // to stdout, then exits -- an operator bootstrap step (run once,
+3
View File
@@ -59,6 +59,9 @@ func main() {
logger.Error("loading config", "error", err) logger.Error("loading config", "error", err)
os.Exit(1) os.Exit(1)
} }
for _, w := range cfg.DevCredentialWarnings() {
logger.Warn(w)
}
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" { if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
os.Exit(runHealthcheck(cfg.HTTPListenAddr)) os.Exit(runHealthcheck(cfg.HTTPListenAddr))
+6 -6
View File
@@ -36,6 +36,7 @@ require (
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0
github.com/sentry/sentry/proto v0.0.0-00010101000000-000000000000 github.com/sentry/sentry/proto v0.0.0-00010101000000-000000000000
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.22.0
google.golang.org/grpc v1.83.0 google.golang.org/grpc v1.83.0
k8s.io/api v0.31.0 k8s.io/api v0.31.0
k8s.io/apimachinery v0.31.0 k8s.io/apimachinery v0.31.0
@@ -45,7 +46,7 @@ require (
require ( require (
github.com/ClickHouse/ch-go v0.74.0 // indirect github.com/ClickHouse/ch-go v0.74.0 // indirect
github.com/andybalholm/brotli v1.2.2 // indirect github.com/andybalholm/brotli v1.2.2 // indirect
github.com/beevik/etree v1.5.0 // indirect github.com/beevik/etree v1.6.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect
@@ -66,7 +67,7 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jonboulle/clockwork v0.2.2 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.19.1 // indirect github.com/klauspost/compress v1.19.1 // indirect
@@ -78,7 +79,7 @@ require (
github.com/paulmach/orb v0.13.0 // indirect github.com/paulmach/orb v0.13.0 // indirect
github.com/pierrec/lz4/v4 v4.1.27 // indirect github.com/pierrec/lz4/v4 v4.1.27 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/russellhaering/goxmldsig v1.4.0 // indirect github.com/russellhaering/goxmldsig v1.6.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/kafka-go v0.4.51 // indirect github.com/segmentio/kafka-go v0.4.51 // indirect
github.com/shopspring/decimal v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect
@@ -86,12 +87,11 @@ require (
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/crypto v0.54.0 // indirect golang.org/x/crypto v0.55.0 // indirect
golang.org/x/net v0.57.0 // indirect golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.40.0 // indirect golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.3.0 // indirect golang.org/x/time v0.3.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/protobuf v1.36.12 // indirect google.golang.org/protobuf v1.36.12 // indirect
+12 -21
View File
@@ -4,9 +4,8 @@ github.com/ClickHouse/clickhouse-go/v2 v2.48.0 h1:auzd4VkapQYhQF8F2Gog7s3x78Bi1J
github.com/ClickHouse/clickhouse-go/v2 v2.48.0/go.mod h1:lBjUCPRG6RpRQdMbkXq+JV8rY0/O5lw+Z7jShgReFjM= github.com/ClickHouse/clickhouse-go/v2 v2.48.0/go.mod h1:lBjUCPRG6RpRQdMbkXq+JV8rY0/O5lw+Z7jShgReFjM=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE=
github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs= github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc=
github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
@@ -69,8 +68,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -79,9 +78,7 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -107,18 +104,15 @@ github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw=
github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k= github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k=
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys= github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks=
github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/kafka-go v0.4.51 h1:JgDPPG75tC1rWIS2Me6MwcvXJ6f49UQ4HjAOef71Hno= github.com/segmentio/kafka-go v0.4.51 h1:JgDPPG75tC1rWIS2Me6MwcvXJ6f49UQ4HjAOef71Hno=
@@ -165,8 +159,8 @@ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUS
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -191,16 +185,16 @@ golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -214,10 +208,8 @@ google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4J
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
@@ -226,7 +218,6 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
@@ -87,6 +87,34 @@ type AuditWriterConfig struct {
Password string Password string
} }
// devOnlyCredential/devOnlyAuditWriterCredential are docker-compose.yml's
// zero-config defaults -- see api/internal/config.Config.
// DevCredentialWarnings for the full reasoning (duplicated here per
// this repo's no-shared-code-between-services convention).
const (
devOnlyCredential = "sentry-dev-only"
devOnlyAuditWriterCredential = "audit-writer-dev-only"
)
// DevCredentialWarnings reports which configured credentials still
// equal their literal dev-only defaults -- cmd/enterprise-api/main.go
// logs each one loudly at startup. A warning, not a startup-refusing
// error: local dev's zero-config docker-compose.yml path legitimately
// leaves these at their default values.
func (c Config) DevCredentialWarnings() []string {
var warnings []string
if c.ClickHouseAdmin.Password == devOnlyCredential {
warnings = append(warnings, "CLICKHOUSE_ADMIN_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev")
}
if c.Postgres.Password == devOnlyCredential {
warnings = append(warnings, "POSTGRES_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev")
}
if c.AuditWriter.Password == devOnlyAuditWriterCredential {
warnings = append(warnings, "AUDIT_WRITER_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev")
}
return warnings
}
func Load() (Config, error) { func Load() (Config, error) {
cfg := Config{ cfg := Config{
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8083"), HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8083"),
+30
View File
@@ -37,6 +37,36 @@ type Config struct {
CORSAllowedOrigin string CORSAllowedOrigin string
} }
// devOnlyCredential is docker-compose.yml's zero-config default for
// every Postgres/ClickHouse password in this repo -- see
// api/internal/config.Config.DevCredentialWarnings for the full
// reasoning (duplicated here per this repo's no-shared-code-between-
// services convention). enterprise-auth also has its own dev-only
// literal for ENTERPRISE_SESSION_SIGNING_KEY, checked alongside it below
// -- per the threat model doc, this is "the single highest-value secret
// in the enterprise deployment," since compromising it lets an attacker
// forge any identity, including the RoleService credential.
const (
devOnlyCredential = "sentry-dev-only"
devOnlySigningKey = "sentry-dev-only-session-signing-key-32bytes+"
)
// DevCredentialWarnings reports which configured secrets still equal
// their literal dev-only defaults -- cmd/enterprise-*/main.go logs each
// one loudly at startup. A warning, not a startup-refusing error: local
// dev's zero-config docker-compose.yml path legitimately leaves these
// at their default values.
func (c Config) DevCredentialWarnings() []string {
var warnings []string
if c.Postgres.Password == devOnlyCredential {
warnings = append(warnings, "POSTGRES_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev")
}
if string(c.SessionSigningKey) == devOnlySigningKey {
warnings = append(warnings, "ENTERPRISE_SESSION_SIGNING_KEY is still the default dev-only value -- this is the single highest-value secret in an enterprise deployment (compromise lets an attacker forge any identity); set a real, random one before this is reachable outside local dev")
}
return warnings
}
type PostgresConfig struct { type PostgresConfig struct {
Addr string Addr string
Database string Database string
@@ -49,6 +49,25 @@ type BatchConfig struct {
FlushIntervalMS int FlushIntervalMS int
} }
// devOnlyCredential is docker-compose.yml's zero-config default for
// every Postgres/ClickHouse password in this repo -- see
// api/internal/config.Config.DevCredentialWarnings for the full
// reasoning (duplicated here per this repo's no-shared-code-between-
// services convention).
const devOnlyCredential = "sentry-dev-only"
// DevCredentialWarnings reports whether the configured Postgres
// credential still equals the literal dev-only default --
// cmd/enterprise-ingest/main.go logs it loudly at startup. A warning,
// not a startup-refusing error: local dev's zero-config
// docker-compose.yml path legitimately leaves it at this value.
func (c Config) DevCredentialWarnings() []string {
if c.Postgres.Password == devOnlyCredential {
return []string{"POSTGRES_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev"}
}
return nil
}
func Load() (Config, error) { func Load() (Config, error) {
cfg := Config{ cfg := Config{
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8084"), HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8084"),
+1 -1
View File
@@ -12,7 +12,7 @@ require (
require ( require (
golang.org/x/net v0.55.0 // indirect golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.39.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/protobuf v1.36.12 // indirect google.golang.org/protobuf v1.36.12 // indirect
) )
+2 -2
View File
@@ -26,8 +26,8 @@ golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+1 -1
View File
@@ -12,7 +12,7 @@ require (
require ( require (
golang.org/x/net v0.55.0 // indirect golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.39.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/protobuf v1.36.12 // indirect google.golang.org/protobuf v1.36.12 // indirect
) )
+2 -2
View File
@@ -26,8 +26,8 @@ golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+3
View File
@@ -69,6 +69,9 @@ func main() {
logger.Error("loading config", "error", err) logger.Error("loading config", "error", err)
os.Exit(1) os.Exit(1)
} }
for _, w := range cfg.DevCredentialWarnings() {
logger.Warn(w)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
@@ -39,6 +39,7 @@ type overrideFields struct {
HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"` HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"`
HeartbeatIntervalMS *uint64 `json:"heartbeat_interval_ms,omitempty"` HeartbeatIntervalMS *uint64 `json:"heartbeat_interval_ms,omitempty"`
JournaldUnit *string `json:"journald_unit,omitempty"` JournaldUnit *string `json:"journald_unit,omitempty"`
ExtraFilePaths []string `json:"extra_file_paths,omitempty"`
} }
type Registry struct { type Registry struct {
@@ -146,6 +147,7 @@ func (r *Registry) CheckIn(ctx context.Context, tenantID string, info grpcserver
HeartbeatEnabled: fields.HeartbeatEnabled, HeartbeatEnabled: fields.HeartbeatEnabled,
HeartbeatIntervalMS: fields.HeartbeatIntervalMS, HeartbeatIntervalMS: fields.HeartbeatIntervalMS,
JournaldUnit: fields.JournaldUnit, JournaldUnit: fields.JournaldUnit,
ExtraFilePaths: fields.ExtraFilePaths,
Version: *desiredVersion, Version: *desiredVersion,
} }
return result, nil return result, nil
+23
View File
@@ -75,6 +75,29 @@ type BatchConfig struct {
FlushIntervalMS int FlushIntervalMS int
} }
// devOnlyCredential is docker-compose.yml's zero-config default for
// every Postgres/ClickHouse password in this repo -- see
// api/internal/config.Config.DevCredentialWarnings for the full
// reasoning (duplicated here per this repo's no-shared-code-between-
// services convention).
const devOnlyCredential = "sentry-dev-only"
// DevCredentialWarnings reports which configured credentials still
// equal the literal dev-only default -- cmd/ingest/main.go logs each
// one loudly at startup. A warning, not a startup-refusing error: local
// dev's zero-config docker-compose.yml path legitimately leaves every
// password at this value.
func (c Config) DevCredentialWarnings() []string {
var warnings []string
if c.ClickHouse.Password == devOnlyCredential {
warnings = append(warnings, "CLICKHOUSE_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev")
}
if c.AgentRegistry.Postgres.Password == devOnlyCredential {
warnings = append(warnings, "AGENT_REGISTRY_POSTGRES_PASSWORD is still the default dev-only value -- set a real password before this is reachable outside local dev")
}
return warnings
}
func Load() (Config, error) { func Load() (Config, error) {
cfg := Config{ cfg := Config{
GRPC: GRPCConfig{ GRPC: GRPCConfig{
+6
View File
@@ -147,6 +147,11 @@ type AgentOverride struct {
HeartbeatEnabled *bool HeartbeatEnabled *bool
HeartbeatIntervalMS *uint64 HeartbeatIntervalMS *uint64
JournaldUnit *string JournaldUnit *string
// Extra file paths this agent should tail in addition to its local
// [source] -- see agent_control.proto's DesiredOverride.
// extra_file_paths comment for why this has no "unset" state the
// way the pointer fields above do.
ExtraFilePaths []string
Version string Version string
} }
@@ -298,6 +303,7 @@ func (s *Server) CheckIn(ctx context.Context, req *agentv1.CheckInRequest) (*age
HeartbeatEnabled: result.Override.HeartbeatEnabled, HeartbeatEnabled: result.Override.HeartbeatEnabled,
HeartbeatIntervalMs: result.Override.HeartbeatIntervalMS, HeartbeatIntervalMs: result.Override.HeartbeatIntervalMS,
JournaldUnit: result.Override.JournaldUnit, JournaldUnit: result.Override.JournaldUnit,
ExtraFilePaths: result.Override.ExtraFilePaths,
Version: result.Override.Version, Version: result.Override.Version,
} }
} }
@@ -0,0 +1,18 @@
-- Local username/password login (single-tenant deployments with no
-- enterprise-auth/SSO configured) -- see api/localauth's package doc
-- comment. Reuses 0017's users table and 0020's tenant_memberships
-- rather than a parallel identity model, so a deployment that later
-- turns on enterprise SSO has one identity graph, not two to reconcile.
--
-- email relaxed to nullable: a local user has no SSO identity to hang
-- an email off of. enterprise/internal/rbacstore.UpsertUserBySSO
-- already requires a non-empty email in Go before ever inserting, so
-- this is a no-op for the SSO path.
ALTER TABLE users
ALTER COLUMN email DROP NOT NULL;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS username TEXT UNIQUE;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS password_hash TEXT;
@@ -0,0 +1,20 @@
-- Opaque, revocable session tokens for local login -- only the SHA-256
-- hash of the token is stored, same posture as 0034's
-- ingest_credentials: the server never needs the raw value back, only
-- "does this match." role/tenant_id are a snapshot taken at login, not
-- a live join to tenant_memberships on every request -- api/localauth's
-- Authorizer runs on every HTTP request, so this keeps that path to one
-- indexed lookup. Consequence: a role change doesn't take effect for an
-- existing session until it's revoked/expires and the user logs in
-- again -- api/localauth.Store.SetPasswordHash deliberately revokes a
-- user's sessions for exactly this reason.
CREATE TABLE IF NOT EXISTS local_sessions
(
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL REFERENCES tenants(id),
role TEXT NOT NULL CHECK (role IN ('viewer', 'editor', 'admin', 'owner')),
token_hash TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
)
+1 -1
View File
@@ -10,6 +10,6 @@ require (
require ( require (
golang.org/x/net v0.55.0 // indirect golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.39.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
) )
+2 -2
View File
@@ -26,8 +26,8 @@ golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+21 -2
View File
@@ -273,6 +273,17 @@ type DesiredOverride struct {
// CheckInRequest.applied_override_version once applied -- it never // CheckInRequest.applied_override_version once applied -- it never
// interprets the value itself. // interprets the value itself.
Version string `protobuf:"bytes,6,opt,name=version,proto3" json:"version,omitempty"` Version string `protobuf:"bytes,6,opt,name=version,proto3" json:"version,omitempty"`
// Extra file paths this agent should tail in addition to whatever its
// local [source] already is -- never a replacement for the primary
// source (an agent whose local source is journald can still be told
// to also tail a file, and vice versa). Unlike every field above,
// there's no real "unset" state for a list: the web UI/CLI always
// resubmit the complete desired list on every edit (same "PUT
// replaces the whole override" convention every other field already
// follows -- see api/agents/handler.go's handleSetConfig), so an
// empty list unambiguously means "no extra paths right now," not
// "don't touch this."
ExtraFilePaths []string `protobuf:"bytes,7,rep,name=extra_file_paths,json=extraFilePaths,proto3" json:"extra_file_paths,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -349,6 +360,13 @@ func (x *DesiredOverride) GetVersion() string {
return "" return ""
} }
func (x *DesiredOverride) GetExtraFilePaths() []string {
if x != nil {
return x.ExtraFilePaths
}
return nil
}
type CheckInResponse struct { type CheckInResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// False when no override has ever been set for this agent -- it // False when no override has ever been set for this agent -- it
@@ -442,14 +460,15 @@ const file_sentry_agent_v1_agent_control_proto_rawDesc = "" +
"\x04host\x18\x01 \x01(\tR\x04host\x12\x18\n" + "\x04host\x18\x01 \x01(\tR\x04host\x12\x18\n" +
"\aservice\x18\x02 \x01(\tR\aservice\x12F\n" + "\aservice\x18\x02 \x01(\tR\aservice\x12F\n" +
"\x0ecurrent_config\x18\x03 \x01(\v2\x1f.sentry.agent.v1.ReportedConfigR\rcurrentConfig\x128\n" + "\x0ecurrent_config\x18\x03 \x01(\v2\x1f.sentry.agent.v1.ReportedConfigR\rcurrentConfig\x128\n" +
"\x18applied_override_version\x18\x04 \x01(\tR\x16appliedOverrideVersion\"\x98\x03\n" + "\x18applied_override_version\x18\x04 \x01(\tR\x16appliedOverrideVersion\"\xc2\x03\n" +
"\x0fDesiredOverride\x12)\n" + "\x0fDesiredOverride\x12)\n" +
"\x0ebatch_max_size\x18\x01 \x01(\x04H\x00R\fbatchMaxSize\x88\x01\x01\x12:\n" + "\x0ebatch_max_size\x18\x01 \x01(\x04H\x00R\fbatchMaxSize\x88\x01\x01\x12:\n" +
"\x17batch_flush_interval_ms\x18\x02 \x01(\x04H\x01R\x14batchFlushIntervalMs\x88\x01\x01\x120\n" + "\x17batch_flush_interval_ms\x18\x02 \x01(\x04H\x01R\x14batchFlushIntervalMs\x88\x01\x01\x120\n" +
"\x11heartbeat_enabled\x18\x03 \x01(\bH\x02R\x10heartbeatEnabled\x88\x01\x01\x127\n" + "\x11heartbeat_enabled\x18\x03 \x01(\bH\x02R\x10heartbeatEnabled\x88\x01\x01\x127\n" +
"\x15heartbeat_interval_ms\x18\x04 \x01(\x04H\x03R\x13heartbeatIntervalMs\x88\x01\x01\x12(\n" + "\x15heartbeat_interval_ms\x18\x04 \x01(\x04H\x03R\x13heartbeatIntervalMs\x88\x01\x01\x12(\n" +
"\rjournald_unit\x18\x05 \x01(\tH\x04R\fjournaldUnit\x88\x01\x01\x12\x18\n" + "\rjournald_unit\x18\x05 \x01(\tH\x04R\fjournaldUnit\x88\x01\x01\x12\x18\n" +
"\aversion\x18\x06 \x01(\tR\aversionB\x11\n" + "\aversion\x18\x06 \x01(\tR\aversion\x12(\n" +
"\x10extra_file_paths\x18\a \x03(\tR\x0eextraFilePathsB\x11\n" +
"\x0f_batch_max_sizeB\x1a\n" + "\x0f_batch_max_sizeB\x1a\n" +
"\x18_batch_flush_interval_msB\x14\n" + "\x18_batch_flush_interval_msB\x14\n" +
"\x12_heartbeat_enabledB\x18\n" + "\x12_heartbeat_enabledB\x18\n" +
+11
View File
@@ -69,6 +69,17 @@ message DesiredOverride {
// CheckInRequest.applied_override_version once applied -- it never // CheckInRequest.applied_override_version once applied -- it never
// interprets the value itself. // interprets the value itself.
string version = 6; string version = 6;
// Extra file paths this agent should tail in addition to whatever its
// local [source] already is -- never a replacement for the primary
// source (an agent whose local source is journald can still be told
// to also tail a file, and vice versa). Unlike every field above,
// there's no real "unset" state for a list: the web UI/CLI always
// resubmit the complete desired list on every edit (same "PUT
// replaces the whole override" convention every other field already
// follows -- see api/agents/handler.go's handleSetConfig), so an
// empty list unambiguously means "no extra paths right now," not
// "don't touch this."
repeated string extra_file_paths = 7;
} }
// AgentCommand is a one-shot action, not a persistent desired state like // AgentCommand is a one-shot action, not a persistent desired state like
+7 -7
View File
@@ -344,7 +344,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -534,9 +534,9 @@ dependencies = [
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.15" version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
@@ -698,7 +698,7 @@ dependencies = [
"libc", "libc",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2 0.6.5", "socket2 0.5.10",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -1089,7 +1089,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -1548,7 +1548,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.12.1", "linux-raw-sys 0.12.1",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -1954,7 +1954,7 @@ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix 1.1.4", "rustix 1.1.4",
"windows-sys 0.61.2", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
+9 -9
View File
@@ -48,15 +48,15 @@ require (
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/zclconf/go-cty v1.18.1 // indirect github.com/zclconf/go-cty v1.18.1 // indirect
golang.org/x/crypto v0.50.0 // indirect golang.org/x/crypto v0.53.0 // indirect
golang.org/x/mod v0.35.0 // indirect golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.52.0 // indirect golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.39.0 // indirect
golang.org/x/tools v0.43.0 // indirect golang.org/x/tools v0.47.0 // indirect
google.golang.org/appengine v1.6.8 // indirect google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/grpc v1.79.3 // indirect google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )
+30 -30
View File
@@ -163,34 +163,34 @@ github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -202,32 +202,32 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+7
View File
@@ -22,9 +22,16 @@ COPY . .
ARG VITE_API_BASE_URL=http://localhost:8080 ARG VITE_API_BASE_URL=http://localhost:8080
ARG VITE_ALERTING_API_BASE_URL=http://localhost:8081 ARG VITE_ALERTING_API_BASE_URL=http://localhost:8081
ARG VITE_ENTERPRISE_AUTH_BASE_URL ARG VITE_ENTERPRISE_AUTH_BASE_URL
# Off by default -- local dev's plain WithCORS (wildcard
# Access-Control-Allow-Origin) can't be combined with a credentialed
# fetch at all, so api.ts must not send cookies unless the deployment
# actually turned local auth on server-side too (LOCAL_AUTH_ENABLED).
# See api.ts's requestFrom/alertingRequest doc comment.
ARG VITE_LOCAL_AUTH_ENABLED=false
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
ENV VITE_ALERTING_API_BASE_URL=${VITE_ALERTING_API_BASE_URL} ENV VITE_ALERTING_API_BASE_URL=${VITE_ALERTING_API_BASE_URL}
ENV VITE_ENTERPRISE_AUTH_BASE_URL=${VITE_ENTERPRISE_AUTH_BASE_URL} ENV VITE_ENTERPRISE_AUTH_BASE_URL=${VITE_ENTERPRISE_AUTH_BASE_URL}
ENV VITE_LOCAL_AUTH_ENABLED=${VITE_LOCAL_AUTH_ENABLED}
RUN npm run build RUN npm run build
# Not distroless: serving a static SPA needs *some* HTTP server, and # Not distroless: serving a static SPA needs *some* HTTP server, and
+37
View File
@@ -3,6 +3,43 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Security-audit remediation (M-3): baseline browser security headers,
# absent entirely before this. HSTS/nosniff/frame-options/referrer-
# policy/permissions-policy carry no functional risk to this app and
# are unconditionally safe to add.
#
# CSP is the one directive that needed real care rather than a
# copy-pasted strict default: adapter-static's own build output
# (web/build/index.html) genuinely contains two inline <script>
# blocks -- SvelteKit's own hydration bootstrap and the dark-mode-
# before-paint snippet -- so a naive `script-src 'self'` would break
# every page load, not just XSS. Hash-pinning those two scripts
# instead was considered and rejected: nginx.conf is a static file
# this Docker image ships unmodified, with nothing in the build
# pipeline that regenerates it per build, and SvelteKit's bootstrap
# script embeds a build-specific identifier -- a hardcoded hash here
# would silently start blocking the app's own hydration script on the
# next unrelated rebuild, a far worse outcome than the gap this is
# closing. `script-src`/`style-src` therefore keep `'unsafe-inline'`
# (inline style attributes are also genuinely used, e.g. the Hosts
# page's dynamic bar-width styling) -- this CSP is not a script-
# injection defense by itself (the codebase already has no known XSS
# vector to defend against -- see the security audit's "verified
# clean" section), it's the other directives doing real work:
# `object-src none` (no plugin-embed vector), `frame-ancestors none`
# (clickjacking, redundant with X-Frame-Options but cheap insurance),
# `base-uri self` (blocks base-tag hijacking), `form-action self`.
# `connect-src *` stays permissive rather than enumerating this
# deployment's actual api/alerting/enterprise-auth hosts, since those
# are configured per-deployment via VITE_*_BASE_URL build args (see
# web/Dockerfile) and a static nginx.conf has no way to know them.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src *; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
location / { location / {
# adapter-static writes prerendered routes as flat <route>.html # adapter-static writes prerendered routes as flat <route>.html
# files (e.g. /dashboards -> dashboards.html, confirmed by # files (e.g. /dashboards -> dashboards.html, confirmed by
+164
View File
@@ -14,6 +14,18 @@ export const alertingBase = import.meta.env.VITE_ALERTING_API_BASE_URL ?? 'http:
// disabled, no broken links. // disabled, no broken links.
export const enterpriseAuthBase = import.meta.env.VITE_ENTERPRISE_AUTH_BASE_URL as string | undefined; export const enterpriseAuthBase = import.meta.env.VITE_ENTERPRISE_AUTH_BASE_URL as string | undefined;
// Local login (see api/localauth's package doc comment). Baked in at
// build time same as the base URLs above -- requestFrom/alertingRequest
// below only send `credentials: 'include'` when this is true, since
// api's/alerting's own CORS stays the permissive wildcard-friendly
// WithCORS (no Access-Control-Allow-Credentials) unless the deployment
// set LOCAL_AUTH_ENABLED server-side too -- browsers categorically
// refuse to combine a credentialed fetch with a wildcard
// Access-Control-Allow-Origin, so sending credentials unconditionally
// would break every plain `docker compose up` local-dev deployment,
// which never sets either of these.
export const localAuthEnabled = import.meta.env.VITE_LOCAL_AUTH_ENABLED === 'true';
export type Language = '' | 'sql' | 'spl'; export type Language = '' | 'sql' | 'spl';
// warnings (Phase 7) is populated by the shared costguard package's // warnings (Phase 7) is populated by the shared costguard package's
@@ -59,6 +71,7 @@ class ApiError extends Error {}
async function requestFrom<T>(base: string, path: string, init?: RequestInit): Promise<T> { async function requestFrom<T>(base: string, path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${base}${path}`, { const res = await fetch(`${base}${path}`, {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
...(localAuthEnabled ? { credentials: 'include' as RequestCredentials } : {}),
...init ...init
}); });
if (!res.ok) { if (!res.ok) {
@@ -393,6 +406,74 @@ export function injectTimeRange(query: string, earliest: string, latest: string)
return `${clauses.join(' ')} ${query}`; return `${clauses.join(' ')} ${query}`;
} }
// --- local login (single-tenant mode, see api/localauth) --------------
export type LocalSession = { user_id: string; tenant_id: string; username: string; role: string };
export function login(username: string, password: string): Promise<LocalSession & { token: string }> {
return request('/auth/login', {
method: 'POST',
credentials: 'include',
body: JSON.stringify({ username, password })
});
}
export function logout(): Promise<void> {
return request('/auth/logout', { method: 'POST', credentials: 'include' });
}
// getLocalSession is three-state, not a boolean -- +layout.ts's route
// guard needs to tell "not logged in" (null, redirect to /login) apart
// from "this deployment doesn't have local auth turned on at all"
// ('disabled', let the request through) -- GET /auth/session is only
// ever registered server-side when LOCAL_AUTH_ENABLED is set (see
// api/localauth.Handler.RegisterRoutes' doc comment), so a 404 here
// means the latter, same "absence is a normal deployment shape" posture
// getAuthFeatures/getCurrentSession above already use for enterprise
// auth. Always sends credentials regardless of the module-level
// localAuthEnabled flag -- this is the one call the route guard makes
// unconditionally to *discover* whether local auth is on, so it can't
// rely on that flag being true first.
export async function getLocalSession(): Promise<LocalSession | 'disabled' | null> {
try {
const res = await fetch(`${apiBase}/auth/session`, { credentials: 'include' });
if (res.status === 404) return 'disabled';
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
export type LocalUser = { id: string; username: string; role: string; created_at: string };
export function listUsers(): Promise<LocalUser[]> {
return request<LocalUser[]>('/auth/users', { credentials: 'include' }).then((u) => u ?? []);
}
export function createUser(username: string, password: string, role: string): Promise<LocalUser> {
return request('/auth/users', {
method: 'POST',
credentials: 'include',
body: JSON.stringify({ username, password, role })
});
}
export function deleteUser(id: string): Promise<void> {
return request(`/auth/users/${id}`, { method: 'DELETE', credentials: 'include' });
}
// resetPassword's response only carries `password` when the caller
// didn't supply one -- see api/localauth/handler.go's
// resetPasswordResponse doc comment.
export function resetPassword(id: string, newPassword?: string): Promise<{ password?: string }> {
return request(`/auth/users/${id}/reset-password`, {
method: 'POST',
credentials: 'include',
body: JSON.stringify(newPassword ? { password: newPassword } : {})
});
}
// --- alerting --------------------------------------------------------- // --- alerting ---------------------------------------------------------
export type ConditionType = 'threshold' | 'absence'; export type ConditionType = 'threshold' | 'absence';
@@ -484,6 +565,7 @@ export type ConfigOverride = {
heartbeat_enabled?: boolean; heartbeat_enabled?: boolean;
heartbeat_interval_ms?: number; heartbeat_interval_ms?: number;
journald_unit?: string; journald_unit?: string;
extra_file_paths?: string[];
}; };
export type Agent = { export type Agent = {
@@ -545,3 +627,85 @@ export function issueAgentCommand(host: string, command: 'restart'): Promise<Age
body: JSON.stringify({ command }) body: JSON.stringify({ command })
}); });
} }
// ---- Host CPU/memory/disk metrics ----
// No new REST endpoints -- a metrics sample is an ordinary log record
// (see agent/README.md's "Host CPU/memory/disk metrics" section and
// agent/sentry-agent/src/main.rs's send_metrics), tagged
// `sentry.metrics=true`, fetched through the same POST /query every
// other page already uses via runQuery(). Only ever set on one agent
// process per physical host, so `stats count by host` over this tag
// naturally lists real hosts, not every fragmented per-source agent
// identity `/agents` shows (see the deployment notes on why several
// agent processes can share one physical host under different
// `[agent] host` values).
export type HostSummary = { host: string; sampleCount: number };
export async function listMetricsHosts(): Promise<HostSummary[]> {
const result = await runQuery('sentry.metrics=true | stats count by host', 'spl');
const hostIdx = result.columns.indexOf('host');
const countIdx = result.columns.indexOf('count');
return result.rows.map((r) => ({ host: String(r[hostIdx]), sampleCount: Number(r[countIdx]) }));
}
export type HostMetrics = {
host: string;
timestamp: string;
cpuPercent: number;
memUsedBytes: number;
memTotalBytes: number;
diskUsedBytes: number;
diskTotalBytes: number;
// Static-or-slow-changing context (see agent/src/metrics.rs's
// Metrics doc comment) -- sent on the same record specifically so a
// viewer never has to correlate two different samples to make sense
// of the utilization numbers above (is 21% CPU busy or idle depends
// on core count; is this usage normal depends on uptime).
cpuCores: number;
osName: string;
kernelVersion: string;
arch: string;
uptimeSeconds: number;
ipv4Addresses: string[];
ipv6Addresses: string[];
};
// Reads straight out of the record's `attributes` object (already
// returned in full on every query result row) rather than trying to
// project attribute-derived fields as top-level query-language columns
// -- simpler, and doesn't depend on `fields` supporting synthetic
// attribute columns the same way filtering does.
export async function getHostMetrics(host: string): Promise<HostMetrics | null> {
const result = await runQuery(
`host="${host}" sentry.metrics=true | sort -timestamp | head 1`,
'spl'
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
const timestampIdx = result.columns.indexOf('timestamp');
const attributesIdx = result.columns.indexOf('attributes');
const attrs = (row[attributesIdx] ?? {}) as Record<string, string>;
const num = (key: string) => Number(attrs[key] ?? 0);
// Comma-joined by the agent (see agent/src/main.rs's send_metrics) --
// split back into a list here, filtering out the empty string a
// host with no addresses of a given family produces (''.split(',')
// is [''], not [], so the filter is load-bearing, not defensive).
const addrList = (key: string) => (attrs[key] ?? '').split(',').filter((a) => a !== '');
return {
host,
timestamp: String(row[timestampIdx]),
cpuPercent: num('cpu_percent'),
memUsedBytes: num('mem_used_bytes'),
memTotalBytes: num('mem_total_bytes'),
diskUsedBytes: num('disk_used_bytes'),
diskTotalBytes: num('disk_total_bytes'),
cpuCores: num('cpu_cores'),
osName: attrs['os_name'] ?? 'unknown',
kernelVersion: attrs['kernel_version'] ?? 'unknown',
arch: attrs['arch'] ?? 'unknown',
uptimeSeconds: num('uptime_seconds'),
ipv4Addresses: addrList('ipv4_addresses'),
ipv6Addresses: addrList('ipv6_addresses')
};
}
+49 -1
View File
@@ -1,6 +1,14 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { getCurrentSession, enterpriseAuthBase, type CurrentSession } from '$lib/api'; import {
getCurrentSession,
enterpriseAuthBase,
localAuthEnabled,
getLocalSession,
logout,
type CurrentSession,
type LocalSession
} from '$lib/api';
import { getTheme, setTheme, type Theme } from '$lib/theme.svelte'; import { getTheme, setTheme, type Theme } from '$lib/theme.svelte';
import { getDensity, toggleDensity } from '$lib/density.svelte'; import { getDensity, toggleDensity } from '$lib/density.svelte';
@@ -16,6 +24,7 @@
{ href: '/alerts', label: 'Alerts', icon: '▲' }, { href: '/alerts', label: 'Alerts', icon: '▲' },
{ href: '/data-sources', label: 'Data Sources', icon: '◈' }, { href: '/data-sources', label: 'Data Sources', icon: '◈' },
{ href: '/agents', label: 'Agents', icon: '●' }, { href: '/agents', label: 'Agents', icon: '●' },
{ href: '/hosts', label: 'Hosts', icon: '▣' },
{ href: '/settings', label: 'Settings', icon: '⚙' } { href: '/settings', label: 'Settings', icon: '⚙' }
]; ];
@@ -29,6 +38,22 @@
getCurrentSession().then((s) => (session = s)); getCurrentSession().then((s) => (session = s));
}); });
let localSession: LocalSession | null = $state(null);
$effect(() => {
if (!localAuthEnabled) return;
getLocalSession().then((s) => (localSession = s === 'disabled' ? null : s));
});
let loggingOut = $state(false);
async function handleLogout() {
loggingOut = true;
try {
await logout();
} finally {
window.location.href = '/login';
}
}
const themeOptions: { value: Theme; label: string }[] = [ const themeOptions: { value: Theme; label: string }[] = [
{ value: 'dark', label: 'Dark' }, { value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' }, { value: 'light', label: 'Light' },
@@ -60,6 +85,17 @@
<a class="switch signin" href="{enterpriseAuthBase}/auth/oidc/login">Sign in</a> <a class="switch signin" href="{enterpriseAuthBase}/auth/oidc/login">Sign in</a>
{/if} {/if}
</div> </div>
{:else if localAuthEnabled && localSession}
<div class="tenant">
<div class="tenant-pill">
<span class="dot" aria-hidden="true"></span>
<span class="tenant-name">{localSession.username}</span>
<span class="role">{localSession.role}</span>
</div>
<button type="button" class="switch logout-btn" onclick={handleLogout} disabled={loggingOut}>
{loggingOut ? 'Signing out…' : 'Log out'}
</button>
</div>
{/if} {/if}
<nav aria-label="Main"> <nav aria-label="Main">
@@ -177,6 +213,18 @@
.switch:hover { .switch:hover {
color: var(--color-accent); color: var(--color-accent);
} }
.logout-btn {
background: none;
border: none;
font-family: var(--font-ui);
width: 100%;
text-align: left;
cursor: pointer;
}
.logout-btn:disabled {
cursor: default;
opacity: 0.6;
}
.switch.signin { .switch.signin {
display: block; display: block;
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
+70 -3
View File
@@ -3,10 +3,60 @@
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import NavSidebar from '$lib/components/NavSidebar.svelte'; import NavSidebar from '$lib/components/NavSidebar.svelte';
import CommandPalette from '$lib/components/CommandPalette.svelte'; import CommandPalette from '$lib/components/CommandPalette.svelte';
import { page } from '$app/state';
import { getLocalSession } from '$lib/api';
let { children } = $props(); let { children } = $props();
let paletteOpen = $state(false); let paletteOpen = $state(false);
let mobileNavOpen = $state(false); let mobileNavOpen = $state(false);
const isLoginPage = $derived(page.url.pathname === '/login');
// Gates rendering of {@render children()} entirely until the first
// auth check resolves -- set once, on the very first check, never
// reset by later ones (see the $effect below). Without this, the
// shell (and every child page's own onMount/effect data-fetching)
// mounted immediately on every navigation, so a protected page's real
// content -- and a first request for it, before the redirect below
// even fired -- was visible for one frame on every load. Login-page
// visits and "local auth isn't configured on this deployment" both
// count as immediately checked -- neither has anything to gate.
let initialCheckDone = $state(false);
let authorized = $state(false);
// Route guard for local login (see api/localauth's package doc
// comment) -- client-only, same "no +page.ts/hooks.server.ts load,
// everything client-fetched" posture every other data-dependent page
// in this app already uses (this is a prerendered static SPA;
// checking during SvelteKit's build-time prerender pass would mean
// fetching a live endpoint at build time, which nothing else here
// does). getLocalSession() returning 'disabled' means this
// deployment has no local auth configured at all -- same as a
// deployment with neither enterprise-auth nor local auth turned on,
// let every route through unchanged. Re-runs on every navigation
// ($effect re-fires when isLoginPage's dependency, page.url, changes),
// same "poll on every navigation" posture GET /auth/session's own
// doc comment (api/localauth/handler.go) describes -- but only ever
// sets initialCheckDone, never clears it, so a session expiring
// mid-use redirects without re-blanking an already-rendered page (a
// full navigation to /login is already underway by the time that'd
// matter anyway).
$effect(() => {
if (isLoginPage) {
authorized = true;
initialCheckDone = true;
return;
}
getLocalSession().then((session) => {
if (session === null) {
const next = encodeURIComponent(page.url.pathname + page.url.search);
window.location.href = `/login?next=${next}`;
} else {
authorized = true;
}
initialCheckDone = true;
});
});
</script> </script>
<svelte:head> <svelte:head>
@@ -14,7 +64,10 @@
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
</svelte:head> </svelte:head>
<div class="shell"> {#if isLoginPage}
{@render children()}
{:else if initialCheckDone && authorized}
<div class="shell">
<NavSidebar <NavSidebar
onOpenPalette={() => (paletteOpen = true)} onOpenPalette={() => (paletteOpen = true)}
mobileOpen={mobileNavOpen} mobileOpen={mobileNavOpen}
@@ -28,11 +81,25 @@
{@render children()} {@render children()}
</div> </div>
</div> </div>
</div> </div>
<CommandPalette bind:open={paletteOpen} /> <CommandPalette bind:open={paletteOpen} />
{:else}
<!-- initialCheckDone is false: the auth check is still in flight (or
we're already navigating away to /login) -- nothing protected has
mounted yet, this is the whole point. A brief blank screen with no
feedback could look frozen on a slow connection, so show the same
plain "Loading…" text every other data-dependent page in this app
already uses (see e.g. settings/+page.svelte) rather than nothing
at all. -->
<p class="auth-loading">Loading…</p>
{/if}
<style> <style>
.auth-loading {
color: var(--color-text-muted);
padding: var(--space-6);
}
.shell { .shell {
display: grid; display: grid;
grid-template-columns: 15rem 1fr; grid-template-columns: 15rem 1fr;
+51 -1
View File
@@ -26,6 +26,13 @@
let heartbeatEnabled = $state(true); let heartbeatEnabled = $state(true);
let heartbeatIntervalMs = $state('0'); let heartbeatIntervalMs = $state('0');
let journaldUnit = $state(''); let journaldUnit = $state('');
// Extra file paths to tail in addition to the agent's primary
// source -- unlike every field above, there's no "effective value"
// to fall back to when no override exists yet (this is purely a
// remote-override concept, agent.toml has no equivalent field), so
// an agent with no override starts with an empty list, not
// something derived from `agent`.
let extraFilePaths = $state<string[]>([]);
function resetForm(a: Agent) { function resetForm(a: Agent) {
const o = a.desired_override; const o = a.desired_override;
@@ -34,6 +41,15 @@
heartbeatEnabled = o?.heartbeat_enabled ?? a.heartbeat_enabled; heartbeatEnabled = o?.heartbeat_enabled ?? a.heartbeat_enabled;
heartbeatIntervalMs = String(o?.heartbeat_interval_ms ?? a.heartbeat_interval_ms); heartbeatIntervalMs = String(o?.heartbeat_interval_ms ?? a.heartbeat_interval_ms);
journaldUnit = o?.journald_unit ?? ''; journaldUnit = o?.journald_unit ?? '';
extraFilePaths = o?.extra_file_paths ? [...o.extra_file_paths] : [];
}
function addExtraFilePath() {
extraFilePaths = [...extraFilePaths, ''];
}
function removeExtraFilePath(index: number) {
extraFilePaths = extraFilePaths.filter((_, i) => i !== index);
} }
async function load() { async function load() {
@@ -59,7 +75,8 @@
batch_flush_interval_ms: Number(batchFlushIntervalMs), batch_flush_interval_ms: Number(batchFlushIntervalMs),
heartbeat_enabled: heartbeatEnabled, heartbeat_enabled: heartbeatEnabled,
heartbeat_interval_ms: Number(heartbeatIntervalMs), heartbeat_interval_ms: Number(heartbeatIntervalMs),
...(agent?.source_kind === 'journald' ? { journald_unit: journaldUnit } : {}) ...(agent?.source_kind === 'journald' ? { journald_unit: journaldUnit } : {}),
extra_file_paths: extraFilePaths.map((p) => p.trim()).filter((p) => p !== '')
}); });
} catch (e) { } catch (e) {
saveError = e instanceof Error ? e.message : String(e); saveError = e instanceof Error ? e.message : String(e);
@@ -179,6 +196,20 @@
<Input id="journald-unit" placeholder="(empty = whole journal)" bind:value={journaldUnit} /> <Input id="journald-unit" placeholder="(empty = whole journal)" bind:value={journaldUnit} />
</div> </div>
{/if} {/if}
<div class="field extra-paths">
<span class="field-label">Additional log paths</span>
<p class="hint">
Extra files this agent should tail alongside its primary source above -- never a replacement for it. Applied
the same way as every other field here, on the agent's next check-in.
</p>
{#each extraFilePaths as _, i}
<div class="path-row">
<Input placeholder="/var/log/example.log" bind:value={extraFilePaths[i]} />
<Button variant="secondary" onclick={() => removeExtraFilePath(i)}>Remove</Button>
</div>
{/each}
<Button variant="secondary" onclick={addExtraFilePath}>Add path</Button>
</div>
{#if saveError}<p class="error">Error: {saveError}</p>{/if} {#if saveError}<p class="error">Error: {saveError}</p>{/if}
@@ -275,6 +306,9 @@
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
max-width: 20rem; max-width: 20rem;
} }
.field.extra-paths {
max-width: none;
}
.field label { .field label {
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--color-text-muted); color: var(--color-text-muted);
@@ -285,6 +319,22 @@
gap: var(--space-2); gap: var(--space-2);
color: var(--color-text); color: var(--color-text);
} }
.field-label {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.field .hint {
margin-bottom: var(--space-2);
}
.path-row {
display: flex;
gap: var(--space-2);
align-items: center;
margin-bottom: var(--space-2);
}
.path-row :global(input) {
flex: 1;
}
.actions { .actions {
display: flex; display: flex;
gap: var(--space-3); gap: var(--space-3);
+90
View File
@@ -0,0 +1,90 @@
<script lang="ts">
import { listMetricsHosts, type HostSummary } from '$lib/api';
import { EmptyState, Skeleton, Table } from '$lib/components/ui';
let hosts = $state<HostSummary[]>([]);
let loading = $state(true);
let error = $state('');
async function load() {
loading = true;
error = '';
try {
hosts = await listMetricsHosts();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
load();
</script>
<main>
<h1>Hosts</h1>
<p class="subtitle">
CPU, memory, and disk usage for every host reporting metrics. Only one agent process per
physical host reports these -- see agent/README.md's "Host CPU/memory/disk metrics" section.
</p>
{#if error}<p class="error">Error: {error}</p>{/if}
{#if loading}
<div class="skeleton-list">
{#each Array(3) as _, i (i)}
<Skeleton height="2.25rem" />
{/each}
</div>
{:else if hosts.length === 0}
<EmptyState
icon="▣"
title="No hosts reporting metrics yet"
description="A host appears here once an agent with [metrics] enabled = true has sent its first sample -- see agent/README.md."
/>
{:else}
<Table>
<thead>
<tr>
<th>Host</th>
</tr>
</thead>
<tbody>
{#each hosts as h (h.host)}
<tr>
<td><a href={`/hosts/${encodeURIComponent(h.host)}`}>{h.host}</a></td>
</tr>
{/each}
</tbody>
</Table>
{/if}
</main>
<style>
main {
max-width: 56rem;
}
h1 {
font-size: var(--text-xl);
margin-bottom: var(--space-2);
}
.subtitle {
color: var(--color-text-muted);
font-size: var(--text-sm);
margin-bottom: var(--space-5);
}
.error {
color: var(--color-danger);
}
.skeleton-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
a {
color: var(--color-text);
font-weight: var(--font-weight-medium);
text-decoration: none;
}
a:hover {
color: var(--color-accent);
}
</style>
+3
View File
@@ -0,0 +1,3 @@
// No route params, data comes from a client-side fetch -- same shape as
// the agents list page's +page.ts.
export const prerender = true;
+186
View File
@@ -0,0 +1,186 @@
<script lang="ts">
import { page } from '$app/state';
import { getHostMetrics, type HostMetrics } from '$lib/api';
import { Card, Skeleton } from '$lib/components/ui';
const host = page.params.host!;
let metrics = $state<HostMetrics | null>(null);
let loading = $state(true);
let error = $state('');
async function load() {
loading = true;
error = '';
try {
metrics = await getHostMetrics(host);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
load();
function formatBytes(bytes: number): string {
if (bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`;
}
function percent(used: number, total: number): number {
if (total <= 0) return 0;
return Math.min(100, Math.max(0, (used / total) * 100));
}
function relativeTime(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
if (ms < 60_000) return `${Math.max(0, Math.round(ms / 1000))}s ago`;
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`;
if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h ago`;
return `${Math.round(ms / 86_400_000)}d ago`;
}
function formatUptime(seconds: number): string {
if (seconds <= 0) return '—';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
</script>
<main>
<a class="back" href="/hosts">← Hosts</a>
<h1>{host}</h1>
{#if loading}
<Skeleton height="12rem" />
{:else if error}
<p class="error">Error: {error}</p>
{:else if !metrics}
<p class="hint">No metrics samples for this host yet.</p>
{:else}
<p class="hint">Last sample {relativeTime(metrics.timestamp)}.</p>
<section class="system">
<dl>
<dt>OS</dt>
<dd>{metrics.osName}</dd>
<dt>Kernel</dt>
<dd>{metrics.kernelVersion}</dd>
<dt>Architecture</dt>
<dd>{metrics.arch}</dd>
<dt>Uptime</dt>
<dd>{formatUptime(metrics.uptimeSeconds)}</dd>
<dt>IPv4</dt>
<dd>{metrics.ipv4Addresses.length > 0 ? metrics.ipv4Addresses.join(', ') : '—'}</dd>
<dt>IPv6</dt>
<dd>{metrics.ipv6Addresses.length > 0 ? metrics.ipv6Addresses.join(', ') : '—'}</dd>
</dl>
</section>
<div class="stats">
<Card title="CPU">
<div class="big-number">{metrics.cpuPercent.toFixed(1)}%</div>
<div class="bar">
<div class="bar-fill" style="width: {metrics.cpuPercent.toFixed(1)}%"></div>
</div>
<div class="detail">{metrics.cpuCores} core{metrics.cpuCores === 1 ? '' : 's'}</div>
</Card>
<Card title="Memory">
<div class="big-number">{percent(metrics.memUsedBytes, metrics.memTotalBytes).toFixed(1)}%</div>
<div class="bar">
<div
class="bar-fill"
style="width: {percent(metrics.memUsedBytes, metrics.memTotalBytes).toFixed(1)}%"
></div>
</div>
<div class="detail">{formatBytes(metrics.memUsedBytes)} / {formatBytes(metrics.memTotalBytes)}</div>
</Card>
<Card title="Disk (/)">
<div class="big-number">{percent(metrics.diskUsedBytes, metrics.diskTotalBytes).toFixed(1)}%</div>
<div class="bar">
<div
class="bar-fill"
style="width: {percent(metrics.diskUsedBytes, metrics.diskTotalBytes).toFixed(1)}%"
></div>
</div>
<div class="detail">{formatBytes(metrics.diskUsedBytes)} / {formatBytes(metrics.diskTotalBytes)}</div>
</Card>
</div>
{/if}
</main>
<style>
main {
max-width: 48rem;
}
.back {
font-size: var(--text-sm);
color: var(--color-text-muted);
text-decoration: none;
}
.back:hover {
color: var(--color-accent);
}
h1 {
font-size: var(--text-xl);
margin: var(--space-2) 0 var(--space-2);
font-family: var(--font-mono);
}
.hint {
color: var(--color-text-muted);
font-size: var(--text-sm);
margin-bottom: var(--space-5);
}
.error {
color: var(--color-danger);
}
.system {
margin-bottom: var(--space-5);
}
.system dl {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-1) var(--space-4);
font-size: var(--text-sm);
}
.system dt {
color: var(--color-text-muted);
}
.system dd {
margin: 0;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: var(--space-4);
}
.big-number {
font-size: var(--text-xl);
font-weight: var(--font-weight-bold);
margin-bottom: var(--space-3);
}
.bar {
height: 0.5rem;
border-radius: var(--radius-sm);
background: var(--color-bg);
border: 1px solid var(--color-border);
overflow: hidden;
}
.bar-fill {
height: 100%;
background: var(--color-accent);
}
.detail {
margin-top: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
</style>
+4
View File
@@ -0,0 +1,4 @@
// The host param doesn't exist at build time -- same reasoning as
// agents/[host]/+page.ts.
export const prerender = false;
export const ssr = false;
+109
View File
@@ -0,0 +1,109 @@
<script lang="ts">
import { page } from '$app/state';
import { login } from '$lib/api';
let username = $state('');
let password = $state('');
let error = $state('');
let submitting = $state(false);
async function submit(e: SubmitEvent) {
e.preventDefault();
if (submitting) return;
submitting = true;
error = '';
try {
await login(username, password);
// Full navigation, not SvelteKit's router -- same reasoning
// select-tenant/+page.svelte's choose() gives for the tenant
// picker: reloading picks up the session cookie login() just
// set, which client-side routing wouldn't need to know about
// but a fresh page load makes unambiguous.
const next = page.url.searchParams.get('next') || '/';
window.location.href = next;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
submitting = false;
}
}
</script>
<main>
<h1>Sign in</h1>
<form onsubmit={submit}>
{#if error}
<p class="error">{error}</p>
{/if}
<label>
<span>Username</span>
<input type="text" autocomplete="username" bind:value={username} disabled={submitting} required />
</label>
<label>
<span>Password</span>
<input
type="password"
autocomplete="current-password"
bind:value={password}
disabled={submitting}
required
/>
</label>
<button type="submit" disabled={submitting}>{submitting ? 'Signing in…' : 'Sign in'}</button>
</form>
</main>
<style>
main {
max-width: 22rem;
margin: var(--space-8) auto;
}
h1 {
font-size: var(--text-lg);
margin-bottom: var(--space-5);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
label {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
input {
font-family: var(--font-ui);
font-size: var(--text-base);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
}
input:focus {
outline: none;
border-color: var(--color-accent);
}
button {
margin-top: var(--space-2);
padding: var(--space-2) var(--space-4);
font-family: var(--font-ui);
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
color: var(--color-bg);
background: var(--color-accent);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.6;
}
.error {
color: var(--color-danger);
font-size: var(--text-sm);
}
</style>
+4
View File
@@ -0,0 +1,4 @@
// Client-only, same reasoning as select-tenant/+page.ts -- nothing to
// prerender server-side, everything here is a fetch against api's
// /auth/login.
export const prerender = true;
+222 -1
View File
@@ -1,5 +1,17 @@
<script lang="ts"> <script lang="ts">
import { getAuthFeatures, enterpriseAuthBase, type AuthFeatures } from '$lib/api'; import {
getAuthFeatures,
enterpriseAuthBase,
localAuthEnabled,
getLocalSession,
listUsers,
createUser,
deleteUser,
resetPassword,
type AuthFeatures,
type LocalSession,
type LocalUser
} from '$lib/api';
import { getTheme, setTheme, type Theme } from '$lib/theme.svelte'; import { getTheme, setTheme, type Theme } from '$lib/theme.svelte';
import { getDensity, setDensity, type Density } from '$lib/density.svelte'; import { getDensity, setDensity, type Density } from '$lib/density.svelte';
@@ -13,6 +25,75 @@
} }
load(); load();
// --- local user management (owner-role only, see api/localauth) ---
let localSession = $state<LocalSession | 'disabled' | null>(null);
let users = $state<LocalUser[]>([]);
let usersLoading = $state(false);
let usersError = $state('');
let newUsername = $state('');
let newPassword = $state('');
let newRole = $state('editor');
let creating = $state(false);
// lastReset holds a just-generated password so it can be shown once
// (never stored, never recoverable after -- same posture
// -seed-admin's initial password takes, see cmd/api/main.go).
let lastReset = $state<{ userId: string; password: string } | null>(null);
async function loadUsers() {
if (!localAuthEnabled) return;
localSession = await getLocalSession();
if (localSession === 'disabled' || localSession === null || localSession.role !== 'owner') return;
usersLoading = true;
usersError = '';
try {
users = await listUsers();
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
} finally {
usersLoading = false;
}
}
loadUsers();
async function handleCreate(e: SubmitEvent) {
e.preventDefault();
if (creating) return;
creating = true;
usersError = '';
try {
await createUser(newUsername, newPassword, newRole);
newUsername = '';
newPassword = '';
newRole = 'editor';
await loadUsers();
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
} finally {
creating = false;
}
}
async function handleDelete(id: string) {
usersError = '';
try {
await deleteUser(id);
await loadUsers();
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
}
}
async function handleReset(id: string) {
usersError = '';
lastReset = null;
try {
const { password } = await resetPassword(id);
if (password) lastReset = { userId: id, password };
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
}
}
const themeOptions: { value: Theme; label: string; hint: string }[] = [ const themeOptions: { value: Theme; label: string; hint: string }[] = [
{ value: 'dark', label: 'Dark', hint: 'Default' }, { value: 'dark', label: 'Dark', hint: 'Default' },
{ value: 'light', label: 'Light', hint: '' }, { value: 'light', label: 'Light', hint: '' },
@@ -64,6 +145,69 @@
<p>Single-tenant deployment settings live here. Nothing configurable yet.</p> <p>Single-tenant deployment settings live here. Nothing configurable yet.</p>
</section> </section>
{#if localAuthEnabled && localSession && localSession !== 'disabled'}
<section>
<h2>Users</h2>
{#if localSession.role !== 'owner'}
<p class="note">Only an owner can manage users. Signed in as {localSession.username} ({localSession.role}).</p>
{:else}
{#if usersError}<p class="error">{usersError}</p>{/if}
{#if usersLoading}
<p class="muted">Loading…</p>
{:else}
<table>
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th></th>
</tr>
</thead>
<tbody>
{#each users as u (u.id)}
<tr>
<td>{u.username}</td>
<td class="role-cell">{u.role}</td>
<td class="actions">
<button type="button" onclick={() => handleReset(u.id)}>Reset password</button>
<button type="button" class="danger" onclick={() => handleDelete(u.id)}>Delete</button>
</td>
</tr>
{#if lastReset?.userId === u.id}
<tr>
<td colspan="3">
<p class="note">
New password (shown once): <code>{lastReset.password}</code>
</p>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{/if}
<form onsubmit={handleCreate} class="create-user">
<input type="text" placeholder="Username" bind:value={newUsername} disabled={creating} required />
<input
type="password"
placeholder="Password (min. 8 characters)"
bind:value={newPassword}
disabled={creating}
required
/>
<select bind:value={newRole} disabled={creating}>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
<option value="owner">Owner</option>
</select>
<button type="submit" disabled={creating}>{creating ? 'Adding…' : 'Add user'}</button>
</form>
{/if}
</section>
{/if}
{#if loading} {#if loading}
<p class="muted">Loading…</p> <p class="muted">Loading…</p>
{:else if features.sso_configured} {:else if features.sso_configured}
@@ -154,4 +298,81 @@
.option.selected .option-hint { .option.selected .option-hint {
color: var(--color-text); color: var(--color-text);
} }
table {
width: 100%;
border-collapse: collapse;
margin-bottom: var(--space-4);
font-size: var(--text-sm);
}
th,
td {
text-align: left;
padding: var(--space-2) var(--space-2);
border-bottom: 1px solid var(--color-border);
}
.role-cell {
color: var(--color-text-muted);
text-transform: capitalize;
}
.actions {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
.actions button {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
cursor: pointer;
}
.actions button.danger {
color: var(--color-danger);
border-color: var(--color-danger);
}
.create-user {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
align-items: center;
}
.create-user input,
.create-user select {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
}
.create-user button {
padding: var(--space-2) var(--space-4);
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--color-bg);
background: var(--color-accent);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.create-user button:disabled {
cursor: default;
opacity: 0.6;
}
.error {
color: var(--color-danger);
font-size: var(--text-sm);
}
code {
font-family: var(--font-mono);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 3px;
padding: 0.1rem 0.4rem;
}
</style> </style>