diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1f7c79b..cbd1c942 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: if: github.event_name == 'push' || inputs.Docker steps: - name: Install Cosign - uses: sigstore/cosign-installer@v4 + uses: sigstore/cosign-installer@v4.1.1 - name: Log In to GitHub Container Registry uses: docker/login-action@v4 with: @@ -377,6 +377,11 @@ jobs: needs: [linux, windows, macos] runs-on: ubuntu-latest steps: + # Must run before artifacts are downloaded — checkout cleans the workspace. + - name: Checkout (for CHANGELOG) + if: startsWith(github.ref, 'refs/tags/') + uses: actions/checkout@v6.0.2 + - name: Download Artifacts uses: actions/download-artifact@v8 with: @@ -424,6 +429,20 @@ jobs: archive/**/*.tar.gz archive/**/*.zip + - name: Build release body + run: | + if [ "${{ startsWith(github.ref, 'refs/tags/') }}" = "true" ]; then + awk '/^## \[/{c++} c==1' CHANGELOG.md > release_body.md + echo "" >> release_body.md + else + : > release_body.md + fi + cat >> release_body.md < + + ### Check binary attestation [here](${{ steps.attest.outputs.attestation-url }}) + EOF + - name: Release uses: softprops/action-gh-release@v3 with: @@ -433,9 +452,34 @@ jobs: archive/**/*.sigstore.json prerelease: ${{!startsWith(github.ref, 'refs/tags/') || null}} tag_name: ${{!startsWith(github.ref, 'refs/tags/') && 'nightly' || null}} - # TODO add instructions about using cosign to verify binary artifact - append_body: true - body: | -
+ # Tag-push releases are created as drafts; the `publish` job un-drafts + # them only after all build jobs succeed, so watcher notifications + # don't fire on broken builds. + draft: ${{ startsWith(github.ref, 'refs/tags/') || null }} + body_path: release_body.md - ### Check binary attestation at [here](${{ steps.attest.outputs.attestation-url }}) + publish: + name: Publish release + needs: [linux, windows, macos, multiarch, release] + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Un-draft release + env: + GH_TOKEN: ${{ github.token }} + run: gh release edit "${{ github.ref_name }}" --draft=false --latest --repo "${{ github.repository }}" + + cleanup: + name: Cleanup failed release + needs: [linux, windows, macos, multiarch, release] + if: failure() && startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Delete draft release and tag + env: + GH_TOKEN: ${{ github.token }} + run: gh release delete "${{ github.ref_name }}" --yes --cleanup-tag --repo "${{ github.repository }}" || true diff --git a/crates/common/src/config/network.rs b/crates/common/src/config/network.rs index 413e05d8..4e175fee 100644 --- a/crates/common/src/config/network.rs +++ b/crates/common/src/config/network.rs @@ -196,12 +196,14 @@ impl Network { }, }; - let mut http_host = system.default_hostname.clone(); + let default_hostname = if !system.default_hostname.is_empty() { + system.default_hostname.as_str() + } else { + bp.registry.local_hostname() + }; + let mut http_host = default_hostname.to_string(); for (service, details) in &system.services { - let hostname = details - .hostname - .as_deref() - .unwrap_or(&system.default_hostname); + let hostname = details.hostname.as_deref().unwrap_or(default_hostname); match service { ServiceProtocol::Jmap => { @@ -319,7 +321,7 @@ impl Network { .unwrap(); let mut network = Network { node_id: bp.node_id() as u64, - server_name: system.default_hostname, + server_name: default_hostname.to_string(), security: Security::parse(bp).await, contact_form: ContactForm::parse(bp).await, asn_geo_lookup: AsnGeoLookupConfig::parse(bp).await.unwrap_or_default(), @@ -395,7 +397,7 @@ impl Http { let use_permissive_cors = true; #[cfg(not(feature = "dev_mode"))] - let use_permissive_cors = http.use_permissive_cors; + let use_permissive_cors = http.use_permissive_cors || bp.registry.is_recovery_mode(); if use_permissive_cors { http_headers.push(( @@ -425,8 +427,16 @@ impl Http { } Http { - url_https: format!("https://{}", server_name), - url_http: format!("http://{}", server_name), + url_https: if !bp.registry.is_bootstrap_mode() { + format!("https://{server_name}") + } else { + String::new() + }, + url_http: if !bp.registry.is_bootstrap_mode() { + format!("http://{server_name}") + } else { + String::new() + }, allowed_endpoint: bp .compile_expr(ObjectType::Http.singleton(), &http.ctx_allowed_endpoints()), rate_authenticated: http.rate_limit_authenticated, diff --git a/crates/common/src/manager/boot.rs b/crates/common/src/manager/boot.rs index 3213acbb..fc2bc88f 100644 --- a/crates/common/src/manager/boot.rs +++ b/crates/common/src/manager/boot.rs @@ -163,6 +163,7 @@ impl BootManager { if bootstrap.registry.is_bootstrap_mode() { trc::event!( Server(trc::ServerEvent::BootstrapMode), + Hostname = bootstrap.registry.local_hostname().to_string(), Details = "No configuration file was found. Port 8080 is open for initial setup.", Version = env!("CARGO_PKG_VERSION"), diff --git a/crates/dav/src/request.rs b/crates/dav/src/request.rs index 56ec939e..2aaa3cbb 100644 --- a/crates/dav/src/request.rs +++ b/crates/dav/src/request.rs @@ -179,7 +179,7 @@ impl DavRequestDispatcher for Server { Report::AclPrincipalPropSet(report) => { // Validate permissions if !self.core.groupware.allow_directory_query - && !access_token.has_permission(Permission::SysAccountQuery) + && !access_token.has_permission(Permission::DavPrincipalAcl) { return Err(DavError::Condition( DavErrorCondition::new( @@ -198,7 +198,7 @@ impl DavRequestDispatcher for Server { Report::PrincipalMatch(report) => { // Validate permissions if !self.core.groupware.allow_directory_query - && !access_token.has_permission(Permission::SysAccountQuery) + && !access_token.has_permission(Permission::DavPrincipalMatch) { return Err(DavError::Condition( DavErrorCondition::new( @@ -218,7 +218,7 @@ impl DavRequestDispatcher for Server { if resource == DavResourceName::Principal { // Validate permissions if !self.core.groupware.allow_directory_query - && !access_token.has_permission(Permission::SysAccountQuery) + && !access_token.has_permission(Permission::DavPrincipalSearch) { return Err(DavError::Condition( DavErrorCondition::new( diff --git a/crates/http-proto/src/context.rs b/crates/http-proto/src/context.rs index a79f18ee..8134c215 100644 --- a/crates/http-proto/src/context.rs +++ b/crates/http-proto/src/context.rs @@ -33,10 +33,14 @@ impl<'x> HttpContext<'x> { } else { #[cfg(not(any(feature = "dev_mode", feature = "test_mode")))] { - format!( - "{}:{}", - server.core.network.http.url_http, self.session.local_port - ) + if !server.registry().is_bootstrap_mode() { + format!( + "{}:{}", + server.core.network.http.url_http, self.session.local_port + ) + } else { + server.core.network.http.url_http.clone() + } } #[cfg(any(feature = "dev_mode", feature = "test_mode"))] diff --git a/crates/http/src/auth/permissions.rs b/crates/http/src/auth/permissions.rs index f3beb5e1..f8880f73 100644 --- a/crates/http/src/auth/permissions.rs +++ b/crates/http/src/auth/permissions.rs @@ -87,6 +87,18 @@ impl AccountApiHandler for Server { permissions.clear(p.to_id() as usize); } + if !self.core.groupware.allow_directory_query { + for p in [ + Permission::JmapPrincipalQuery, + Permission::JmapPrincipalQueryChanges, + Permission::JmapPrincipalGet, + Permission::JmapPrincipalGetAvailability, + Permission::JmapPrincipalChanges, + ] { + permissions.clear(p.to_id() as usize); + } + } + if is_external_directory || is_recovery_admin { permissions.clear(Permission::SysAccountPasswordGet.to_id() as usize); permissions.clear(Permission::SysAccountPasswordUpdate.to_id() as usize); diff --git a/crates/jmap/src/principal/availability.rs b/crates/jmap/src/principal/availability.rs index 32618fbf..6b604429 100644 --- a/crates/jmap/src/principal/availability.rs +++ b/crates/jmap/src/principal/availability.rs @@ -62,7 +62,7 @@ impl PrincipalGetAvailability for Server { access_token: &AccessToken, ) -> trc::Result { if !self.core.groupware.allow_directory_query - && !access_token.has_permission(Permission::SysAccountQuery) + && !access_token.has_permission(Permission::JmapPrincipalGetAvailability) { return Err(trc::JmapEvent::Forbidden .into_err() diff --git a/crates/jmap/src/principal/get.rs b/crates/jmap/src/principal/get.rs index ec9240ea..685e8346 100644 --- a/crates/jmap/src/principal/get.rs +++ b/crates/jmap/src/principal/get.rs @@ -32,7 +32,7 @@ impl PrincipalGet for Server { access_token: &AccessToken, ) -> trc::Result> { if !self.core.groupware.allow_directory_query - && !access_token.has_permission(Permission::SysAccountQuery) + && !access_token.has_permission(Permission::JmapPrincipalGet) { return Err(trc::JmapEvent::Forbidden .into_err() diff --git a/crates/jmap/src/principal/query.rs b/crates/jmap/src/principal/query.rs index 7d1afc2e..81172176 100644 --- a/crates/jmap/src/principal/query.rs +++ b/crates/jmap/src/principal/query.rs @@ -42,7 +42,7 @@ impl PrincipalQuery for Server { access_token: &AccessToken, ) -> trc::Result { if !self.core.groupware.allow_directory_query - && !access_token.has_permission(Permission::SysAccountQuery) + && !access_token.has_permission(Permission::JmapPrincipalQuery) { return Err(trc::JmapEvent::Forbidden .into_err() diff --git a/crates/jmap/src/registry/mapping/account.rs b/crates/jmap/src/registry/mapping/account.rs index 13968c33..036b7168 100644 --- a/crates/jmap/src/registry/mapping/account.rs +++ b/crates/jmap/src/registry/mapping/account.rs @@ -120,7 +120,18 @@ pub(crate) async fn account_set( set.response.not_updated.append(id, SetError::not_found()); } - let mut account_pass = AccountPassword::default(); + let mut account_pass = AccountPassword { + secret: None, + current_secret: None, + otp_auth: OtpAuth { + otp_code: None, + otp_url: if old_credential.otp_auth.is_some() { + Some(MASKED_PASSWORD.to_string()) + } else { + None + }, + }, + }; for (key, value) in value.into_expanded_object() { let ptr = match key { @@ -149,23 +160,26 @@ pub(crate) async fn account_set( } } - let is_empty_secret = - account_pass.secret.is_empty() || account_pass.secret == MASKED_PASSWORD; - let is_empty_otp = account_pass - .otp_auth - .otp_url + let is_empty_secret = account_pass + .secret .as_ref() - .is_none_or(|url| url != MASKED_PASSWORD); + .is_none_or(|secret| secret == MASKED_PASSWORD); + let is_empty_otp = account_pass.otp_auth.otp_url.as_deref() + == Some(MASKED_PASSWORD) + || (account_pass.otp_auth.otp_url.is_none() + && old_credential.otp_auth.is_none()); if !is_empty_secret || !is_empty_otp { - if is_empty_secret { - account_pass.secret = old_credential.secret.clone(); - } + let user_provided_secret = if !is_empty_secret { + account_pass.secret.as_ref().unwrap() + } else { + old_credential.secret.as_str() + }; if is_empty_otp { account_pass.otp_auth.otp_url = old_credential.otp_auth.clone(); } // Password changes are not supported when using external directories - if (account_pass.secret != old_credential.secret + if (user_provided_secret != old_credential.secret || account_pass.otp_auth.otp_url != old_credential.otp_auth) && set .server @@ -183,7 +197,7 @@ pub(crate) async fn account_set( continue 'outer; } - if account_pass.secret != old_credential.secret + if user_provided_secret != old_credential.secret || account_pass.otp_auth.otp_url != old_credential.otp_auth { if old_credential.secret.is_empty() { @@ -249,9 +263,9 @@ pub(crate) async fn account_set( } } - if account_pass.secret != old_credential.secret { + if user_provided_secret != old_credential.secret { if let Err(err) = - set.server.is_secure_password(&account_pass.secret, &[]) + set.server.is_secure_password(user_provided_secret, &[]) { set.response.not_updated.append( id, @@ -278,7 +292,7 @@ pub(crate) async fn account_set( old_credential.secret = hash_secret( set.server.core.network.security.password_hash_algorithm, - account_pass.secret.into_bytes(), + user_provided_secret.as_bytes().to_vec(), ) .await .caused_by(trc::location!())?; @@ -722,7 +736,7 @@ pub(crate) async fn account_get( None }, }, - secret: MASKED_PASSWORD.into(), + secret: MASKED_PASSWORD.to_string().into(), } .into_value(), ); diff --git a/crates/migration/src/lib.rs b/crates/migration/src/lib.rs index 145465f7..483b59ef 100644 --- a/crates/migration/src/lib.rs +++ b/crates/migration/src/lib.rs @@ -56,6 +56,7 @@ pub async fn try_migrate(server: &Server) -> trc::Result<()> { } _ => { if is_new_install(server).await.caused_by(trc::location!())? { + write_schema_version(server).await?; return Ok(()); } else { abort(concat!( @@ -67,7 +68,10 @@ pub async fn try_migrate(server: &Server) -> trc::Result<()> { } migrate_v0_16(server).await?; + write_schema_version(server).await +} +async fn write_schema_version(server: &Server) -> trc::Result<()> { let mut batch = BatchBuilder::new(); batch.set( ValueClass::Any(AnyClass { diff --git a/crates/registry/src/schema/structs.rs b/crates/registry/src/schema/structs.rs index 13c4cef2..24642375 100644 --- a/crates/registry/src/schema/structs.rs +++ b/crates/registry/src/schema/structs.rs @@ -18,7 +18,7 @@ pub enum Account { #[serde(default)] pub struct AccountPassword { #[serde(rename = "secret")] - pub secret: String, + pub secret: Option, #[serde(rename = "currentSecret")] pub current_secret: Option, #[serde(rename = "otpAuth")] diff --git a/crates/registry/src/schema/structs_impl.rs b/crates/registry/src/schema/structs_impl.rs index 585ebfe6..25c04a99 100644 --- a/crates/registry/src/schema/structs_impl.rs +++ b/crates/registry/src/schema/structs_impl.rs @@ -119,9 +119,10 @@ impl ObjectImpl for AccountPassword { fn validate(&self, errors: &mut Vec) -> bool { let neb = errors.len(); - let value = &self.secret; - if value.is_empty() { - errors.push(ValidationError::required(Property::Secret)); + if let Some(value) = &self.secret { + if value.is_empty() { + errors.push(ValidationError::required(Property::Secret)); + } } if let Some(value) = &self.current_secret { if value.is_empty() { @@ -165,7 +166,9 @@ impl Default for AccountPassword { impl IntoValue for AccountPassword { fn into_value(self) -> JmapValue<'static> { let mut map = jmap_tools::Map::with_capacity(5); - map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into())); + if self.secret.is_some() { + map.insert_unchecked(Property::Secret, JmapValue::Str(MASKED_PASSWORD.into())); + } if self.current_secret.is_some() { map.insert_unchecked( Property::CurrentSecret, diff --git a/install.sh.new b/install.sh.new index 841e7275..535bb732 100644 --- a/install.sh.new +++ b/install.sh.new @@ -204,6 +204,9 @@ write_env_file() { # Environment variables for the Stalwart service. # Uncomment and edit an entry to override its default. +# Override the hostname used in HTTP responses +#STALWART_HOSTNAME=mail.example.com + # Enable bootstrap / recovery mode on startup. Accepted: 1, true. Default: false. #STALWART_RECOVERY_MODE=true diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index 9dc5ed61..a6ac7ab5 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index c87eb0ca..8335e5e9 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -cfUVQJSeFKFiYn3iirIvn58LaqJ3_HppFEBrqbXrr-E \ No newline at end of file +_NV9JZvmSHwGCNQ7Gw4Uj027Bpp2UT3LGf2zHyp4tOM \ No newline at end of file