Skip to main content

wings_api/
extra.rs

1use garde::Validate;
2use serde::{Deserialize, Serialize};
3use utoipa::ToSchema;
4
5#[derive(Debug, ToSchema, Deserialize, Serialize, Clone)]
6#[serde(transparent)]
7pub struct Config(pub serde_json::Value);
8
9impl Config {
10    pub fn view<T: serde::de::DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
11        T::deserialize(&self.0)
12    }
13}
14
15#[derive(Debug, ToSchema, Deserialize, Serialize, Clone)]
16#[serde(tag = "type", rename_all = "snake_case")]
17pub enum QueryValue {
18    Null,
19    Text { value: String, truncated: bool },
20    Binary { value: String, truncated: bool },
21}
22
23#[derive(Debug, ToSchema, Deserialize, Serialize, Clone)]
24#[serde(tag = "type", rename_all = "snake_case")]
25#[non_exhaustive]
26pub enum ServerSelector {
27    Uuids {
28        uuids: std::collections::HashSet<uuid::Uuid>,
29    },
30    All,
31}
32
33#[derive(Debug, ToSchema, Deserialize, Serialize, Clone)]
34#[serde(tag = "adapter")]
35#[non_exhaustive]
36pub enum BackupTestTarget {
37    #[serde(rename = "wings")]
38    Wings,
39    #[serde(rename = "s3")]
40    S3 {
41        configuration: super::S3TestConfiguration,
42    },
43    #[serde(rename = "ddup-bak")]
44    DdupBak,
45    #[serde(rename = "btrfs")]
46    Btrfs,
47    #[serde(rename = "zfs")]
48    Zfs,
49    #[serde(rename = "restic")]
50    Restic {
51        configuration: Option<super::ResticBackupConfiguration>,
52    },
53    #[serde(rename = "proxmox-backup-server")]
54    ProxmoxBackupServer {
55        configuration: super::PbsRepositoryConfiguration,
56    },
57    #[serde(rename = "kopia")]
58    Kopia {
59        configuration: super::KopiaBackupConfiguration,
60    },
61}
62
63impl std::fmt::Display for super::StreamableArchiveFormat {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        write!(
66            f,
67            "{}",
68            match self {
69                super::StreamableArchiveFormat::Tar => "tar",
70                super::StreamableArchiveFormat::TarGz => "tar_gz",
71                super::StreamableArchiveFormat::TarXz => "tar_xz",
72                super::StreamableArchiveFormat::TarLzip => "tar_lzip",
73                super::StreamableArchiveFormat::TarBz2 => "tar_bz2",
74                super::StreamableArchiveFormat::TarLz4 => "tar_lz4",
75                super::StreamableArchiveFormat::TarZstd => "tar_zstd",
76                super::StreamableArchiveFormat::Itaf => "itaf",
77                super::StreamableArchiveFormat::ItafGz => "itaf_gz",
78                super::StreamableArchiveFormat::ItafXz => "itaf_xz",
79                super::StreamableArchiveFormat::ItafLzip => "itaf_lzip",
80                super::StreamableArchiveFormat::ItafBz2 => "itaf_bz2",
81                super::StreamableArchiveFormat::ItafLz4 => "itaf_lz4",
82                super::StreamableArchiveFormat::ItafZstd => "itaf_zstd",
83                super::StreamableArchiveFormat::Zip => "zip",
84            }
85        )
86    }
87}
88
89#[allow(clippy::derivable_impls)]
90impl Default for super::StreamableArchiveFormat {
91    #[inline]
92    fn default() -> Self {
93        super::StreamableArchiveFormat::TarGz
94    }
95}
96
97impl std::fmt::Display for super::BackupAdapter {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        write!(
100            f,
101            "{}",
102            match self {
103                super::BackupAdapter::Wings => "wings",
104                super::BackupAdapter::S3 => "s3",
105                super::BackupAdapter::DdupBak => "ddup-bak",
106                super::BackupAdapter::Btrfs => "btrfs",
107                super::BackupAdapter::Zfs => "zfs",
108                super::BackupAdapter::Restic => "restic",
109                super::BackupAdapter::ProxmoxBackupServer => "proxmox-backup-server",
110                super::BackupAdapter::Kopia => "kopia",
111            }
112        )
113    }
114}
115
116#[derive(Debug, ToSchema, Deserialize, Serialize, Clone, Copy)]
117#[serde(rename_all = "lowercase")]
118#[non_exhaustive]
119pub enum Algorithm {
120    Md5,
121    Crc32,
122    Sha1,
123    Sha224,
124    Sha256,
125    Sha384,
126    Sha512,
127    Curseforge,
128}
129
130impl std::fmt::Display for Algorithm {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        write!(
133            f,
134            "{}",
135            match self {
136                Algorithm::Md5 => "md5",
137                Algorithm::Crc32 => "crc32",
138                Algorithm::Sha1 => "sha1",
139                Algorithm::Sha224 => "sha224",
140                Algorithm::Sha256 => "sha256",
141                Algorithm::Sha384 => "sha384",
142                Algorithm::Sha512 => "sha512",
143                Algorithm::Curseforge => "curseforge",
144            }
145        )
146    }
147}
148
149#[derive(Debug, Default, ToSchema, Deserialize, Serialize, Clone, Copy)]
150#[serde(rename_all = "snake_case")]
151#[non_exhaustive]
152pub enum DirectorySortingMode {
153    #[default]
154    NameAsc,
155    NameDesc,
156    SizeAsc,
157    SizeDesc,
158    PhysicalSizeAsc,
159    PhysicalSizeDesc,
160    ModifiedAsc,
161    ModifiedDesc,
162    CreatedAsc,
163    CreatedDesc,
164}
165
166impl std::fmt::Display for DirectorySortingMode {
167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        write!(
169            f,
170            "{}",
171            match self {
172                DirectorySortingMode::NameAsc => "name_asc",
173                DirectorySortingMode::NameDesc => "name_desc",
174                DirectorySortingMode::SizeAsc => "size_asc",
175                DirectorySortingMode::SizeDesc => "size_desc",
176                DirectorySortingMode::PhysicalSizeAsc => "physical_size_asc",
177                DirectorySortingMode::PhysicalSizeDesc => "physical_size_desc",
178                DirectorySortingMode::ModifiedAsc => "modified_asc",
179                DirectorySortingMode::ModifiedDesc => "modified_desc",
180                DirectorySortingMode::CreatedAsc => "created_asc",
181                DirectorySortingMode::CreatedDesc => "created_desc",
182            }
183        )
184    }
185}
186
187#[derive(ToSchema, Debug, Clone, Copy, Deserialize, Serialize)]
188#[serde(rename_all = "lowercase")]
189pub enum ServerBackupStatus {
190    Starting,
191    Finished,
192    Failed,
193}
194
195#[derive(Debug, ToSchema, Deserialize, Serialize, Clone, Copy)]
196#[serde(rename_all = "snake_case")]
197#[non_exhaustive]
198pub enum Game {
199    MinecraftJava,
200}
201
202impl std::fmt::Display for Game {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        write!(
205            f,
206            "{}",
207            match self {
208                Game::MinecraftJava => "minecraft_java",
209            }
210        )
211    }
212}
213
214#[derive(ToSchema, Clone, Deserialize, Serialize, Validate)]
215pub struct ScheduleVariable {
216    #[garde(length(chars, min = 1, max = 255))]
217    #[schema(min_length = 1, max_length = 255)]
218    pub variable: compact_str::CompactString,
219}
220
221#[derive(ToSchema, Clone, Deserialize, Serialize, Validate)]
222#[serde(untagged)]
223pub enum ScheduleDynamicParameter {
224    Raw(#[garde(length(chars, min = 1, max = 16384))] compact_str::CompactString),
225    Variable(#[garde(dive)] ScheduleVariable),
226}
227
228#[derive(ToSchema, Deserialize, Serialize, Clone, Copy)]
229#[serde(rename_all = "snake_case")]
230pub enum ScheduleHttpMethod {
231    Get,
232    Post,
233    Put,
234    Patch,
235    Delete,
236    Head,
237}
238
239#[derive(ToSchema, Clone, Deserialize, Serialize, Validate)]
240pub struct ScheduleHttpHeader {
241    #[garde(length(chars, min = 1, max = 128))]
242    #[schema(min_length = 1, max_length = 128)]
243    pub name: compact_str::CompactString,
244    #[garde(dive)]
245    pub value: ScheduleDynamicParameter,
246}
247
248#[derive(ToSchema, Deserialize, Serialize)]
249pub struct ScheduleAction {
250    pub uuid: uuid::Uuid,
251
252    #[serde(flatten)]
253    pub inner: ScheduleActionInner,
254}
255
256#[derive(ToSchema, Validate, Deserialize, Serialize, Clone)]
257#[serde(rename_all = "snake_case", tag = "mode")]
258#[non_exhaustive]
259pub enum ScheduleBackupSelector {
260    Latest {
261        #[garde(skip)]
262        #[serde(default)]
263        backup_group_uuid: Option<uuid::Uuid>,
264    },
265    Oldest {
266        #[garde(skip)]
267        #[serde(default)]
268        backup_group_uuid: Option<uuid::Uuid>,
269    },
270    Uuid {
271        #[garde(dive)]
272        uuid: ScheduleDynamicParameter,
273    },
274    Name {
275        #[garde(dive)]
276        name: ScheduleDynamicParameter,
277        #[garde(skip)]
278        #[serde(default)]
279        backup_group_uuid: Option<uuid::Uuid>,
280        #[garde(skip)]
281        #[serde(default)]
282        oldest: bool,
283    },
284}
285
286#[derive(ToSchema, Validate, Deserialize, Serialize, Clone)]
287#[serde(rename_all = "snake_case", tag = "type")]
288#[non_exhaustive]
289pub enum ScheduleActionInner {
290    Sleep {
291        #[garde(range(min = 1, max = 24 * 60 * 60 * 1000))]
292        #[schema(minimum = 1, maximum = 86400000)]
293        duration: u64,
294    },
295    Ensure {
296        #[garde(dive, custom(ScheduleCondition::validate_nesting))]
297        condition: ScheduleCondition,
298    },
299    If {
300        #[garde(dive, custom(ScheduleCondition::validate_nesting))]
301        condition: ScheduleCondition,
302    },
303    ElseIf {
304        #[garde(dive, custom(ScheduleCondition::validate_nesting))]
305        condition: ScheduleCondition,
306    },
307    Else,
308    EndIf,
309    Exit {
310        #[garde(skip)]
311        successful: bool,
312    },
313    WaitForState {
314        #[garde(skip)]
315        ignore_failure: bool,
316
317        #[garde(skip)]
318        state: super::ServerState,
319        #[garde(range(min = 1, max = 24 * 60 * 60 * 1000))]
320        #[schema(minimum = 1, maximum = 86400000)]
321        timeout: u64,
322    },
323    Format {
324        #[garde(length(chars, min = 1, max = 16384))]
325        #[schema(min_length = 1, max_length = 16384)]
326        format: String,
327        #[garde(dive)]
328        output_into: ScheduleVariable,
329    },
330    MatchRegex {
331        #[garde(dive)]
332        input: ScheduleDynamicParameter,
333
334        #[garde(skip)]
335        #[serde(with = "serde_regex")]
336        #[schema(value_type = String, format = "regex")]
337        regex: regex::Regex,
338
339        #[garde(skip)]
340        output_into: Vec<Option<ScheduleVariable>>,
341    },
342    WaitForConsoleLine {
343        #[garde(skip)]
344        ignore_failure: bool,
345
346        #[garde(dive)]
347        contains: ScheduleDynamicParameter,
348        #[garde(skip)]
349        #[serde(default)]
350        case_insensitive: bool,
351        #[garde(range(min = 1, max = 24 * 60 * 60 * 1000))]
352        #[schema(minimum = 1, maximum = 86400000)]
353        timeout: u64,
354
355        #[garde(dive)]
356        output_into: Option<ScheduleVariable>,
357    },
358    SendPower {
359        #[garde(skip)]
360        ignore_failure: bool,
361
362        #[garde(skip)]
363        action: super::ServerPowerAction,
364    },
365    SendCommand {
366        #[garde(skip)]
367        ignore_failure: bool,
368
369        #[garde(dive)]
370        command: ScheduleDynamicParameter,
371    },
372    CreateBackup {
373        #[garde(skip)]
374        ignore_failure: bool,
375        #[garde(skip)]
376        foreground: bool,
377
378        #[garde(dive)]
379        name: Option<ScheduleDynamicParameter>,
380        #[garde(skip)]
381        #[serde(default)]
382        backup_group_uuid: Option<uuid::Uuid>,
383        #[garde(skip)]
384        ignored_files: Vec<compact_str::CompactString>,
385
386        #[garde(dive)]
387        #[serde(default)]
388        output_into: Option<ScheduleVariable>,
389    },
390    RestoreBackup {
391        #[garde(skip)]
392        ignore_failure: bool,
393        #[garde(skip)]
394        truncate_directory: bool,
395        #[garde(skip)]
396        #[serde(default)]
397        restore_startup: bool,
398
399        #[garde(dive)]
400        backup: ScheduleBackupSelector,
401    },
402    DeleteBackup {
403        #[garde(skip)]
404        #[serde(default)]
405        ignore_failure: bool,
406
407        #[garde(dive)]
408        backup: ScheduleBackupSelector,
409    },
410    MoveBackup {
411        #[garde(skip)]
412        #[serde(default)]
413        ignore_failure: bool,
414
415        #[garde(dive)]
416        backup: ScheduleBackupSelector,
417        #[garde(skip)]
418        #[serde(default)]
419        backup_group_uuid: Option<uuid::Uuid>,
420    },
421    CreateDatabaseBackup {
422        #[garde(skip)]
423        ignore_failure: bool,
424        #[garde(skip)]
425        foreground: bool,
426
427        #[garde(dive)]
428        name: Option<ScheduleDynamicParameter>,
429        #[garde(skip)]
430        database_instance_uuid: uuid::Uuid,
431        #[garde(skip)]
432        #[serde(default)]
433        backup_group_uuid: Option<uuid::Uuid>,
434
435        #[garde(dive)]
436        #[serde(default)]
437        output_into: Option<ScheduleVariable>,
438    },
439    DeleteDatabaseBackup {
440        #[garde(skip)]
441        #[serde(default)]
442        ignore_failure: bool,
443
444        #[garde(dive)]
445        backup: ScheduleBackupSelector,
446        #[garde(skip)]
447        #[serde(default)]
448        database_instance_uuid: Option<uuid::Uuid>,
449    },
450    MoveDatabaseBackup {
451        #[garde(skip)]
452        #[serde(default)]
453        ignore_failure: bool,
454
455        #[garde(dive)]
456        backup: ScheduleBackupSelector,
457        #[garde(skip)]
458        #[serde(default)]
459        database_instance_uuid: Option<uuid::Uuid>,
460        #[garde(skip)]
461        #[serde(default)]
462        backup_group_uuid: Option<uuid::Uuid>,
463    },
464    RestoreDatabaseBackup {
465        #[garde(skip)]
466        #[serde(default)]
467        ignore_failure: bool,
468
469        #[garde(dive)]
470        backup: ScheduleBackupSelector,
471        #[garde(skip)]
472        #[serde(default)]
473        source_database_instance_uuid: Option<uuid::Uuid>,
474        #[garde(skip)]
475        #[serde(default)]
476        database_instance_uuid: Option<uuid::Uuid>,
477    },
478    CreateDirectory {
479        #[garde(skip)]
480        ignore_failure: bool,
481
482        #[garde(dive)]
483        root: ScheduleDynamicParameter,
484        #[garde(dive)]
485        name: ScheduleDynamicParameter,
486    },
487    WriteFile {
488        #[garde(skip)]
489        ignore_failure: bool,
490        #[garde(skip)]
491        append: bool,
492
493        #[garde(dive)]
494        file: ScheduleDynamicParameter,
495        #[garde(dive)]
496        content: ScheduleDynamicParameter,
497    },
498    CopyFile {
499        #[garde(skip)]
500        ignore_failure: bool,
501        #[garde(skip)]
502        foreground: bool,
503
504        #[garde(dive)]
505        file: ScheduleDynamicParameter,
506        #[garde(dive)]
507        destination: ScheduleDynamicParameter,
508    },
509    DeleteFiles {
510        #[garde(skip)]
511        #[serde(default)]
512        ignore_failure: bool,
513        #[garde(skip)]
514        #[serde(default)]
515        trash: bool,
516
517        #[garde(dive)]
518        root: ScheduleDynamicParameter,
519        #[garde(skip)]
520        files: Vec<compact_str::CompactString>,
521    },
522    RenameFiles {
523        #[garde(skip)]
524        #[serde(default)]
525        ignore_failure: bool,
526
527        #[garde(dive)]
528        root: ScheduleDynamicParameter,
529        #[garde(skip)]
530        #[schema(inline)]
531        files: Vec<super::servers_server_files_rename::put::RequestBodyFiles>,
532    },
533    CompressFiles {
534        #[garde(skip)]
535        ignore_failure: bool,
536        #[garde(skip)]
537        foreground: bool,
538
539        #[garde(dive)]
540        root: ScheduleDynamicParameter,
541        #[garde(skip)]
542        files: Vec<compact_str::CompactString>,
543        #[garde(skip)]
544        format: super::ArchiveFormat,
545        #[garde(dive)]
546        name: ScheduleDynamicParameter,
547    },
548    DecompressFile {
549        #[garde(skip)]
550        ignore_failure: bool,
551        #[garde(skip)]
552        foreground: bool,
553
554        #[garde(dive)]
555        root: ScheduleDynamicParameter,
556        #[garde(dive)]
557        file: ScheduleDynamicParameter,
558    },
559    PullFile {
560        #[garde(skip)]
561        ignore_failure: bool,
562        #[garde(skip)]
563        foreground: bool,
564
565        #[garde(dive)]
566        root: ScheduleDynamicParameter,
567        #[garde(dive)]
568        url: ScheduleDynamicParameter,
569        #[garde(dive)]
570        #[serde(default)]
571        file_name: Option<ScheduleDynamicParameter>,
572        #[garde(skip)]
573        #[serde(default)]
574        use_header: bool,
575    },
576    UpdateStartupVariable {
577        #[garde(skip)]
578        ignore_failure: bool,
579
580        #[garde(dive)]
581        env_variable: ScheduleDynamicParameter,
582        #[garde(dive)]
583        value: ScheduleDynamicParameter,
584    },
585    UpdateStartupCommand {
586        #[garde(skip)]
587        ignore_failure: bool,
588
589        #[garde(dive)]
590        command: ScheduleDynamicParameter,
591    },
592    UpdateStartupDockerImage {
593        #[garde(skip)]
594        ignore_failure: bool,
595
596        #[garde(dive)]
597        image: ScheduleDynamicParameter,
598    },
599    HttpRequest {
600        #[garde(skip)]
601        ignore_failure: bool,
602
603        #[garde(skip)]
604        method: ScheduleHttpMethod,
605        #[garde(skip)]
606        #[schema(value_type = String, format = "uri")]
607        url: reqwest::Url,
608        #[garde(length(max = 32), dive)]
609        #[serde(default)]
610        headers: Vec<ScheduleHttpHeader>,
611        #[garde(dive)]
612        #[serde(default)]
613        body: Option<ScheduleDynamicParameter>,
614        #[garde(range(min = 1, max = 60 * 1000))]
615        #[schema(minimum = 1, maximum = 60000)]
616        timeout: u64,
617        #[garde(skip)]
618        #[serde(default)]
619        ignore_error_status: bool,
620
621        #[garde(dive)]
622        #[serde(default)]
623        output_status_into: Option<ScheduleVariable>,
624        #[garde(dive)]
625        #[serde(default)]
626        output_body_into: Option<ScheduleVariable>,
627    },
628}
629
630impl ScheduleActionInner {
631    pub fn permissions(&self) -> &'static [&'static str] {
632        match self {
633            ScheduleActionInner::Sleep { .. } => &[],
634            ScheduleActionInner::Ensure { .. } => &[],
635            ScheduleActionInner::If { .. } => &[],
636            ScheduleActionInner::ElseIf { .. } => &[],
637            ScheduleActionInner::Else => &[],
638            ScheduleActionInner::EndIf => &[],
639            ScheduleActionInner::Exit { .. } => &[],
640            ScheduleActionInner::WaitForState { .. } => &[],
641            ScheduleActionInner::Format { .. } => &[],
642            ScheduleActionInner::MatchRegex { .. } => &[],
643            ScheduleActionInner::WaitForConsoleLine { .. } => &["control.read-console"],
644            ScheduleActionInner::SendPower { action, .. } => match action {
645                super::ServerPowerAction::Start => &["control.start"],
646                super::ServerPowerAction::Stop => &["control.stop"],
647                super::ServerPowerAction::Restart => &["control.restart"],
648                super::ServerPowerAction::Kill => &["control.stop"],
649            },
650            ScheduleActionInner::SendCommand { .. } => &["control.console"],
651            ScheduleActionInner::CreateBackup { .. } => &["backups.create"],
652            ScheduleActionInner::RestoreBackup { .. } => &["backups.restore"],
653            ScheduleActionInner::DeleteBackup { .. } => &["backups.delete"],
654            ScheduleActionInner::MoveBackup { .. } => &["backups.update"],
655            ScheduleActionInner::CreateDatabaseBackup { .. } => {
656                &["backups.create", "database-instances.read"]
657            }
658            ScheduleActionInner::DeleteDatabaseBackup { .. } => {
659                &["backups.delete", "database-instances.read"]
660            }
661            ScheduleActionInner::MoveDatabaseBackup { .. } => {
662                &["backups.update", "database-instances.read"]
663            }
664            ScheduleActionInner::RestoreDatabaseBackup { .. } => {
665                &["backups.restore", "database-instances.read"]
666            }
667            ScheduleActionInner::CreateDirectory { .. } => &["files.create"],
668            ScheduleActionInner::WriteFile { .. } => &["files.update"],
669            ScheduleActionInner::CopyFile { .. } => &["files.update"],
670            ScheduleActionInner::DeleteFiles { .. } => &["files.delete"],
671            ScheduleActionInner::RenameFiles { .. } => &["files.update"],
672            ScheduleActionInner::CompressFiles { .. } => &["files.archive"],
673            ScheduleActionInner::DecompressFile { .. } => &["files.archive"],
674            ScheduleActionInner::PullFile { .. } => &["files.create"],
675            ScheduleActionInner::UpdateStartupVariable { .. } => &["startup.update"],
676            ScheduleActionInner::UpdateStartupCommand { .. } => &["startup.command"],
677            ScheduleActionInner::UpdateStartupDockerImage { .. } => &["startup.docker-image"],
678            ScheduleActionInner::HttpRequest { .. } => &[],
679        }
680    }
681}
682
683#[derive(ToSchema, Validate, Deserialize, Serialize, Clone)]
684#[serde(rename_all = "snake_case", tag = "type")]
685#[non_exhaustive]
686pub enum ScheduleTrigger {
687    Cron {
688        #[garde(skip)]
689        #[schema(value_type = String, example = "* * * * * *")]
690        schedule: Box<croner::Cron>,
691    },
692    PowerAction {
693        #[garde(skip)]
694        action: super::ServerPowerAction,
695    },
696    ServerState {
697        #[garde(skip)]
698        state: super::ServerState,
699    },
700    BackupStatus {
701        #[garde(skip)]
702        status: ServerBackupStatus,
703    },
704    DatabaseBackupStatus {
705        #[garde(skip)]
706        status: ServerBackupStatus,
707    },
708    ScheduleCompletion {
709        #[garde(skip)]
710        schedule: uuid::Uuid,
711        #[garde(skip)]
712        successful: bool,
713    },
714    ResourceUsage {
715        #[garde(skip)]
716        metric: ScheduleResourceMetric,
717        #[garde(skip)]
718        comparator: ScheduleConditionComparator,
719        #[garde(range(min = 0.0))]
720        value: f64,
721        #[garde(range(min = 0, max = 24 * 60 * 60))]
722        #[schema(minimum = 0, maximum = 86400)]
723        #[serde(default)]
724        for_seconds: u64,
725    },
726    ConsoleLine {
727        #[garde(length(chars, min = 1, max = 1024))]
728        #[schema(min_length = 1, max_length = 1024)]
729        contains: compact_str::CompactString,
730        #[garde(skip)]
731        #[serde(default)]
732        case_insensitive: bool,
733        #[garde(dive)]
734        output_into: Option<ScheduleVariable>,
735    },
736    Crash,
737}
738
739impl ScheduleTrigger {
740    pub fn permissions(&self) -> &'static [&'static str] {
741        match self {
742            ScheduleTrigger::Cron { .. } => &[],
743            ScheduleTrigger::PowerAction { .. } => &[],
744            ScheduleTrigger::ServerState { .. } => &[],
745            ScheduleTrigger::BackupStatus { .. } => &[],
746            ScheduleTrigger::DatabaseBackupStatus { .. } => &[],
747            ScheduleTrigger::ScheduleCompletion { .. } => &[],
748            ScheduleTrigger::ResourceUsage { .. } => &[],
749            ScheduleTrigger::ConsoleLine { .. } => &["control.read-console"],
750            ScheduleTrigger::Crash => &[],
751        }
752    }
753}
754
755#[derive(ToSchema, Deserialize, Serialize, Clone, Copy)]
756#[serde(rename_all = "snake_case")]
757pub enum ScheduleConditionComparator {
758    SmallerThan,
759    SmallerThanOrEquals,
760    Equal,
761    GreaterThan,
762    GreaterThanOrEquals,
763}
764
765#[derive(ToSchema, Deserialize, Serialize, Clone, Copy)]
766#[serde(rename_all = "snake_case")]
767pub enum ScheduleResourceMetric {
768    Cpu,
769    Memory,
770    Disk,
771}
772
773#[derive(ToSchema, Deserialize, Serialize, Clone, Validate)]
774#[serde(rename_all = "snake_case", tag = "type")]
775#[schema(no_recursion)]
776pub enum ScheduleCondition {
777    None,
778    And {
779        #[garde(dive)]
780        conditions: Vec<ScheduleCondition>,
781    },
782    Or {
783        #[garde(dive)]
784        conditions: Vec<ScheduleCondition>,
785    },
786    Not {
787        #[garde(dive)]
788        condition: Box<ScheduleCondition>,
789    },
790    ServerState {
791        #[garde(skip)]
792        state: super::ServerState,
793    },
794    Uptime {
795        #[garde(skip)]
796        comparator: ScheduleConditionComparator,
797        #[garde(skip)]
798        value: u64,
799    },
800    ResourceUsage {
801        #[garde(skip)]
802        metric: ScheduleResourceMetric,
803        #[garde(skip)]
804        comparator: ScheduleConditionComparator,
805        #[garde(range(min = 0.0))]
806        value: f64,
807    },
808    FileExists {
809        #[garde(length(chars, min = 1, max = 255))]
810        #[schema(min_length = 1, max_length = 255)]
811        file: String,
812    },
813    VariableExists {
814        #[garde(dive)]
815        variable: ScheduleVariable,
816    },
817    VariableEquals {
818        #[garde(dive)]
819        variable: ScheduleVariable,
820        #[garde(dive)]
821        equals: ScheduleDynamicParameter,
822    },
823    VariableContains {
824        #[garde(dive)]
825        variable: ScheduleVariable,
826        #[garde(dive)]
827        contains: ScheduleDynamicParameter,
828    },
829    VariableStartsWith {
830        #[garde(dive)]
831        variable: ScheduleVariable,
832        #[garde(dive)]
833        starts_with: ScheduleDynamicParameter,
834    },
835    VariableEndsWith {
836        #[garde(dive)]
837        variable: ScheduleVariable,
838        #[garde(dive)]
839        ends_with: ScheduleDynamicParameter,
840    },
841}
842
843impl ScheduleCondition {
844    pub const MAX_NESTING_DEPTH: usize = 3;
845
846    fn nested_within_limit(&self, depth: usize) -> bool {
847        match self {
848            ScheduleCondition::And { conditions } | ScheduleCondition::Or { conditions } => {
849                depth < Self::MAX_NESTING_DEPTH
850                    && conditions.iter().all(|c| c.nested_within_limit(depth + 1))
851            }
852            ScheduleCondition::Not { condition } => {
853                depth < Self::MAX_NESTING_DEPTH && condition.nested_within_limit(depth + 1)
854            }
855            _ => true,
856        }
857    }
858
859    pub fn validate_nesting(value: &Self, _context: &()) -> garde::Result {
860        if value.nested_within_limit(0) {
861            Ok(())
862        } else {
863            Err(garde::Error::new(format!(
864                "condition may not nest groups more than {} levels deep",
865                Self::MAX_NESTING_DEPTH
866            )))
867        }
868    }
869
870    pub fn validate_optional_nesting(value: &Option<Self>, context: &()) -> garde::Result {
871        match value {
872            Some(condition) => Self::validate_nesting(condition, context),
873            None => Ok(()),
874        }
875    }
876}
877
878// mirrors wings config::FORBIDDEN_PATHS
879pub const FORBIDDEN_CONFIG_PATHS: &[&str] = &[
880    "uuid",
881    "token",
882    "token_id",
883    "remote",
884    "remote_headers",
885    "system.root_directory",
886    "system.log_directory",
887    "system.data",
888    "system.diffs_directory",
889    "system.vmount_directory",
890    "system.archive_directory",
891    "system.backup_directory",
892    "system.tmp_directory",
893    "system.passwd.directory",
894    "system.backups.restic.repository",
895    "system.backups.restic.password_file",
896    "system.backups.mounting.path",
897    "system.username",
898    "system.user",
899    "system.passwd",
900    "docker.socket",
901    "tundra.data_directory",
902    "tundra.binary",
903    "tundra.image",
904    "tundra.source_image",
905    "allowed_mounts",
906    "allowed_devices",
907    "ignore_panel_config_updates",
908    "ignore_panel_wings_upgrades",
909    "api.host",
910    "api.port",
911    "api.ssl",
912    "api.trusted_proxies",
913    "api.disable_remote_download",
914    "api.remote_download_blocked_cidrs",
915    "api.schedule.steps.http_request",
916];
917
918/// Config paths holding secrets, `*` matching every key of a map.
919pub const SECRET_CONFIG_PATHS: &[&str] = &[
920    "token",
921    "token_id",
922    "remote_headers.*",
923    "docker.registries.*.password",
924    "docker.log_config.config.*",
925    "system.backups.restic.environment.*",
926];
927
928pub fn strip_config_paths(value: &mut serde_json::Value) -> Vec<&'static str> {
929    let mut stripped = Vec::new();
930
931    for path in FORBIDDEN_CONFIG_PATHS {
932        let mut cursor = &mut *value;
933        let mut parts = path.split('.').peekable();
934
935        while let Some(part) = parts.next() {
936            let serde_json::Value::Object(map) = cursor else {
937                break;
938            };
939
940            if parts.peek().is_none() {
941                if map.remove(part).is_some() {
942                    stripped.push(*path);
943                }
944                break;
945            }
946
947            if map.get(part).is_some_and(|next| !next.is_object()) {
948                map.remove(part);
949                stripped.push(*path);
950                break;
951            }
952
953            match map.get_mut(part) {
954                Some(next) => cursor = next,
955                None => break,
956            }
957        }
958    }
959
960    stripped
961}
962
963/// What a node reports about the tundra mesh daemon it manages. Hand-written rather than
964/// generated because the panel drives these routes directly, not through the shim.
965#[derive(Debug, ToSchema, Deserialize, Serialize, Clone)]
966pub struct TundraStatus {
967    /// False covers both a node that cannot run the mesh and one with it turned off.
968    pub supported: bool,
969    pub connected: bool,
970    pub epoch: Option<u64>,
971}
972
973/// The mesh daemon reports considerably more than this - per-path congestion detail, UDP
974/// socket counters, restart handover state - and serde drops what is not declared here. The
975/// fields below are the ones the panel renders, so a daemon that stops sending one fails
976/// deserialization on the panel rather than the browser.
977#[derive(Debug, ToSchema, Deserialize, Serialize, Clone)]
978pub struct TundraMetrics {
979    pub node: TundraNodeMetrics,
980    pub peers: Vec<TundraPeerMetrics>,
981}
982
983#[derive(Debug, ToSchema, Deserialize, Serialize, Clone)]
984pub struct TundraNodeMetrics {
985    pub uptime_secs: u64,
986    pub epoch: u64,
987    /// "up" or "down", the state of the daemon's control link back to the panel.
988    pub remote_link: compact_str::CompactString,
989    pub frontends: u64,
990    pub snapshots_applied: u64,
991    pub local_flows_open: u64,
992    pub local_drops: u64,
993    pub frozen_flows: u64,
994    pub peers_connected: u64,
995}
996
997#[derive(Debug, ToSchema, Deserialize, Serialize, Clone)]
998pub struct TundraPeerMetrics {
999    pub uuid: uuid::Uuid,
1000    pub name: compact_str::CompactString,
1001    /// "initiator" when this node dialled the peer, "acceptor" when the peer dialled it.
1002    pub role: compact_str::CompactString,
1003    pub remote_addr: compact_str::CompactString,
1004    pub established_secs: u64,
1005
1006    pub path: TundraPeerPathMetrics,
1007    pub relay: TundraPeerRelayMetrics,
1008    pub flows: TundraPeerFlowMetrics,
1009    pub drops: TundraPeerDropMetrics,
1010}
1011
1012#[derive(Debug, ToSchema, Deserialize, Serialize, Clone)]
1013pub struct TundraPeerPathMetrics {
1014    pub rtt_ms: f64,
1015    pub current_mtu: u16,
1016    pub lost_packets: u64,
1017    pub congestion_events: u64,
1018}
1019
1020#[derive(Debug, ToSchema, Deserialize, Serialize, Clone)]
1021pub struct TundraPeerRelayMetrics {
1022    pub stream_bytes_in: u64,
1023    pub stream_bytes_out: u64,
1024    pub datagram_bytes_in: u64,
1025    pub datagram_bytes_out: u64,
1026    pub streams_open: u64,
1027    pub streams_total: u64,
1028}
1029
1030#[derive(Debug, ToSchema, Deserialize, Serialize, Clone)]
1031pub struct TundraPeerFlowMetrics {
1032    pub open: u64,
1033    pub opened_total: u64,
1034    pub tcp_open: u64,
1035}
1036
1037#[derive(Debug, ToSchema, Deserialize, Serialize, Clone)]
1038pub struct TundraPeerDropMetrics {
1039    pub send_buffer_full: u64,
1040    pub unknown_flow: u64,
1041    pub frag_timeout: u64,
1042    pub frag_limit: u64,
1043    pub oversize: u64,
1044    pub malformed: u64,
1045}
1046
1047impl super::client::WingsClient {
1048    async fn tundra_json<T: serde::de::DeserializeOwned>(
1049        &self,
1050        method: reqwest::Method,
1051        endpoint: &str,
1052    ) -> Result<T, super::client::ApiHttpError> {
1053        let response = self
1054            .request_raw(method, endpoint)
1055            .header("Accept", "application/json")
1056            .send()
1057            .await
1058            .map_err(super::client::ApiHttpError::Reqwest)?;
1059
1060        let status = response.status();
1061        if !status.is_success() {
1062            return Err(super::client::ApiHttpError::Http(
1063                status,
1064                response
1065                    .json::<super::ApiError>()
1066                    .await
1067                    .unwrap_or_else(|err| super::ApiError {
1068                        error: err.to_string().into(),
1069                    }),
1070            ));
1071        }
1072
1073        response
1074            .json()
1075            .await
1076            .map_err(super::client::ApiHttpError::Reqwest)
1077    }
1078
1079    pub async fn get_tundra(&self) -> Result<TundraStatus, super::client::ApiHttpError> {
1080        self.tundra_json(reqwest::Method::GET, "/api/tundra").await
1081    }
1082
1083    /// Latency reduction only - the node polls the panel on its own schedule regardless.
1084    pub async fn post_tundra_sync(&self) -> Result<(), super::client::ApiHttpError> {
1085        self.tundra_json::<serde::de::IgnoredAny>(reqwest::Method::POST, "/api/tundra/sync")
1086            .await?;
1087
1088        Ok(())
1089    }
1090
1091    /// Rotates the token the node's daemon authenticates with and drops its websocket.
1092    pub async fn post_tundra_rotate(&self) -> Result<(), super::client::ApiHttpError> {
1093        self.tundra_json::<serde::de::IgnoredAny>(reqwest::Method::POST, "/api/tundra/rotate")
1094            .await?;
1095
1096        Ok(())
1097    }
1098
1099    pub async fn get_tundra_metrics(&self) -> Result<TundraMetrics, super::client::ApiHttpError> {
1100        self.tundra_json(reqwest::Method::GET, "/api/tundra/metrics")
1101            .await
1102    }
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107    use super::*;
1108
1109    fn encode(value: &impl Serialize) -> Vec<u8> {
1110        let mut bytes = Vec::new();
1111        let mut serializer = rmp_serde::Serializer::new(&mut bytes)
1112            .with_struct_map()
1113            .with_human_readable();
1114        value.serialize(&mut serializer).unwrap();
1115
1116        bytes
1117    }
1118
1119    #[test]
1120    fn search_content_matches_preserve_older_node_omission() {
1121        for (payload, expected_present) in [
1122            (serde_json::json!({ "results": [] }), false),
1123            (
1124                serde_json::json!({ "results": [], "content_matches": [] }),
1125                true,
1126            ),
1127        ] {
1128            let response: crate::servers_server_files_search::post::Response =
1129                rmp_serde::from_slice(&encode(&payload)).unwrap();
1130
1131            assert!(response.results.is_empty());
1132            assert_eq!(response.content_matches.is_some(), expected_present);
1133        }
1134    }
1135
1136    #[test]
1137    fn file_content_response_preserves_text_and_nullable_line_bounds() {
1138        let payload = serde_json::json!({
1139            "results": [],
1140            "content_matches": [{
1141                "file": "test.txt",
1142                "truncated": false,
1143                "blocks": [{
1144                    "start_line": 3,
1145                    "end_line": 4,
1146                    "content": "é\r\nHello\r\n",
1147                    "matches": [{ "start_byte": 4, "end_byte": 9 }],
1148                }],
1149            }],
1150        });
1151        let response: crate::servers_server_files_search::post::Response =
1152            rmp_serde::from_slice(&encode(&payload)).unwrap();
1153        assert_eq!(serde_json::to_value(response).unwrap(), payload);
1154
1155        for payload in [
1156            serde_json::json!({
1157                "start_line": null, "end_line": null, "content": "", "eof": true,
1158            }),
1159            serde_json::json!({
1160                "start_line": 3, "end_line": 4, "content": "é\r\nHello\r\n", "eof": false,
1161            }),
1162        ] {
1163            let response: crate::servers_server_files_lines::get::Response =
1164                rmp_serde::from_slice(&encode(&payload)).unwrap();
1165            assert_eq!(serde_json::to_value(response).unwrap(), payload);
1166        }
1167    }
1168
1169    /// The generated client sends the compat properties that were taken out of the `RequestBody`
1170    /// structs through a flattened overlay, which makes the outer map an unknown-length one. Assert
1171    /// rmp-serde still writes exactly what a plain struct would, since a mismatch would only ever
1172    /// show up against a real node.
1173    #[test]
1174    fn overlay_matches_flat_body() {
1175        #[derive(Serialize)]
1176        struct Inner {
1177            root: compact_str::CompactString,
1178            files: Vec<compact_str::CompactString>,
1179            nested: Vec<Nested>,
1180            foreground: Option<bool>,
1181        }
1182
1183        #[derive(Serialize)]
1184        struct Nested {
1185            #[serde(rename = "toName")]
1186            to_name: compact_str::CompactString,
1187        }
1188
1189        #[derive(Serialize)]
1190        struct Overlay<'a> {
1191            #[serde(flatten)]
1192            inner: &'a Inner,
1193            ignored: &'a Vec<compact_str::CompactString>,
1194        }
1195
1196        #[derive(Serialize)]
1197        struct Flat {
1198            root: compact_str::CompactString,
1199            files: Vec<compact_str::CompactString>,
1200            nested: Vec<Nested>,
1201            foreground: Option<bool>,
1202            ignored: Vec<compact_str::CompactString>,
1203        }
1204
1205        let ignored = vec!["*.log".into(), "secret/**".into()];
1206        let inner = Inner {
1207            root: "/".into(),
1208            files: vec!["a.txt".into()],
1209            nested: vec![Nested {
1210                to_name: "b.txt".into(),
1211            }],
1212            foreground: Some(true),
1213        };
1214
1215        assert_eq!(
1216            encode(&Overlay {
1217                inner: &inner,
1218                ignored: &ignored,
1219            }),
1220            encode(&Flat {
1221                root: inner.root.clone(),
1222                files: inner.files.clone(),
1223                nested: vec![Nested {
1224                    to_name: "b.txt".into(),
1225                }],
1226                foreground: inner.foreground,
1227                ignored,
1228            })
1229        );
1230    }
1231}