Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ release promotes it to a version heading; the `bump-version` skill does that.
connection's own address. `peer_ip` is always the connection's.
- `server.external_url` may be a template over those variables, rendered per
request: `http://{{ host }}/` answers each client with its own address.
- `DOPPEL_SENTRY_DSN` provides the Sentry DSN, or overrides `sentry.dsn`: a DSN is
a credential, and the environment is where a deployment keeps one.

### Changed

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ full list.
upstream's redirects. Needed behind a port mapping or an ingress; otherwise
`server.host:server.port` is used.

`DOPPEL_ADMIN_TOKENS` and `DOPPEL_SENTRY_DSN` keep the two credentials out of the
configuration file. Both override what the document says.

## A minimal configuration

```yaml
Expand Down
12 changes: 10 additions & 2 deletions crates/doppel-cli/src/commands/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,16 @@ pub async fn serve(store: Arc<dyn ConfigStore>, config: Config) -> Result<(), Cl
//
// Bound to a name, not `_`: the guard flushes and stops reporting when
// dropped, and `let _ = ...` would drop it here.
let _sentry = doppel_telemetry::sentry::init(config.sentry.as_ref())
.map_err(|err| CliError::Failed(err.to_string()))?;
//
// `DOPPEL_SENTRY_DSN` is read here rather than merged into the document, for
// the reason the admin tokens are not: the revision is a hash of the
// configuration's content, and folding the environment into it would make two
// instances reading one stored document disagree about the revision.
let _sentry = doppel_telemetry::sentry::init(
config.sentry.as_ref(),
doppel_core::config::sentry_dsn_from_env().as_deref(),
)
.map_err(|err| CliError::Failed(err.to_string()))?;

// Checked before anything binds, and a bad value fails startup. A
// malformed token variable that was logged and skipped would leave an
Expand Down
68 changes: 68 additions & 0 deletions crates/doppel-cli/tests/logging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,71 @@ fn a_dsn_without_the_feature_is_reported_rather_than_ignored() {
);
assert!(!both.contains("s3cr3tsentrykey"), "{both}");
}

/// The DSN from the environment, through the built binary.
///
/// The unit tests decide precedence; this is the half only a process can answer:
/// that the variable is read at all, that the startup line says which source won,
/// and that the key does not travel any further from the environment than it does
/// from the document.
#[test]
fn a_dsn_from_the_environment_is_read_and_its_source_named() {
let up = upstream();
// No `sentry` section in the document at all -- the case a deployment that
// provisions credentials through the environment actually has.
let server = Server::start_with_env(up.port, config, &[("DOPPEL_SENTRY_DSN", SENTRY_DSN)]);
server.get("/anything");

send_sigterm(server.pid());
let (_status, stdout, stderr) =
wait_after_signal(server.into_child(), "SIGTERM", SIGNAL_WAIT_DEADLINE);

let both = format!("{stdout}{stderr}");
assert!(
!both.contains("s3cr3tsentrykey"),
"the key leaked into the logs: {both}"
);
// Named the way an operator would grep for it, whether or not this build can
// report: with the feature the line says reporting is enabled, without it that
// nothing will be reported, and both carry the source.
assert!(
both.contains("DOPPEL_SENTRY_DSN"),
"startup must say which source the dsn came from: {both}"
);
assert!(
both.contains("sentry.invalid"),
"the host survives redaction, which is what makes the line useful: {both}"
);
}

/// An empty variable leaves the document's DSN alone.
///
/// The direction matters: `DOPPEL_SENTRY_DSN=${SENTRY_DSN}` with nothing behind
/// `SENTRY_DSN` is a compose file that means nothing by it, and silently turning
/// error reporting off is the worse reading of that.
#[test]
fn an_empty_variable_does_not_disable_a_configured_dsn() {
let up = upstream();
let server = Server::start_with_env(
up.port,
|ports, socket, templates| {
config(ports, socket, templates).replace(
"proxies:",
&format!("sentry:\n dsn: \"{SENTRY_DSN}\"\nproxies:"),
)
},
&[("DOPPEL_SENTRY_DSN", "")],
);
server.get("/anything");

send_sigterm(server.pid());
let (_status, stdout, stderr) =
wait_after_signal(server.into_child(), "SIGTERM", SIGNAL_WAIT_DEADLINE);

let both = format!("{stdout}{stderr}");
assert!(
both.contains("sentry.dsn"),
"the document's dsn must still be the one in force: {both}"
);
assert!(!both.contains("s3cr3tsentrykey"), "{both}");
}
36 changes: 34 additions & 2 deletions crates/doppel-core/src/config/env.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//! Configuration supplied by the environment: the admin tokens, and the
//! external url.
//! Configuration supplied by the environment: the admin tokens, the external
//! url, and the Sentry DSN.
//!
//! A deployment that provisions its secrets through the environment should
//! not have to write them into the configuration document to use them. These
Expand Down Expand Up @@ -36,6 +36,38 @@ pub struct EnvExternalUrlError {
pub reason: UrlError,
}

/// The variable that provides `sentry.dsn`, or replaces it.
pub const SENTRY_DSN_VAR: &str = "DOPPEL_SENTRY_DSN";

/// `DOPPEL_SENTRY_DSN`, or `None` when it is unset or empty.
///
/// A DSN carries the key that authorises sending events, which is why this
/// exists: it is the one Sentry setting that is a credential, and a deployment
/// that provisions credentials through the environment should not have to write
/// this one into a document the admin API returns and the store keeps.
///
/// Not validated here. A malformed value fails startup where the client is
/// built, which is the one place that knows what a DSN has to parse as -- and it
/// reports the value with its credential redacted, which this module has no way
/// to do.
///
/// Empty counts as unset, like the two variables above, and that direction is
/// deliberate: `DOPPEL_SENTRY_DSN=${SENTRY_DSN}` with nothing behind `SENTRY_DSN`
/// leaves a configured DSN in force rather than silently turning reporting off.
/// Turning it off is `dsn: ""` in the document, or no `sentry` section at all.
///
/// Doppel reads its own name and not the conventional `SENTRY_DSN`. A variable
/// that is in the environment for the service beside this one should not make
/// this one start reporting to it.
#[must_use]
pub fn sentry_dsn_from_env() -> Option<String> {
match std::env::var(SENTRY_DSN_VAR) {
Ok(raw) if raw.trim().is_empty() => None,
Ok(raw) => Some(raw.trim().to_owned()),
Err(_) => None,
}
}

/// `DOPPEL_EXTERNAL_URL`, or `None` when it is unset or empty.
///
/// Empty counts as unset, like the token variable: `DOPPEL_EXTERNAL_URL=${HOST}`
Expand Down
5 changes: 4 additions & 1 deletion crates/doppel-core/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ pub use admin::{
UploadConfig,
};
pub use duration::{Seconds, SecondsError, TimeoutError, TimeoutSeconds};
pub use env::{EnvExternalUrlError, EnvTokens, EnvTokensError, external_url_from_env};
pub use env::{
EnvExternalUrlError, EnvTokens, EnvTokensError, SENTRY_DSN_VAR, external_url_from_env,
sentry_dsn_from_env,
};
pub use header::{HeaderName, HeaderNameError, HeaderValue, HeaderValueError};
pub use mock::{MockConfig, MockProxyOverride, MockRequest, MockResponse};
pub use name::{MAX_PROXY, Name, NameError, ProxyName};
Expand Down
5 changes: 5 additions & 0 deletions crates/doppel-core/src/config/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ impl Default for TemplatesConfig {
pub struct SentryConfig {
/// The Sentry DSN to report to. Empty disables reporting, so a deployment
/// can blank it without removing the section.
///
/// `DOPPEL_SENTRY_DSN` overrides it. A DSN carries the key that authorises
/// sending events, so a deployment that keeps credentials in the environment
/// can leave this field out entirely; an empty variable counts as unset and
/// leaves this value in force.
#[schema(examples("https://key@o0.ingest.sentry.io/0"))]
pub dsn: String,
}
Expand Down
Loading