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:
@@ -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
|
||||
Generated
+8
-8
@@ -258,7 +258,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -348,9 +348,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.15"
|
||||
version = "0.4.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -477,7 +477,7 @@ dependencies = [
|
||||
"hyper",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.5",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -737,9 +737,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.36.2"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
|
||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -842,7 +842,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1079,7 +1079,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+6
-1
@@ -8,8 +8,13 @@ WORKDIR /src
|
||||
COPY proto ./proto
|
||||
COPY agent ./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 \
|
||||
&& 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
|
||||
COPY --from=builder /src/agent/target/x86_64-unknown-linux-musl/release/sentry-agent /sentry-agent
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
"A native Windows service, not a WSL wrapper" means implementing the Win32
|
||||
|
||||
@@ -47,7 +47,7 @@ windows = { version = "0.58", features = [
|
||||
"Win32_Security",
|
||||
] }
|
||||
windows-service = "0.7"
|
||||
quick-xml = "0.36"
|
||||
quick-xml = "0.41"
|
||||
|
||||
[build-dependencies]
|
||||
tonic-build = "0.12"
|
||||
|
||||
@@ -51,6 +51,17 @@ interval = "60s"
|
||||
# interval = "5m"
|
||||
# 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]
|
||||
endpoint = "https://ingest.internal:4317"
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ pub struct Config {
|
||||
pub source: SourceConfig,
|
||||
pub batch: BatchConfig,
|
||||
pub heartbeat: HeartbeatConfig,
|
||||
pub metrics: MetricsConfig,
|
||||
pub ingest: IngestConfig,
|
||||
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
|
||||
/// -- "30s", "5m", "1h" -- deliberately the same s/m/h vocabulary
|
||||
/// `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)]
|
||||
#[serde(default)]
|
||||
pub struct IngestConfig {
|
||||
|
||||
+149
-11
@@ -1,6 +1,7 @@
|
||||
mod batch;
|
||||
mod config;
|
||||
mod grpc;
|
||||
mod metrics;
|
||||
mod source;
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -22,6 +23,8 @@ use clap::Parser;
|
||||
use config::Config;
|
||||
use pb::agent::v1::{agent_control_client::AgentControlClient, AgentCommand, CheckInRequest, DesiredOverride, ReportedConfig};
|
||||
use pb::{log_ingest_client::LogIngestClient, LogRecord, Severity};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
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 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
|
||||
// field (see apply_override below) can change it at runtime, which
|
||||
// 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_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)
|
||||
.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 heartbeat_enabled = cfg.heartbeat.enabled;
|
||||
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
|
||||
// every CheckIn as-is so the server can tell "pending" (an edit
|
||||
// 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.
|
||||
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 {
|
||||
tokio::select! {
|
||||
_ = metrics_ticker.tick(), if metrics_enabled => {
|
||||
send_metrics(&mut client, &host, &service).await;
|
||||
}
|
||||
_ = heartbeat_ticker.tick() => {
|
||||
if heartbeat_enabled {
|
||||
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 heartbeat_enabled, &mut heartbeat_interval,
|
||||
&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,
|
||||
).await;
|
||||
applied_override_version = ov.version.clone();
|
||||
@@ -272,7 +312,8 @@ async fn apply_override(
|
||||
heartbeat_ticker: &mut tokio::time::Interval,
|
||||
source_cfg: &mut config::SourceConfig,
|
||||
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>,
|
||||
) {
|
||||
if let Some(v) = ov.batch_max_size {
|
||||
@@ -310,19 +351,57 @@ async fn apply_override(
|
||||
if *current_unit != new_unit {
|
||||
*source_cfg = config::SourceConfig::Journald { unit: new_unit };
|
||||
source_handle.abort();
|
||||
let (new_handle, new_rx) = spawn_source_task(source_cfg.clone());
|
||||
*source_handle = new_handle;
|
||||
*rx = new_rx;
|
||||
*source_handle = spawn_source_task(source_cfg.clone(), tx.clone());
|
||||
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(¤t) {
|
||||
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>) {
|
||||
let (tx, rx) = mpsc::channel(1024);
|
||||
let handle = tokio::spawn(spawn_source(source_cfg, tx));
|
||||
(handle, rx)
|
||||
fn spawn_source_task(source_cfg: config::SourceConfig, tx: source::LineSender) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(spawn_source(source_cfg, tx))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -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 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/sentry/sentry/alerting/internal/notifystore"
|
||||
"github.com/sentry/sentry/alerting/internal/queryclient"
|
||||
"github.com/sentry/sentry/alerting/internal/rulestore"
|
||||
"github.com/sentry/sentry/alerting/internal/sessioncheck"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -39,6 +40,9 @@ func main() {
|
||||
logger.Error("loading config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, w := range cfg.DevCredentialWarnings() {
|
||||
logger.Warn(w)
|
||||
}
|
||||
|
||||
// -healthcheck: self-check mode for Docker's HEALTHCHECK, mirrors
|
||||
// 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)
|
||||
mux := http.NewServeMux()
|
||||
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{
|
||||
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)
|
||||
|
||||
+1
-1
@@ -12,5 +12,5 @@ require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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
@@ -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=
|
||||
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/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -16,6 +16,11 @@ type Config struct {
|
||||
APIServiceToken string // RoleService credential presented to /api's POST /query -- see queryclient.New's doc comment
|
||||
CORSAllowedOrigin string
|
||||
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 {
|
||||
@@ -43,6 +48,25 @@ type EvaluatorConfig struct {
|
||||
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) {
|
||||
cfg := Config{
|
||||
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8081"),
|
||||
@@ -90,6 +114,12 @@ func Load() (Config, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -162,6 +162,17 @@ func (w *Worker) attempt(ctx context.Context, c claimedDelivery) {
|
||||
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))
|
||||
if err != nil {
|
||||
w.fail(ctx, c, 0, fmt.Sprintf("building request: %v", err))
|
||||
|
||||
@@ -151,6 +151,10 @@ func (h *Handler) handleCreateTarget(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "webhook_url must not be empty")
|
||||
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 {
|
||||
h.logger.Error("creating notification target", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "creating notification target failed")
|
||||
|
||||
@@ -231,12 +231,29 @@ func TestCreateTargetRejectsInvalidKind(t *testing.T) {
|
||||
|
||||
func TestCreateSlackTarget(t *testing.T) {
|
||||
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 {
|
||||
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) {
|
||||
deliveries := &fakeDeliveryReader{entries: []rulestore.DeliveryLogEntry{
|
||||
{ID: 1, RuleID: "rule-1", EventType: "firing", Status: "sent"},
|
||||
|
||||
@@ -22,3 +22,27 @@ func WithCORS(next http.Handler, allowedOrigin string) http.Handler {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/sentry/sentry/api/authz"
|
||||
)
|
||||
@@ -128,6 +131,30 @@ func (h *Handler) handleSetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
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))
|
||||
if err != nil {
|
||||
h.writeStoreErr(w, err, "setting agent config")
|
||||
@@ -202,9 +229,97 @@ func validateOverride(o ConfigOverride) error {
|
||||
if o.HeartbeatIntervalMS != nil && *o.HeartbeatIntervalMS < 5000 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "agent not found")
|
||||
|
||||
@@ -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) {
|
||||
h := newTestHandler(newFakeStore())
|
||||
interval := int64(30000)
|
||||
|
||||
+6
-5
@@ -35,11 +35,12 @@ func validCommand(c string) bool {
|
||||
// grpcserver.TenantIDHeaderKey, enterprise/internal/apiconfig.AIConfig).
|
||||
// Keep the three in sync by hand.
|
||||
type ConfigOverride struct {
|
||||
BatchMaxSize *int64 `json:"batch_max_size,omitempty"`
|
||||
BatchFlushIntervalMS *int64 `json:"batch_flush_interval_ms,omitempty"`
|
||||
HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"`
|
||||
HeartbeatIntervalMS *int64 `json:"heartbeat_interval_ms,omitempty"`
|
||||
JournaldUnit *string `json:"journald_unit,omitempty"`
|
||||
BatchMaxSize *int64 `json:"batch_max_size,omitempty"`
|
||||
BatchFlushIntervalMS *int64 `json:"batch_flush_interval_ms,omitempty"`
|
||||
HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"`
|
||||
HeartbeatIntervalMS *int64 `json:"heartbeat_interval_ms,omitempty"`
|
||||
JournaldUnit *string `json:"journald_unit,omitempty"`
|
||||
ExtraFilePaths []string `json:"extra_file_paths,omitempty"`
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
|
||||
+96
-4
@@ -7,7 +7,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -28,6 +32,7 @@ import (
|
||||
"github.com/sentry/sentry/api/dashboards"
|
||||
"github.com/sentry/sentry/api/httpserver"
|
||||
"github.com/sentry/sentry/api/internal/config"
|
||||
"github.com/sentry/sentry/api/localauth"
|
||||
"github.com/sentry/sentry/api/queryapi"
|
||||
"github.com/sentry/sentry/api/querylang/executor"
|
||||
"github.com/sentry/sentry/api/searchclient"
|
||||
@@ -48,6 +53,9 @@ func main() {
|
||||
logger.Error("loading config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, w := range cfg.DevCredentialWarnings() {
|
||||
logger.Warn(w)
|
||||
}
|
||||
|
||||
// -healthcheck: a self-check mode for Docker's HEALTHCHECK, not a
|
||||
// flag anyone runs by hand. The api image is distroless (no shell,
|
||||
@@ -59,6 +67,13 @@ func main() {
|
||||
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)
|
||||
defer stop()
|
||||
|
||||
@@ -101,12 +116,25 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *seedAdmin {
|
||||
os.Exit(runSeedAdmin(ctx, logger, os.Stdout, localauth.NewStore(pgPool)))
|
||||
}
|
||||
|
||||
// authorizer is nil (RequireRole* becomes a no-op) unless
|
||||
// ENTERPRISE_AUTH_URL is configured -- matches Phase 0-3 behavior
|
||||
// for a single-tenant deployment with no enterprise/ deployed.
|
||||
// ENTERPRISE_AUTH_URL or LOCAL_AUTH_ENABLED is configured -- matches
|
||||
// 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
|
||||
if cfg.EnterpriseAuthURL != "" {
|
||||
var localAuthStore *localauth.Store
|
||||
switch {
|
||||
case cfg.EnterpriseAuthURL != "":
|
||||
authorizer = authz.NewHTTPAuthorizer(cfg.EnterpriseAuthURL)
|
||||
case cfg.LocalAuth.Enabled:
|
||||
localAuthStore = localauth.NewStore(pgPool)
|
||||
authorizer = localauth.NewAuthorizer(localAuthStore)
|
||||
}
|
||||
|
||||
sqlRunner := executor.NewChRunner(conn)
|
||||
@@ -139,6 +167,18 @@ func main() {
|
||||
dashboardsHandler.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
|
||||
// is set -- an unconfigured deployment gets a plain 404 on /ai/*
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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{
|
||||
Addr: cfg.HTTPListenAddr,
|
||||
Handler: httpserver.WithCORS(mux, cfg.CORSAllowedOrigin),
|
||||
Handler: corsHandler,
|
||||
}
|
||||
|
||||
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
|
||||
// Docker's HEALTHCHECK to exec directly (see the -healthcheck flag
|
||||
// above). listenAddr is HTTP_LISTEN_ADDR-shaped (e.g. ":8080") --
|
||||
|
||||
+3
-2
@@ -5,7 +5,9 @@ go 1.25.0
|
||||
require (
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.48.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
|
||||
golang.org/x/crypto v0.55.0
|
||||
google.golang.org/grpc v1.83.0
|
||||
)
|
||||
|
||||
@@ -19,7 +21,6 @@ require (
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // 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/klauspost/compress v1.19.1 // 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/sync v0.22.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/protobuf v1.36.12 // indirect
|
||||
)
|
||||
|
||||
+4
-2
@@ -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/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
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/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
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/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
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.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
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/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
|
||||
@@ -18,6 +18,32 @@ type Config struct {
|
||||
CORSAllowedOrigin string
|
||||
EnterpriseAuthURL string
|
||||
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) --
|
||||
@@ -50,6 +76,33 @@ type PostgresConfig struct {
|
||||
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) {
|
||||
cfg := Config{
|
||||
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8080"),
|
||||
@@ -98,6 +151,25 @@ func Load() (Config, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -30,3 +30,23 @@ func TestLoadInvalidTimeoutErrors(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +259,24 @@ func defaultAggAlias(a ast.AggCall) string {
|
||||
// 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`)
|
||||
|
||||
// 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 {
|
||||
trimmed := strings.TrimSpace(sql)
|
||||
if trimmed == "" {
|
||||
@@ -281,6 +299,9 @@ func validateSelectOnly(sql string) error {
|
||||
if disallowedKeyword.MatchString(trimmed) {
|
||||
return fmt.Errorf("query contains a disallowed keyword")
|
||||
}
|
||||
if disallowedTableFunction.MatchString(trimmed) {
|
||||
return fmt.Errorf("query contains a disallowed table function")
|
||||
}
|
||||
|
||||
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) {
|
||||
// "select" as a bare free-text search term -- would be misdetected
|
||||
// as SQL by the heuristic alone, hence the override.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// from a file (agents config set, agents restart).
|
||||
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 {
|
||||
fmt.Fprintf(stderr, "building request: %v\n", err)
|
||||
return 1
|
||||
|
||||
@@ -40,6 +40,8 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
return cmdAlerts(args[1:], stdout, stderr)
|
||||
case "agents":
|
||||
return cmdAgents(args[1:], stdout, stderr)
|
||||
case "users":
|
||||
return cmdUsers(args[1:], os.Stdin, stdout, stderr)
|
||||
case "-h", "--help", "help":
|
||||
usage(stdout)
|
||||
return 0
|
||||
@@ -67,6 +69,11 @@ Usage:
|
||||
[--heartbeat-enabled true|false] [--heartbeat-interval-ms N]
|
||||
[--journald-unit UNIT] [--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:
|
||||
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
|
||||
log collection on that host and prompts for confirmation
|
||||
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.
|
||||
--alerting-api defaults to $SENTRYCTL_ALERTING_API_URL, or `+defaultAlertingURL+` if unset.
|
||||
|
||||
@@ -45,11 +45,11 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.26.0 // 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/sys v0.21.0 // indirect
|
||||
golang.org/x/term v0.21.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
|
||||
+10
-10
@@ -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-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.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
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/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
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-20190412213103-97732733099d/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.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
|
||||
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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/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.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
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/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-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-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.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -73,6 +73,9 @@ func main() {
|
||||
logger.Error("loading config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, w := range cfg.DevCredentialWarnings() {
|
||||
logger.Warn(w)
|
||||
}
|
||||
|
||||
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
|
||||
os.Exit(runHealthcheck(cfg.HTTPListenAddr))
|
||||
|
||||
@@ -56,6 +56,9 @@ func main() {
|
||||
logger.Error("loading config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, w := range cfg.DevCredentialWarnings() {
|
||||
logger.Warn(w)
|
||||
}
|
||||
|
||||
// -mint-service-token issues a RoleService credential and prints it
|
||||
// to stdout, then exits -- an operator bootstrap step (run once,
|
||||
|
||||
@@ -59,6 +59,9 @@ func main() {
|
||||
logger.Error("loading config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, w := range cfg.DevCredentialWarnings() {
|
||||
logger.Warn(w)
|
||||
}
|
||||
|
||||
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
|
||||
os.Exit(runHealthcheck(cfg.HTTPListenAddr))
|
||||
|
||||
+6
-6
@@ -36,6 +36,7 @@ require (
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/sentry/sentry/proto v0.0.0-00010101000000-000000000000
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sync v0.22.0
|
||||
google.golang.org/grpc v1.83.0
|
||||
k8s.io/api v0.31.0
|
||||
k8s.io/apimachinery v0.31.0
|
||||
@@ -45,7 +46,7 @@ require (
|
||||
require (
|
||||
github.com/ClickHouse/ch-go v0.74.0 // 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // 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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.19.1 // indirect
|
||||
@@ -78,7 +79,7 @@ require (
|
||||
github.com/paulmach/orb v0.13.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.27 // 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/kafka-go v0.4.51 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
@@ -86,12 +87,11 @@ require (
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.opentelemetry.io/otel 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/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.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
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
|
||||
+12
-21
@@ -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/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
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.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs=
|
||||
github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
|
||||
github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE=
|
||||
github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc=
|
||||
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/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/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/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
|
||||
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
|
||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
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/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
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/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
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.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
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/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
|
||||
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/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.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
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/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys=
|
||||
github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw=
|
||||
github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks=
|
||||
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/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
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.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
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/mod v0.2.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=
|
||||
@@ -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/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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
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/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-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-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
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-20191011141410-1b5146add898/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/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
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/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/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
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/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-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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
||||
|
||||
@@ -87,6 +87,34 @@ type AuditWriterConfig struct {
|
||||
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) {
|
||||
cfg := Config{
|
||||
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8083"),
|
||||
|
||||
@@ -37,6 +37,36 @@ type Config struct {
|
||||
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 {
|
||||
Addr string
|
||||
Database string
|
||||
|
||||
@@ -49,6 +49,25 @@ type BatchConfig struct {
|
||||
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) {
|
||||
cfg := Config{
|
||||
HTTPListenAddr: getenv("HTTP_LISTEN_ADDR", ":8084"),
|
||||
|
||||
@@ -12,7 +12,7 @@ require (
|
||||
require (
|
||||
golang.org/x/net v0.55.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/protobuf v1.36.12 // indirect
|
||||
)
|
||||
|
||||
@@ -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/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
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.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
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/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
|
||||
@@ -12,7 +12,7 @@ require (
|
||||
require (
|
||||
golang.org/x/net v0.55.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/protobuf v1.36.12 // indirect
|
||||
)
|
||||
|
||||
@@ -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/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
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.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
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/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
|
||||
@@ -69,6 +69,9 @@ func main() {
|
||||
logger.Error("loading config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, w := range cfg.DevCredentialWarnings() {
|
||||
logger.Warn(w)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
@@ -34,11 +34,12 @@ const defaultTenantID = "default"
|
||||
// across a module boundary rather than couple two independently
|
||||
// deployable services' builds together. Keep the two in sync by hand.
|
||||
type overrideFields struct {
|
||||
BatchMaxSize *uint64 `json:"batch_max_size,omitempty"`
|
||||
BatchFlushIntervalMS *uint64 `json:"batch_flush_interval_ms,omitempty"`
|
||||
HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"`
|
||||
HeartbeatIntervalMS *uint64 `json:"heartbeat_interval_ms,omitempty"`
|
||||
JournaldUnit *string `json:"journald_unit,omitempty"`
|
||||
BatchMaxSize *uint64 `json:"batch_max_size,omitempty"`
|
||||
BatchFlushIntervalMS *uint64 `json:"batch_flush_interval_ms,omitempty"`
|
||||
HeartbeatEnabled *bool `json:"heartbeat_enabled,omitempty"`
|
||||
HeartbeatIntervalMS *uint64 `json:"heartbeat_interval_ms,omitempty"`
|
||||
JournaldUnit *string `json:"journald_unit,omitempty"`
|
||||
ExtraFilePaths []string `json:"extra_file_paths,omitempty"`
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
@@ -146,6 +147,7 @@ func (r *Registry) CheckIn(ctx context.Context, tenantID string, info grpcserver
|
||||
HeartbeatEnabled: fields.HeartbeatEnabled,
|
||||
HeartbeatIntervalMS: fields.HeartbeatIntervalMS,
|
||||
JournaldUnit: fields.JournaldUnit,
|
||||
ExtraFilePaths: fields.ExtraFilePaths,
|
||||
Version: *desiredVersion,
|
||||
}
|
||||
return result, nil
|
||||
|
||||
@@ -75,6 +75,29 @@ type BatchConfig struct {
|
||||
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) {
|
||||
cfg := Config{
|
||||
GRPC: GRPCConfig{
|
||||
|
||||
@@ -147,7 +147,12 @@ type AgentOverride struct {
|
||||
HeartbeatEnabled *bool
|
||||
HeartbeatIntervalMS *uint64
|
||||
JournaldUnit *string
|
||||
Version 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
|
||||
}
|
||||
|
||||
func New(logger *slog.Logger, grpcCfg config.GRPCConfig, tlsCfg config.TLSConfig, p batchProducer, resolver TenantResolver, agents AgentRegistry) *Server {
|
||||
@@ -298,6 +303,7 @@ func (s *Server) CheckIn(ctx context.Context, req *agentv1.CheckInRequest) (*age
|
||||
HeartbeatEnabled: result.Override.HeartbeatEnabled,
|
||||
HeartbeatIntervalMs: result.Override.HeartbeatIntervalMS,
|
||||
JournaldUnit: result.Override.JournaldUnit,
|
||||
ExtraFilePaths: result.Override.ExtraFilePaths,
|
||||
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
@@ -10,6 +10,6 @@ require (
|
||||
require (
|
||||
golang.org/x/net v0.55.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
|
||||
)
|
||||
|
||||
+2
-2
@@ -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/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
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.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
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/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
|
||||
@@ -272,9 +272,20 @@ type DesiredOverride struct {
|
||||
// agent's only obligation is to echo it back as
|
||||
// CheckInRequest.applied_override_version once applied -- it never
|
||||
// interprets the value itself.
|
||||
Version string `protobuf:"bytes,6,opt,name=version,proto3" json:"version,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
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
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *DesiredOverride) Reset() {
|
||||
@@ -349,6 +360,13 @@ func (x *DesiredOverride) GetVersion() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DesiredOverride) GetExtraFilePaths() []string {
|
||||
if x != nil {
|
||||
return x.ExtraFilePaths
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CheckInResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// 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" +
|
||||
"\aservice\x18\x02 \x01(\tR\aservice\x12F\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" +
|
||||
"\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" +
|
||||
"\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" +
|
||||
"\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" +
|
||||
"\x18_batch_flush_interval_msB\x14\n" +
|
||||
"\x12_heartbeat_enabledB\x18\n" +
|
||||
|
||||
@@ -69,6 +69,17 @@ message DesiredOverride {
|
||||
// CheckInRequest.applied_override_version once applied -- it never
|
||||
// interprets the value itself.
|
||||
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
|
||||
|
||||
Generated
+7
-7
@@ -344,7 +344,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -534,9 +534,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.15"
|
||||
version = "0.4.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -698,7 +698,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.5",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -1089,7 +1089,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1548,7 +1548,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1954,7 +1954,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+9
-9
@@ -48,15 +48,15 @@ require (
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/zclconf/go-cty v1.18.1 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.39.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
google.golang.org/grpc v1.82.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
+30
-30
@@ -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=
|
||||
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/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
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-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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
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-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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
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-20200116001909-b77594299b42/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-20220722155257-8c9f86f7a55f/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.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
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-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.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.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
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-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.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
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.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||
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-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
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/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
@@ -22,9 +22,16 @@ COPY . .
|
||||
ARG VITE_API_BASE_URL=http://localhost:8080
|
||||
ARG VITE_ALERTING_API_BASE_URL=http://localhost:8081
|
||||
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_ALERTING_API_BASE_URL=${VITE_ALERTING_API_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
|
||||
|
||||
# Not distroless: serving a static SPA needs *some* HTTP server, and
|
||||
|
||||
@@ -3,6 +3,43 @@ server {
|
||||
root /usr/share/nginx/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 / {
|
||||
# adapter-static writes prerendered routes as flat <route>.html
|
||||
# files (e.g. /dashboards -> dashboards.html, confirmed by
|
||||
|
||||
@@ -14,6 +14,18 @@ export const alertingBase = import.meta.env.VITE_ALERTING_API_BASE_URL ?? 'http:
|
||||
// disabled, no broken links.
|
||||
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';
|
||||
|
||||
// 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> {
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...(localAuthEnabled ? { credentials: 'include' as RequestCredentials } : {}),
|
||||
...init
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -393,6 +406,74 @@ export function injectTimeRange(query: string, earliest: string, latest: string)
|
||||
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 ---------------------------------------------------------
|
||||
|
||||
export type ConditionType = 'threshold' | 'absence';
|
||||
@@ -484,6 +565,7 @@ export type ConfigOverride = {
|
||||
heartbeat_enabled?: boolean;
|
||||
heartbeat_interval_ms?: number;
|
||||
journald_unit?: string;
|
||||
extra_file_paths?: string[];
|
||||
};
|
||||
|
||||
export type Agent = {
|
||||
@@ -545,3 +627,85 @@ export function issueAgentCommand(host: string, command: 'restart'): Promise<Age
|
||||
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')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<script lang="ts">
|
||||
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 { getDensity, toggleDensity } from '$lib/density.svelte';
|
||||
|
||||
@@ -16,6 +24,7 @@
|
||||
{ href: '/alerts', label: 'Alerts', icon: '▲' },
|
||||
{ href: '/data-sources', label: 'Data Sources', icon: '◈' },
|
||||
{ href: '/agents', label: 'Agents', icon: '●' },
|
||||
{ href: '/hosts', label: 'Hosts', icon: '▣' },
|
||||
{ href: '/settings', label: 'Settings', icon: '⚙' }
|
||||
];
|
||||
|
||||
@@ -29,6 +38,22 @@
|
||||
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 }[] = [
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
@@ -60,6 +85,17 @@
|
||||
<a class="switch signin" href="{enterpriseAuthBase}/auth/oidc/login">Sign in</a>
|
||||
{/if}
|
||||
</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}
|
||||
|
||||
<nav aria-label="Main">
|
||||
@@ -177,6 +213,18 @@
|
||||
.switch:hover {
|
||||
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 {
|
||||
display: block;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
|
||||
@@ -3,10 +3,60 @@
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import NavSidebar from '$lib/components/NavSidebar.svelte';
|
||||
import CommandPalette from '$lib/components/CommandPalette.svelte';
|
||||
import { page } from '$app/state';
|
||||
import { getLocalSession } from '$lib/api';
|
||||
|
||||
let { children } = $props();
|
||||
let paletteOpen = $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>
|
||||
|
||||
<svelte:head>
|
||||
@@ -14,25 +64,42 @@
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="shell">
|
||||
<NavSidebar
|
||||
onOpenPalette={() => (paletteOpen = true)}
|
||||
mobileOpen={mobileNavOpen}
|
||||
onCloseMobile={() => (mobileNavOpen = false)}
|
||||
/>
|
||||
<div class="main-col">
|
||||
<button type="button" class="menu-toggle" onclick={() => (mobileNavOpen = true)} aria-label="Open menu">
|
||||
☰
|
||||
</button>
|
||||
<div class="page">
|
||||
{@render children()}
|
||||
{#if isLoginPage}
|
||||
{@render children()}
|
||||
{:else if initialCheckDone && authorized}
|
||||
<div class="shell">
|
||||
<NavSidebar
|
||||
onOpenPalette={() => (paletteOpen = true)}
|
||||
mobileOpen={mobileNavOpen}
|
||||
onCloseMobile={() => (mobileNavOpen = false)}
|
||||
/>
|
||||
<div class="main-col">
|
||||
<button type="button" class="menu-toggle" onclick={() => (mobileNavOpen = true)} aria-label="Open menu">
|
||||
☰
|
||||
</button>
|
||||
<div class="page">
|
||||
{@render children()}
|
||||
</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>
|
||||
.auth-loading {
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 15rem 1fr;
|
||||
|
||||
@@ -26,6 +26,13 @@
|
||||
let heartbeatEnabled = $state(true);
|
||||
let heartbeatIntervalMs = $state('0');
|
||||
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) {
|
||||
const o = a.desired_override;
|
||||
@@ -34,6 +41,15 @@
|
||||
heartbeatEnabled = o?.heartbeat_enabled ?? a.heartbeat_enabled;
|
||||
heartbeatIntervalMs = String(o?.heartbeat_interval_ms ?? a.heartbeat_interval_ms);
|
||||
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() {
|
||||
@@ -59,7 +75,8 @@
|
||||
batch_flush_interval_ms: Number(batchFlushIntervalMs),
|
||||
heartbeat_enabled: heartbeatEnabled,
|
||||
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) {
|
||||
saveError = e instanceof Error ? e.message : String(e);
|
||||
@@ -179,6 +196,20 @@
|
||||
<Input id="journald-unit" placeholder="(empty = whole journal)" bind:value={journaldUnit} />
|
||||
</div>
|
||||
{/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}
|
||||
|
||||
@@ -275,6 +306,9 @@
|
||||
margin-bottom: var(--space-3);
|
||||
max-width: 20rem;
|
||||
}
|
||||
.field.extra-paths {
|
||||
max-width: none;
|
||||
}
|
||||
.field label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
@@ -285,6 +319,22 @@
|
||||
gap: var(--space-2);
|
||||
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 {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -1,5 +1,17 @@
|
||||
<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 { getDensity, setDensity, type Density } from '$lib/density.svelte';
|
||||
|
||||
@@ -13,6 +25,75 @@
|
||||
}
|
||||
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 }[] = [
|
||||
{ value: 'dark', label: 'Dark', hint: 'Default' },
|
||||
{ value: 'light', label: 'Light', hint: '' },
|
||||
@@ -64,6 +145,69 @@
|
||||
<p>Single-tenant deployment settings live here. Nothing configurable yet.</p>
|
||||
</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}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if features.sso_configured}
|
||||
@@ -154,4 +298,81 @@
|
||||
.option.selected .option-hint {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user