Files
windmill/backend/windmill-common/src/ee_oss.rs
T
9c6cd8c852 offline (URL-bound) license keys (#9089)
* [ee] feat(license): offline (URL-bound) license keys

Offline keys are a 4-segment variant for air-gapped customers — no
phone-home, embedded seat/CU caps, locked to the instance's base_url.
Existing 3-segment online keys are unchanged.

Companion PRs:
- windmill-labs/windmill-ee-private (full design + EE impl)
- windmill-labs/windmill-customer-service (issuance + portal)
- windmill-labs/windmill-cf-worker-keygen (signing)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [ee] refactor(license): bind offline keys via instance hash; simpler CU enforcement

- /settings/license_status now surfaces an `instance_hash` superadmins share
  with support when requesting an offline key
- OfflineMetadata: `hash` replaces `base_url`; OfflineCapStatus reports
  `current_cu` (last 2min) and drops the grace-period fields
- verify_license_key now takes a db so EE can recheck the hash
- InstanceSetting.svelte: hash copy-block + simpler status panel
- Bump ee-repo-ref

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [ee] chore(license): bump ee-repo-ref

Pulls in the current_cu clamp + prod public key restoration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [ee] refactor(license): split instance_hash endpoint; minimal cap UI; restore workers expiry toast

- `instance_hash` is no longer part of /settings/license_status responses; it
  lives at GET /settings/instance_hash (super-admin only) so it isn't re-emitted
  on every status poll. The UI doesn't show it — admins fetch it explicitly when
  requesting a key from support.
- InstanceSetting offline cap UI is now two compact green/red status lines
  (Seats X.X/Y and CUs X.X/Y) placed above the action buttons, matching the
  existing "Latest key renewal" badge style. The block-panel is gone.
- "Latest key renewal" line and the "Renew key" button are now hidden when an
  offline key is loaded (renewal is server-disabled for offline keys).
- Restore parseLicenseKey + checkLicenseExpiration toast on /workers
  (works for both 3- and 4-segment keys).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [ee] chore(license): bump ee-repo-ref

Pulls in the plain-SHA256 instance hash + stats_ee revert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [ee] chore(license): bump ee-repo-ref

Picks up the alert wording change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [ee] chore(license): bump ee-repo-ref

Picks up the instance_uid cache so the periodic verify_license_key cycle
no longer hits global_settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [ee] refactor(license): rename /settings/license_status → /offline_license_status

The endpoint was only used by the offline-license UI; the other fields it
returned (license_key_id, license_key_valid, kind, offline metadata) were
unused. Rename to clarify scope and flatten the response — it now returns
just the OfflineCapStatus (or null when no offline license is loaded).

Frontend uses `offlineCapStatus != null` as the "is offline" check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [ee] fix(ci): regenerate sqlx cache for the inline worker_ping query

After reverting unused stats_ee helpers (fetch_worker_pings*), the
inline `sqlx::query_as!(WorkerPingRecord, ...)` in get_stats_payload
lost its cache entry — CI's check_ee_full + cargo_test were failing
under SQLX_OFFLINE=true with E0282 type-inference errors.

Re-running update_sqlx.sh regenerates the cache file under its
current hash and prunes a couple of stale entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [ee] fix(license): address cubic-bot review

- get_offline_license_status: propagate enforce_offline_caps errors as 500
  instead of swallowing into a "no offline license" (Option::None) response
- canonical_base_url: rewrite the doc to match the actual fallback behavior
  (lowercase + trailing-slash strip on URL parse failure); the original
  cross-service contract is gone since the customer-service no longer
  canonicalizes (treats the instance hash as opaque)
- check_seat_cap_for_new_user: take an email and short-circuit when the
  email is already in `usr ∪ workspace_invite` so net-zero invite upserts
  and invite→user transitions aren't spuriously blocked at cap. Mirrors
  the dedup rule the count itself uses.
- Bump ee-repo-ref to pull in the EE-side change

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [ee] chore(license): bump ee-repo-ref

Picks up the exact-delta seat-cap check (replaces the simple existence
short-circuit). Regenerates the new sqlx cache for the bool_and query.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [ee] fix(license): propagate get_instance_hash errors; bump ee-repo-ref

- get_instance_hash: replace `.ok().flatten()` with map_err+? so DB errors
  during instance_uid lookup surface as 500 instead of silently returning
  `{"instance_hash": null}` (same pattern get_offline_license_status already uses)
- Bump ee-repo-ref to pull in the enforce_offline_caps cached-state preservation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to c6cd1afe2d9e04809b30751cd1687b28a65e62b1

This commit updates the EE repository reference after PR #566 was merged in windmill-ee-private.

Previous ee-repo-ref: a6d91016ae0d43c46604313aecae3aa9c778c8e0

New ee-repo-ref: c6cd1afe2d9e04809b30751cd1687b28a65e62b1

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-11 22:09:21 +00:00

178 lines
4.9 KiB
Rust

#[cfg(feature = "private")]
#[allow(unused)]
pub use crate::ee::*;
#[cfg(all(feature = "enterprise", not(feature = "private")))]
use crate::db::DB;
#[cfg(not(feature = "private"))]
use crate::ee_oss::LicensePlan::Community;
#[cfg(all(feature = "enterprise", not(feature = "private")))]
use crate::error;
#[cfg(not(feature = "private"))]
use serde::Deserialize;
#[cfg(not(feature = "private"))]
use std::sync::atomic::AtomicBool;
#[cfg(not(feature = "private"))]
lazy_static::lazy_static! {
pub static ref LICENSE_KEY_VALID: AtomicBool = AtomicBool::new(true);
pub static ref LICENSE_KEY_ID: arc_swap::ArcSwap<String> = arc_swap::ArcSwap::from_pointee("".to_string());
pub static ref LICENSE_KEY: arc_swap::ArcSwap<String> = arc_swap::ArcSwap::from_pointee("".to_string());
pub static ref LICENSE_OFFLINE_METADATA: arc_swap::ArcSwap<Option<OfflineMetadata>> = arc_swap::ArcSwap::from_pointee(None);
pub static ref LICENSE_OFFLINE_OVER_CU_CAP: AtomicBool = AtomicBool::new(false);
pub static ref LICENSE_OFFLINE_LAST_STATUS: arc_swap::ArcSwap<Option<OfflineCapStatus>> = arc_swap::ArcSwap::from_pointee(None);
pub static ref LICENSE_OFFLINE_LAST_CHECKED_AT: arc_swap::ArcSwap<Option<chrono::DateTime<chrono::Utc>>> = arc_swap::ArcSwap::from_pointee(None);
}
#[cfg(not(feature = "private"))]
#[derive(Clone, Debug, Deserialize, serde::Serialize)]
pub struct OfflineMetadata {
pub v: u32,
pub kind: String,
pub hash: String,
pub seats: i64,
pub cu_limit: f64,
}
#[cfg(not(feature = "private"))]
impl OfflineMetadata {
pub fn is_offline(&self) -> bool {
self.kind == "offline"
}
}
#[cfg(not(feature = "private"))]
#[derive(Clone, Debug, serde::Serialize)]
pub struct OfflineCapStatus {
pub seats_used: f64,
pub seats_cap: i64,
pub author_count: i64,
pub operator_count: i64,
pub current_cu: f64,
pub cu_cap: f64,
pub cu_over_cap: bool,
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn check_seat_cap_for_new_user(
_db: &DB,
_email: &str,
_new_user_is_operator: bool,
) -> anyhow::Result<Option<String>> {
Ok(None)
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn compute_instance_hash(_db: &DB) -> anyhow::Result<Option<String>> {
// Implementation is not open source
Ok(None)
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn enforce_offline_caps(_db: &DB) -> anyhow::Result<Option<OfflineCapStatus>> {
// Implementation is not open source
Ok(None)
}
#[cfg(not(feature = "private"))]
#[derive(PartialEq, Eq)]
pub enum LicensePlan {
Community,
Pro,
Enterprise,
}
#[cfg(not(feature = "private"))]
pub async fn get_license_plan() -> LicensePlan {
// Implementation is not open source
return Community;
}
#[derive(Deserialize)]
#[serde(untagged)]
#[cfg(not(feature = "private"))]
pub enum CriticalErrorChannel {
Email { email: String },
Slack { slack_channel: String },
Teams { teams_channel: TeamsChannel },
}
#[derive(Deserialize)]
#[cfg(not(feature = "private"))]
pub struct TeamsChannel {
pub team_id: String,
pub team_name: String,
pub channel_id: String,
pub channel_name: String,
}
#[cfg(not(feature = "private"))]
pub enum CriticalAlertKind {
#[cfg(feature = "enterprise")]
CriticalError,
#[cfg(feature = "enterprise")]
RecoveredCriticalError,
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn send_critical_alert(
_error_message: String,
_db: &DB,
_kind: CriticalAlertKind,
_channels: Option<Vec<CriticalErrorChannel>>,
) {
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn maybe_renew_license_key_on_start(
_http_client: &reqwest::Client,
_db: &crate::db::DB,
force_renew_now: bool,
) -> bool {
// Implementation is not open source
force_renew_now
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub enum RenewReason {
Manual,
Schedule,
OnStart,
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn renew_license_key(
_http_client: &reqwest::Client,
_db: &crate::db::DB,
_key: Option<String>,
_reason: RenewReason,
) -> String {
// Implementation is not open source
"".to_string()
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn create_customer_portal_session(
_http_client: &reqwest::Client,
_key: Option<String>,
) -> error::Result<String> {
// Implementation is not open source
Ok("".to_string())
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn worker_groups_alerts(_db: &DB) {}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn jobs_waiting_alerts(_db: &DB) {}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn low_disk_alerts(
_db: &DB,
_server_mode: bool,
_worker_mode: bool,
_workers: Vec<String>,
) {
// Implementation is not open source
}