diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index b72fe4c9..2744c6ce 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -122,6 +122,7 @@ jobs: docker buildx build \ --platform linux/amd64 \ --provenance=false --sbom=false \ + --build-arg INBUXA_REVISION="${{ github.sha }}" \ --tag "$IMAGE:$VERSION-amd64" \ --tag "$IMAGE:$VERSION" \ --push . @@ -163,6 +164,7 @@ jobs: docker buildx build \ --platform linux/arm64 \ --provenance=false --sbom=false \ + --build-arg INBUXA_REVISION="${{ github.sha }}" \ --tag "$IMAGE:$VERSION-arm64" \ --push . args="" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fd9d0ba..7b8d42a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -264,6 +264,7 @@ jobs: platforms: linux/${{ matrix.arch }} provenance: false sbom: false + build-args: INBUXA_REVISION=${{ github.sha }} push: true tags: | ${{ env.IMAGE }}:${{ env.VERSION }}-${{ matrix.arch }} diff --git a/Dockerfile b/Dockerfile index ee563794..8785c616 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,11 @@ COPY --from=planner /recipe.json /recipe.json COPY vendor/ vendor/ RUN RUSTFLAGS="$(cat /flags.txt)" cargo chef cook --target "$(cat /target.txt)" --release --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" --recipe-path /recipe.json COPY . . +# inbuxa: the commit being built, for /.well-known/source. The context has no +# .git, so the release workflow passes it; a build without it serves no +# source document. +ARG INBUXA_REVISION="" +ENV INBUXA_REVISION=${INBUXA_REVISION} RUN RUSTFLAGS="$(cat /flags.txt)" cargo build --target "$(cat /target.txt)" --release -p inbuxa --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" RUN mv "/build/target/$(cat /target.txt)/release" "/output" diff --git a/crates/http/src/lib.rs b/crates/http/src/lib.rs index 6ee5d829..4e3a6751 100644 --- a/crates/http/src/lib.rs +++ b/crates/http/src/lib.rs @@ -19,6 +19,7 @@ pub mod form; pub mod live; // inbuxa: monitoring (MON-20 to MON-24) pub mod request; pub mod scim; // inbuxa: SCIM 2.0 provisioning +pub mod source; // inbuxa: /.well-known/source use common::Inner; use std::sync::Arc; diff --git a/crates/http/src/request.rs b/crates/http/src/request.rs index 570f01fb..e1c9e324 100644 --- a/crates/http/src/request.rs +++ b/crates/http/src/request.rs @@ -350,6 +350,22 @@ impl ParseHttp for Server { }; } } + // inbuxa: where this build's source is (/.well-known/source) + ("source", &Method::GET | &Method::HEAD) => { + // Limit anonymous requests + self.is_http_anonymous_request_allowed(session.remote_ip) + .await?; + + return match crate::source::document() { + Some(document) => { + Ok(Resource::new("application/json", document.into_bytes()) + .into_http_response() + .with_no_cache() + .with_cors_unrestricted()) + } + None => Err(trc::ResourceEvent::NotFound.into_err()), + }; + } ("mta-sts.txt", &Method::GET) => { // Limit anonymous requests self.is_http_anonymous_request_allowed(session.remote_ip) diff --git a/crates/http/src/source.rs b/crates/http/src/source.rs new file mode 100644 index 00000000..a3707e6d --- /dev/null +++ b/crates/http/src/source.rs @@ -0,0 +1,89 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! `/.well-known/source`: what the server runs and where its source is, in +//! the form https://coffeylabs.org/specs/well-known-source/ gives it. +//! +//! The server names its public repository and the commit it was built from. +//! A build that doesn't know its commit says nothing, and the address answers +//! 404, since a repository without a revision doesn't name the build. + +use serde::Serialize; + +#[derive(Serialize)] +struct Document<'a> { + version: u32, + software: [Software<'a>; 1], +} + +#[derive(Serialize)] +struct Software<'a> { + name: &'a str, + version: &'a str, + license: &'a str, + repository: &'a str, + revision: &'a str, +} + +/// The document for this build, if it knows its commit. +pub fn document() -> Option { + document_for(types::branding::BUILD_REVISION) +} + +fn document_for(revision: Option<&str>) -> Option { + let revision = revision.filter(|r| is_commit_id(r))?; + serde_json::to_string(&Document { + version: 1, + software: [Software { + name: types::brand_server!(), + version: types::brand_version!(), + license: "AGPL-3.0-only", + repository: types::brand_repository!(), + revision, + }], + }) + .ok() +} + +/// A full commit id: 40 hex digits (SHA-1) or 64 (SHA-256). +fn is_commit_id(revision: &str) -> bool { + matches!(revision.len(), 40 | 64) && revision.bytes().all(|b| b.is_ascii_hexdigit()) +} + +#[cfg(test)] +mod tests { + use super::document_for; + + const COMMIT: &str = "f17b0943c2a1d5e8b7f6a9c0d1e2f3a4b5c6d7e8"; + + #[test] + fn names_the_repository_and_commit() { + let doc: serde_json::Value = + serde_json::from_str(&document_for(Some(COMMIT)).unwrap()).unwrap(); + assert_eq!(doc["version"], 1); + let software = doc["software"].as_array().unwrap(); + assert_eq!(software.len(), 1); + assert_eq!(software[0]["name"], types::brand_server!()); + assert_eq!(software[0]["version"], types::brand_version!()); + assert_eq!(software[0]["license"], "AGPL-3.0-only"); + assert_eq!(software[0]["repository"], types::brand_repository!()); + assert_eq!(software[0]["revision"], COMMIT); + } + + #[test] + fn says_nothing_without_a_full_commit() { + let not_hex = COMMIT.replace('f', "g"); + for revision in [ + None, + Some(""), + Some("f17b094"), + Some("v27.1"), + Some(not_hex.as_str()), + ] { + assert_eq!(document_for(revision), None, "{revision:?}"); + } + } +} diff --git a/crates/types/src/branding.rs b/crates/types/src/branding.rs index e00ac3d5..22abdda0 100644 --- a/crates/types/src/branding.rs +++ b/crates/types/src/branding.rs @@ -52,6 +52,19 @@ macro_rules! brand_url { }; } +/// The public repository the server's source is published in, named in +/// `/.well-known/source`. +#[macro_export] +macro_rules! brand_repository { + () => { + "https://git.coffeylabs.org/inbuxa/inbuxa-server" + }; +} + +/// The commit this build was made from, as the release build passes it in +/// `INBUXA_REVISION`. A build that wasn't told has none. +pub const BUILD_REVISION: Option<&str> = option_env!("INBUXA_REVISION"); + /// Reads one of the server's environment variables by its unprefixed name, /// such as `RECOVERY_ADMIN`, from `INBUXA_`. ///