Skip to main content

kopiur_api/
server.rs

1//! Optional kopia **web-UI server** surface for `Repository` / `ClusterRepository`.
2//!
3//! When `spec.server` is present, the operator runs `kopia server start` in a
4//! Deployment and exposes it via a Service so users can browse the repository
5//! through kopia's built-in HTML UI. Networking (Ingress/HTTPRoute) is left to the
6//! user — only the `Service` is created.
7//!
8//! ## Security reality (read before changing defaults)
9//!
10//! By default the server holds a **read-write** repository connection: kopia's UI is
11//! full read-write-**delete**, and the server process holds the repository decryption
12//! key, so exposing it exposes full mutation of all backups. Consequences encoded here:
13//!   * [`ServerAuth`] defaults (when `auth` is omitted) to [`ServerAuth::Generate`] —
14//!     never to no-auth.
15//!   * the no-auth variant ([`ServerAuth::Insecure`]) carries a mandatory
16//!     `acknowledgeInsecure: true`, webhook-rejected otherwise (see `api::validate`).
17//!
18//! ### Read-only (`readOnly`)
19//!
20//! [`ServerSpec::read_only`] (and a `Repository` with `spec.mode: ReadOnly`) connects the
21//! server's kopia process **read-only** (`kopia repository connect --readonly`), so
22//! creating/deleting/altering backups through the UI is rejected by the repository. kopia
23//! 0.23 has no server-level read-only *flag*; the guarantee comes from the read-only
24//! *connection*, which every later operation on that connection inherits. Two caveats it
25//! does **not** remove: the server still holds the decryption key (anyone reaching the UI
26//! can still **read and restore** every backup — read-only is not confidentiality), and
27//! kopia's UI does not hide the now-non-functional write/delete buttons. The **effective**
28//! read-only state is `mode: ReadOnly` OR `readOnly: true`, and is pinned to
29//! `status.server.readOnly`.
30//!
31//! ## Encoding
32//!
33//! [`ServerAuth`] is an **externally-tagged enum** (the same representation as
34//! [`crate::backend::Backend`]), so a value is always exactly one variant and
35//! reconcilers `match` it exhaustively. Every variant wraps a struct (never a unit
36//! variant — which would serialize as a bare string — and never a bare `bool`).
37
38use crate::common::SecretRef;
39use k8s_openapi::api::core::v1::{PodSecurityContext, ResourceRequirements, SecurityContext};
40use schemars::JsonSchema;
41use serde::{Deserialize, Serialize};
42use std::collections::BTreeMap;
43
44/// kopia's default server listen port; the resolved value is pinned to status.
45pub const DEFAULT_SERVER_PORT: u16 = 51515;
46
47/// Namespace-agnostic kopia web-UI server configuration; presence of `spec.server` means enabled.
48#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
49#[serde(rename_all = "camelCase")]
50pub struct ServerSpec {
51    /// UI authentication mode; omitted ⇒ [`ServerAuth::Generate`] (never no-auth).
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub auth: Option<ServerAuth>,
54    /// Connect the server's repository read-only so the UI cannot mutate backups (browse + restore only).
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub read_only: Option<bool>,
57    /// How the server is exposed as a Kubernetes `Service`.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub service: Option<ServerService>,
60    /// Resource requests/limits for the server pod.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub resources: Option<ResourceRequirements>,
63    /// Override the hardened default container security context.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub security_context: Option<SecurityContext>,
66    /// Pod-level security context for the server pod (notably `supplementalGroups` for NFS exports).
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub pod_security_context: Option<PodSecurityContext>,
69}
70
71/// `ClusterRepository`-only server config; the target `namespace` is required.
72#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
73#[serde(rename_all = "camelCase")]
74pub struct ClusterServerSpec {
75    /// Target namespace for the server Deployment/Service (and generated Secret).
76    pub namespace: String,
77    /// The shared, namespace-agnostic server configuration (auth/service/resources).
78    #[serde(flatten)]
79    pub server: ServerSpec,
80}
81
82/// UI authentication mode; exactly one variant by construction.
83#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
84#[serde(rename_all = "camelCase")]
85pub enum ServerAuth {
86    /// The operator mints random UI credentials into an owned `Secret`; the safe default.
87    Generate(GenerateAuth),
88    /// The user supplies UI credentials via a `Secret` (`name` + the two keys).
89    SecretRef(ServerSecretRef),
90    /// No UI authentication; requires an explicit `acknowledgeInsecure: true`.
91    Insecure(InsecureAuth),
92}
93
94impl ServerAuth {
95    /// Stable discriminant string for status/metrics/logs.
96    pub fn kind_str(&self) -> &'static str {
97        match self {
98            ServerAuth::Generate(_) => "Generate",
99            ServerAuth::SecretRef(_) => "SecretRef",
100            ServerAuth::Insecure(_) => "Insecure",
101        }
102    }
103}
104
105/// Payload for [`ServerAuth::Generate`].
106#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
107#[serde(rename_all = "camelCase")]
108pub struct GenerateAuth {
109    /// UI username to provision (default `kopia`).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub username: Option<String>,
112}
113
114/// User-supplied UI credentials.
115#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
116#[serde(rename_all = "camelCase")]
117pub struct ServerSecretRef {
118    /// Name of the `Secret` holding the UI credentials.
119    pub name: String,
120    /// Key within the secret holding the username.
121    pub username_key: String,
122    /// Key within the secret holding the password.
123    pub password_key: String,
124}
125
126/// Payload for [`ServerAuth::Insecure`].
127#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
128#[serde(rename_all = "camelCase")]
129pub struct InsecureAuth {
130    /// Must be `true`; the webhook rejects insecure mode otherwise.
131    #[serde(default)]
132    pub acknowledge_insecure: bool,
133}
134
135/// How the kopia server is exposed as a Kubernetes `Service`.
136#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
137#[serde(rename_all = "camelCase")]
138pub struct ServerService {
139    /// Service type. Defaults to `ClusterIP` (routing is the user's responsibility).
140    #[serde(default)]
141    pub r#type: ServiceType,
142    /// Listen/Service port. Defaults to [`DEFAULT_SERVER_PORT`] when omitted.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    #[schemars(default = "default_server_port")]
145    pub port: Option<u16>,
146    /// Annotations applied to the `Service` — the seam users wire their own
147    /// Ingress/LoadBalancer controller onto.
148    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
149    pub annotations: BTreeMap<String, String>,
150}
151
152/// schemars default for [`ServerService::port`] — [`DEFAULT_SERVER_PORT`]
153/// (`51515`), matching `resolved_port`'s absent→CONST resolution. Returns the
154/// field's `Option` type so schemars 1 emits the schema `default:`.
155fn default_server_port() -> Option<u16> {
156    Some(DEFAULT_SERVER_PORT)
157}
158
159impl ServerService {
160    /// The effective Service/listen port (`port` or [`DEFAULT_SERVER_PORT`]).
161    pub fn resolved_port(&self) -> u16 {
162        self.port.unwrap_or(DEFAULT_SERVER_PORT)
163    }
164}
165
166/// Kubernetes `Service.spec.type`.
167#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
168pub enum ServiceType {
169    /// In-cluster only (default); routing to the outside is the user's job.
170    #[default]
171    ClusterIP,
172    /// Exposed on each node's IP at a static port.
173    NodePort,
174    /// Provisioned through the cloud/load-balancer controller.
175    LoadBalancer,
176}
177
178impl ServiceType {
179    /// The Kubernetes `Service.spec.type` string.
180    pub fn as_str(&self) -> &'static str {
181        match self {
182            ServiceType::ClusterIP => "ClusterIP",
183            ServiceType::NodePort => "NodePort",
184            ServiceType::LoadBalancer => "LoadBalancer",
185        }
186    }
187}
188
189/// Status block for the kopia server, pinned by the reconciler; never carries a password.
190#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
191#[serde(rename_all = "camelCase")]
192pub struct ServerStatus {
193    /// In-cluster endpoint, `<service>.<namespace>.svc:<port>`.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub endpoint: Option<String>,
196    /// Namespace the server objects were last applied to.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub namespace: Option<String>,
199    /// Resolved auth mode discriminant (`Generate`/`SecretRef`/`Insecure`).
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub auth_mode: Option<String>,
202    /// Effective read-only state of the served connection.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub read_only: Option<bool>,
205    /// For `Generate` mode: the operator-owned Secret holding the UI credentials.
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub generated_secret_ref: Option<SecretRef>,
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::testutil::from_yaml;
214
215    #[test]
216    fn generate_auth_is_externally_tagged_empty_object() {
217        // The empty-struct variant is a path the rest of the API never exercises;
218        // prove it encodes as `{ generate: {} }`, not a bare string.
219        let auth: ServerAuth = from_yaml("generate: {}\n");
220        assert_eq!(auth.kind_str(), "Generate");
221        let v = serde_json::to_value(&auth).unwrap();
222        assert!(v.get("generate").is_some(), "wire shape: {v}");
223        assert!(v["generate"].is_object());
224    }
225
226    #[test]
227    fn secret_ref_auth_roundtrips() {
228        let auth: ServerAuth =
229            from_yaml("secretRef:\n  name: ui\n  usernameKey: username\n  passwordKey: password\n");
230        match &auth {
231            ServerAuth::SecretRef(s) => {
232                assert_eq!(s.name, "ui");
233                assert_eq!(s.username_key, "username");
234                assert_eq!(s.password_key, "password");
235            }
236            other => panic!("expected SecretRef, got {}", other.kind_str()),
237        }
238        let reparsed: ServerAuth =
239            serde_json::from_value(serde_json::to_value(&auth).unwrap()).unwrap();
240        assert_eq!(auth, reparsed);
241    }
242
243    #[test]
244    fn insecure_auth_carries_acknowledgement() {
245        let auth: ServerAuth = from_yaml("insecure:\n  acknowledgeInsecure: true\n");
246        match &auth {
247            ServerAuth::Insecure(i) => assert!(i.acknowledge_insecure),
248            other => panic!("expected Insecure, got {}", other.kind_str()),
249        }
250        // Default (no ack) is the un-acknowledged state.
251        let auth2: ServerAuth = from_yaml("insecure: {}\n");
252        assert!(matches!(auth2, ServerAuth::Insecure(i) if !i.acknowledge_insecure));
253    }
254
255    #[test]
256    fn unknown_auth_variant_is_rejected() {
257        let value: serde_json::Value = serde_yaml::from_str("oauth: {}\n").unwrap();
258        assert!(serde_json::from_value::<ServerAuth>(value).is_err());
259    }
260
261    #[test]
262    fn service_type_serializes_with_exact_k8s_casing() {
263        // The regression guard: `rename_all = camelCase` here would emit `clusterIp`.
264        assert_eq!(
265            serde_json::to_value(ServiceType::ClusterIP).unwrap(),
266            serde_json::json!("ClusterIP")
267        );
268        assert_eq!(
269            serde_json::to_value(ServiceType::NodePort).unwrap(),
270            serde_json::json!("NodePort")
271        );
272        assert_eq!(
273            serde_json::to_value(ServiceType::LoadBalancer).unwrap(),
274            serde_json::json!("LoadBalancer")
275        );
276        let t: ServiceType = serde_json::from_value(serde_json::json!("LoadBalancer")).unwrap();
277        assert_eq!(t, ServiceType::LoadBalancer);
278    }
279
280    #[test]
281    fn service_defaults_type_clusterip_and_resolves_default_port() {
282        let svc = ServerService::default();
283        assert_eq!(svc.r#type, ServiceType::ClusterIP);
284        assert_eq!(svc.resolved_port(), DEFAULT_SERVER_PORT);
285        assert_eq!(
286            ServerService {
287                port: Some(8080),
288                ..Default::default()
289            }
290            .resolved_port(),
291            8080
292        );
293    }
294
295    #[test]
296    fn read_only_defaults_off_and_roundtrips() {
297        // Omitted ⇒ None (the controller resolves the effective value).
298        let off: ServerSpec = from_yaml("auth:\n  generate: {}\n");
299        assert_eq!(off.read_only, None);
300        // Present ⇒ pinned bool, externally visible as `readOnly`.
301        let on: ServerSpec = from_yaml("readOnly: true\nauth:\n  generate: {}\n");
302        assert_eq!(on.read_only, Some(true));
303        let v = serde_json::to_value(&on).unwrap();
304        assert_eq!(v["readOnly"], serde_json::json!(true));
305        let reparsed: ServerSpec = serde_json::from_value(v).unwrap();
306        assert_eq!(on, reparsed);
307        // Default ServerSpec leaves it unset (so an unconfigured server is unaffected).
308        assert_eq!(ServerSpec::default().read_only, None);
309    }
310
311    #[test]
312    fn status_pins_effective_read_only() {
313        let st: ServerStatus = from_yaml("readOnly: true\nauthMode: Generate\n");
314        assert_eq!(st.read_only, Some(true));
315        let v = serde_json::to_value(&st).unwrap();
316        assert_eq!(v["readOnly"], serde_json::json!(true));
317    }
318
319    #[test]
320    fn server_spec_carries_pod_security_context_supplemental_groups() {
321        // The NFS-shared-group knob: a supplemental GID the server joins so it can
322        // write a group-owned export (fsGroup being a no-op on NFS).
323        let spec: ServerSpec =
324            from_yaml("auth:\n  generate: {}\npodSecurityContext:\n  supplementalGroups: [3001]\n");
325        let psc = spec
326            .pod_security_context
327            .as_ref()
328            .expect("podSecurityContext present");
329        assert_eq!(psc.supplemental_groups.as_deref(), Some(&[3001i64][..]));
330        // Structurally stable through the cluster's YAML→JSON→typed→JSON path.
331        let reparsed: ServerSpec =
332            serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap();
333        assert_eq!(spec, reparsed);
334        // Absent by default (skip_serializing_if) so the wire shape stays minimal.
335        let bare: ServerSpec = from_yaml("auth:\n  generate: {}\n");
336        assert!(bare.pod_security_context.is_none());
337        assert!(
338            serde_json::to_value(&bare)
339                .unwrap()
340                .get("podSecurityContext")
341                .is_none()
342        );
343    }
344
345    #[test]
346    fn cluster_server_spec_flattens_pod_security_context() {
347        let cs: ClusterServerSpec = from_yaml(
348            "namespace: kopiur-system\npodSecurityContext:\n  supplementalGroups: [3001]\n  fsGroup: 3001\n",
349        );
350        assert_eq!(cs.namespace, "kopiur-system");
351        let psc = cs.server.pod_security_context.as_ref().unwrap();
352        assert_eq!(psc.supplemental_groups.as_deref(), Some(&[3001i64][..]));
353        assert_eq!(psc.fs_group, Some(3001));
354    }
355
356    #[test]
357    fn cluster_server_spec_flattens_namespace_with_base() {
358        let cs: ClusterServerSpec = from_yaml(
359            "namespace: kopiur-system\nauth:\n  generate: {}\nservice:\n  type: NodePort\n  port: 30515\n",
360        );
361        assert_eq!(cs.namespace, "kopiur-system");
362        assert_eq!(cs.server.auth.as_ref().unwrap().kind_str(), "Generate");
363        let svc = cs.server.service.as_ref().unwrap();
364        assert_eq!(svc.r#type, ServiceType::NodePort);
365        assert_eq!(svc.resolved_port(), 30515);
366        // Flattened round-trip is structurally stable.
367        let reparsed: ClusterServerSpec =
368            serde_json::from_value(serde_json::to_value(&cs).unwrap()).unwrap();
369        assert_eq!(cs, reparsed);
370    }
371}