diff --git a/.github/workflows/container.yaml b/.github/workflows/container.yaml index 073aa25..18631e3 100644 --- a/.github/workflows/container.yaml +++ b/.github/workflows/container.yaml @@ -18,7 +18,7 @@ jobs: uses: actions/download-artifact@v4.1.8 with: name: rust - path: artifacts + path: ./identity-server/artifacts - name: Build Image id: build-image @@ -28,6 +28,7 @@ jobs: tags: commit-${{ github.sha }} ${{ inputs.additional-tags }} platforms: linux/arm64,linux/amd64,windows/amd64 oci: true + context: ./identity-server containerfiles: | ./identity-server/Dockerfile diff --git a/.gitignore b/.gitignore index 76901e2..6daec49 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ # For local development .direnv .envrc -my_config.toml +my-config.toml # SQLite *.db diff --git a/identity-server/.empty b/identity-server/.empty new file mode 100644 index 0000000..e69de29 diff --git a/identity-server/Dockerfile b/identity-server/Dockerfile index 369d02a..6e3b816 100644 --- a/identity-server/Dockerfile +++ b/identity-server/Dockerfile @@ -9,18 +9,25 @@ ARG TARGETPLATFORM COPY --from=distroless /etc/passwd /etc/passwd COPY --from=distroless /etc/group /etc/group -USER nonroot +USER nonroot:nonroot ENV USER=nonroot -ENV XDG_CACHE_HOME=/home/nonroot/.cache -VOLUME ["/home/nonroot/.cache"] +ENV XDG_CACHE_HOME=/var/.cache +# Only here to create the .cache folder +COPY --chmod=644 --chown=nonroot:nonroot .empty /var/.cache/.empty +VOLUME ["/var"] +WORKDIR ["/var"] + +ENV XDG_CONFIG_HOME=/etc/cfg +COPY --chmod=644 --chown=nonroot:nonroot ./default-config.toml /etc/cfg/config.toml +VOLUME ["/etc/cfg"] # Bring in the actual binary we will run COPY --from=distroless --chmod=544 --chown=nonroot:nonroot /artifacts/$TARGETPLATFORM/identity-server /opt/identity-server -ENTRYPOINT ["/opt/identity-server"] -VOLUME ["/var/db"] -WORKDIR ["/var/db"] -EXPOSE 443/tcp -EXPOSE 80/tcp +EXPOSE 8443/tcp +EXPOSE 8443/udp +ENV RUST_BACKTRACE=1 +ENTRYPOINT ["/opt/identity-server"] +CMD ["serve", "--config", "/etc/cfg/config.toml"] diff --git a/identity-server/default_config.toml b/identity-server/default-config.toml similarity index 90% rename from identity-server/default_config.toml rename to identity-server/default-config.toml index f98a264..9aeaa40 100644 --- a/identity-server/default_config.toml +++ b/identity-server/default-config.toml @@ -3,16 +3,16 @@ # Note: When using TLS, we will always send the HSTS header to force clients to only # use https urls. [http] -port = 443 # also supports 0 to mean random +port = 8443 # also supports 0 to mean random # Settings related to configuring TLS certificates. In most cases, the "acme" type is # the simplest to set up. [http.tls] -type = "acme" # requires publicly visible port to be 443, otherwise the challenge fails +type = "acme" # publicly visible port MUST be 443, otherwise the challenge fails domains = [] # You must fill this in with your public domain name(s). +# domains = ["socialvr.net", "socialvr.net:1337", "10.11.12.13"] is_prod = true # we are using LetsEncrypt's main, production directory. email = "" # optional: you can fill in your email address here -# domains = ["socialvr.net", "socialvr.net:1337", "10.11.12.13"] # [http.tls] # type = "disable" # disables TLS and everything will use HTTP instead. diff --git a/identity-server/justfile b/identity-server/justfile index 22cc74c..c296fd8 100644 --- a/identity-server/justfile +++ b/identity-server/justfile @@ -15,7 +15,6 @@ rust-build: # Copy artifacts from cargo target dir into to the artifacts directory artifacts: rust-build - pwd mkdir -p {{artifacts_dir}} cp {{target_dir}}/aarch64-apple-darwin/artifact/{{package_name}} {{artifacts_dir}}/{{package_name}}-macos-aarch64 cp {{target_dir}}/x86_64-unknown-linux-musl/artifact/{{package_name}} {{artifacts_dir}}/{{package_name}}-linux-x86_64 diff --git a/identity-server/src/config.rs b/identity-server/src/config.rs index 2eaffea..e9d8a88 100644 --- a/identity-server/src/config.rs +++ b/identity-server/src/config.rs @@ -6,7 +6,7 @@ use std::{path::PathBuf, str::FromStr}; use serde::{Deserialize, Serialize}; -pub const DEFAULT_CONFIG_CONTENTS: &str = include_str!("../default_config.toml"); +pub const DEFAULT_CONFIG_CONTENTS: &str = include_str!("../default-config.toml"); const CACHE_DIR_SUFFIX: &str = "nexus_identity_server"; #[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Clone)] @@ -54,6 +54,17 @@ pub struct HttpConfig { pub tls: TlsConfig, } +impl HttpConfig { + fn validate(&self) -> Result<(), ValidationError> { + if let TlsConfig::Acme { ref domains, .. } = self.tls { + if domains.is_empty() { + return Err(ValidationError::UnspecifiedDomain); + } + } + Ok(()) + } +} + impl Default for HttpConfig { fn default() -> Self { Self { @@ -65,7 +76,7 @@ impl Default for HttpConfig { impl HttpConfig { const fn default_port() -> u16 { - 443 + 8443 } } @@ -131,10 +142,18 @@ fn default_some() -> Option { Some(T::default()) } -#[derive(Debug, thiserror::Error)] +#[derive(Debug, thiserror::Error, Eq, PartialEq)] pub enum ConfigError { - #[error("error in toml file: {0}")] + #[error("error deserializing toml file: {0}")] Toml(#[from] toml::de::Error), + #[error("config file was invalid: {0}")] + FailedValidation(#[from] ValidationError), +} + +#[derive(Debug, thiserror::Error, Eq, PartialEq)] +pub enum ValidationError { + #[error("when using ACME tls, you *must* specify at least one domain")] + UnspecifiedDomain, } /// The contents of the config file. Contains all settings customizeable during @@ -152,6 +171,14 @@ pub struct Config { pub third_party: ThirdPartySettings, } +impl Config { + /// Validates the deserialized config + pub fn validate(&self) -> Result<(), ValidationError> { + self.http.validate()?; + Ok(()) + } +} + impl FromStr for Config { type Err = ConfigError; @@ -173,7 +200,7 @@ mod test { db_file: PathBuf::from("./identities.db"), }, http: HttpConfig { - port: 443, + port: 8443, tls: TlsConfig::Acme { email: String::new(), domains: Vec::new(), @@ -197,10 +224,14 @@ mod test { } #[test] - fn test_default_config_deserializes_correctly() { + fn test_default_config_deserializes_correctly_but_fails_validation() { let deserialized: Config = toml::from_str(DEFAULT_CONFIG_CONTENTS) .expect("default config file should always deserialize"); assert_eq!(deserialized, Config::default()); + assert_eq!( + deserialized.validate(), + Err(ValidationError::UnspecifiedDomain) + ) } #[test] diff --git a/identity-server/src/main.rs b/identity-server/src/main.rs index bc57d83..a41f7bb 100644 --- a/identity-server/src/main.rs +++ b/identity-server/src/main.rs @@ -1,15 +1,20 @@ use std::{io::IsTerminal as _, path::PathBuf}; use clap::Parser as _; -use color_eyre::eyre::{bail, Context, OptionExt, Result}; +use color_eyre::{ + eyre::{bail, Context, OptionExt, Result}, + Section as _, +}; use futures::FutureExt; -use tokio::sync::oneshot; use tokio::task::JoinHandle; +use tokio::{io::AsyncWriteExt as _, sync::oneshot}; use tracing::{debug, info}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; use identity_server::{ - config::{Config, DatabaseConfig, TlsConfig}, + config::{ + Config, DatabaseConfig, TlsConfig, ValidationError, DEFAULT_CONFIG_CONTENTS, + }, jwks_provider::JwksProvider, spawn_http_server, spawn_https_server, MigratedDbPool, }; @@ -19,10 +24,122 @@ const GOOGLE_CLIENT_ID_DOCS_URL: &str = "https://developers.google.com/identity/ #[derive(clap::Parser, Debug)] #[clap(version)] struct Cli { + #[clap(subcommand)] + command: Commands, +} + +#[derive(clap::Parser, Debug)] +enum Commands { + Serve(ServeArgs), + DefaultConfig(DefaultConfigArgs), +} + +/// Runs the server +#[derive(clap::Parser, Debug)] +struct ServeArgs { #[clap(long, env)] config: PathBuf, } +impl ServeArgs { + async fn run(self) -> Result<()> { + let cli = self; + + let config_file = tokio::fs::read_to_string(&cli.config) + .await + .wrap_err("failed to read config file") + .with_note(|| format!("Config file path: {}", cli.config.display()))?; + debug!(contents = config_file, "config file contents"); + let config_file: Config = + config_file.parse().wrap_err("config file was invalid")?; + + let validation_result = config_file.validate(); + if let Err(ref err) = validation_result { + let suggestion = match err { + ValidationError::UnspecifiedDomain => { + "try adding your domain in the `http.tls.domains` list" + } + }; + validation_result + .wrap_err("config file was invalid") + .suggestion(suggestion)?; + } + + let db_pool = { + let DatabaseConfig::Sqlite { ref db_file } = config_file.database; + let connect_opts = sqlx::sqlite::SqliteConnectOptions::new() + .create_if_missing(true) + .filename(db_file); + let pool_opts = sqlx::sqlite::SqlitePoolOptions::new(); + let pool = pool_opts + .connect_with(connect_opts.clone()) + .await + .wrap_err_with(|| { + format!( + "failed to connect to database with path {}", + connect_opts.get_filename().display() + ) + })?; + MigratedDbPool::new(pool) + .await + .wrap_err("failed to migrate db pool")? + }; + let reqwest_client = reqwest::Client::new(); + + let v1_cfg = identity_server::v1::RouterConfig { + uuid_provider: Default::default(), + db_pool, + }; + let oauth_cfg = identity_server::oauth::OAuthConfig { + google_client_id: config_file + .third_party + .google + .clone() + .ok_or_eyre(format!( + "currently, setting up google is required. Please follow the \ + instructions at {GOOGLE_CLIENT_ID_DOCS_URL} and fill in the \ + `third_party.google.oauth2_client_id` field in the config.toml", + ))? + .oauth2_client_id, + google_jwks_provider: JwksProvider::google(reqwest_client.clone()), + }; + let router = identity_server::RouterConfig { + v1: v1_cfg, + oauth: oauth_cfg, + } + .build() + .await + .wrap_err("failed to build router")?; + + let cache_dir = config_file.cache.dir(); + debug!("using cache dir {}", cache_dir.display()); + // .join(if cli.prod_tls { "prod" } else { "dev" }); + tokio::fs::create_dir_all(&cache_dir) + .await + .wrap_err("failed to create cache directory for certs")?; + + Tasks::spawn(config_file, router) + .await + .wrap_err("failed to spawn tasks")? + .run() + .await + } +} + +/// Echoes the default config to stdout +#[derive(clap::Parser, Debug)] +struct DefaultConfigArgs {} + +impl DefaultConfigArgs { + async fn run(self) -> Result<()> { + tokio::io::stdout() + .write_all(DEFAULT_CONFIG_CONTENTS.as_bytes()) + .await + .expect("should never fail"); + Ok(()) + } +} + /// Convenient container to manager all tasks that need to be monitored and reaped. #[derive(Debug)] struct Tasks { @@ -41,7 +158,7 @@ impl Tasks { } else { let tuple = spawn_https_server(config_file, router) .await - .wrap_err("failed to spawn http server")?; + .wrap_err("failed to spawn https server")?; (tuple.0, tuple.1) }; @@ -74,6 +191,14 @@ impl Tasks { } } +fn is_root() -> bool { + #[cfg(unix)] + let result = rustix::process::getuid().is_root(); + #[cfg(windows)] + let result = false; + result +} + #[tokio::main] async fn main() -> Result<()> { color_eyre::install()?; @@ -86,82 +211,13 @@ async fn main() -> Result<()> { bail!("You should only run this program as a non-root user"); } - if std::io::stdout().is_terminal() { + if !std::io::stdout().is_terminal() { debug!("We don't appear to be in a terminal"); } let cli = Cli::parse(); - - let config_file = tokio::fs::read_to_string(cli.config) - .await - .wrap_err("failed to read config file")?; - let config_file: Config = - config_file.parse().wrap_err("config file was invalid")?; - - let db_pool = { - let DatabaseConfig::Sqlite { ref db_file } = config_file.database; - let connect_opts = sqlx::sqlite::SqliteConnectOptions::new() - .create_if_missing(true) - .filename(db_file); - let pool_opts = sqlx::sqlite::SqlitePoolOptions::new(); - let pool = pool_opts - .connect_with(connect_opts.clone()) - .await - .wrap_err_with(|| { - format!( - "failed to connect to database with path {}", - connect_opts.get_filename().display() - ) - })?; - MigratedDbPool::new(pool) - .await - .wrap_err("failed to migrate db pool")? - }; - let reqwest_client = reqwest::Client::new(); - - let v1_cfg = identity_server::v1::RouterConfig { - uuid_provider: Default::default(), - db_pool, - }; - let oauth_cfg = identity_server::oauth::OAuthConfig { - google_client_id: config_file - .third_party - .google - .clone() - .ok_or_eyre(format!( - "currently, setting up google is required. Please follow the \ - instructions at {GOOGLE_CLIENT_ID_DOCS_URL} and fill in the \ - `third_party.google.oauth2_client_id` field in the config.toml", - ))? - .oauth2_client_id, - google_jwks_provider: JwksProvider::google(reqwest_client.clone()), - }; - let router = identity_server::RouterConfig { - v1: v1_cfg, - oauth: oauth_cfg, + match cli.command { + Commands::Serve(args) => args.run().await, + Commands::DefaultConfig(args) => args.run().await, } - .build() - .await - .wrap_err("failed to build router")?; - - let cache_dir = config_file.cache.dir(); - debug!("using cache dir {}", cache_dir.display()); - // .join(if cli.prod_tls { "prod" } else { "dev" }); - tokio::fs::create_dir_all(&cache_dir) - .await - .wrap_err("failed to create cache directory for certs")?; - - Tasks::spawn(config_file, router) - .await - .wrap_err("failed to spawn tasks")? - .run() - .await -} - -fn is_root() -> bool { - #[cfg(unix)] - let result = rustix::process::getuid().is_root(); - #[cfg(windows)] - let result = false; - result }