Sign Up
Log In
Log In
or
Sign Up
Places
All Projects
Status Monitor
Collapse sidebar
openSUSE:Leap:16.0:Staging:C
agama
agama.obscpio
Overview
Repositories
Revisions
Requests
Users
Attributes
Meta
File agama.obscpio of Package agama
07070100000000000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000000D00000000agama/.cargo07070100000001000081A4000000000000000000000001671F5A6400000029000000000000000000000000000000000000001900000000agama/.cargo/config.toml[alias] xtask = "run --package xtask --" 07070100000002000081A4000000000000000000000001671F5A64000000AC000000000000000000000000000000000000001100000000agama/.gitignore# Generated by Cargo # will have compiled files and executables /target/ # These are backup files generated by rustfmt **/*.rs.bk # Agama configuration file /etc/agama.d 07070100000003000081A4000000000000000000000001671F5A640001DF96000000000000000000000000000000000000001100000000agama/Cargo.lock# This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 3 [[package]] name = "addr2line" version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ "gimli", ] [[package]] name = "adler2" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" [[package]] name = "agama-cli" version = "1.0.0" dependencies = [ "agama-lib", "anyhow", "async-trait", "clap", "console", "curl", "fs_extra", "indicatif", "inquire", "nix 0.27.1", "reqwest 0.11.27", "serde_json", "tempfile", "thiserror", "tokio", "url", ] [[package]] name = "agama-lib" version = "1.0.0" dependencies = [ "anyhow", "async-trait", "chrono", "cidr", "curl", "env_logger", "futures-util", "home", "httpmock", "jsonschema", "jsonwebtoken", "log", "reqwest 0.12.8", "serde", "serde_json", "serde_repr", "strum", "tempfile", "thiserror", "tokio", "tokio-stream", "url", "utoipa", "zbus", ] [[package]] name = "agama-locale-data" version = "0.1.0" dependencies = [ "anyhow", "chrono-tz", "flate2", "quick-xml", "regex", "serde", "thiserror", "utoipa", ] [[package]] name = "agama-server" version = "0.1.0" dependencies = [ "agama-lib", "agama-locale-data", "anyhow", "async-trait", "axum", "axum-extra", "cidr", "clap", "config", "futures-util", "gethostname", "gettext-rs", "http-body-util", "hyper 1.4.1", "hyper-util", "libsystemd", "log", "macaddr", "openssl", "pam", "pin-project", "rand", "regex", "sd-notify", "serde", "serde_json", "serde_with", "subprocess", "thiserror", "tokio", "tokio-openssl", "tokio-stream", "tokio-test", "tower 0.4.13", "tower-http", "tracing", "tracing-journald", "tracing-subscriber", "utoipa", "uuid", "zbus", ] [[package]] name = "ahash" version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if", "getrandom", "once_cell", "serde", "version_check", "zerocopy", ] [[package]] name = "aho-corasick" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] [[package]] name = "alloc-no-stdlib" version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" dependencies = [ "alloc-no-stdlib", ] [[package]] name = "android-tzdata" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" [[package]] name = "android_system_properties" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] [[package]] name = "anstream" version = "0.6.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" [[package]] name = "anstyle-parse" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" dependencies = [ "windows-sys 0.52.0", ] [[package]] name = "anstyle-wincon" version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" dependencies = [ "anstyle", "windows-sys 0.52.0", ] [[package]] name = "anyhow" version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" [[package]] name = "ascii-canvas" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" dependencies = [ "term", ] [[package]] name = "assert-json-diff" version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" dependencies = [ "serde", "serde_json", ] [[package]] name = "async-attributes" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" dependencies = [ "quote", "syn 1.0.109", ] [[package]] name = "async-broadcast" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c48ccdbf6ca6b121e0f586cbc0e73ae440e56c67c30fa0873b4e110d9c26d2b" dependencies = [ "event-listener 2.5.3", "futures-core", ] [[package]] name = "async-channel" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" dependencies = [ "concurrent-queue", "event-listener 2.5.3", "futures-core", ] [[package]] name = "async-channel" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" dependencies = [ "concurrent-queue", "event-listener-strategy", "futures-core", "pin-project-lite", ] [[package]] name = "async-compression" version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e614738943d3f68c628ae3dbce7c3daffb196665f82f8c8ea6b65de73c79429" dependencies = [ "brotli", "futures-core", "memchr", "pin-project-lite", "tokio", ] [[package]] name = "async-executor" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" dependencies = [ "async-task", "concurrent-queue", "fastrand 2.1.1", "futures-lite 2.3.0", "slab", ] [[package]] name = "async-global-executor" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" dependencies = [ "async-channel 2.3.1", "async-executor", "async-io 2.3.4", "async-lock 3.4.0", "blocking", "futures-lite 2.3.0", "once_cell", ] [[package]] name = "async-io" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" dependencies = [ "async-lock 2.8.0", "autocfg", "cfg-if", "concurrent-queue", "futures-lite 1.13.0", "log", "parking", "polling 2.8.0", "rustix 0.37.27", "slab", "socket2 0.4.10", "waker-fn", ] [[package]] name = "async-io" version = "2.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "444b0228950ee6501b3568d3c93bf1176a1fdbc3b758dcd9475046d30f4dc7e8" dependencies = [ "async-lock 3.4.0", "cfg-if", "concurrent-queue", "futures-io", "futures-lite 2.3.0", "parking", "polling 3.7.3", "rustix 0.38.37", "slab", "tracing", "windows-sys 0.59.0", ] [[package]] name = "async-lock" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" dependencies = [ "event-listener 2.5.3", ] [[package]] name = "async-lock" version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" dependencies = [ "event-listener 5.3.1", "event-listener-strategy", "pin-project-lite", ] [[package]] name = "async-object-pool" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "333c456b97c3f2d50604e8b2624253b7f787208cb72eb75e64b0ad11b221652c" dependencies = [ "async-std", ] [[package]] name = "async-process" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6438ba0a08d81529c69b36700fa2f95837bfe3e776ab39cde9c14d9149da88" dependencies = [ "async-io 1.13.0", "async-lock 2.8.0", "async-signal", "blocking", "cfg-if", "event-listener 3.1.0", "futures-lite 1.13.0", "rustix 0.38.37", "windows-sys 0.48.0", ] [[package]] name = "async-process" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" dependencies = [ "async-channel 2.3.1", "async-io 2.3.4", "async-lock 3.4.0", "async-signal", "async-task", "blocking", "cfg-if", "event-listener 5.3.1", "futures-lite 2.3.0", "rustix 0.38.37", "tracing", ] [[package]] name = "async-recursion" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", "syn 2.0.79", ] [[package]] name = "async-signal" version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" dependencies = [ "async-io 2.3.4", "async-lock 3.4.0", "atomic-waker", "cfg-if", "futures-core", "futures-io", "rustix 0.38.37", "signal-hook-registry", "slab", "windows-sys 0.59.0", ] [[package]] name = "async-std" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c634475f29802fde2b8f0b505b1bd00dfe4df7d4a000f0b36f7671197d5c3615" dependencies = [ "async-attributes", "async-channel 1.9.0", "async-global-executor", "async-io 2.3.4", "async-lock 3.4.0", "async-process 2.3.0", "crossbeam-utils", "futures-channel", "futures-core", "futures-io", "futures-lite 2.3.0", "gloo-timers", "kv-log-macro", "log", "memchr", "once_cell", "pin-project-lite", "pin-utils", "slab", "wasm-bindgen-futures", ] [[package]] name = "async-stream" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" dependencies = [ "async-stream-impl", "futures-core", "pin-project-lite", ] [[package]] name = "async-stream-impl" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", "syn 2.0.79", ] [[package]] name = "async-task" version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" version = "0.1.83" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", "syn 2.0.79", ] [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "axum" version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "504e3947307ac8326a5437504c517c4b56716c9d98fac0028c2acc7ca47d70ae" dependencies = [ "async-trait", "axum-core", "base64 0.22.1", "bytes", "futures-util", "http 1.1.0", "http-body 1.0.1", "http-body-util", "hyper 1.4.1", "hyper-util", "itoa", "matchit", "memchr", "mime", "percent-encoding", "pin-project-lite", "rustversion", "serde", "serde_json", "serde_path_to_error", "serde_urlencoded", "sha1", "sync_wrapper 1.0.1", "tokio", "tokio-tungstenite", "tower 0.5.1", "tower-layer", "tower-service", "tracing", ] [[package]] name = "axum-core" version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" dependencies = [ "async-trait", "bytes", "futures-util", "http 1.1.0", "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", "rustversion", "sync_wrapper 1.0.1", "tower-layer", "tower-service", "tracing", ] [[package]] name = "axum-extra" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73c3220b188aea709cf1b6c5f9b01c3bd936bb08bd2b5184a12b35ac8131b1f9" dependencies = [ "axum", "axum-core", "bytes", "cookie", "futures-util", "headers", "http 1.1.0", "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", "serde", "tower 0.5.1", "tower-layer", "tower-service", "tracing", ] [[package]] name = "backtrace" version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" dependencies = [ "addr2line", "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", "windows-targets 0.52.6", ] [[package]] name = "base64" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] name = "base64" version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "basic-cookies" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67bd8fd42c16bdb08688243dc5f0cc117a3ca9efeeaba3a345a18a6159ad96f7" dependencies = [ "lalrpop", "lalrpop-util", "regex", ] [[package]] name = "bindgen" version = "0.69.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a00dc851838a2120612785d195287475a3ac45514741da670b735818822129a0" dependencies = [ "bitflags 2.6.0", "cexpr", "clang-sys", "itertools 0.12.1", "lazy_static", "lazycell", "proc-macro2", "quote", "regex", "rustc-hash", "shlex", "syn 2.0.79", ] [[package]] name = "bit-set" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" dependencies = [ "serde", ] [[package]] name = "block" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ "generic-array", ] [[package]] name = "blocking" version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" dependencies = [ "async-channel 2.3.1", "async-task", "futures-io", "futures-lite 2.3.0", "piper", ] [[package]] name = "brotli" version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", "brotli-decompressor", ] [[package]] name = "brotli-decompressor" version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a45bd2e4095a8b518033b128020dd4a55aab1c0a381ba4404a472630f4bc362" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", ] [[package]] name = "bumpalo" version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "bytecount" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ce89b21cab1437276d2650d57e971f9d548a2d9037cc231abdc0562b97498ce" [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" [[package]] name = "cc" version = "1.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812acba72f0a070b003d3697490d2b55b837230ae7c6c6497f05cc2ddbb8d938" dependencies = [ "shlex", ] [[package]] name = "cexpr" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ "nom", ] [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", "serde", "windows-targets 0.52.6", ] [[package]] name = "chrono-tz" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d59ae0466b83e838b81a54256c39d5d7c20b9d7daa10510a242d9b75abd5936e" dependencies = [ "chrono", "chrono-tz-build", "phf", ] [[package]] name = "chrono-tz-build" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f" dependencies = [ "parse-zoneinfo", "phf", "phf_codegen", ] [[package]] name = "cidr" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bdf600c45bd958cf2945c445264471cca8b6c8e67bc87b71affd6d7e5682621" dependencies = [ "serde", ] [[package]] name = "clang-sys" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ "glob", "libc", ] [[package]] name = "clap" version = "4.5.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7be5744db7978a28d9df86a214130d106a89ce49644cbc4e3f0c22c3fba30615" dependencies = [ "clap_builder", "clap_derive", ] [[package]] name = "clap-markdown" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ebc67e6266e14f8b31541c2f204724fa2ac7ad5c17d6f5908fbb92a60f42cff" dependencies = [ "clap", ] [[package]] name = "clap_builder" version = "4.5.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5fbc17d3ef8278f55b282b2a2e75ae6f6c7d4bb70ed3d0382375104bfafdb4b" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim", "terminal_size", ] [[package]] name = "clap_complete" version = "4.5.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74a01f4f9ee6c066d42a1c8dedf0dcddad16c72a8981a309d6398de3a75b0c39" dependencies = [ "clap", ] [[package]] name = "clap_derive" version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" dependencies = [ "heck", "proc-macro2", "quote", "syn 2.0.79", ] [[package]] name = "clap_lex" version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" [[package]] name = "clap_mangen" version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17415fd4dfbea46e3274fcd8d368284519b358654772afb700dc2e8d2b24eeb" dependencies = [ "clap", "roff", ] [[package]] name = "colorchoice" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" [[package]] name = "concurrent-queue" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ "crossbeam-utils", ] [[package]] name = "config" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7328b20597b53c2454f0b1919720c25c7339051c02b72b7e05409e00b14132be" dependencies = [ "async-trait", "convert_case", "json5", "lazy_static", "nom", "pathdiff", "ron", "rust-ini", "serde", "serde_json", "toml", "yaml-rust", ] [[package]] name = "console" version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" dependencies = [ "encode_unicode", "lazy_static", "libc", "unicode-width", "windows-sys 0.52.0", ] [[package]] name = "const-random" version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" dependencies = [ "const-random-macro", ] [[package]] name = "const-random-macro" version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ "getrandom", "once_cell", "tiny-keccak", ] [[package]] name = "convert_case" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" dependencies = [ "unicode-segmentation", ] [[package]] name = "cookie" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ "percent-encoding", "time", "version_check", ] [[package]] name = "cookie_store" version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4934e6b7e8419148b6ef56950d277af8561060b56afd59e2aadf98b59fce6baa" dependencies = [ "cookie", "idna 0.5.0", "log", "publicsuffix", "serde", "serde_derive", "serde_json", "time", "url", ] [[package]] name = "core-foundation" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" dependencies = [ "libc", ] [[package]] name = "crc32fast" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-utils" version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" [[package]] name = "crossterm" version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" dependencies = [ "bitflags 1.3.2", "crossterm_winapi", "libc", "mio 0.8.11", "parking_lot", "signal-hook", "signal-hook-mio", "winapi", ] [[package]] name = "crossterm_winapi" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" dependencies = [ "winapi", ] [[package]] name = "crunchy" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" [[package]] name = "crypto-common" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "typenum", ] [[package]] name = "curl" version = "0.4.47" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9fb4d13a1be2b58f14d60adba57c9834b78c62fd86c3e76a148f732686e9265" dependencies = [ "curl-sys", "libc", "openssl-probe", "openssl-sys", "schannel", "socket2 0.5.7", "windows-sys 0.52.0", ] [[package]] name = "curl-sys" version = "0.4.77+curl-8.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f469e8a5991f277a208224f6c7ad72ecb5f986e36d09ae1f2c1bb9259478a480" dependencies = [ "cc", "libc", "libz-sys", "openssl-sys", "pkg-config", "vcpkg", "windows-sys 0.52.0", ] [[package]] name = "darling" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ "darling_core", "darling_macro", ] [[package]] name = "darling_core" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", "syn 2.0.79", ] [[package]] name = "darling_macro" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", "syn 2.0.79", ] [[package]] name = "data-encoding" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" [[package]] name = "deranged" version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", "serde", ] [[package]] name = "derivative" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" dependencies = [ "proc-macro2", "quote", "syn 1.0.109", ] [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", "subtle", ] [[package]] name = "dirs-next" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" dependencies = [ "cfg-if", "dirs-sys-next", ] [[package]] name = "dirs-sys-next" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", "redox_users", "winapi", ] [[package]] name = "dlv-list" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" dependencies = [ "const-random", ] [[package]] name = "dyn-clone" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" [[package]] name = "either" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "ena" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" dependencies = [ "log", ] [[package]] name = "encode_unicode" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" [[package]] name = "encoding_rs" version = "0.8.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" dependencies = [ "cfg-if", ] [[package]] name = "enumflags2" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d232db7f5956f3f14313dc2f87985c58bd2c695ce124c8cdd984e08e15ac133d" dependencies = [ "enumflags2_derive", "serde", ] [[package]] name = "enumflags2_derive" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de0d48a183585823424a4ce1aa132d174a6a81bd540895822eb4c8373a8e49e8" dependencies = [ "proc-macro2", "quote", "syn 2.0.79", ] [[package]] name = "env_filter" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" dependencies = [ "log", "regex", ] [[package]] name = "env_logger" version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" dependencies = [ "anstream", "anstyle", "env_filter", "humantime", "log", ] [[package]] name = "equivalent" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" dependencies = [ "libc", "windows-sys 0.52.0", ] [[package]] name = "event-listener" version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "event-listener" version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d93877bcde0eb80ca09131a08d23f0a5c18a620b01db137dba666d18cd9b30c2" dependencies = [ "concurrent-queue", "parking", "pin-project-lite", ] [[package]] name = "event-listener" version = "5.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" dependencies = [ "concurrent-queue", "parking", "pin-project-lite", ] [[package]] name = "event-listener-strategy" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" dependencies = [ "event-listener 5.3.1", "pin-project-lite", ] [[package]] name = "fancy-regex" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0678ab2d46fa5195aaf59ad034c083d351377d4af57f3e073c074d0da3e3c766" dependencies = [ "bit-set", "regex", ] [[package]] name = "fastrand" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" dependencies = [ "instant", ] [[package]] name = "fastrand" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" [[package]] name = "fixedbitset" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" version = "1.0.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" dependencies = [ "crc32fast", "miniz_oxide", ] [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foreign-types" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ "foreign-types-shared", ] [[package]] name = "foreign-types-shared" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" dependencies = [ "percent-encoding", ] [[package]] name = "fraction" version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7aa5de57a62c2440ece64342ea59efb7171aa7d016faf8dfcb8795066a17146b" dependencies = [ "lazy_static", "num", ] [[package]] name = "fs_extra" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures-channel" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" dependencies = [ "futures-core", ] [[package]] name = "futures-core" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" [[package]] name = "futures-io" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" [[package]] name = "futures-lite" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" dependencies = [ "fastrand 1.9.0", "futures-core", "futures-io", "memchr", "parking", "pin-project-lite", "waker-fn", ] [[package]] name = "futures-lite" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5" dependencies = [ "fastrand 2.1.1", "futures-core", "futures-io", "parking", "pin-project-lite", ] [[package]] name = "futures-macro" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", "syn 2.0.79", ] [[package]] name = "futures-sink" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" [[package]] name = "futures-task" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" [[package]] name = "futures-util" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ "futures-core", "futures-macro", "futures-sink", "futures-task", "pin-project-lite", "pin-utils", "slab", ] [[package]] name = "fxhash" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" dependencies = [ "byteorder", ] [[package]] name = "generic-array" version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", ] [[package]] name = "gethostname" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" dependencies = [ "libc", "windows-targets 0.48.5", ] [[package]] name = "getrandom" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "js-sys", "libc", "wasi", "wasm-bindgen", ] [[package]] name = "gettext-rs" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a6716b8a0db461a2720b850ba1623e5b69e4b1aa0224cf5e1fb23a0fe49e65c" dependencies = [ "gettext-sys", "locale_config", ] [[package]] name = "gettext-sys" version = "0.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7b8797f28f2dabfbe2caadb6db4f7fd739e251b5ede0a2ba49e506071edcf67" dependencies = [ "cc", "temp-dir", ] [[package]] name = "gimli" version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "glob" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" [[package]] name = "gloo-timers" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" dependencies = [ "futures-channel", "futures-core", "js-sys", "wasm-bindgen", ] [[package]] name = "h2" version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" dependencies = [ "bytes", "fnv", "futures-core", "futures-sink", "futures-util", "http 0.2.12", "indexmap 2.6.0", "slab", "tokio", "tokio-util", "tracing", ] [[package]] name = "h2" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", "http 1.1.0", "indexmap 2.6.0", "slab", "tokio", "tokio-util", "tracing", ] [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" [[package]] name = "hashbrown" version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" [[package]] name = "headers" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9" dependencies = [ "base64 0.21.7", "bytes", "headers-core", "http 1.1.0", "httpdate", "mime", "sha1", ] [[package]] name = "headers-core" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ "http 1.1.0", ] [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] name = "hermit-abi" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ "digest", ] [[package]] name = "home" version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" dependencies = [ "windows-sys 0.52.0", ] [[package]] name = "http" version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ "bytes", "fnv", "itoa", ] [[package]] name = "http" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" dependencies = [ "bytes", "fnv", "itoa", ] [[package]] name = "http-body" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", "http 0.2.12", "pin-project-lite", ] [[package]] name = "http-body" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", "http 1.1.0", ] [[package]] name = "http-body-util" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", "http 1.1.0", "http-body 1.0.1", "pin-project-lite", ] [[package]] name = "http-range-header" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08a397c49fec283e3d6211adbe480be95aae5f304cfb923e9970e08956d5168a" [[package]] name = "httparse" version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" [[package]] name = "httpdate" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "httpmock" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08ec9586ee0910472dec1a1f0f8acf52f0fdde93aea74d70d4a3107b4be0fd5b" dependencies = [ "assert-json-diff", "async-object-pool", "async-std", "async-trait", "base64 0.21.7", "basic-cookies", "crossbeam-utils", "form_urlencoded", "futures-util", "hyper 0.14.30", "lazy_static", "levenshtein", "log", "regex", "serde", "serde_json", "serde_regex", "similar", "tokio", "url", ] [[package]] name = "humantime" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" version = "0.14.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a152ddd61dfaec7273fe8419ab357f33aee0d914c5f4efbf0d96fa749eea5ec9" dependencies = [ "bytes", "futures-channel", "futures-core", "futures-util", "h2 0.3.26", "http 0.2.12", "http-body 0.4.6", "httparse", "httpdate", "itoa", "pin-project-lite", "socket2 0.5.7", "tokio", "tower-service", "tracing", "want", ] [[package]] name = "hyper" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" dependencies = [ "bytes", "futures-channel", "futures-util", "h2 0.4.6", "http 1.1.0", "http-body 1.0.1", "httparse", "httpdate", "itoa", "pin-project-lite", "smallvec", "tokio", "want", ] [[package]] name = "hyper-rustls" version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" dependencies = [ "futures-util", "http 1.1.0", "hyper 1.4.1", "hyper-util", "rustls", "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", ] [[package]] name = "hyper-tls" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ "bytes", "hyper 0.14.30", "native-tls", "tokio", "tokio-native-tls", ] [[package]] name = "hyper-tls" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", "hyper 1.4.1", "hyper-util", "native-tls", "tokio", "tokio-native-tls", "tower-service", ] [[package]] name = "hyper-util" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41296eb09f183ac68eec06e03cdbea2e759633d4067b2f6552fc2e009bcad08b" dependencies = [ "bytes", "futures-channel", "futures-util", "http 1.1.0", "http-body 1.0.1", "hyper 1.4.1", "pin-project-lite", "socket2 0.5.7", "tokio", "tower-service", "tracing", ] [[package]] name = "iana-time-zone" version = "0.1.61" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "wasm-bindgen", "windows-core", ] [[package]] name = "iana-time-zone-haiku" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ "cc", ] [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" dependencies = [ "unicode-bidi", "unicode-normalization", ] [[package]] name = "idna" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" dependencies = [ "unicode-bidi", "unicode-normalization", ] [[package]] name = "indexmap" version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", "serde", ] [[package]] name = "indexmap" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", "hashbrown 0.15.0", "serde", ] [[package]] name = "indicatif" version = "0.17.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "763a5a8f45087d6bcea4222e7b72c291a054edf80e4ef6efd2a4979878c7bea3" dependencies = [ "console", "instant", "number_prefix", "portable-atomic", "unicode-width", ] [[package]] name = "inquire" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" dependencies = [ "bitflags 2.6.0", "crossterm", "dyn-clone", "fxhash", "newline-converter", "once_cell", "unicode-segmentation", "unicode-width", ] [[package]] name = "instant" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" dependencies = [ "cfg-if", ] [[package]] name = "io-lifetimes" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" dependencies = [ "hermit-abi 0.3.9", "libc", "windows-sys 0.48.0", ] [[package]] name = "ipnet" version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "iso8601" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "296af15e112ec6dc38c9fd3ae027b5337a75466e8eed757bd7d5cf742ea85eb6" dependencies = [ "nom", ] [[package]] name = "itertools" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" dependencies = [ "either", ] [[package]] name = "itertools" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" dependencies = [ "either", ] [[package]] name = "itoa" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "js-sys" version = "0.3.70" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" dependencies = [ "wasm-bindgen", ] [[package]] name = "json5" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" dependencies = [ "pest", "pest_derive", "serde", ] [[package]] name = "jsonschema" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ca9e2b45609132ae2214d50482c03aeee78826cd6fd53a8940915b81acedf16" dependencies = [ "ahash", "anyhow", "base64 0.13.1", "bytecount", "fancy-regex", "fraction", "iso8601", "itoa", "lazy_static", "memchr", "num-cmp", "parking_lot", "percent-encoding", "regex", "serde", "serde_json", "time", "url", "uuid", ] [[package]] name = "jsonwebtoken" version = "9.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f" dependencies = [ "base64 0.21.7", "js-sys", "pem", "ring", "serde", "serde_json", "simple_asn1", ] [[package]] name = "kv-log-macro" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" dependencies = [ "log", ] [[package]] name = "lalrpop" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" dependencies = [ "ascii-canvas", "bit-set", "ena", "itertools 0.11.0", "lalrpop-util", "petgraph", "pico-args", "regex", "regex-syntax", "string_cache", "term", "tiny-keccak", "unicode-xid", "walkdir", ] [[package]] name = "lalrpop-util" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" dependencies = [ "regex-automata", ] [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "lazycell" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "levenshtein" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" [[package]] name = "libc" version = "0.2.159" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "libredox" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags 2.6.0", "libc", ] [[package]] name = "libsystemd" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c592dc396b464005f78a5853555b9f240bc5378bf5221acc4e129910b2678869" dependencies = [ "hmac", "libc", "log", "nix 0.27.1", "nom", "once_cell", "serde", "sha2", "thiserror", "uuid", ] [[package]] name = "libz-sys" version = "1.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2d16453e800a8cf6dd2fc3eb4bc99b786a9b90c663b8559a5b1a041bf89e472" dependencies = [ "cc", "libc", "pkg-config", "vcpkg", ] [[package]] name = "linked-hash-map" version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] name = "linux-raw-sys" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" [[package]] name = "linux-raw-sys" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "locale_config" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d2c35b16f4483f6c26f0e4e9550717a2f6575bcd6f12a53ff0c490a94a6934" dependencies = [ "lazy_static", "objc", "objc-foundation", "regex", "winapi", ] [[package]] name = "lock_api" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", ] [[package]] name = "log" version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" dependencies = [ "value-bag", ] [[package]] name = "macaddr" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baee0bbc17ce759db233beb01648088061bf678383130602a298e6998eedb2d8" dependencies = [ "serde", ] [[package]] name = "malloc_buf" version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" dependencies = [ "libc", ] [[package]] name = "matchit" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "memchr" version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "memoffset" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" dependencies = [ "autocfg", ] [[package]] name = "memoffset" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" dependencies = [ "autocfg", ] [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mime_guess" version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" dependencies = [ "mime", "unicase", ] [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" dependencies = [ "adler2", ] [[package]] name = "mio" version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", "log", "wasi", "windows-sys 0.48.0", ] [[package]] name = "mio" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ "hermit-abi 0.3.9", "libc", "wasi", "windows-sys 0.52.0", ] [[package]] name = "native-tls" version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" dependencies = [ "libc", "log", "openssl", "openssl-probe", "openssl-sys", "schannel", "security-framework", "security-framework-sys", "tempfile", ] [[package]] name = "new_debug_unreachable" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "newline-converter" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" dependencies = [ "unicode-segmentation", ] [[package]] name = "nix" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" dependencies = [ "bitflags 1.3.2", "cfg-if", "libc", "memoffset 0.7.1", ] [[package]] name = "nix" version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" dependencies = [ "bitflags 2.6.0", "cfg-if", "libc", "memoffset 0.9.1", ] [[package]] name = "nom" version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ "memchr", "minimal-lexical", ] [[package]] name = "nu-ansi-term" version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" dependencies = [ "overload", "winapi", ] [[package]] name = "num" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ "num-bigint", "num-complex", "num-integer", "num-iter", "num-rational", "num-traits", ] [[package]] name = "num-bigint" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ "num-integer", "num-traits", ] [[package]] name = "num-cmp" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" [[package]] name = "num-complex" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ "num-traits", ] [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-integer" version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ "num-traits", ] [[package]] name = "num-iter" version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" dependencies = [ "autocfg", "num-integer", "num-traits", ] [[package]] name = "num-rational" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ "num-bigint", "num-integer", "num-traits", ] [[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] [[package]] name = "number_prefix" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" [[package]] name = "objc" version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" dependencies = [ "malloc_buf", ] [[package]] name = "objc-foundation" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" dependencies = [ "block", "objc", "objc_id", ] [[package]] name = "objc_id" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" dependencies = [ "objc", ] [[package]] name = "object" version = "0.36.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" dependencies = [ "memchr", ] [[package]] name = "once_cell" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82881c4be219ab5faaf2ad5e5e5ecdff8c66bd7402ca3160975c93b24961afd1" dependencies = [ "portable-atomic", ] [[package]] name = "openssl" version = "0.10.66" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1" dependencies = [ "bitflags 2.6.0", "cfg-if", "foreign-types", "libc", "once_cell", "openssl-macros", "openssl-sys", ] [[package]] name = "openssl-macros" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", "syn 2.0.79", ] [[package]] name = "openssl-probe" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" version = "0.9.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" dependencies = [ "cc", "libc", "pkg-config", "vcpkg", ] [[package]] name = "ordered-multimap" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ed8acf08e98e744e5384c8bc63ceb0364e68a6854187221c18df61c4797690e" dependencies = [ "dlv-list", "hashbrown 0.13.2", ] [[package]] name = "ordered-stream" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" dependencies = [ "futures-core", "pin-project-lite", ] [[package]] name = "overload" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "pam" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ab553c52103edb295d8f7d6a3b593dc22a30b1fb99643c777a8f36915e285ba" dependencies = [ "libc", "memchr", "pam-macros", "pam-sys", "users", ] [[package]] name = "pam-macros" version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c94f3b9b97df3c6d4e51a14916639b24e02c7d15d1dba686ce9b1118277cb811" dependencies = [ "proc-macro2", "quote", "syn 1.0.109", ] [[package]] name = "pam-sys" version = "1.0.0-alpha5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce9484729b3e52c0bacdc5191cb6a6a5f31ef4c09c5e4ab1209d3340ad9e997b" dependencies = [ "bindgen", "libc", ] [[package]] name = "parking" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" dependencies = [ "lock_api", "parking_lot_core", ] [[package]] name = "parking_lot_core" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", "windows-targets 0.52.6", ] [[package]] name = "parse-zoneinfo" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" dependencies = [ "regex", ] [[package]] name = "pathdiff" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" [[package]] name = "pem" version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" dependencies = [ "base64 0.22.1", "serde", ] [[package]] name = "percent-encoding" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pest" version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdbef9d1d47087a895abd220ed25eb4ad973a5e26f6a4367b038c25e28dfc2d9" dependencies = [ "memchr", "thiserror", "ucd-trie", ] [[package]] name = "pest_derive" version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3a6e3394ec80feb3b6393c725571754c6188490265c61aaf260810d6b95aa0" dependencies = [ "pest", "pest_generator", ] [[package]] name = "pest_generator" version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94429506bde1ca69d1b5601962c73f4172ab4726571a59ea95931218cb0e930e" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", "syn 2.0.79", ] [[package]] name = "pest_meta" version = "2.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac8a071862e93690b6e34e9a5fb8e33ff3734473ac0245b27232222c4906a33f" dependencies = [ "once_cell", "pest", "sha2", ] [[package]] name = "petgraph" version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", "indexmap 2.6.0", ] [[package]] name = "phf" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" dependencies = [ "phf_shared 0.11.2", ] [[package]] name = "phf_codegen" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" dependencies = [ "phf_generator", "phf_shared 0.11.2", ] [[package]] name = "phf_generator" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" dependencies = [ "phf_shared 0.11.2", "rand", ] [[package]] name = "phf_shared" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" dependencies = [ "siphasher", ] [[package]] name = "phf_shared" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" dependencies = [ "siphasher", ] [[package]] name = "pico-args" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" [[package]] name = "pin-project" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", "syn 2.0.79", ] [[package]] name = "pin-project-lite" version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" [[package]] name = "pin-utils" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" dependencies = [ "atomic-waker", "fastrand 2.1.1", "futures-io", ] [[package]] name = "pkg-config" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" [[package]] name = "polling" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" dependencies = [ "autocfg", "bitflags 1.3.2", "cfg-if", "concurrent-queue", "libc", "log", "pin-project-lite", "windows-sys 0.48.0", ] [[package]] name = "polling" version = "3.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc2790cd301dec6cd3b7a025e4815cf825724a51c98dccfe6a3e55f05ffb6511" dependencies = [ "cfg-if", "concurrent-queue", "hermit-abi 0.4.0", "pin-project-lite", "rustix 0.38.37", "tracing", "windows-sys 0.59.0", ] [[package]] name = "portable-atomic" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" [[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" dependencies = [ "zerocopy", ] [[package]] name = "precomputed-hash" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "proc-macro-crate" version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" dependencies = [ "once_cell", "toml_edit 0.19.15", ] [[package]] name = "proc-macro-error" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" dependencies = [ "proc-macro-error-attr", "proc-macro2", "quote", "syn 1.0.109", "version_check", ] [[package]] name = "proc-macro-error-attr" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" dependencies = [ "proc-macro2", "quote", "version_check", ] [[package]] name = "proc-macro2" version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" dependencies = [ "unicode-ident", ] [[package]] name = "psl-types" version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" [[package]] name = "publicsuffix" version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96a8c1bda5ae1af7f99a2962e49df150414a43d62404644d98dd5c3a93d07457" dependencies = [ "idna 0.3.0", "psl-types", ] [[package]] name = "quick-xml" version = "0.28.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce5e73202a820a31f8a0ee32ada5e21029c81fd9e3ebf668a40832e4219d9d1" dependencies = [ "memchr", "serde", ] [[package]] name = "quote" version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] [[package]] name = "rand" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", "rand_core", ] [[package]] name = "rand_chacha" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", "rand_core", ] [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ "getrandom", ] [[package]] name = "redox_syscall" version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" dependencies = [ "bitflags 2.6.0", ] [[package]] name = "redox_users" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom", "libredox", "thiserror", ] [[package]] name = "regex" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" version = "0.11.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ "base64 0.21.7", "bytes", "encoding_rs", "futures-core", "futures-util", "h2 0.3.26", "http 0.2.12", "http-body 0.4.6", "hyper 0.14.30", "hyper-tls 0.5.0", "ipnet", "js-sys", "log", "mime", "native-tls", "once_cell", "percent-encoding", "pin-project-lite", "rustls-pemfile 1.0.4", "serde", "serde_json", "serde_urlencoded", "sync_wrapper 0.1.2", "system-configuration 0.5.1", "tokio", "tokio-native-tls", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", "winreg", ] [[package]] name = "reqwest" version = "0.12.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f713147fbe92361e52392c73b8c9e48c04c6625bce969ef54dc901e58e042a7b" dependencies = [ "base64 0.22.1", "bytes", "cookie", "cookie_store", "encoding_rs", "futures-core", "futures-util", "h2 0.4.6", "http 1.1.0", "http-body 1.0.1", "http-body-util", "hyper 1.4.1", "hyper-rustls", "hyper-tls 0.6.0", "hyper-util", "ipnet", "js-sys", "log", "mime", "native-tls", "once_cell", "percent-encoding", "pin-project-lite", "rustls-pemfile 2.2.0", "serde", "serde_json", "serde_urlencoded", "sync_wrapper 1.0.1", "system-configuration 0.6.1", "tokio", "tokio-native-tls", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", "windows-registry", ] [[package]] name = "ring" version = "0.17.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" dependencies = [ "cc", "cfg-if", "getrandom", "libc", "spin", "untrusted", "windows-sys 0.52.0", ] [[package]] name = "roff" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" [[package]] name = "ron" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" dependencies = [ "base64 0.21.7", "bitflags 2.6.0", "serde", "serde_derive", ] [[package]] name = "rust-ini" version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e2a3bcec1f113553ef1c88aae6c020a369d03d55b58de9869a0908930385091" dependencies = [ "cfg-if", "ordered-multimap", ] [[package]] name = "rustc-demangle" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustc-hash" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustix" version = "0.37.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea8ca367a3a01fe35e6943c400addf443c0f57670e6ec51196f71a4b8762dd2" dependencies = [ "bitflags 1.3.2", "errno", "io-lifetimes", "libc", "linux-raw-sys 0.3.8", "windows-sys 0.48.0", ] [[package]] name = "rustix" version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ "bitflags 2.6.0", "errno", "libc", "linux-raw-sys 0.4.14", "windows-sys 0.52.0", ] [[package]] name = "rustls" version = "0.23.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2dabaac7466917e566adb06783a81ca48944c6898a1b08b9374106dd671f4c8" dependencies = [ "once_cell", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] [[package]] name = "rustls-pemfile" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ "base64 0.21.7", ] [[package]] name = "rustls-pemfile" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" dependencies = [ "rustls-pki-types", ] [[package]] name = "rustls-pki-types" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e696e35370c65c9c541198af4543ccd580cf17fc25d8e05c5a242b202488c55" [[package]] name = "rustls-webpki" version = "0.102.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" dependencies = [ "ring", "rustls-pki-types", "untrusted", ] [[package]] name = "rustversion" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" [[package]] name = "ryu" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "same-file" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" dependencies = [ "winapi-util", ] [[package]] name = "schannel" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9aaafd5a2b6e3d657ff009d82fbd630b6bd54dd4eb06f21693925cdf80f9b8b" dependencies = [ "windows-sys 0.59.0", ] [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sd-notify" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4646d6f919800cd25c50edb49438a1381e2cd4833c027e75e8897981c50b8b5e" [[package]] name = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.6.0", "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", ] [[package]] name = "security-framework-sys" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "serde" version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", "syn 2.0.79", ] [[package]] name = "serde_json" version = "1.0.128" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" dependencies = [ "itoa", "memchr", "ryu", "serde", ] [[package]] name = "serde_path_to_error" version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" dependencies = [ "itoa", "serde", ] [[package]] name = "serde_regex" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" dependencies = [ "regex", "serde", ] [[package]] name = "serde_repr" version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", "syn 2.0.79", ] [[package]] name = "serde_spanned" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" dependencies = [ "serde", ] [[package]] name = "serde_urlencoded" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ "form_urlencoded", "itoa", "ryu", "serde", ] [[package]] name = "serde_with" version = "3.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9720086b3357bcb44fce40117d769a4d068c70ecfa190850a980a71755f66fcc" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", "indexmap 2.6.0", "serde", "serde_derive", "serde_json", "serde_with_macros", "time", ] [[package]] name = "serde_with_macros" version = "3.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f1abbfe725f27678f4663bcacb75a83e829fd464c25d78dd038a3a29e307cec" dependencies = [ "darling", "proc-macro2", "quote", "syn 2.0.79", ] [[package]] name = "sha1" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", "digest", ] [[package]] name = "sha2" version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures", "digest", ] [[package]] name = "sharded-slab" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" dependencies = [ "lazy_static", ] [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" dependencies = [ "libc", "signal-hook-registry", ] [[package]] name = "signal-hook-mio" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", "mio 0.8.11", "signal-hook", ] [[package]] name = "signal-hook-registry" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" dependencies = [ "libc", ] [[package]] name = "similar" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" [[package]] name = "simple_asn1" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc4e5204eb1910f40f9cfa375f6f05b68c3abac4b6fd879c8ff5e7ae8a0a085" dependencies = [ "num-bigint", "num-traits", "thiserror", "time", ] [[package]] name = "siphasher" version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "slab" version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" dependencies = [ "autocfg", ] [[package]] name = "smallvec" version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "socket2" version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" dependencies = [ "libc", "winapi", ] [[package]] name = "socket2" version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" dependencies = [ "libc", "windows-sys 0.52.0", ] [[package]] name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "string_cache" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" dependencies = [ "new_debug_unreachable", "once_cell", "parking_lot", "phf_shared 0.10.0", "precomputed-hash", ] [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ "heck", "proc-macro2", "quote", "rustversion", "syn 2.0.79", ] [[package]] name = "subprocess" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c2e86926081dda636c546d8c5e641661049d7562a68f5488be4a1f7f66f6086" dependencies = [ "libc", "winapi", ] [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "syn" version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "sync_wrapper" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" [[package]] name = "sync_wrapper" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" dependencies = [ "futures-core", ] [[package]] name = "system-configuration" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", "core-foundation", "system-configuration-sys 0.5.0", ] [[package]] name = "system-configuration" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.6.0", "core-foundation", "system-configuration-sys 0.6.0", ] [[package]] name = "system-configuration-sys" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "system-configuration-sys" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "temp-dir" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc1ee6eef34f12f765cb94725905c6312b6610ab2b0940889cfe58dae7bc3c72" [[package]] name = "tempfile" version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" dependencies = [ "cfg-if", "fastrand 2.1.1", "once_cell", "rustix 0.38.37", "windows-sys 0.59.0", ] [[package]] name = "term" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" dependencies = [ "dirs-next", "rustversion", "winapi", ] [[package]] name = "terminal_size" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f599bd7ca042cfdf8f4512b277c02ba102247820f9d9d4a9f521f496751a6ef" dependencies = [ "rustix 0.38.37", "windows-sys 0.59.0", ] [[package]] name = "thiserror" version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", "syn 2.0.79", ] [[package]] name = "thread_local" version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" dependencies = [ "cfg-if", "once_cell", ] [[package]] name = "time" version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", "serde", "time-core", "time-macros", ] [[package]] name = "time-core" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" dependencies = [ "num-conv", "time-core", ] [[package]] name = "tiny-keccak" version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" dependencies = [ "crunchy", ] [[package]] name = "tinyvec" version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" dependencies = [ "tinyvec_macros", ] [[package]] name = "tinyvec_macros" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" version = "1.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" dependencies = [ "backtrace", "bytes", "libc", "mio 1.0.2", "pin-project-lite", "signal-hook-registry", "socket2 0.5.7", "tokio-macros", "tracing", "windows-sys 0.52.0", ] [[package]] name = "tokio-macros" version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", "syn 2.0.79", ] [[package]] name = "tokio-native-tls" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" dependencies = [ "native-tls", "tokio", ] [[package]] name = "tokio-openssl" version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59df6849caa43bb7567f9a36f863c447d95a11d5903c9cc334ba32576a27eadd" dependencies = [ "openssl", "openssl-sys", "tokio", ] [[package]] name = "tokio-rustls" version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ "rustls", "rustls-pki-types", "tokio", ] [[package]] name = "tokio-stream" version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f4e6ce100d0eb49a2734f8c0812bcd324cf357d21810932c5df6b96ef2b86f1" dependencies = [ "futures-core", "pin-project-lite", "tokio", ] [[package]] name = "tokio-test" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" dependencies = [ "async-stream", "bytes", "futures-core", "tokio", "tokio-stream", ] [[package]] name = "tokio-tungstenite" version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" dependencies = [ "futures-util", "log", "tokio", "tungstenite", ] [[package]] name = "tokio-util" version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", ] [[package]] name = "toml" version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" dependencies = [ "serde", "serde_spanned", "toml_datetime", "toml_edit 0.22.22", ] [[package]] name = "toml_datetime" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" dependencies = [ "serde", ] [[package]] name = "toml_edit" version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.6.0", "toml_datetime", "winnow 0.5.40", ] [[package]] name = "toml_edit" version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ "indexmap 2.6.0", "serde", "serde_spanned", "toml_datetime", "winnow 0.6.20", ] [[package]] name = "tower" version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" dependencies = [ "futures-core", "futures-util", "pin-project", "pin-project-lite", "tower-layer", "tower-service", "tracing", ] [[package]] name = "tower" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2873938d487c3cfb9aed7546dc9f2711d867c9f90c46b889989a2cb84eba6b4f" dependencies = [ "futures-core", "futures-util", "pin-project-lite", "sync_wrapper 0.1.2", "tokio", "tower-layer", "tower-service", "tracing", ] [[package]] name = "tower-http" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ "async-compression", "bitflags 2.6.0", "bytes", "futures-core", "futures-util", "http 1.1.0", "http-body 1.0.1", "http-body-util", "http-range-header", "httpdate", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "tokio", "tokio-util", "tower-layer", "tower-service", "tracing", ] [[package]] name = "tower-layer" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-service" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ "log", "pin-project-lite", "tracing-attributes", "tracing-core", ] [[package]] name = "tracing-attributes" version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", "syn 2.0.79", ] [[package]] name = "tracing-core" version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", "valuable", ] [[package]] name = "tracing-journald" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba316a74e8fc3c3896a850dba2375928a9fa171b085ecddfc7c054d39970f3fd" dependencies = [ "libc", "tracing-core", "tracing-subscriber", ] [[package]] name = "tracing-log" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ "log", "once_cell", "tracing-core", ] [[package]] name = "tracing-subscriber" version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" dependencies = [ "nu-ansi-term", "sharded-slab", "smallvec", "thread_local", "tracing-core", "tracing-log", ] [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" dependencies = [ "byteorder", "bytes", "data-encoding", "http 1.1.0", "httparse", "log", "rand", "sha1", "thiserror", "utf-8", ] [[package]] name = "typenum" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] name = "ucd-trie" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "uds_windows" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ "memoffset 0.9.1", "tempfile", "winapi", ] [[package]] name = "unicase" version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" dependencies = [ "version_check", ] [[package]] name = "unicode-bidi" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" [[package]] name = "unicode-ident" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "unicode-normalization" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" dependencies = [ "tinyvec", ] [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", "idna 0.5.0", "percent-encoding", ] [[package]] name = "users" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa4227e95324a443c9fcb06e03d4d85e91aabe9a5a02aa818688b6918b6af486" dependencies = [ "libc", "log", ] [[package]] name = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utoipa" version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23" dependencies = [ "indexmap 2.6.0", "serde", "serde_json", "utoipa-gen", ] [[package]] name = "utoipa-gen" version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7bf0e16c02bc4bf5322ab65f10ab1149bdbcaa782cba66dc7057370a3f8190be" dependencies = [ "proc-macro-error", "proc-macro2", "quote", "regex", "syn 2.0.79", "uuid", ] [[package]] name = "uuid" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" dependencies = [ "getrandom", "serde", ] [[package]] name = "valuable" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" [[package]] name = "value-bag" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a84c137d37ab0142f0f2ddfe332651fdbf252e7b7dbb4e67b6c1f1b2e925101" [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "waker-fn" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" [[package]] name = "walkdir" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ "same-file", "winapi-util", ] [[package]] name = "want" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ "try-lock", ] [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" dependencies = [ "cfg-if", "once_cell", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" dependencies = [ "bumpalo", "log", "once_cell", "proc-macro2", "quote", "syn 2.0.79", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed" dependencies = [ "cfg-if", "js-sys", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" dependencies = [ "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", "syn 2.0.79", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" [[package]] name = "web-sys" version = "0.3.70" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ "winapi-i686-pc-windows-gnu", "winapi-x86_64-pc-windows-gnu", ] [[package]] name = "winapi-i686-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ "windows-sys 0.59.0", ] [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-core" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-registry" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" dependencies = [ "windows-result", "windows-strings", "windows-targets 0.52.6", ] [[package]] name = "windows-result" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-strings" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ "windows-result", "windows-targets 0.52.6", ] [[package]] name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ "windows-targets 0.48.5", ] [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-targets" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ "windows_aarch64_gnullvm 0.48.5", "windows_aarch64_msvc 0.48.5", "windows_i686_gnu 0.48.5", "windows_i686_msvc 0.48.5", "windows_x86_64_gnu 0.48.5", "windows_x86_64_gnullvm 0.48.5", "windows_x86_64_msvc 0.48.5", ] [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" version = "0.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" dependencies = [ "memchr", ] [[package]] name = "winnow" version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" dependencies = [ "memchr", ] [[package]] name = "winreg" version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" dependencies = [ "cfg-if", "windows-sys 0.48.0", ] [[package]] name = "xdg-home" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" dependencies = [ "libc", "windows-sys 0.59.0", ] [[package]] name = "xtask" version = "0.1.0" dependencies = [ "agama-cli", "agama-server", "clap", "clap-markdown", "clap_complete", "clap_mangen", ] [[package]] name = "yaml-rust" version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" dependencies = [ "linked-hash-map", ] [[package]] name = "zbus" version = "3.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "675d170b632a6ad49804c8cf2105d7c31eddd3312555cffd4b740e08e97c25e6" dependencies = [ "async-broadcast", "async-process 1.8.1", "async-recursion", "async-trait", "byteorder", "derivative", "enumflags2", "event-listener 2.5.3", "futures-core", "futures-sink", "futures-util", "hex", "nix 0.26.4", "once_cell", "ordered-stream", "rand", "serde", "serde_repr", "sha1", "static_assertions", "tokio", "tracing", "uds_windows", "winapi", "xdg-home", "zbus_macros", "zbus_names", "zvariant", ] [[package]] name = "zbus_macros" version = "3.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7131497b0f887e8061b430c530240063d33bf9455fa34438f388a245da69e0a5" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", "regex", "syn 1.0.109", "zvariant_utils", ] [[package]] name = "zbus_names" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "437d738d3750bed6ca9b8d423ccc7a8eb284f6b1d6d4e225a0e4e6258d864c8d" dependencies = [ "serde", "static_assertions", "zvariant", ] [[package]] name = "zerocopy" version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ "byteorder", "zerocopy-derive", ] [[package]] name = "zerocopy-derive" version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", "syn 2.0.79", ] [[package]] name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" [[package]] name = "zvariant" version = "3.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4eef2be88ba09b358d3b58aca6e41cd853631d44787f319a1383ca83424fb2db" dependencies = [ "byteorder", "enumflags2", "libc", "serde", "static_assertions", "zvariant_derive", ] [[package]] name = "zvariant_derive" version = "3.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c24dc0bed72f5f90d1f8bb5b07228cbf63b3c6e9f82d82559d4bae666e7ed9" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", "syn 1.0.109", "zvariant_utils", ] [[package]] name = "zvariant_utils" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7234f0d811589db492d16893e3f21e8e2fd282e6d01b0cddee310322062cc200" dependencies = [ "proc-macro2", "quote", "syn 1.0.109", ] 07070100000004000081A4000000000000000000000001671F5A64000000E3000000000000000000000000000000000000001100000000agama/Cargo.toml[workspace] members = [ "agama-cli", "agama-server", "agama-lib", "agama-locale-data", "xtask", ] resolver = "2" [workspace.package] # IpAddr::to_canonical is stable since 1.75 rust-version = "1.75" edition = "2021" 07070100000005000081A4000000000000000000000001671F5A64000046AC000000000000000000000000000000000000000E00000000agama/LICENSE GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. <one line to give the program's name and a brief idea of what it does.> Copyright (C) <year> <name of author> This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. <signature of Ty Coon>, 1 April 1989 Ty Coon, President of Vice This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. 07070100000006000081A4000000000000000000000001671F5A64000004C6000000000000000000000000000000000000001000000000agama/README.md# Agama Server According to [Agama's architecture](../doc/architecture.md) this project implements the following components: * The *Agama server*, excluding *Agama YaST* which lives in the [service](../service) directory. * The *Agama D-Bus service*. * The *Command Line Interface*. ## Code organization We have set up [Cargo workspace](https://doc.rust-lang.org/book/ch14-03-cargo-workspaces.html) with three packages: * [agama-lib](./agama-lib): code that can be reused to access the [Agama D-Bus API](https://github.com/yast/agama/blob/master/doc/dbus_api.md) and a model for the configuration settings. * [agama-cli](./agama-cli): code specific to the command-line interface. * [agama-settings](./agama-settingS) and [agama-derive](./agama-derive): includes a [procedural macro](https://doc.rust-lang.org/reference/procedural-macros.html) to reduce the boilerplate code. * [agama-locale-data](./agama-locale-data): specific library to provide data for localization D-Bus API * [agama-server](./agama-server): implements the HTTP/JSON (and WebSocket) API. Additionally, it offers a minimal D-Bus API for internal communication. ## Other resources * [Web server development notes](/rust/WEB-SERVER.md). 07070100000007000081A4000000000000000000000001671F5A64000012F2000000000000000000000000000000000000001400000000agama/WEB-SERVER.md# Web server development notes This document includes some notes about the usage and development of Agama's web server. ## Installing Rust and related tools It is recommended to use Rustup to install Rust and related tools. In openSUSE distributions, rustup is available as a package. After rustup is installed, you can proceed to install the toolchain: ``` zypper --non-interactive in rustup rustup install stable ``` In addition to the Rust compiler, the previous command would install some additionall components like `cargo`, `clippy`, `rustfmt`, documentation, etc. Another interesting addition might be [cargo-binstall](https://github.com/cargo-bins/cargo-binstall), which allows to install Rust binaries. If you are fine with this approach, just run: ``` cargo install cargo-binstall ``` ## Setting up PAM The web sever will use [Pluggable Authentication Modules (PAM)](https://github.com/linux-pam/linux-pam) for authentication. For that reason, you need to copy the `agama` service definition for PAM to `/usr/lib/pam.d`. Otherwise, PAM would not know how to authenticate the service: ``` cp share/agama.pam /usr/lib/pam.d/agama ``` For further information, see [Authenticating with PAM](https://doc.opensuse.org/documentation/leap/security/single-html/book-security/index.html#cha-pam). ## Running the server > [!NOTE] > The web server needs to connect to Agama's D-Bus daemon. So you can either start the Agama service > or just start the D-Bus daemon (`sudo bundle exec bin/agamactl -f` from the `service/` directory). You need to run the server as `root`, so you cannot use `cargo run` directly. Instead, just do: ``` $ cargo build $ sudo ./target/debug/agama-web-server serve ``` If it fails to compile, please check whether `clang-devel` and `pam-devel` are installed. By default the server uses port 80 and listens on all network interfaces. You can use the `--address` option if you want to use a different port or a specific network interface: ``` $ sudo ./target/debug/agama-web-server serve --address :::5678 ``` Some more examples: - Both IPv6 and IPv4, all interfaces: `--address :::5678` - Both IPv6 and IPv4, only local loopback : `--address ::1:5678` - IPv4 only, all interfaces: `--address 0.0.0.0:5678` - IPv4 only, only local loopback : `--address 127.0.0.1:5678` - IPv4, only specific interface: `--address 192.168.1.2:5678` (use the IP address of that interface) The server can optionally listen on a secondary address, use the `--address2` option for that. ## Trying the server You can check whether the server is up and running by just performing a ping: ``` $ curl http://localhost/ping ``` ### Authentication The web server uses a bearer token for HTTP authentication. You can get the token by providing your password to the `/auth` endpoint. ``` $ curl http://localhost/api/auth \ -H "Content-Type: application/json" \ -d '{"password": "your-password"}' {"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3MDg1MTA5MzB9.3HmKAC5u4H_FigMqEa9e74OFAq40UldjlaExrOGqE0U"}⏎ ``` Now you can access protected routes by including the token in the header: ``` $ curl -X GET http://localhost/protected \ -H "Accept: application/json" \ -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3MDg1MTA5MzB9.3HmKAC5u4H_FigMqEa9e74OFAq40UldjlaExrOGqE0U" ``` ### Connecting to the websocket You can use `websocat` to connect to the websocket. To install the tool, just run: ``` $ cargo binstall websocat ``` If you did not install `cargo-binstall`, you can do: ``` $ cargo install websocat ``` Now, you can use the following command to connect: ``` $ websocat ws://localhost/ws -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3MDg1MTA5MzB9.3HmKAC5u4H_FigMqEa9e74OFAq40UldjlaExrOGqE0U" ``` ## SSL/TLS (HTTPS) Support The web server supports encrypted communication using the HTTPS protocol. The SSL certificate used by the server can be specified by the `--cert` and `--key` command line options which should point to the PEM files: ``` $ sudo ./target/debug/agama-web-server serve --cert certificate.pem --key key.pem ``` The certificate is expected in the PEM format, if you have a certificate in another format you can convert it using the openSSL tools. If a SSL certificate is not specified via command line then the server generates a self-signed certificate. Currently it is only kept in memory and generated again at each start. The HTTPS protocol is required for external connections, the HTTP connections are automatically redirected to HTTPS. *But it still means that the original HTTP communication can be intercepted by an attacker, do not rely on this redirection!* For internal connections coming from the same machine (via the `http://localhost` URL) the unencrypted HTTP communication is allowed. 07070100000008000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001000000000agama/agama-cli07070100000009000081A4000000000000000000000001671F5A6400000374000000000000000000000000000000000000001B00000000agama/agama-cli/Cargo.toml[package] name = "agama-cli" version = "1.0.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] clap = { version = "4.5.19", features = ["derive", "wrap_help"] } curl = { version = "0.4.47", features = ["protocol-ftp"] } agama-lib = { path="../agama-lib" } serde_json = "1.0.128" indicatif= "0.17.8" thiserror = "1.0.64" console = "0.15.8" anyhow = "1.0.89" # tempdir, fs_extra, nix is for logs (sub)command tempfile = "3.13.0" fs_extra = "1.3.0" nix = { version = "0.27.1", features = ["user"] } tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } async-trait = "0.1.83" reqwest = { version = "0.11", features = ["json"] } url = "2.5.2" inquire = { version = "0.7.5", default-features = false, features = ["crossterm", "one-liners"] } [[bin]] name = "agama" path = "src/main.rs" 0707010000000A000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001400000000agama/agama-cli/src0707010000000B000081A4000000000000000000000001671F5A640000104F000000000000000000000000000000000000001C00000000agama/agama-cli/src/auth.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use agama_lib::{auth::AuthToken, error::ServiceError}; use clap::Subcommand; use crate::error::CliError; use agama_lib::base_http_client::BaseHTTPClient; use inquire::Password; use std::collections::HashMap; use std::io::{self, IsTerminal}; /// HTTP client to handle authentication struct AuthHTTPClient { api: BaseHTTPClient, } impl AuthHTTPClient { pub fn load(client: BaseHTTPClient) -> Result<Self, ServiceError> { Ok(Self { api: client }) } /// Query web server for JWT pub async fn authenticate(&self, password: String) -> anyhow::Result<String> { let mut auth_body = HashMap::new(); auth_body.insert("password", password); let response = self .api .post::<HashMap<String, String>>("/auth", &auth_body) .await?; match response.get("token") { Some(token) => Ok(token.clone()), None => Err(anyhow::anyhow!("Failed to get authentication token")), } } } #[derive(Subcommand, Debug)] pub enum AuthCommands { /// Authenticate with Agama's server and store the token. /// /// This command tries to get the password from the standard input. If it is not there, it asks /// the user interactively. Upon successful login, it stores the token in .agama/agama-jwt. The /// token will be automatically sent to authenticate the following requests. Login, /// Deauthenticate by removing the token. Logout, /// Print the used token to the standard output. Show, } /// Main entry point called from agama CLI main loop pub async fn run(client: BaseHTTPClient, subcommand: AuthCommands) -> anyhow::Result<()> { let auth_client = AuthHTTPClient::load(client)?; match subcommand { AuthCommands::Login => login(auth_client, read_password()?).await, AuthCommands::Logout => logout(), AuthCommands::Show => show(), } } /// Reads the password /// /// It reads the password from stdin if available; otherwise, it asks the /// user. fn read_password() -> Result<String, CliError> { let stdin = io::stdin(); let password = if stdin.is_terminal() { ask_password()? } else { let mut buffer = String::new(); stdin .read_line(&mut buffer) .map_err(CliError::StdinPassword)?; buffer }; Ok(password) } /// Asks interactively for the password. (For authentication, not for changing it) fn ask_password() -> Result<String, CliError> { Password::new("Please enter the root password:") .without_confirmation() .prompt() .map_err(CliError::InteractivePassword) } /// Logs into the installation web server and stores JWT for later use. async fn login(client: AuthHTTPClient, password: String) -> anyhow::Result<()> { // 1) ask web server for JWT let res = client.authenticate(password).await?; let token = AuthToken::new(&res); Ok(token.write_user_token()?) } /// Releases JWT fn logout() -> anyhow::Result<()> { Ok(AuthToken::remove_user_token()?) } /// Shows stored JWT on stdout fn show() -> anyhow::Result<()> { // we do not care if jwt() fails or not. If there is something to print, show it otherwise // stay silent if let Some(token) = AuthToken::find() { println!("{}", token.as_str()); } Ok(()) } 0707010000000C000081A4000000000000000000000001671F5A6400001209000000000000000000000000000000000000002000000000agama/agama-cli/src/commands.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use crate::auth::AuthCommands; use crate::config::ConfigCommands; use crate::logs::LogsCommands; use crate::profile::ProfileCommands; use crate::questions::QuestionsCommands; use clap::Subcommand; #[derive(Subcommand, Debug)] pub enum Commands { /// Inspect or change the installation settings. /// /// You can inspect and change installation settings from the command-line. The "show" /// subcommand generates a "profile" which is a JSON document describing the current /// configuration. /// /// If you want to change any configuration value, you can load a profile (complete or partial) /// using the "load" subcommand. #[command(subcommand)] Config(ConfigCommands), /// Analyze the system. /// /// In Agama's jargon, the term 'probing' refers to the process of 'analyzing' the system. This /// includes reading software repositories, analyzing storage devices, and more. The 'probe' /// command initiates this analysis process and returns immediately. /// TODO: do we really need a "probe" action? Probe, /// Start the system installation. /// /// This command starts the installation process. Beware it is a destructive operation because /// it will set up the storage devices, install the packages, etc. /// /// When the preconditions for the installation are not met, it informs the user and returns, /// making no changes to the system. Install, /// Manage auto-installation profiles (retrieving, applying, etc.). #[command(subcommand)] Profile(ProfileCommands), /// Handle installer questions. /// /// Agama might require user intervention at any time. The reasons include providing some /// missing information (e.g., the password to decrypt a file system) or deciding what to do in /// case of an error (e.g., cannot connect to the repository). /// /// This command allows answering such questions directly from the command-line. #[command(subcommand)] Questions(QuestionsCommands), /// Collect the installer logs. /// /// The installer logs are stored in a compressed archive for further inspection. The file /// includes system and Agama-specific logs and configuration files. They are crucial to /// troubleshoot and debug problems. #[command(subcommand)] Logs(LogsCommands), /// Authenticate with Agama's server. /// /// Unless you are executing this program as root, you need to authenticate with Agama's server /// for most operations. You can log in by specifying the root password through the "auth login" /// command. Upon successful authentication, the server returns a JSON Web Token (JWT) which is /// stored to authenticate the following requests. /// /// If you run this program as root, you can skip the authentication step because it /// automatically uses the master token at /run/agama/token. Only the root user must have access /// to such a file. /// /// You can logout at any time by using the "auth logout" command, although this command does /// not affect the root user. #[command(subcommand)] Auth(AuthCommands), /// Download file from given URL /// /// The purpose of this command is to download files using AutoYaST supported schemas (e.g. device:// or relurl://). /// It can be used to download additional scripts, configuration files and so on. /// You can use it for downloading Agama autoinstallation profiles. However, unless you need additional processing, /// the "agama profile import" is recommended. /// If you want to convert an AutoYaST profile, use "agama profile autoyast". Download { /// URL pointing to file for download url: String, }, } 0707010000000D000081A4000000000000000000000001671F5A6400001157000000000000000000000000000000000000001E00000000agama/agama-cli/src/config.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use std::{ io::{self, Read}, path::PathBuf, process::Command, }; use crate::show_progress; use agama_lib::{ base_http_client::BaseHTTPClient, install_settings::InstallSettings, Store as SettingsStore, }; use anyhow::anyhow; use clap::Subcommand; use std::io::Write; use tempfile::Builder; const DEFAULT_EDITOR: &str = "/usr/bin/vi"; #[derive(Subcommand, Debug)] pub enum ConfigCommands { /// Generate an installation profile with the current settings. /// /// It is possible that many configuration settings do not have a value. Those settings /// are not included in the output. /// /// The output of command can be used as input for the "agama config load". Show, /// Read and load a profile from the standard input. Load, /// Edit and update installation option using an external editor. /// /// The changes are not applied if the editor exits with an error code. /// /// If an editor is not specified, it honors the EDITOR environment variable. It falls back to /// `/usr/bin/vi` as a last resort. Edit { /// Editor command (including additional arguments if needed) #[arg(short, long)] editor: Option<String>, }, } pub async fn run(http_client: BaseHTTPClient, subcommand: ConfigCommands) -> anyhow::Result<()> { let store = SettingsStore::new(http_client).await?; match subcommand { ConfigCommands::Show => { let model = store.load().await?; let json = serde_json::to_string_pretty(&model)?; println!("{}", json); Ok(()) } ConfigCommands::Load => { let mut stdin = io::stdin(); let mut contents = String::new(); stdin.read_to_string(&mut contents)?; let result: InstallSettings = serde_json::from_str(&contents)?; Ok(store.store(&result).await?) } ConfigCommands::Edit { editor } => { let model = store.load().await?; let editor = editor .or_else(|| std::env::var("EDITOR").ok()) .unwrap_or(DEFAULT_EDITOR.to_string()); let result = edit(&model, &editor)?; tokio::spawn(async move { show_progress().await.unwrap(); }); store.store(&result).await?; Ok(()) } } } /// Edit the installation settings using an external editor. /// /// If the editor does not return a successful error code, it returns an error. /// /// * `model`: current installation settings. /// * `editor`: editor command. fn edit(model: &InstallSettings, editor: &str) -> anyhow::Result<InstallSettings> { let content = serde_json::to_string_pretty(model)?; let mut file = Builder::new().suffix(".json").tempfile()?; let path = PathBuf::from(file.path()); write!(file, "{}", content)?; let mut command = editor_command(editor); let status = command.arg(path.as_os_str()).status()?; if status.success() { return Ok(InstallSettings::from_file(path)?); } Err(anyhow!( "Ignoring the changes becase the editor was closed with an error code." )) } /// Return the Command to run the editor. /// /// Separate the program and the arguments and build a Command struct. /// /// * `command`: command to run as editor. fn editor_command(command: &str) -> Command { let mut parts = command.split_whitespace(); let program = parts.next().unwrap_or(DEFAULT_EDITOR); let mut command = Command::new(program); command.args(parts.collect::<Vec<&str>>()); command } 0707010000000E000081A4000000000000000000000001671F5A6400000509000000000000000000000000000000000000001D00000000agama/agama-cli/src/error.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use inquire::InquireError; use thiserror::Error; #[derive(Error, Debug)] pub enum CliError { #[error("Cannot perform the installation as the settings are not valid")] Validation, #[error("Could not start the installation")] Installation, #[error("Could not read the password")] InteractivePassword(#[source] InquireError), #[error("Could not read the password from the standard input")] StdinPassword(#[source] std::io::Error), } 0707010000000F000081A4000000000000000000000001671F5A6400001EC2000000000000000000000000000000000000001B00000000agama/agama-cli/src/lib.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use clap::{Args, Parser}; mod auth; mod commands; mod config; mod error; mod logs; mod profile; mod progress; mod questions; use crate::error::CliError; use agama_lib::base_http_client::BaseHTTPClient; use agama_lib::{ error::ServiceError, manager::ManagerClient, progress::ProgressMonitor, transfer::Transfer, }; use auth::run as run_auth_cmd; use commands::Commands; use config::run as run_config_cmd; use inquire::Confirm; use logs::run as run_logs_cmd; use profile::run as run_profile_cmd; use progress::InstallerProgress; use questions::run as run_questions_cmd; use std::{ collections::HashMap, process::{ExitCode, Termination}, thread::sleep, time::Duration, }; /// Agama's CLI global options #[derive(Args)] pub struct GlobalOpts { #[clap(long, default_value = "http://localhost/api")] /// URI pointing to Agama's remote API. If not provided, default https://localhost/api is /// used pub api: String, #[clap(long, default_value = "false")] /// Whether to accept invalid (self-signed, ...) certificates or not pub insecure: bool, } /// Agama's command-line interface /// /// This program allows inspecting or changing Agama's configuration, handling installation /// profiles, starting the installation, monitoring the process, etc. /// /// Please, use the "help" command to learn more. #[derive(Parser)] #[command(name = "agama", about, long_about, max_term_width = 100)] pub struct Cli { #[clap(flatten)] pub opts: GlobalOpts, #[command(subcommand)] pub command: Commands, } async fn probe() -> anyhow::Result<()> { let another_manager = build_manager().await?; let probe = tokio::spawn(async move { let _ = another_manager.probe().await; }); show_progress().await?; Ok(probe.await?) } /// Starts the installation process /// /// Before starting, it makes sure that the manager is idle. /// /// * `manager`: the manager client. async fn install(manager: &ManagerClient<'_>, max_attempts: u8) -> anyhow::Result<()> { if manager.is_busy().await { println!("Agama's manager is busy. Waiting until it is ready..."); } // Make sure that the manager is ready manager.wait().await?; if !manager.can_install().await? { return Err(CliError::Validation)?; } let progress = tokio::spawn(async { show_progress().await }); // Try to start the installation up to max_attempts times. let mut attempts = 1; loop { match manager.install().await { Ok(()) => break, Err(e) => { eprintln!( "Could not start the installation process: {e}. Attempt {}/{}.", attempts, max_attempts ); } } if attempts == max_attempts { eprintln!("Giving up."); return Err(CliError::Installation)?; } attempts += 1; sleep(Duration::from_secs(1)); } let _ = progress.await; Ok(()) } async fn show_progress() -> Result<(), ServiceError> { // wait 1 second to give other task chance to start, so progress can display something tokio::time::sleep(Duration::from_secs(1)).await; let conn = agama_lib::connection().await?; let mut monitor = ProgressMonitor::new(conn).await.unwrap(); let presenter = InstallerProgress::new(); monitor .run(presenter) .await .expect("failed to monitor the progress"); Ok(()) } async fn wait_for_services(manager: &ManagerClient<'_>) -> Result<(), ServiceError> { let services = manager.busy_services().await?; // TODO: having it optional if !services.is_empty() { eprintln!("The Agama service is busy. Waiting for it to be available..."); show_progress().await? } Ok(()) } async fn build_manager<'a>() -> anyhow::Result<ManagerClient<'a>> { let conn = agama_lib::connection().await?; Ok(ManagerClient::new(conn).await?) } /// True if use of the remote API is allowed (yes by default when the API is secure, the user is /// asked if the API is insecure - e.g. when it uses self-signed certificate) async fn allowed_insecure_api(use_insecure: bool, api_url: String) -> Result<bool, ServiceError> { // fake client used for remote site detection let mut ping_client = BaseHTTPClient::default(); ping_client.base_url = api_url; // decide whether access to remote site has to be insecure (self-signed certificate or not) match ping_client.get::<HashMap<String, String>>("/ping").await { // Problem with http remote API reachability Err(ServiceError::HTTPError(_)) => Ok(use_insecure || Confirm::new("There was a problem with the remote API and it is treated as insecure. Do you want to continue?") .with_default(false) .prompt() .unwrap_or(false)), // another error Err(e) => Err(e), // success doesn't bother us here Ok(_) => Ok(false) } } pub async fn run_command(cli: Cli) -> Result<(), ServiceError> { // somehow check whether we need to ask user for self-signed certificate acceptance let api_url = cli.opts.api.trim_end_matches('/').to_string(); let mut client = BaseHTTPClient::default(); client.base_url = api_url.clone(); if allowed_insecure_api(cli.opts.insecure, api_url.clone()).await? { client = client.insecure(); } // we need to distinguish commands on those which assume that authentication JWT is already // available and those which not (or don't need it) client = if let Commands::Auth(_) = cli.command { client.unauthenticated()? } else { // this deals with authentication need inside client.authenticated()? }; match cli.command { Commands::Config(subcommand) => run_config_cmd(client, subcommand).await?, Commands::Probe => { let manager = build_manager().await?; wait_for_services(&manager).await?; probe().await? } Commands::Profile(subcommand) => run_profile_cmd(subcommand).await?, Commands::Install => { let manager = build_manager().await?; install(&manager, 3).await? } Commands::Questions(subcommand) => run_questions_cmd(client, subcommand).await?, // TODO: logs command was originally designed with idea that agama's cli and agama // installation runs on the same machine, so it is unable to do remote connection Commands::Logs(subcommand) => run_logs_cmd(subcommand).await?, Commands::Download { url } => Transfer::get(&url, std::io::stdout())?, Commands::Auth(subcommand) => { run_auth_cmd(client, subcommand).await?; } }; Ok(()) } /// Represents the result of execution. pub enum CliResult { /// Successful execution. Ok = 0, /// Something went wrong. Error = 1, } impl Termination for CliResult { fn report(self) -> ExitCode { ExitCode::from(self as u8) } } 07070100000010000081A4000000000000000000000001671F5A64000036BD000000000000000000000000000000000000001C00000000agama/agama-cli/src/logs.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use clap::Subcommand; use fs_extra::copy_items; use fs_extra::dir::CopyOptions; use nix::unistd::Uid; use std::fs; use std::fs::File; use std::io; use std::io::Write; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Command; use tempfile::TempDir; // definition of "agama logs" subcommands, see clap crate for details #[derive(Subcommand, Debug)] pub enum LogsCommands { /// Collect and store the logs in a tar archive. Store { #[clap(long, short = 'v')] /// Verbose output verbose: bool, #[clap(long, short = 'd')] /// Path to destination directory and, optionally, the archive file name. The extension will /// be added automatically. destination: Option<PathBuf>, }, /// List the logs to collect List, } /// Main entry point called from agama CLI main loop pub async fn run(subcommand: LogsCommands) -> anyhow::Result<()> { match subcommand { LogsCommands::Store { verbose, destination, } => { // feed internal options structure by what was received from user // for now we always use / add defaults if any let destination = parse_destination(destination)?; let options = LogOptions { verbose, destination, ..Default::default() }; Ok(store(options)?) } LogsCommands::List => { list(LogOptions::default()); Ok(()) } } } /// Whatewer passed in destination formed into an absolute path with archive name /// /// # Arguments: /// * destination /// - if None then a default is returned /// - if a path to a directory then a default file name for the archive will be appended to the /// path /// - if path with a file name then it is used as is for resulting archive, just extension will /// be appended later on (depends on used compression) fn parse_destination(destination: Option<PathBuf>) -> Result<PathBuf, io::Error> { let err = io::Error::new(io::ErrorKind::InvalidInput, "Invalid destination path"); let mut buffer = destination.unwrap_or(PathBuf::from(DEFAULT_RESULT)); let path = buffer.as_path(); // existing directory -> append an archive name if path.is_dir() { buffer.push("agama-logs"); // a path with file name // sadly, is_some_and is unstable } else if path.parent().is_some() { // validate if parent directory realy exists if !path.parent().unwrap().is_dir() { return Err(err); } } // buffer is <directory> or <directory>/<file_name> here // and we know that directory tree which leads to the <file_name> is valid. // <file_name> creation can still fail later on. Ok(buffer) } const DEFAULT_COMMANDS: [(&str, &str); 3] = [ // (<command to be executed>, <file name used for storing result of the command>) ("journalctl -u agama", "agama"), ("journalctl -u agama-auto", "agama-auto"), ("journalctl --dmesg", "dmesg"), ]; const DEFAULT_PATHS: [&str; 14] = [ // logs "/var/log/YaST2", "/var/log/zypper.log", "/var/log/zypper/history*", "/var/log/zypper/pk_backend_zypp", "/var/log/pbl.log", "/var/log/linuxrc.log", "/var/log/wickedd.log", "/var/log/NetworkManager", "/var/log/messages", "/var/log/boot.msg", "/var/log/udev.log", // config "/etc/install.inf", "/etc/os-release", "/linuxrc.config", ]; const DEFAULT_RESULT: &str = "/tmp/agama-logs"; // what compression is used by default: // (<compression as distinguished by tar>, <an extension for resulting archive>) const DEFAULT_COMPRESSION: (&str, &str) = ("gzip", "tar.gz"); const TMP_DIR_PREFIX: &str = "agama-logs."; /// A wrapper around println which shows (or not) the text depending on the boolean variable fn showln(show: bool, text: &str) { if !show { return; } println!("{}", text); } /// A wrapper around println which shows (or not) the text depending on the boolean variable fn show(show: bool, text: &str) { if !show { return; } print!("{}", text); } /// Configurable parameters of the "agama logs" which can be /// set by user when calling a (sub)command struct LogOptions { paths: Vec<String>, commands: Vec<(String, String)>, verbose: bool, destination: PathBuf, } impl Default for LogOptions { fn default() -> Self { Self { paths: DEFAULT_PATHS.iter().map(|p| p.to_string()).collect(), commands: DEFAULT_COMMANDS .iter() .map(|(cmd, name)| (cmd.to_string(), name.to_string())) .collect(), verbose: false, destination: PathBuf::from(DEFAULT_RESULT), } } } /// Struct for log represented by a file struct LogPath { // log source src_path: String, // directory where to collect logs dst_path: PathBuf, } impl LogPath { fn new(src: &str, dst: &Path) -> Self { Self { src_path: src.to_string(), dst_path: dst.to_owned(), } } } /// Struct for log created on demand by a command struct LogCmd { // command which stdout / stderr is logged cmd: String, // user defined log file name (if any) file_name: String, // place where to collect logs dst_path: PathBuf, } impl LogCmd { fn new(cmd: &str, file_name: &str, dst: &Path) -> Self { Self { cmd: cmd.to_string(), file_name: file_name.to_string(), dst_path: dst.to_owned(), } } } trait LogItem { // definition of log source fn from(&self) -> &String; // definition of destination as path to a file fn to(&self) -> PathBuf; // performs whatever is needed to store logs from "from" at "to" path fn store(&self) -> Result<(), io::Error>; } impl LogItem for LogPath { fn from(&self) -> &String { &self.src_path } fn to(&self) -> PathBuf { // remove leading '/' if any from the path (reason see later) let r_path = Path::new(self.src_path.as_str()).strip_prefix("/").unwrap(); // here is the reason, join overwrites the content if the joined path is absolute self.dst_path.join(r_path) } fn store(&self) -> Result<(), io::Error> { let dst_file = self.to(); let dst_path = dst_file.parent().unwrap(); // for now keep directory structure close to the original // e.g. what was in /etc will be in /<tmp dir>/etc/ fs::create_dir_all(dst_path)?; let options = CopyOptions::new(); // fs_extra's own Error doesn't implement From trait so ? operator is unusable match copy_items(&[self.src_path.as_str()], dst_path, &options) { Ok(_p) => Ok(()), Err(_e) => Err(io::Error::new( io::ErrorKind::Other, "Copying of a file failed", )), } } } impl LogItem for LogCmd { fn from(&self) -> &String { &self.cmd } fn to(&self) -> PathBuf { let mut file_name; if self.file_name.is_empty() { file_name = self.cmd.clone(); } else { file_name = self.file_name.clone(); }; file_name.retain(|c| c != ' '); self.dst_path.as_path().join(&file_name) } fn store(&self) -> Result<(), io::Error> { let cmd_parts = self.cmd.split_whitespace().collect::<Vec<&str>>(); let file_path = self.to(); let output = Command::new(cmd_parts[0]) .args(cmd_parts[1..].iter()) .output()?; let mut file_stdout = File::create(format!("{}.out.log", file_path.display()))?; let mut file_stderr = File::create(format!("{}.err.log", file_path.display()))?; file_stdout.write_all(&output.stdout)?; file_stderr.write_all(&output.stderr)?; Ok(()) } } /// Collect existing / requested paths which should already exist in the system. /// Turns them into list of log sources fn paths_to_log_sources(paths: &[String], tmp_dir: &TempDir) -> Vec<Box<dyn LogItem>> { let mut log_sources: Vec<Box<dyn LogItem>> = Vec::new(); for path in paths.iter() { // assumption: path is full path if Path::new(path).try_exists().is_ok() { log_sources.push(Box::new(LogPath::new(path.as_str(), tmp_dir.path()))); } } log_sources } /// Some info can be collected via particular commands only, turn it into log sources fn cmds_to_log_sources(commands: &[(String, String)], tmp_dir: &TempDir) -> Vec<Box<dyn LogItem>> { let mut log_sources: Vec<Box<dyn LogItem>> = Vec::new(); for cmd in commands.iter() { log_sources.push(Box::new(LogCmd::new( cmd.0.as_str(), cmd.1.as_str(), tmp_dir.path(), ))); } log_sources } /// Compress given directory into a tar archive fn compress_logs(tmp_dir: &TempDir, result: &String) -> io::Result<()> { let compression = DEFAULT_COMPRESSION.0; let tmp_path = tmp_dir .path() .parent() .and_then(|p| p.as_os_str().to_str()) .ok_or(io::Error::new( io::ErrorKind::InvalidInput, "Malformed path to temporary directory", ))?; let dir = tmp_dir .path() .file_name() .and_then(|f| f.to_str()) .ok_or(io::Error::new( io::ErrorKind::InvalidInput, "Malformed path to temporary director", ))?; let compress_cmd = format!( "tar -c -f {} --warning=no-file-changed --{} --dereference -C {} {}", result, compression, tmp_path, dir, ); let cmd_parts = compress_cmd.split_whitespace().collect::<Vec<&str>>(); let res = Command::new(cmd_parts[0]) .args(cmd_parts[1..].iter()) .status()?; if res.success() { set_archive_permissions(result) } else { Err(io::Error::new( io::ErrorKind::Other, "Cannot create tar archive", )) } } /// Sets the archive owner to root:root. Also sets the file permissions to read/write for the /// owner only. fn set_archive_permissions(archive: &String) -> io::Result<()> { let attr = fs::metadata(archive)?; let mut permissions = attr.permissions(); // set the archive file permissions to -rw------- permissions.set_mode(0o600); fs::set_permissions(archive, permissions)?; // set the archive owner to root:root // note: std::os::unix::fs::chown is unstable for now Command::new("chown") .args(["root:root", archive.as_str()]) .status()?; Ok(()) } /// Handler for the "agama logs store" subcommand fn store(options: LogOptions) -> Result<(), io::Error> { if !Uid::effective().is_root() { panic!("No Root, no logs. Sorry."); } // preparation, e.g. in later features some log commands can be added / excluded per users request or let commands = options.commands; let paths = options.paths; let verbose = options.verbose; let opt_dest = options.destination.into_os_string(); let destination = opt_dest.to_str().ok_or(io::Error::new( io::ErrorKind::InvalidInput, "Malformed destination path", ))?; let result = format!("{}.{}", destination, DEFAULT_COMPRESSION.1); showln(verbose, "Collecting Agama logs:"); // create temporary directory where to collect all files (similar to what old save_y2logs // does) let tmp_dir = TempDir::with_prefix(TMP_DIR_PREFIX)?; let mut log_sources = paths_to_log_sources(&paths, &tmp_dir); showln(verbose, "\t- proceeding well known paths"); log_sources.append(&mut cmds_to_log_sources(&commands, &tmp_dir)); // some info can be collected via particular commands only showln(verbose, "\t- proceeding output of commands"); // store it if verbose { showln(true, format!("Storing result in: \"{}\"", result).as_str()); } else { showln(true, result.as_str()); } for log in log_sources.iter() { show( verbose, format!("\t- storing: \"{}\" ... ", log.from()).as_str(), ); // for now keep directory structure close to the original // e.g. what was in /etc will be in /<tmp dir>/etc/ let res = match fs::create_dir_all(log.to().parent().unwrap()) { Ok(_p) => match log.store() { Ok(_p) => "[Ok]", Err(_e) => "[Failed]", }, Err(_e) => "[Failed]", }; showln(verbose, res.to_string().as_str()); } compress_logs(&tmp_dir, &result) } /// Handler for the "agama logs list" subcommand fn list(options: LogOptions) { for list in [ ("Log paths: ", options.paths), ( "Log commands: ", options.commands.iter().map(|c| c.0.clone()).collect(), ), ] { println!("{}", list.0); for item in list.1.iter() { println!("\t{}", item); } println!(); } } 07070100000011000081A4000000000000000000000001671F5A6400000454000000000000000000000000000000000000001C00000000agama/agama-cli/src/main.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use agama_cli::{run_command, Cli, CliResult}; use clap::Parser; #[tokio::main] async fn main() -> CliResult { let cli = Cli::parse(); if let Err(error) = run_command(cli).await { eprintln!("{:?}", error); return CliResult::Error; } CliResult::Ok } 07070100000012000081A4000000000000000000000001671F5A6400001804000000000000000000000000000000000000001F00000000agama/agama-cli/src/profile.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use agama_lib::{ base_http_client::BaseHTTPClient, install_settings::InstallSettings, profile::{AutoyastProfile, ProfileEvaluator, ProfileValidator, ValidationResult}, transfer::Transfer, Store as SettingsStore, }; use anyhow::Context; use clap::Subcommand; use std::os::unix::process::CommandExt; use std::{ fs::File, io::stdout, path::{Path, PathBuf}, process::Command, }; use tempfile::TempDir; use url::Url; #[derive(Subcommand, Debug)] pub enum ProfileCommands { /// Download the autoyast profile and print resulting json Autoyast { /// AutoYaST profile's URL. Any AutoYaST scheme, ERB and rules/classes are supported. /// all schemas that autoyast supports. url: String, }, /// Validate a profile using JSON Schema /// /// Schema is available at /usr/share/agama-cli/profile.schema.json Validate { /// Local path to the JSON file to validate path: PathBuf, }, /// Evaluate a profile, injecting the hardware information from D-Bus /// /// For an example of Jsonnet-based profile, see /// https://github.com/openSUSE/agama/blob/master/rust/agama-lib/share/examples/profile.jsonnet Evaluate { /// Path to jsonnet file. path: PathBuf, }, /// Process autoinstallation profile and loads it into agama /// /// This is top level command that do all autoinstallation processing beside starting /// installation. Unless there is a need to inject additional commands between processing /// use this command instead of set of underlying commands. Import { /// Profile's URL. Supports the same schemas than te "download" command plus /// AutoYaST specific ones. Supported files are json, jsonnet, sh for Agama profiles and ERB, XML, and rules/classes directories /// for AutoYaST support. url: String, /// Specific directory where all processing happens. By default it uses a temporary directory dir: Option<PathBuf>, }, } fn validate(path: &PathBuf) -> anyhow::Result<()> { let validator = ProfileValidator::default_schema()?; // let path = Path::new(&path); let result = validator .validate_file(path) .context(format!("Could not validate the profile {:?}", path))?; match result { ValidationResult::Valid => { println!("The profile is valid") } ValidationResult::NotValid(errors) => { eprintln!("The profile is not valid. Please, check the following errors:\n"); for error in errors { println!("* {error}") } } } Ok(()) } fn evaluate(path: &Path) -> anyhow::Result<()> { let evaluator = ProfileEvaluator {}; evaluator .evaluate(path, stdout()) .context("Could not evaluate the profile".to_string())?; Ok(()) } async fn import(url_string: String, dir: Option<PathBuf>) -> anyhow::Result<()> { let url = Url::parse(&url_string)?; let tmpdir = TempDir::new()?; // TODO: create it only if dir is not passed let path = url.path(); let output_file = if path.ends_with(".sh") { "profile.sh" } else if path.ends_with(".jsonnet") { "profile.jsonnet" } else { "profile.json" }; let output_dir = dir.unwrap_or_else(|| tmpdir.into_path()); let mut output_path = output_dir.join(output_file); let output_fd = File::create(output_path.clone())?; if path.ends_with(".xml") || path.ends_with(".erb") || path.ends_with('/') { // autoyast specific download and convert to json AutoyastProfile::new(&url)?.read_into(output_fd)?; } else { // just download profile Transfer::get(&url_string, output_fd)?; } // exec shell scripts if output_file.ends_with(".sh") { let err = Command::new("bash") .args([output_path.to_str().context("Wrong path to shell script")?]) .exec(); eprintln!("Exec failed: {}", err); } // evaluate jsonnet profiles if output_file.ends_with(".jsonnet") { let fd = File::create(output_dir.join("profile.json"))?; let evaluator = ProfileEvaluator {}; evaluator .evaluate(&output_path, fd) .context("Could not evaluate the profile".to_string())?; output_path = output_dir.join("profile.json"); } validate(&output_path)?; store_settings(&output_path).await?; Ok(()) } async fn store_settings<P: AsRef<Path>>(path: P) -> anyhow::Result<()> { let store = SettingsStore::new(BaseHTTPClient::default().authenticated()?).await?; let settings = InstallSettings::from_file(&path)?; store.store(&settings).await?; Ok(()) } fn autoyast(url_string: String) -> anyhow::Result<()> { let url = Url::parse(&url_string)?; let reader = AutoyastProfile::new(&url)?; reader.read_into(std::io::stdout())?; Ok(()) } pub async fn run(subcommand: ProfileCommands) -> anyhow::Result<()> { match subcommand { ProfileCommands::Autoyast { url } => autoyast(url), ProfileCommands::Validate { path } => validate(&path), ProfileCommands::Evaluate { path } => evaluate(&path), ProfileCommands::Import { url, dir } => import(url, dir).await, } } 07070100000013000081A4000000000000000000000001671F5A6400000A40000000000000000000000000000000000000002000000000agama/agama-cli/src/progress.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use agama_lib::progress::{Progress, ProgressPresenter}; use async_trait::async_trait; use console::style; use indicatif::{ProgressBar, ProgressStyle}; use std::time::Duration; /// Reports the installer progress through the terminal pub struct InstallerProgress { bar: Option<ProgressBar>, } impl InstallerProgress { pub fn new() -> Self { Self { bar: None } } fn update_bar(&mut self, progress: &Progress) { let bar = self.bar.get_or_insert_with(|| { let style = ProgressStyle::with_template("{spinner:.green} {msg}").unwrap(); let bar = ProgressBar::new(0).with_style(style); bar.enable_steady_tick(Duration::from_millis(120)); bar }); bar.set_length(progress.max_steps.into()); bar.set_position(progress.current_step.into()); bar.set_message(progress.current_title.to_owned()); } } #[async_trait] impl ProgressPresenter for InstallerProgress { async fn start(&mut self, progress: &Progress) { if !progress.finished { self.update_main(progress).await; } } async fn update_main(&mut self, progress: &Progress) { let counter = format!("[{}/{}]", &progress.current_step, &progress.max_steps); println!( "{} {}", style(&counter).bold().green(), &progress.current_title ); } async fn update_detail(&mut self, progress: &Progress) { if progress.finished { if let Some(bar) = self.bar.take() { bar.finish_and_clear(); } } else { self.update_bar(progress); } } async fn finish(&mut self) { if let Some(bar) = self.bar.take() { bar.finish_and_clear(); } } } 07070100000014000081A4000000000000000000000001671F5A64000010D2000000000000000000000000000000000000002100000000agama/agama-cli/src/questions.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use agama_lib::proxies::Questions1Proxy; use agama_lib::questions::http_client::HTTPClient; use agama_lib::{base_http_client::BaseHTTPClient, connection, error::ServiceError}; use clap::{Args, Subcommand, ValueEnum}; // TODO: use for answers also JSON to be consistent #[derive(Subcommand, Debug)] pub enum QuestionsCommands { /// Set the mode for answering questions. Mode(ModesArgs), /// Load predefined answers. /// /// It allows predefining answers for specific questions in order to skip them in interactive /// mode or change the answer in automatic mode. /// /// Please check Agama documentation for more details and examples: /// https://github.com/openSUSE/agama/blob/master/doc/questions.md Answers { /// Path to a file containing the answers in JSON format. path: String, }, /// Prints the list of questions that are waiting for an answer in JSON format List, /// Reads a question definition in JSON from stdin and prints the response when it is answered. Ask, } #[derive(Args, Debug)] pub struct ModesArgs { #[arg(value_enum)] value: Modes, } #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] pub enum Modes { /// Ask the user and block the installation. Interactive, /// Do not block the installation. NonInteractive, } async fn set_mode(proxy: Questions1Proxy<'_>, value: Modes) -> Result<(), ServiceError> { proxy .set_interactive(value == Modes::Interactive) .await .map_err(|e| e.into()) } async fn set_answers(proxy: Questions1Proxy<'_>, path: String) -> Result<(), ServiceError> { proxy .add_answer_file(path.as_str()) .await .map_err(|e| e.into()) } async fn list_questions(client: BaseHTTPClient) -> Result<(), ServiceError> { let client = HTTPClient::new(client)?; let questions = client.list_questions().await?; // FIXME: if performance is bad, we can skip converting json from http to struct and then // serialize it, but it won't be pretty string let questions_json = serde_json::to_string_pretty(&questions) .map_err(|e| ServiceError::InternalError(e.to_string()))?; println!("{}", questions_json); Ok(()) } async fn ask_question(client: BaseHTTPClient) -> Result<(), ServiceError> { let client = HTTPClient::new(client)?; let question = serde_json::from_reader(std::io::stdin())?; let created_question = client.create_question(&question).await?; let Some(id) = created_question.generic.id else { return Err(ServiceError::InternalError( "Created question does not get id".to_string(), )); }; let answer = client.get_answer(id).await?; let answer_json = serde_json::to_string_pretty(&answer) .map_err(|e| ServiceError::InternalError(e.to_string()))?; println!("{}", answer_json); client.delete_question(id).await?; Ok(()) } pub async fn run( client: BaseHTTPClient, subcommand: QuestionsCommands, ) -> Result<(), ServiceError> { let connection = connection().await?; let proxy = Questions1Proxy::new(&connection).await?; match subcommand { QuestionsCommands::Mode(value) => set_mode(proxy, value.value).await, QuestionsCommands::Answers { path } => set_answers(proxy, path).await, QuestionsCommands::List => list_questions(client).await, QuestionsCommands::Ask => ask_question(client).await, } } 07070100000015000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001000000000agama/agama-lib07070100000016000081A4000000000000000000000001671F5A6400000497000000000000000000000000000000000000001B00000000agama/agama-lib/Cargo.toml[package] name = "agama-lib" version = "1.0.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1.0" async-trait = "0.1.83" cidr = { version = "0.2.3", features = ["serde"] } futures-util = "0.3.30" jsonschema = { version = "0.16.1", default-features = false } log = "0.4" reqwest = { version = "0.12.8", features = ["json", "cookies"] } serde = { version = "1.0.210", features = ["derive"] } serde_json = { version = "1.0.128", features = ["raw_value"] } serde_repr = "0.1.19" tempfile = "3.13.0" thiserror = "1.0.64" tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } tokio-stream = "0.1.16" url = "2.5.2" utoipa = "4.2.3" zbus = { version = "3", default-features = false, features = ["tokio"] } # Needed to define curl error in profile errors curl = { version = "0.4.47", features = ["protocol-ftp"] } jsonwebtoken = "9.3.0" chrono = { version = "0.4.38", default-features = false, features = ["now", "std", "alloc", "clock"] } home = "0.5.9" strum = { version = "0.26.3", features = ["derive"] } [dev-dependencies] httpmock = "0.7.0" env_logger = "0.11.5" 07070100000017000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001600000000agama/agama-lib/share07070100000018000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001F00000000agama/agama-lib/share/examples07070100000019000081A4000000000000000000000001671F5A6400000060000000000000000000000000000000000000002D00000000agama/agama-lib/share/examples/autoyast.json{ "legacyAutoyastStorage": [ { "device": "/dev/vdc", "use": "all" } ] } 0707010000001A000081A4000000000000000000000001671F5A6400000856000000000000000000000000000000000000002F00000000agama/agama-lib/share/examples/profile.jsonnet// This is a Jsonnet file. Please, check https://jsonnet.org/ for more // information about the language. // For the schema, see // https://github.com/openSUSE/agama/blob/master/rust/agama-lib/share/profile.schema.json // The "hw.libsonnet" file contains hardware information from the "lshw" tool. // Agama generates this file at runtime by running (with root privileges): // // lshw -json // // There are included also helpers to search this hardware tree. To see helpers check // "/usr/share/agama-cli/agama.libsonnet" local agama = import 'hw.libsonnet'; // Find the biggest disk which is suitable for installing the system. local findBiggestDisk(disks) = local sizedDisks = std.filter(function(d) std.objectHas(d, 'size'), disks); local sorted = std.sort(sizedDisks, function(x) -x.size); sorted[0].logicalname; // Find how much physical memory system has. local memory = agama.findByID(agama.lshw, 'memory').size; { product: { id: if memory < 8000000000 then 'MicroOS' else 'Tumbleweed', }, software: { patterns: [ 'gnome' ], }, user: { fullName: 'Jane Doe', userName: 'jane.doe', password: '123456', }, root: { password: 'nots3cr3t', sshKey: '...', }, // look ma, there are comments! localization: { language: 'en_US', keyboard: 'us', }, storage: { bootDevice: findBiggestDisk(agama.selectByClass(agama.lshw, 'disk')), }, network: { connections: [ { id: 'AgamaNetwork', wireless: { password: 'agama.test', security: 'wpa-psk', ssid: 'AgamaNetwork', mode: 'infrastructure' } }, { id: 'Etherned device 1', method4: 'manual', gateway4: '192.168.122.1', addresses: [ '192.168.122.100/24' ], nameservers: [ '1.2.3.4' ], match: { path: ["pci-0000:00:19.0"] } }, { id: 'bond0', bond: { ports: ['eth0', 'eth1'], mode: 'active-backup', options: "primary=eth1" } } ] } } 0707010000001B000081A4000000000000000000000001671F5A64000003A4000000000000000000000000000000000000002F00000000agama/agama-lib/share/examples/profile_tw.json{ "localization": { "keyboard": "us", "language": "en_US" }, "software": { "patterns": [ "gnome" ] }, "product": { "id": "Tumbleweed" }, "storage": { "guided": { "boot": { "configure": true, "device": "/dev/dm-1" } } }, "user": { "fullName": "Jane Doe", "password": "123456", "userName": "jane.doe" }, "root": { "password": "nots3cr3t", "sshPublicKey": "..." }, "network": { "connections": [ { "id": "Ethernet network device 1", "method4": "manual", "method6": "manual", "interface": "eth0", "addresses": [ "192.168.122.100/24", "::ffff:c0a8:7ac7/64" ], "gateway4": "192.168.122.1", "gateway6": "::ffff:c0a8:7a01", "nameservers": [ "192.168.122.1", "2001:4860:4860::8888" ] } ] } } 0707010000001C000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000002700000000agama/agama-lib/share/examples/storage0707010000001D000081A4000000000000000000000001671F5A64000007DD000000000000000000000000000000000000003300000000agama/agama-lib/share/examples/storage/drives.json{ "storage": { "boot": { "configure": true, "device": "/dev/vda" }, "drives": [ { "search": "/dev/vda", "ptableType": "gpt", "partitions": [ { "search": { "ifNotFound": "skip" }, "delete": true }, { "id": "linux", "size": "10 GiB", "encryption": { "luks1": { "password": "notsecret" } }, "filesystem": { "type": { "btrfs": { "snapshots": true } }, "path": "/", "mountBy": "uuid", "mountOptions": ["ro"] } }, { "encryption": { "luks2": { "password": "notsecret", "label": "home" } }, "filesystem": { "type": "xfs", "path": "/home" } }, { "encryption": "random_swap", "filesystem": { "type": "swap", "path": "swap" }, "size": "2 GiB" } ] }, { "search": "/dev/vdb", "partitions": [ { "search": { "condition": { "name": "/dev/vdb1" }, "ifNotFound": "skip" }, "deleteIfNeeded": true }, { "search": "*", "delete": true }, { "filesystem": { "type": "xfs", "path": "/data" }, "size": { "min": "50 GiB" } } ] }, { "search": { "ifNotFound": "skip" }, "filesystem": { "reuseIfPossible": true, "type": "ext4", "path": "/var/log" } } ] } } 0707010000001E000081A4000000000000000000000001671F5A6400000430000000000000000000000000000000000000003700000000agama/agama-lib/share/examples/storage/encryption.json{ "storage": { "drives": [ { "encryption": { "luks1": { "password": "12345", "cipher": "aes-xts-plain64", "keySize": 512 } } }, { "partitions": [ { "encryption": { "luks2": { "password": "12345", "cipher": "aes-xts-plain64", "keySize": 512, "pbkdFunction": "argon2i", "label": "data" } } }, { "encryption": { "pervasiveLuks2": { "password": "12345" } } }, { "encryption": { "tpmFde": { "password": "12345" } } }, { "encryption": "protected_swap" }, { "encryption": "secure_swap" }, { "encryption": "random_swap" } ] } ] } } 0707010000001F000081A4000000000000000000000001671F5A6400000189000000000000000000000000000000000000003900000000agama/agama-lib/share/examples/storage/generate_lvs.json{ "storage": { "drives": [ { "partitions": [ { "alias": "pv1", "id": "lvm", "size": { "min": "10 GiB" } } ] } ], "volumeGroups": [ { "name": "system", "physicalVolumes": ["pv1"], "logicalVolumes": [ { "generate": "default" } ] } ] } } 07070100000020000081A4000000000000000000000001671F5A640000034C000000000000000000000000000000000000004200000000agama/agama-lib/share/examples/storage/generate_lvs_extended.json{ "storage": { "drives": [ { "partitions": [ { "alias": "pv1", "id": "lvm", "size": { "min": "10 GiB" } } ] } ], "volumeGroups": [ { "name": "system", "physicalVolumes": ["pv1"], "logicalVolumes": [ { "generate": { "logicalVolumes": "mandatory", "encryption": { "luks2": { "password": "12345" } }, "stripes": 10, "stripeSize": "4 KiB" } }, { "name": "data", "size": "5 GiB", "filesystem": { "path": "/data", "type": "xfs" } } ] } ] } } 07070100000021000081A4000000000000000000000001671F5A640000009B000000000000000000000000000000000000004000000000agama/agama-lib/share/examples/storage/generate_partitions.json{ "storage": { "drives": [ { "partitions": [ { "generate": "mandatory" } ] } ] } } 07070100000022000081A4000000000000000000000001671F5A64000001C5000000000000000000000000000000000000004900000000agama/agama-lib/share/examples/storage/generate_partitions_extended.json{ "storage": { "drives": [ { "partitions": [ { "size": "10 GiB", "filesystem": { "type": "vfat" } }, { "generate": { "partitions": "default", "encryption": { "luks2": { "password": "12345" } } } } ] } ] } } 07070100000023000081A4000000000000000000000001671F5A640000036D000000000000000000000000000000000000003900000000agama/agama-lib/share/examples/storage/generate_pvs.json{ "storage": { "drives": [ { "alias": "first-disk" }, { "partitions": [ { "alias": "pv1", "id": "lvm", "size": { "min": "10 GiB" } } ] } ], "volumeGroups": [ { "name": "system", "physicalVolumes": ["pv1"], "logicalVolumes": [ { "filesystem": { "path": "/" } } ] }, { "name": "logs", "physicalVolumes": [ { "generate": ["first-disk"] } ] }, { "name": "data", "physicalVolumes": [ { "generate": { "targetDevices": ["first-disk"], "encryption": { "luks2": { "password": "12345" } } } } ] } ] } } 07070100000024000081A4000000000000000000000001671F5A64000004EB000000000000000000000000000000000000003300000000agama/agama-lib/share/examples/storage/guided.json{ "storage": { "guided": { "target": { "disk": "/dev/vdc" }, "boot": { "configure": true, "device": "/dev/vda" }, "encryption": { "password": "notsecret", "method": "luks2", "pbkdFunction": "argon2i" }, "space": { "policy": "custom", "actions": [ { "resize": "/dev/vda" }, { "forceDelete": "/dev/vdb1" } ] }, "volumes": [ { "mount": { "path": "/", "options": ["ro"] }, "filesystem": { "btrfs": { "snapshots": true } }, "size": [1024, "5 Gib"], "target": "default" }, { "mount": { "path": "/home" }, "filesystem": "xfs", "size": { "min": "5 GiB", "max": "20 GiB" }, "target": { "newVg": "/dev/vda" } }, { "mount": { "path": "swap" }, "filesystem": "swap", "size": "8 GiB", "target": { "newPartition": "/dev/vda" } } ] } } } 07070100000025000081A4000000000000000000000001671F5A64000005B0000000000000000000000000000000000000003000000000agama/agama-lib/share/examples/storage/lvm.json{ "storage": { "drives": [ { "partitions": [ { "alias": "pv1", "id": "lvm", "size": { "min": "10 GiB" } } ] }, { "partitions": [ { "alias": "pv2", "id": "lvm", "size": { "min": "10 GiB" } } ] } ], "volumeGroups": [ { "name": "system", "physicalVolumes": ["pv1", "pv2"], "extentSize": "8 MiB", "logicalVolumes": [ { "name": "root", "size": { "min": "10 GiB" }, "encryption": { "luks2": { "password": "notsecret" } }, "filesystem": { "type": "btrfs", "path": "/" } }, { "name": "home", "size": "5 GiB", "filesystem": { "type": "xfs", "path": "/home" } }, { "alias": "lvm_thin_pool", "pool": true, "name": "pool", "size": { "min": "5 GiB" }, "stripes": 10, "stripeSize": "4 KiB" }, { "name": "data", "size": "100 GiB", "usedPool": "lvm_thin_pool" } ] } ] } } 07070100000026000081A4000000000000000000000001671F5A640000045C000000000000000000000000000000000000003200000000agama/agama-lib/share/examples/storage/sizes.json{ "storage": { "drives": [ { "partitions": [ { "size": 2048 }, { "size": "10 GiB" }, { "size": ["1 GiB"] }, { "size": [1024, "50 GiB"] }, { "size": { "min": "1 GiB" } }, { "size": { "min": 1024, "max": "50 GiB" } }, { "search": {}, "size": ["current"] }, { "search": {}, "size": [0, "current"] }, { "search": {}, "size": ["current", "10 GiB"] }, { "size": { "min": "current" } }, { "size": { "min": 0, "max": "current" } }, { "size": { "min": "current", "max": "10 GiB" } } ] } ] } } 07070100000027000081A4000000000000000000000001671F5A640000C344000000000000000000000000000000000000002A00000000agama/agama-lib/share/profile.schema.json{ "$comment": "based on doc/auto_storage.md", "$schema": "https://json-schema.org/draft/2019-09/schema", "$id": "https://github.com/openSUSE/agama/blob/master/rust/agama-lib/share/profile.schema.json", "title": "Profile", "description": "Profile definition for automated installation", "type": "object", "additionalProperties": false, "properties": { "scripts": { "title": "User-defined installation scripts", "description": "User-defined scripts to run at different points of the installation", "type": "object", "additionalProperties": false, "properties": { "pre": { "title": "Pre-installation scripts", "description": "User-defined scripts to run before the installation starts", "type": "array", "items": { "$ref": "#/$defs/script" } }, "post": { "title": "Post-installation scripts", "description": "User-defined scripts to run after the installation finishes", "type": "array", "items": { "$ref": "#/$defs/script" } } } }, "software": { "title": "Software settings", "type": "object", "properties": { "patterns": { "title": "List of patterns to install", "type": "array", "items": { "type": "string", "examples": ["minimal_base"] } } } }, "product": { "title": "Product to install", "type": "object", "additionalProperties": false, "required": ["id"], "properties": { "id": { "title": "Product identifier", "description": "The id field from a products.d/foo.yaml file", "icon": "Product Icon path specified in products.d/foo.yaml file", "type": "string" }, "registrationCode": { "title": "Product registration code", "type": "string" }, "registrationEmail": { "title": "Product registration email", "type": "string" } } }, "network": { "title": "Network settings", "type": "object", "additionalProperties": false, "properties": { "connections": { "title": "Network connections to be defined", "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["id"], "properties": { "id": { "title": "Connection ID", "type": "string" }, "interface": { "title": "The name of the network interface bound to this connection", "type": "string" }, "mac-address": { "title": "Custom mac-address", "description": "Can also be 'preserve', 'permanent', 'random' or 'stable'.", "type": "string" }, "mtu": { "description": "Connection MTU", "type": "integer", "minimum": 0 }, "method4": { "title": "IPv4 configuration method", "type": "string", "enum": ["auto", "manual", "link-local", "disabled"] }, "method6": { "title": "IPv6 configuration method", "type": "string", "enum": ["auto", "manual", "link-local", "disabled"] }, "gateway4": { "title": "Connection gateway address", "type": "string", "examples": ["192.168.122.1"] }, "gateway6": { "title": "Connection gateway address", "type": "string", "examples": ["::ffff:c0a8:7a01"] }, "addresses": { "type": "array", "items": { "title": "Connection addresses", "type": "string" } }, "nameservers": { "type": "array", "items": { "title": "Nameservers", "description": "IPv4 and/or IPv6 are allowed.", "type": "string" } }, "dns_searchlist": { "type": "array", "items": { "description": "DNS search domains", "type": "string", "additionalProperties": false } }, "ignore_auto_dns": { "description": "Whether DNS options provided via DHCP are used or not", "type": "boolean" }, "wireless": { "type": "object", "title": "Wireless configuration", "additionalProperties": false, "properties": { "password": { "title": "Password of the wireless network", "type": "string" }, "security": { "title": "Security method/key management", "type": "string", "enum": [ "none", "owe", "ieee8021x", "wpa-psk", "sae", "wpa-eap", "wpa-eap-suite-b-192" ] }, "ssid": { "title": "SSID of the wireless network", "type": "string" }, "mode": { "title": "Wireless network mode", "type": "string", "enum": ["infrastructure", "adhoc", "mesh", "ap"] }, "hidden": { "title": "Indicates that the wireless network is not broadcasting its SSID", "type": "boolean" }, "band": { "title": "Frequency band of the wireless network", "type": "string", "enum": ["a", "bg"] }, "channel": { "title": "Wireless channel of the wireless network", "type": "integer", "minimum": 0 }, "bssid": { "title": "Only allow connection to this mac address", "type": "string" }, "groupAlgorithms": { "type": "array", "items": { "title": "A list of group/broadcast encryption algorithms", "type": "string", "enum": ["wep40", "wep104", "tkip", "ccmp"] } }, "pairwiseAlgorithms": { "type": "array", "items": { "title": "A list of pairwise encryption algorithms", "type": "string", "enum": ["tkip", "ccmp"] } }, "wpaProtocolVersions": { "type": "array", "items": { "title": "A list of allowed WPA protocol versions", "type": "string", "enum": ["wpa", "rsn"] } }, "pmf": { "title": "Indicates whether Protected Management Frames must be enabled for the connection", "type": "integer" } } }, "bond": { "type": "object", "title": "Bonding configuration", "additionalProperties": false, "properties": { "mode": { "type": "string", "enum": [ "balance-rr", "active-backup", "balance-xor", "broadcast", "802.3ad", "balance-tlb", "balance-alb" ] }, "options": { "type": "string" }, "ports": { "type": "array", "items": { "title": "A list of the interfaces or connections to be bonded", "type": "string" } } } }, "match": { "type": "object", "title": "Match settings", "description": "Identifies the network interface to apply the connection settings to", "additionalProperties": false, "properties": { "kernel": { "type": "array", "items": { "title": "A list of kernel command line arguments to match", "type": "string" } }, "interface": { "type": "array", "items": { "title": "A list of interface names to match", "type": "string" } }, "driver": { "type": "array", "items": { "title": "A list of driver names to match", "type": "string" } }, "path": { "type": "array", "items": { "title": "A list of paths to match against the ID_PATH udev property of devices", "type": "string" } } } }, "ieee-8021x": { "type": "object", "title": "IEEE 802.1x (EAP) settings", "properties": { "eap": { "type": "array", "items": { "title": "List of EAP methods used", "type": "string", "enum": [ "leap", "md5", "tls", "peap", "ttls", "pwd", "fast" ] } }, "phase2Auth": { "title": "Phase 2 inner auth method", "type": "string", "enum": [ "pap", "chap", "mschap", "mschapv2", "gtc", "otp", "md5", "tls" ] }, "identity": { "title": "Identity string, often for example the user's login name", "type": "string" }, "password": { "title": "Password string used for EAP authentication", "type": "string" }, "caCert": { "title": "Path to CA certificate", "type": "string" }, "caCertPassword": { "title": "Password string for CA certificate if it is encrypted", "type": "string" }, "clientCert": { "title": "Path to client certificate", "type": "string" }, "clientCertPassword": { "title": "Password string for client certificate if it is encrypted", "type": "string" }, "privateKey": { "title": "Path to private key", "type": "string" }, "privateKeyPassword": { "title": "Password string for private key if it is encrypted", "type": "string" }, "anonymousIdentity": { "title": "Anonymous identity string for EAP authentication methods", "type": "string" }, "peapVersion": { "title": "Which PEAP version is used when PEAP is set as the EAP method in the 'eap' property", "type": "string", "enum": ["0", "1"] }, "peapLabel": { "title": "Force the use of the new PEAP label during key derivation", "type": "boolean" } } } } } } } }, "user": { "title": "First user settings", "type": "object", "additionalProperties": false, "properties": { "fullName": { "title": "Full name", "type": "string", "examples": ["Jane Doe"] }, "userName": { "title": "User login name", "type": "string", "examples": ["jane.doe"] }, "password": { "title": "User password", "type": "string", "examples": ["nots3cr3t"] } }, "required": ["fullName", "userName", "password"] }, "root": { "title": "Root authentication settings", "type": "object", "additionalProperties": false, "properties": { "password": { "title": "Root password", "type": "string" }, "sshPublicKey": { "title": "SSH public key", "type": "string" } } }, "localization": { "title": "Localization settings", "type": "object", "properties": { "language": { "title": "System language ID", "type": "string", "examples": ["en_US.UTF-8", "en_US"] }, "keyboard": { "title": "Keyboard layout ID", "type": "string" }, "timezone": { "title": "Time zone identifier such as 'Europe/Berlin'", "type": "string", "examples": ["Europe/Berlin"] } } }, "storage": { "title": "Storage settings", "type": "object", "additionalProperties": false, "properties": { "boot": { "$ref": "#/$defs/boot" }, "drives": { "title": "Drive devices", "description": "Section describing drives (disks, BIOS RAIDs and multipath devices).", "type": "array", "items": { "anyOf": [ { "title": "Unpartitioned drive", "description": "Drive without a partition table (e.g., directly formatted).", "type": "object", "additionalProperties": false, "properties": { "search": { "description": "The search is limited to drives scope.", "$ref": "#/$defs/search" }, "alias": { "$ref": "#/$defs/alias" }, "encryption": { "$ref": "#/$defs/encryption" }, "filesystem": { "description": "The partition table (if any) is deleted.", "$ref": "#/$defs/filesystem" } } }, { "title": "Partitioned drive", "type": "object", "additionalProperties": false, "properties": { "search": { "description": "The search is limited to drives scope.", "$ref": "#/$defs/search" }, "alias": { "$ref": "#/$defs/alias" }, "ptableType": { "title": "Partition table type", "description": "The partition table is created only if all the current partitions are deleted.", "enum": ["gpt", "msdos", "dasd"] }, "partitions": { "$ref": "#/$defs/partitions" } } } ] } }, "volumeGroups": { "title": "LVM volume groups", "description": "Section describing the LVM volume groups.", "type": "array", "items": { "title": "LVM volume group", "type": "object", "additionalProperties": false, "properties": { "name": { "title": "Volume group name", "type": "string", "examples": ["vg0"] }, "extentSize": { "title": "Extent size", "$ref": "#/$defs/sizeValue" }, "physicalVolumes": { "title": "Physical volumes", "description": "Devices to use as physical volumes.", "$comment": "In the future it would be possible to indicate both aliases and 'generate' items together.", "anyOf": [ { "type": "array", "items": { "title": "Device alias", "type": "string" } }, { "type": "array", "items": { "title": "Generate physical volumes", "description": "Automatically creates the needed physical volumes in the indicated devices.", "type": "object", "additionalProperties": false, "required": ["generate"], "properties": { "generate": { "anyOf": [ { "type": "array", "items": { "title": "Device alias", "type": "string" } }, { "type": "object", "additionalProperties": false, "required": ["targetDevices"], "properties": { "targetDevices": { "type": "array", "items": { "title": "Device alias", "type": "string" } }, "encryption": { "$ref": "#/$defs/encryption" } } } ] } } } } ] }, "logicalVolumes": { "title": "Logical volumes", "type": "array", "items": { "anyOf": [ { "$ref": "#/$defs/generateVolumes" }, { "title": "Generate logical volumes", "description": "Creates the default or mandatory logical volumes configured by the selected product, allowing to customize some properties.", "type": "object", "additionalProperties": false, "required": ["generate"], "properties": { "generate": { "type": "object", "additionalProperties": false, "required": ["logicalVolumes"], "properties": { "logicalVolumes": { "enum": ["default", "mandatory"] }, "encryption": { "$ref": "#/$defs/encryption" }, "stripes": { "$ref": "#/$defs/lvStripes" }, "stripeSize": { "$ref": "#/$defs/lvStripeSize" } } } } }, { "title": "Logical volume", "type": "object", "additionalProperties": false, "properties": { "name": { "title": "Logical volume name", "type": "string", "examples": ["lv0"] }, "size": { "title": "Logical volume size", "$ref": "#/$defs/size" }, "stripes": { "$ref": "#/$defs/lvStripes" }, "stripeSize": { "$ref": "#/$defs/lvStripeSize" }, "encryption": { "$ref": "#/$defs/encryption" }, "filesystem": { "$ref": "#/$defs/filesystem" } } }, { "title": "Thin pool logical volume", "type": "object", "additionalProperties": false, "properties": { "pool": { "title": "LVM thin pool", "const": true }, "alias": { "$ref": "#/$defs/alias" }, "name": { "title": "Logical volume name", "type": "string", "examples": ["lv0"] }, "size": { "title": "Logical volume size", "$ref": "#/$defs/size" }, "stripes": { "$ref": "#/$defs/lvStripes" }, "stripeSize": { "$ref": "#/$defs/lvStripeSize" }, "encryption": { "$ref": "#/$defs/encryption" } } }, { "title": "Thin logical volume", "type": "object", "additionalProperties": false, "required": ["usedPool"], "properties": { "name": { "title": "Thin logical volume name", "type": "string", "examples": ["lv0"] }, "size": { "title": "Thin logical volume size", "$ref": "#/$defs/size" }, "usedPool": { "title": "Used LVM thin pool", "description": "Alias of a LVM thin pool.", "type": "string" }, "encryption": { "$ref": "#/$defs/encryption" }, "filesystem": { "$ref": "#/$defs/filesystem" } } } ] } } } } }, "guided": { "title": "Guided proposal settings", "$comment": "This guided section will be extracted to a separate schema. Only storage and legacyAutoyastStorage will be offered as valid schemas for the storage config.", "type": "object", "additionalProperties": false, "properties": { "target": { "anyOf": [ { "title": "Target for installing", "description": "Indicates whether to install in a disk or a new LVM.", "enum": ["disk", "newLvmVg"] }, { "title": "Target disk", "description": "Indicates to install in a specific disk device.", "type": "object", "additionalProperties": false, "required": ["disk"], "properties": { "disk": { "title": "Device name", "type": "string", "examples": ["/dev/vda"] } } }, { "title": "New LVM", "description": "Indicates to install in a new LVM created over some specific devices.", "type": "object", "additionalProperties": false, "required": ["newLvmVg"], "properties": { "newLvmVg": { "description": "List of devices in which to create the physical volumes.", "type": "array", "items": { "title": "Device name", "type": "string", "examples": ["/dev/vda"] } } } } ] }, "boot": { "$ref": "#/$defs/boot" }, "encryption": { "title": "Encryption", "description": "Indicates the options for encrypting the new partitions.", "type": "object", "additionalProperties": false, "required": ["password"], "properties": { "password": { "$ref": "#/$defs/encryptionPassword" }, "method": { "title": "Encryption method", "description": "Method used to encrypt the devices.", "enum": ["luks2", "tpm_fde"] }, "pbkdFunction": { "$ref": "#/$defs/encryptionPbkdFunction" } } }, "space": { "title": "Space policy", "description": "Indicates how to find space for the new partitions.", "type": "object", "additionalProperties": false, "properties": { "policy": { "enum": ["delete", "resize", "keep", "custom"] }, "actions": { "type": "array" } }, "if": { "properties": { "policy": { "const": "custom" } } }, "then": { "required": ["policy", "actions"], "properties": { "actions": { "title": "Custom actions", "description": "Indicates what to do with specific devices.", "type": "array", "items": { "anyOf": [ { "title": "Force delete", "description": "Indicates to delete a specific device.", "type": "object", "required": ["forceDelete"], "additionalProperties": false, "properties": { "forceDelete": { "description": "Name of the device to delete.", "type": "string", "examples": ["/dev/vda"] } } }, { "title": "Allow shinking", "description": "Indicates whether a specific device can be shrunk if needed.", "type": "object", "required": ["resize"], "additionalProperties": false, "properties": { "resize": { "description": "Name of the shrinkable device.", "type": "string", "examples": ["/dev/vda"] } } } ] } } } }, "else": { "required": ["policy"], "properties": { "actions": { "type": "array", "maxItems": 0 } } } }, "volumes": { "title": "System volumes", "description": "List of volumes (file systems) to create.", "type": "array", "items": { "type": "object", "additionalProperties": false, "required": ["mount"], "properties": { "mount": { "title": "Mount properties", "type": "object", "additionalProperties": false, "required": ["path"], "properties": { "path": { "title": "Mount path", "type": "string", "examples": ["/dev/vda"] }, "options": { "title": "Mount options", "description": "Options to add to the fourth field of fstab.", "type": "array", "items": { "type": "string" } } } }, "filesystem": { "$ref": "#/$defs/filesystemType" }, "size": { "$ref": "#/$defs/size" }, "target": { "title": "Volume target", "description": "Options to indicate the location of a volume.", "anyOf": [ { "title": "Default target", "description": "The volume is created in the target device for installing.", "const": "default" }, { "title": "New partition", "description": "The volume is created over a new partition in a specific disk.", "type": "object", "required": ["newPartition"], "additionalProperties": false, "properties": { "newPartition": { "description": "Name of a disk device.", "type": "string", "examples": ["/dev/vda"] } } }, { "title": "Dedicated LVM volume group", "description": "The volume is created over a new dedicated LVM.", "type": "object", "additionalProperties": false, "required": ["newVg"], "properties": { "newVg": { "description": "Name of a disk device.", "type": "string", "examples": ["/dev/vda"] } } }, { "title": "Re-used existing device", "description": "The volume is created over an existing device.", "type": "object", "additionalProperties": false, "required": ["device"], "properties": { "device": { "description": "Name of a device.", "type": "string", "examples": ["/dev/vda1"] } } }, { "title": "Re-used existing file system", "description": "An existing file system is reused (without formatting).", "type": "object", "additionalProperties": false, "required": ["filesystem"], "properties": { "filesystem": { "description": "Name of a device containing the file system.", "type": "string", "examples": ["/dev/vda1"] } } } ] } } } } } } } }, "legacyAutoyastStorage": { "title": "Legacy AutoYaST storage settings", "description": "Accepts all options of the AutoYaST partitioning section (i.e., XML to JSON)", "type": "array", "items": { "type": "object" } } }, "$defs": { "sizeString": { "title": "Human readable size", "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?(\\s*([KkMmGgTtPpEeZzYy][iI]?)?[Bb])?$", "examples": ["2 GiB", "1.5 TB", "1TIB", "1073741824 b", "1073741824"] }, "sizeInteger": { "title": "Size in bytes", "type": "integer", "minimum": 0, "examples": [1024, 2048] }, "sizeValue": { "anyOf": [ { "$ref": "#/$defs/sizeString" }, { "$ref": "#/$defs/sizeInteger" } ] }, "sizeValueWithCurrent": { "anyOf": [ { "$ref": "#/$defs/sizeValue" }, { "title": "Current size", "description": "The current size of the device.", "const": "current" } ] }, "size": { "title": "Size options", "anyOf": [ { "$ref": "#/$defs/sizeValue" }, { "title": "Size range (tuple syntax)", "description": "Lower size limit and optionally upper size limit.", "type": "array", "items": { "$ref": "#/$defs/sizeValueWithCurrent" }, "minItems": 1, "maxItems": 2, "examples": [ [1024, "current"], ["1 GiB", "5 GiB"], [1024, "2 GiB"], ["2 GiB"] ] }, { "title": "Size range", "type": "object", "additionalProperties": false, "required": ["min"], "properties": { "min": { "title": "Mandatory lower size limit", "$ref": "#/$defs/sizeValueWithCurrent" }, "max": { "title": "Optional upper size limit", "$ref": "#/$defs/sizeValueWithCurrent" } } } ] }, "searchName": { "title": "Device name", "type": "string", "examples": ["/dev/vda", "/dev/disk/by-id/ata-WDC_WD3200AAKS-75L9"] }, "searchAll": { "title": "Search all devices", "description": "Shortcut to match all devices if there is any (equivalent to specify no conditions and to skip the entry if no device is found).", "const": "*" }, "searchByName": { "title": "Search by name condition", "type": "object", "additionalProperties": false, "required": ["name"], "properties": { "name": { "$ref": "#/$defs/searchName" } } }, "search": { "anyOf": [ { "$ref": "#/$defs/searchAll" }, { "$ref": "#/$defs/searchName" }, { "title": "Search options", "type": "object", "additionalProperties": false, "properties": { "condition": { "$ref": "#/$defs/searchByName" }, "ifNotFound": { "title": "Not found action", "description": "How to handle the section if the device is not found.", "enum": ["skip", "error"], "default": "error" } } } ] }, "alias": { "title": "Alias", "description": "Name used to reference a device.", "type": "string" }, "boot": { "title": "Boot options", "description": "Allows configuring boot partitions automatically.", "type": "object", "additionalProperties": false, "required": ["configure"], "properties": { "configure": { "title": "Configure boot", "description": "Whether to configure partitions for booting.", "type": "boolean" }, "device": { "title": "Boot device", "description": "The target installation device is used by default.", "type": "string", "examples": ["/dev/vda"] } } }, "encryptionPassword": { "title": "Encryption password", "description": "Password to use when creating a new encryption device.", "type": "string" }, "encryptionCipher": { "title": "LUKS cipher", "description": "The value must be compatible with the --cipher argument of the command cryptsetup.", "type": "string" }, "encryptionKeySize": { "title": "LUKS key size", "description": "The value (in bits) has to be a multiple of 8. The possible key sizes are limited by the used cipher.", "type": "integer" }, "encryptionPbkdFunction": { "title": "LUKS2 password-based key derivation", "enum": ["pbkdf2", "argon2i", "argon2id"] }, "encryptionLUKS1": { "title": "LUKS1 encryption", "type": "object", "additionalProperties": false, "required": ["luks1"], "properties": { "luks1": { "type": "object", "additionalProperties": false, "required": ["password"], "properties": { "password": { "$ref": "#/$defs/encryptionPassword" }, "cipher": { "$ref": "#/$defs/encryptionCipher" }, "keySize": { "$ref": "#/$defs/encryptionKeySize" } } } } }, "encryptionLUKS2": { "title": "LUKS2 encryption", "type": "object", "additionalProperties": false, "required": ["luks2"], "properties": { "luks2": { "type": "object", "additionalProperties": false, "required": ["password"], "properties": { "password": { "$ref": "#/$defs/encryptionPassword" }, "cipher": { "$ref": "#/$defs/encryptionCipher" }, "keySize": { "$ref": "#/$defs/encryptionKeySize" }, "pbkdFunction": { "$ref": "#/$defs/encryptionPbkdFunction" }, "label": { "title": "LUKS2 label", "type": "string" } } } } }, "encryptionPervasiveLUKS2": { "title": "LUKS2 pervasive encryption", "type": "object", "additionalProperties": false, "required": ["pervasiveLuks2"], "properties": { "pervasiveLuks2": { "type": "object", "additionalProperties": false, "required": ["password"], "properties": { "password": { "$ref": "#/$defs/encryptionPassword" } } } } }, "encryptionTPM": { "title": "TPM-Based Full Disk Encrytion", "type": "object", "additionalProperties": false, "required": ["tpmFde"], "properties": { "tpmFde": { "type": "object", "additionalProperties": false, "required": ["password"], "properties": { "password": { "$ref": "#/$defs/encryptionPassword" } } } } }, "encryptionSwap": { "title": "Swap encryptions", "enum": ["protected_swap", "secure_swap", "random_swap"] }, "encryption": { "anyOf": [ { "$ref": "#/$defs/encryptionLUKS1" }, { "$ref": "#/$defs/encryptionLUKS2" }, { "$ref": "#/$defs/encryptionPervasiveLUKS2" }, { "$ref": "#/$defs/encryptionTPM" }, { "$ref": "#/$defs/encryptionSwap" } ] }, "filesystemType": { "anyOf": [ { "title": "File system type", "enum": [ "bcachefs", "btrfs", "exfat", "ext2", "ext3", "ext4", "f2fs", "jfs", "nfs", "nilfs2", "ntfs", "reiserfs", "swap", "tmpfs", "vfat", "xfs" ] }, { "title": "Btrfs file system", "type": "object", "additionalProperties": false, "required": ["btrfs"], "properties": { "btrfs": { "type": "object", "additionalProperties": false, "properties": { "snapshots": { "title": "Btrfs snapshots", "description": "Whether to configrue Btrfs snapshots.", "type": "boolean" } } } } } ] }, "filesystem": { "title": "File system options", "type": "object", "additionalProperties": false, "properties": { "reuseIfPossible": { "title": "Reuse file system", "description": "Try to reuse the existing file system. In some cases the file system could not be reused, for example, if the device is re-encrypted.", "type": "boolean", "default": false }, "type": { "$ref": "#/$defs/filesystemType" }, "label": { "title": "File system label", "type": "string" }, "path": { "title": "Mount path", "type": "string", "examples": ["/var/log"] }, "mountBy": { "title": "How to mount the device", "enum": ["device", "id", "label", "path", "uuid"] }, "mkfsOptions": { "title": "mkfs options", "description": "Options for creating the file system.", "type": "array", "items": { "type": "string" } }, "mountOptions": { "title": "Mount options", "description": "Options to add to the fourth field of fstab.", "type": "array", "items": { "type": "string" } } } }, "generateVolumes": { "title": "Generate volumes automatically", "description": "Creates the default or mandatory volumes configured by the selected product.", "type": "object", "additionalProperties": false, "required": ["generate"], "properties": { "generate": { "enum": ["default", "mandatory"] } } }, "partitions": { "title": "Partitions", "type": "array", "items": { "anyOf": [ { "$ref": "#/$defs/generateVolumes" }, { "title": "Generate partitions", "description": "Creates the default or mandatory partitions configured by the selected product, allowing to customize some properties.", "type": "object", "additionalProperties": false, "required": ["generate"], "properties": { "generate": { "type": "object", "additionalProperties": false, "required": ["partitions"], "properties": { "partitions": { "enum": ["default", "mandatory"] }, "encryption": { "$ref": "#/$defs/encryption" } } } } }, { "title": "Partition to create or reuse", "type": "object", "additionalProperties": false, "properties": { "search": { "description": "The search is limited to the partitions of the selected device scope.", "$ref": "#/$defs/search" }, "alias": { "$ref": "#/$defs/alias" }, "id": { "title": "Partition ID", "enum": [ "linux", "swap", "lvm", "raid", "esp", "prep", "bios_boot" ] }, "size": { "title": "Partition size", "$ref": "#/$defs/size" }, "encryption": { "$ref": "#/$defs/encryption" }, "filesystem": { "$ref": "#/$defs/filesystem" } } }, { "title": "Partition to delete if needed", "type": "object", "additionalProperties": false, "required": ["deleteIfNeeded", "search"], "properties": { "deleteIfNeeded": { "title": "Delete if needed", "description": "Delete the partition if needed to make space.", "const": true }, "search": { "description": "The search is limited to the partitions of the selected device scope.", "$ref": "#/$defs/search" }, "size": { "title": "Partition size", "$ref": "#/$defs/size" } } }, { "title": "Partition to delete", "type": "object", "additionalProperties": false, "required": ["delete", "search"], "properties": { "delete": { "title": "Delete", "description": "Delete the partition.", "const": true }, "search": { "description": "The search is limited to the partitions of the selected device scope.", "$ref": "#/$defs/search" } } } ] } }, "lvStripes": { "title": "Number of stripes", "type": "integer", "minimum": 1, "maximum": 128 }, "lvStripeSize": { "title": "Stripe size", "$ref": "#/$defs/sizeValue" }, "script": { "title": "User-defined installation script", "type": "object", "additionalProperties": false, "properties": { "name": { "description": "Script name, to be used as file name", "type": "string" }, "body": { "title": "Script content", "description": "Script content, starting with the shebang", "type": "string" }, "url": { "title": "Script URL", "description": "URL to fetch the script from" } }, "required": ["name"], "oneOf": [{ "required": ["body"] }, { "required": ["url"] }] } } } 07070100000028000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001400000000agama/agama-lib/src07070100000029000081A4000000000000000000000001671F5A6400001CAE000000000000000000000000000000000000001C00000000agama/agama-lib/src/auth.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module implements an API to deal with authentication tokens. //! //! Agama web server relies on JSON Web Tokens (JWT) for authentication purposes. //! This module implements a simple API to perform the common operations. //! //! ## The master token //! //! When Agama web server starts, it writes a master token (which is just a regular //! JWT) in `/run/agama/token`. That file is only readable by the root user and //! can be used by any Agama component. //! //! ## The user token //! //! When a user does not have access to the master token it needs to authenticate //! with the server. In that process, it obtains a new token that should be stored //! in user's home directory (`$HOME/.local/agama/token`). //! //! ## A simplistic API //! //! The current API is rather limited and it does not support, for instance, //! keeping tokens for different servers. We might extend this API if needed //! in the future. const USER_TOKEN_PATH: &str = ".local/agama/token"; const AGAMA_TOKEN_FILE: &str = "/run/agama/token"; use std::{ fmt::Display, fs::{self, File}, io::{self, BufRead, BufReader, Write}, os::unix::fs::OpenOptionsExt, path::{Path, PathBuf}, }; use chrono::{Duration, Utc}; use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; use thiserror::Error; #[derive(Error, Debug)] #[error("Invalid authentication token: {0}")] pub struct AuthTokenError(#[from] jsonwebtoken::errors::Error); /// Represents an authentication token (JWT). pub struct AuthToken(String); impl AuthToken { /// Creates a new token with the given content. /// /// * `content`: token raw content. pub fn new(content: &str) -> Self { Self(content.to_string()) } /// Generates a new token using the given secret. /// /// * `secret`: secret to encode the token. pub fn generate(secret: &str) -> Result<Self, AuthTokenError> { let claims = TokenClaims::default(); let token = jsonwebtoken::encode( &Header::default(), &claims, &EncodingKey::from_secret(secret.as_ref()), )?; Ok(AuthToken(token)) } /// Finds an usable token for the current user. /// /// It searches for a token in user's home directory and, if it does not exists, /// it tries to read the master token. pub fn find() -> Option<Self> { if let Ok(path) = Self::user_token_path() { if let Ok(token) = Self::read(path) { return Some(token); } } Self::read(AGAMA_TOKEN_FILE).ok() } /// Reads the token from the given path. /// /// * `path`: file's path to read the token from. pub fn read<P: AsRef<Path>>(path: P) -> io::Result<Self> { let file = File::open(path)?; let mut reader = BufReader::new(file); let mut buf = String::new(); reader.read_line(&mut buf)?; Ok(AuthToken(buf)) } /// Writes the token to the given path. /// /// It takes care of setting the right permissions (0400). /// /// * `path`: file's path to write the token to. pub fn write<P: AsRef<Path>>(&self, path: P) -> io::Result<()> { if let Some(parent) = path.as_ref().parent() { std::fs::create_dir_all(parent)?; } let mut file = fs::OpenOptions::new() .create(true) .truncate(true) .write(true) .mode(0o400) .open(path)?; file.write_all(self.0.as_bytes())?; Ok(()) } /// Removes the user token. pub fn remove_user_token() -> io::Result<()> { let path = Self::user_token_path()?; if path.exists() { fs::remove_file(path)?; } Ok(()) } /// Returns the claims from the token. /// /// * `secret`: secret to decode the token. pub fn claims(&self, secret: &str) -> Result<TokenClaims, AuthTokenError> { let decoding = DecodingKey::from_secret(secret.as_ref()); let token_data = jsonwebtoken::decode(&self.0, &decoding, &Validation::default())?; Ok(token_data.claims) } /// Returns a reference to the token's content. pub fn as_str(&self) -> &str { self.0.as_str() } /// Writes the token to the user's home directory. pub fn write_user_token(&self) -> io::Result<()> { let path = Self::user_token_path()?; self.write(path) } /// Writes the token to Agama's run directory. /// /// For this function to succeed the run directory should exist and the user needs write /// permissions. pub fn write_master_token(&self) -> io::Result<()> { self.write(AGAMA_TOKEN_FILE) } fn user_token_path() -> io::Result<PathBuf> { let Some(path) = home::home_dir() else { return Err(io::Error::new( io::ErrorKind::Other, "Cannot find the user's home directory", )); }; Ok(path.join(USER_TOKEN_PATH)) } } impl Display for AuthToken { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } /// Claims that are included in the token. /// /// See https://datatracker.ietf.org/doc/html/rfc7519 for reference. #[derive(Debug, Serialize, Deserialize)] pub struct TokenClaims { pub exp: i64, } impl Default for TokenClaims { fn default() -> Self { let mut exp = Utc::now(); if let Some(days) = Duration::try_days(1) { exp += days; } Self { exp: exp.timestamp(), } } } #[cfg(test)] mod tests { use tempfile::tempdir; use super::AuthToken; #[test] fn test_generate_token() { let token = AuthToken::generate("nots3cr3t").unwrap(); let decoded = token.claims("nots3cr3t"); assert!(decoded.is_ok()); let wrong = token.claims("wrong"); assert!(wrong.is_err()) } #[test] fn test_write_and_read_token() { // let token = AuthToken::from_path<P: AsRef<Path>>(path: P) // let token = AuthToken::from_path() let token = AuthToken::generate("nots3cr3t").unwrap(); let tmp_dir = tempdir().unwrap(); let path = tmp_dir.path().join("token"); token.write(&path).unwrap(); let read_token = AuthToken::read(&path).unwrap(); let decoded = read_token.claims("nots3cr3t"); assert!(decoded.is_ok()); } } 0707010000002A000081A4000000000000000000000001671F5A640000297D000000000000000000000000000000000000002800000000agama/agama-lib/src/base_http_client.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use reqwest::{header, Response}; use serde::{de::DeserializeOwned, Serialize}; use crate::{auth::AuthToken, error::ServiceError}; /// Base that all HTTP clients should use. /// /// It provides several features including automatic base URL switching, /// websocket events listening or object constructions. /// /// Usage should be just thin layer in domain specific client. /// /// ```no_run /// use agama_lib::questions::model::Question; /// use agama_lib::base_http_client::BaseHTTPClient; /// use agama_lib::error::ServiceError; /// /// async fn get_questions() -> Result<Vec<Question>, ServiceError> { /// let client = BaseHTTPClient::default(); /// client.get("/questions").await /// } /// ``` #[derive(Clone)] pub struct BaseHTTPClient { client: reqwest::Client, insecure: bool, pub base_url: String, } const API_URL: &str = "http://localhost/api"; impl Default for BaseHTTPClient { /// A `default` client /// - is NOT authenticated (maybe you want to call `new` instead) /// - uses `localhost` fn default() -> Self { Self { client: reqwest::Client::new(), insecure: false, base_url: API_URL.to_owned(), } } } impl BaseHTTPClient { /// Allows the client to connect to remote API with insecure certificate (e.g. self-signed) pub fn insecure(self) -> Self { Self { insecure: true, ..self } } /// Uses `localhost`, authenticates with [`AuthToken`]. pub fn authenticated(self) -> Result<Self, ServiceError> { Ok(Self { client: Self::authenticated_client(self.insecure)?, ..self }) } /// Configures itself for connection(s) without authentication token pub fn unauthenticated(self) -> Result<Self, ServiceError> { Ok(Self { client: reqwest::Client::builder() .danger_accept_invalid_certs(self.insecure) .build() .map_err(anyhow::Error::new)?, ..self }) } fn authenticated_client(insecure: bool) -> Result<reqwest::Client, ServiceError> { // TODO: this error is subtly misleading, leading me to believe the SERVER said it, // but in fact it is the CLIENT not finding an auth token let token = AuthToken::find().ok_or(ServiceError::NotAuthenticated)?; let mut headers = header::HeaderMap::new(); // just use generic anyhow error here as Bearer format is constructed by us, so failures can come only from token let value = header::HeaderValue::from_str(format!("Bearer {}", token).as_str()) .map_err(anyhow::Error::new)?; headers.insert(header::AUTHORIZATION, value); let client = reqwest::Client::builder() .danger_accept_invalid_certs(insecure) .default_headers(headers) .build()?; Ok(client) } fn url(&self, path: &str) -> String { self.base_url.clone() + path } /// Simple wrapper around [`Response`] to get object from response. /// /// Arguments: /// /// * `path`: path relative to HTTP API like `/questions` pub async fn get<T>(&self, path: &str) -> Result<T, ServiceError> where T: DeserializeOwned, { let response: Result<_, ServiceError> = self .client .get(self.url(path)) .send() .await .map_err(|e| e.into()); self.deserialize_or_error(response?).await } pub async fn post<T>(&self, path: &str, object: &impl Serialize) -> Result<T, ServiceError> where T: DeserializeOwned, { let response = self .request_response(reqwest::Method::POST, path, object) .await?; self.deserialize_or_error(response).await } /// post object to given path and report error if response is not success /// /// Arguments: /// /// * `path`: path relative to HTTP API like `/questions` /// * `object`: Object that can be serialiazed to JSON as body of request. pub async fn post_void(&self, path: &str, object: &impl Serialize) -> Result<(), ServiceError> { let response = self .request_response(reqwest::Method::POST, path, object) .await?; self.unit_or_error(response).await } /// put object to given path, deserializes the response /// /// Arguments: /// /// * `path`: path relative to HTTP API like `/users/first` /// * `object`: Object that can be serialiazed to JSON as body of request. pub async fn put<T>(&self, path: &str, object: &impl Serialize) -> Result<T, ServiceError> where T: DeserializeOwned, { let response = self .request_response(reqwest::Method::PUT, path, object) .await?; self.deserialize_or_error(response).await } /// put object to given path and report error if response is not success /// /// Arguments: /// /// * `path`: path relative to HTTP API like `/users/first` /// * `object`: Object that can be serialiazed to JSON as body of request. pub async fn put_void(&self, path: &str, object: &impl Serialize) -> Result<(), ServiceError> { let response = self .request_response(reqwest::Method::PUT, path, object) .await?; self.unit_or_error(response).await } /// patch object at given path and report error if response is not success /// /// Arguments: /// /// * `path`: path relative to HTTP API like `/users/first` /// * `object`: Object that can be serialiazed to JSON as body of request. pub async fn patch<T>(&self, path: &str, object: &impl Serialize) -> Result<T, ServiceError> where T: DeserializeOwned, { let response = self .request_response(reqwest::Method::PATCH, path, object) .await?; self.deserialize_or_error(response).await } pub async fn patch_void( &self, path: &str, object: &impl Serialize, ) -> Result<(), ServiceError> { let response = self .request_response(reqwest::Method::PATCH, path, object) .await?; self.unit_or_error(response).await } /// delete call on given path and report error if failed /// /// Arguments: /// /// * `path`: path relative to HTTP API like `/questions/1` pub async fn delete_void(&self, path: &str) -> Result<(), ServiceError> { let response: Result<_, ServiceError> = self .client .delete(self.url(path)) .send() .await .map_err(|e| e.into()); self.unit_or_error(response?).await } /// POST/PUT/PATCH an object to a given path and returns server response. /// Reports Err only if failed to send /// request, but if server returns e.g. 500, it will be in Ok result. /// /// In general unless specific response handling is needed, simple post should be used. /// /// Arguments: /// /// * `method`: for example `reqwest::Method::PUT` /// * `path`: path relative to HTTP API like `/questions` /// * `object`: Object that can be serialiazed to JSON as body of request. async fn request_response( &self, method: reqwest::Method, path: &str, object: &impl Serialize, ) -> Result<Response, ServiceError> { self.client .request(method, self.url(path)) .json(object) .send() .await .map_err(|e| e.into()) } /// Return deserialized JSON body as `Ok(T)` or an `Err` with [`ServiceError::BackendError`] async fn deserialize_or_error<T>(&self, response: Response) -> Result<T, ServiceError> where T: DeserializeOwned, { // DEBUG: This dbg is nice but it omits the body, thus we try harder below // let response = dbg!(response); if response.status().is_success() { // We'd like to simply: // response.json::<T>().await.map_err(|e| e.into()) // BUT also peek into the response text, in case something is wrong // so this copies the implementation from the above and adds a debug part let bytes_r: Result<_, ServiceError> = response.bytes().await.map_err(|e| e.into()); let bytes = bytes_r?; // DEBUG: (we expect JSON so dbg! would escape too much, eprintln! is better) // let text = String::from_utf8_lossy(&bytes); // eprintln!("Response body: {}", text); serde_json::from_slice(&bytes).map_err(|e| e.into()) } else { Err(self.build_backend_error(response).await) } } /// Return `Ok(())` or an `Err` with [`ServiceError::BackendError`] async fn unit_or_error(&self, response: Response) -> Result<(), ServiceError> { if response.status().is_success() { Ok(()) } else { Err(self.build_backend_error(response).await) } } const NO_TEXT: &'static str = "(Failed to extract error text from HTTP response)"; /// Builds [`ServiceError::BackendError`] from response. /// /// It contains also processing of response body, that is why it has to be async. /// /// Arguments: /// /// * `response`: response from which generate error async fn build_backend_error(&self, response: Response) -> ServiceError { let code = response.status().as_u16(); let text = response .text() .await .unwrap_or_else(|_| Self::NO_TEXT.to_string()); ServiceError::BackendError(code, text) } } 0707010000002B000081A4000000000000000000000001671F5A6400001310000000000000000000000000000000000000001C00000000agama/agama-lib/src/dbus.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use std::collections::HashMap; use zbus::zvariant::{self, OwnedObjectPath, OwnedValue, Value}; /// Nested hash to send to D-Bus. pub type NestedHash<'a> = HashMap<&'a str, HashMap<&'a str, zvariant::Value<'a>>>; /// Nested hash as it comes from D-Bus. pub type OwnedNestedHash = HashMap<String, HashMap<String, zvariant::OwnedValue>>; /// Helper to get property of given type from ManagedObjects map or any generic D-Bus Hash with variant as value pub fn get_property<'a, T>( properties: &'a HashMap<String, OwnedValue>, name: &str, ) -> Result<T, zbus::zvariant::Error> where T: TryFrom<Value<'a>>, <T as TryFrom<Value<'a>>>::Error: Into<zbus::zvariant::Error>, { let value: Value = properties .get(name) .ok_or(zbus::zvariant::Error::Message(format!( "Failed to find property '{}'", name )))? .into(); T::try_from(value).map_err(|e| e.into()) } /// It is similar helper like get_property with difference that name does not need to be in HashMap. /// In such case `None` is returned, so type has to be enclosed in `Option`. pub fn get_optional_property<'a, T>( properties: &'a HashMap<String, OwnedValue>, name: &str, ) -> Result<Option<T>, zbus::zvariant::Error> where T: TryFrom<Value<'a>>, <T as TryFrom<Value<'a>>>::Error: Into<zbus::zvariant::Error>, { if let Some(value) = properties.get(name) { let value: Value = value.into(); T::try_from(value).map(|v| Some(v)).map_err(|e| e.into()) } else { Ok(None) } } #[macro_export] macro_rules! property_from_dbus { ($self:ident, $field:ident, $key:expr, $dbus:ident, $type:ty) => { if let Some(v) = get_optional_property($dbus, $key)? { $self.$field = v; } }; } /// Converts a hash map containing zbus non-owned values to hash map with owned ones. /// /// NOTE: we could follow a different approach like building our own type (e.g. /// using the newtype idiom) and offering a better API. /// /// * `source`: hash map containing non-onwed values ([enum@zbus::zvariant::Value]). pub fn to_owned_hash(source: &HashMap<&str, Value<'_>>) -> HashMap<String, OwnedValue> { let mut owned = HashMap::new(); for (key, value) in source.iter() { owned.insert(key.to_string(), value.into()); } owned } /// Extracts the object ID from the path. /// /// TODO: should we merge this feature with the "DeviceSid"? pub fn extract_id_from_path(path: &OwnedObjectPath) -> Result<u32, zvariant::Error> { path.as_str() .rsplit_once('/') .and_then(|(_, id)| id.parse::<u32>().ok()) .ok_or_else(|| { zvariant::Error::Message(format!("Could not extract the ID from {}", path.as_str())) }) } #[cfg(test)] mod tests { use std::collections::HashMap; use zbus::zvariant::{self, OwnedValue, Str}; use crate::dbus::{get_optional_property, get_property}; #[test] fn test_get_property() { let data: HashMap<String, OwnedValue> = HashMap::from([ ("Id".to_string(), 1_u8.into()), ("Device".to_string(), Str::from_static("/dev/sda").into()), ]); let id: u8 = get_property(&data, "Id").unwrap(); assert_eq!(id, 1); let device: String = get_property(&data, "Device").unwrap(); assert_eq!(device, "/dev/sda".to_string()); } #[test] fn test_get_property_wrong_type() { let data: HashMap<String, OwnedValue> = HashMap::from([("Id".to_string(), 1_u8.into())]); let result: Result<u16, _> = get_property(&data, "Id"); assert_eq!(result, Err(zvariant::Error::IncorrectType)); } #[test] fn test_get_optional_property() { let data: HashMap<String, OwnedValue> = HashMap::from([("Id".to_string(), 1_u8.into())]); let id: Option<u8> = get_optional_property(&data, "Id").unwrap(); assert_eq!(id, Some(1)); let device: Option<String> = get_optional_property(&data, "Device").unwrap(); assert_eq!(device, None); } } 0707010000002C000081A4000000000000000000000001671F5A6400000D12000000000000000000000000000000000000001D00000000agama/agama-lib/src/error.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use serde_json; use std::io; use thiserror::Error; use zbus::{self, zvariant}; use crate::transfer::TransferError; #[derive(Error, Debug)] pub enum ServiceError { #[error("Cannot generate Agama logs: {0}")] CannotGenerateLogs(String), #[error("D-Bus service error: {0}")] DBus(#[from] zbus::Error), #[error("Could not connect to Agama bus at '{0}': {1}")] DBusConnectionError(String, #[source] zbus::Error), #[error("D-Bus protocol error: {0}")] DBusProtocol(#[from] zbus::fdo::Error), #[error("Unexpected type on D-Bus '{0}'")] ZVariant(#[from] zvariant::Error), #[error("Failed to communicate with the HTTP backend '{0}'")] HTTPError(#[from] reqwest::Error), // it's fine to say only "Error" because the original // specific error will be printed too #[error("Error: {0}")] Anyhow(#[from] anyhow::Error), // FIXME: It is too generic and starting to looks like an Anyhow error #[error("Network client error: '{0}'")] NetworkClientError(String), #[error("Wrong user parameters: '{0:?}'")] WrongUser(Vec<String>), #[error("Registration failed: '{0}'")] FailedRegistration(String), #[error("Failed to find these patterns: {0:?}")] UnknownPatterns(Vec<String>), #[error("Passed json data is not correct: {0}")] InvalidJson(#[from] serde_json::Error), #[error("Could not perform action '{0}'")] UnsuccessfulAction(String), #[error("Unknown installation phase: {0}")] UnknownInstallationPhase(u32), #[error("Question with id {0} does not exist")] QuestionNotExist(u32), #[error("Backend call failed with status {0} and text '{1}'")] BackendError(u16, String), #[error("You are not logged in. Please use: agama auth login")] NotAuthenticated, // Specific error when something does not work as expected, but it is not user fault #[error("Internal error. Please report a bug and attach logs. Details: {0}")] InternalError(String), #[error("Could not read the file: '{0}'")] CouldNotTransferFile(#[from] TransferError), } #[derive(Error, Debug)] pub enum ProfileError { #[error("Could not read the profile")] Unreachable(#[from] TransferError), #[error("Jsonnet evaluation failed:\n{0}")] EvaluationError(String), #[error("I/O error")] InputOutputError(#[from] io::Error), #[error("The profile is not a valid JSON file")] FormatError(#[from] serde_json::Error), #[error("Error: {0}")] Anyhow(#[from] anyhow::Error), } 0707010000002D000081A4000000000000000000000001671F5A6400000A06000000000000000000000000000000000000002800000000agama/agama-lib/src/install_settings.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Configuration settings handling //! //! This module implements the mechanisms to load and store the installation settings. use crate::{ localization::LocalizationSettings, network::NetworkSettings, product::ProductSettings, scripts::ScriptsConfig, software::SoftwareSettings, users::UserSettings, }; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use std::default::Default; use std::fs::File; use std::io::BufReader; use std::path::Path; /// Installation settings /// /// This struct represents installation settings. It serves as an entry point and it is composed of /// other structs which hold the settings for each area ("users", "software", etc.). #[derive(Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InstallSettings { #[serde(default, flatten)] pub user: Option<UserSettings>, #[serde(default)] pub software: Option<SoftwareSettings>, #[serde(default)] pub product: Option<ProductSettings>, #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] pub storage: Option<Box<RawValue>>, #[serde(default, rename = "legacyAutoyastStorage")] #[serde(skip_serializing_if = "Option::is_none")] pub storage_autoyast: Option<Box<RawValue>>, #[serde(default)] pub network: Option<NetworkSettings>, #[serde(default)] pub localization: Option<LocalizationSettings>, #[serde(default)] pub scripts: Option<ScriptsConfig>, } impl InstallSettings { pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, std::io::Error> { let file = File::open(path)?; let reader = BufReader::new(file); let data = serde_json::from_reader(reader)?; Ok(data) } } 0707010000002E000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001900000000agama/agama-lib/src/jobs0707010000002F000081A4000000000000000000000001671F5A640000075C000000000000000000000000000000000000001C00000000agama/agama-lib/src/jobs.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module implements support for the so-called Jobs. It is a concept hat represents running //! an external command that may take some time, like formatting a DASD device. It is exposed via //! D-Bus and, at this time, only the storage service makes use of it. use std::collections::HashMap; use serde::Serialize; use zbus::zvariant::OwnedValue; use crate::{dbus::get_property, error::ServiceError}; pub mod client; /// Represents a job. #[derive(Clone, Debug, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct Job { /// Artificial job identifier. pub id: String, /// Whether the job is running. pub running: bool, /// Job exit code. pub exit_code: u32, } impl TryFrom<&HashMap<String, OwnedValue>> for Job { type Error = ServiceError; fn try_from(value: &HashMap<String, OwnedValue>) -> Result<Self, Self::Error> { Ok(Job { running: get_property(value, "Running")?, exit_code: get_property(value, "ExitCode")?, ..Default::default() }) } } 07070100000030000081A4000000000000000000000001671F5A64000008DC000000000000000000000000000000000000002300000000agama/agama-lib/src/jobs/client.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements a client to access Agama's Jobs API. use zbus::{fdo::ObjectManagerProxy, zvariant::OwnedObjectPath, Connection}; use crate::error::ServiceError; use super::Job; #[derive(Clone)] pub struct JobsClient<'a> { object_manager_proxy: ObjectManagerProxy<'a>, } impl<'a> JobsClient<'a> { pub async fn new( connection: Connection, destination: &'static str, path: &'static str, ) -> Result<Self, ServiceError> { let object_manager_proxy = ObjectManagerProxy::builder(&connection) .destination(destination)? .path(path)? .build() .await?; Ok(Self { object_manager_proxy, }) } pub async fn jobs(&self) -> Result<Vec<(OwnedObjectPath, Job)>, ServiceError> { let managed_objects = self.object_manager_proxy.get_managed_objects().await?; let mut jobs = vec![]; for (path, ifaces) in managed_objects { let Some(properties) = ifaces.get("org.opensuse.Agama.Storage1.Job") else { continue; }; match Job::try_from(properties) { Ok(mut job) => { job.id = path.to_string(); jobs.push((path, job)); } Err(error) => { log::warn!("Not a valid job: {}", error); } } } Ok(jobs) } } 07070100000031000081A4000000000000000000000001671F5A6400000DE4000000000000000000000000000000000000001B00000000agama/agama-lib/src/lib.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! # Interacting with Agama //! //! This library offers an API to interact with Agama services. At this point, the library allows: //! //! * Reading and writing [installation settings](install_settings::InstallSettings). //! * Monitoring the [progress]. //! * Triggering actions through the [manager] (e.g., starting installation). //! //! ## Handling installation settings //! //! Let's have a look to the components that are involved when dealing with the installation //! settings, as it is the most complex part of the library. The code is organized in a set of //! modules, one for each topic, like [network], [software], and so on. //! //! Each of those modules contains, at least: //! //! * A settings model: it is a representation of the installation settings for the given topic. It //! is expected to implement the [serde::Serialize], [serde::Deserialize] and //! [agama_settings::settings::Settings] traits. //! * A store: it is the responsible for reading/writing the settings to the service. Usually, it //! relies on a D-Bus client for communicating with the service, although it could implement that //! logic itself. Note: we are considering defining a trait for stores too. //! //! As said, those modules might implement additional stuff, like specific types, clients, etc. pub mod auth; pub mod base_http_client; pub mod error; pub mod install_settings; pub mod jobs; pub mod localization; pub mod manager; pub mod network; pub mod product; pub mod profile; pub mod software; pub mod storage; pub mod users; // TODO: maybe expose only clients when we have it? pub mod dbus; pub mod progress; pub mod proxies; mod store; pub use store::Store; pub mod questions; pub mod scripts; pub mod transfer; use crate::error::ServiceError; use reqwest::{header, Client}; const ADDRESS: &str = "unix:path=/run/agama/bus"; pub async fn connection() -> Result<zbus::Connection, ServiceError> { connection_to(ADDRESS).await } pub async fn connection_to(address: &str) -> Result<zbus::Connection, ServiceError> { let connection = zbus::ConnectionBuilder::address(address)? .build() .await .map_err(|e| ServiceError::DBusConnectionError(address.to_string(), e))?; Ok(connection) } pub fn http_client(token: &str) -> Result<reqwest::Client, ServiceError> { let mut headers = header::HeaderMap::new(); let value = header::HeaderValue::from_str(format!("Bearer {}", token).as_str()) .map_err(|e| ServiceError::NetworkClientError(e.to_string()))?; headers.insert(header::AUTHORIZATION, value); let client = Client::builder() .default_headers(headers) .build() .map_err(|e| ServiceError::NetworkClientError(e.to_string()))?; Ok(client) } 07070100000032000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000002100000000agama/agama-lib/src/localization07070100000033000081A4000000000000000000000001671F5A6400000455000000000000000000000000000000000000002400000000agama/agama-lib/src/localization.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements support for handling the localization settings mod http_client; pub mod model; mod proxies; mod settings; mod store; pub use http_client::LocalizationHTTPClient; pub use proxies::LocaleProxy; pub use settings::LocalizationSettings; pub use store::LocalizationStore; 07070100000034000081A4000000000000000000000001671F5A640000058B000000000000000000000000000000000000003000000000agama/agama-lib/src/localization/http_client.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use super::model::LocaleConfig; use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; pub struct LocalizationHTTPClient { client: BaseHTTPClient, } impl LocalizationHTTPClient { pub fn new(base: BaseHTTPClient) -> Result<Self, ServiceError> { Ok(Self { client: base }) } pub async fn get_config(&self) -> Result<LocaleConfig, ServiceError> { self.client.get("/l10n/config").await } pub async fn set_config(&self, config: &LocaleConfig) -> Result<(), ServiceError> { self.client.patch_void("/l10n/config", config).await } } 07070100000035000081A4000000000000000000000001671F5A64000005B4000000000000000000000000000000000000002A00000000agama/agama-lib/src/localization/model.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct LocaleConfig { /// Locales to install in the target system pub locales: Option<Vec<String>>, /// Keymap for the target system pub keymap: Option<String>, /// Timezone for the target system pub timezone: Option<String>, /// User-interface locale. It is actually not related to the `locales` property. pub ui_locale: Option<String>, /// User-interface locale. It is relevant only on local installations. pub ui_keymap: Option<String>, } 07070100000036000081A4000000000000000000000001671F5A6400000892000000000000000000000000000000000000002C00000000agama/agama-lib/src/localization/proxies.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use zbus::dbus_proxy; #[dbus_proxy( interface = "org.opensuse.Agama1.Locale", default_service = "org.opensuse.Agama1", default_path = "/org/opensuse/Agama1/Locale" )] trait Locale { /// Commit method fn commit(&self) -> zbus::Result<()>; /// ListKeymaps method fn list_keymaps(&self) -> zbus::Result<Vec<(String, String)>>; /// ListLocales method fn list_locales(&self) -> zbus::Result<Vec<(String, String, String)>>; /// ListTimezones method fn list_timezones(&self) -> zbus::Result<Vec<(String, Vec<String>)>>; /// Keymap property #[dbus_proxy(property)] fn keymap(&self) -> zbus::Result<String>; #[dbus_proxy(property)] fn set_keymap(&self, value: &str) -> zbus::Result<()>; /// Locales property #[dbus_proxy(property)] fn locales(&self) -> zbus::Result<Vec<String>>; #[dbus_proxy(property)] fn set_locales(&self, value: &[&str]) -> zbus::Result<()>; /// Timezone property #[dbus_proxy(property)] fn timezone(&self) -> zbus::Result<String>; #[dbus_proxy(property)] fn set_timezone(&self, value: &str) -> zbus::Result<()>; /// UILocale property #[dbus_proxy(property, name = "UILocale")] fn uilocale(&self) -> zbus::Result<String>; #[dbus_proxy(property, name = "UILocale")] fn set_uilocale(&self, value: &str) -> zbus::Result<()>; } 07070100000037000081A4000000000000000000000001671F5A640000056B000000000000000000000000000000000000002D00000000agama/agama-lib/src/localization/settings.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Representation of the localization settings use serde::{Deserialize, Serialize}; /// Localization settings for the system being installed (not the UI) /// FIXME: this one is close to CLI. A possible duplicate close to HTTP is LocaleConfig #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct LocalizationSettings { /// like "en_US.UTF-8" pub language: Option<String>, /// like "cz(qwerty)" pub keyboard: Option<String>, /// like "Europe/Berlin" pub timezone: Option<String>, } 07070100000038000081A4000000000000000000000001671F5A6400001700000000000000000000000000000000000000002A00000000agama/agama-lib/src/localization/store.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements the store for the localization settings. // TODO: for an overview see crate::store (?) use super::{LocalizationHTTPClient, LocalizationSettings}; use crate::base_http_client::BaseHTTPClient; use crate::error::ServiceError; use crate::localization::model::LocaleConfig; /// Loads and stores the storage settings from/to the D-Bus service. pub struct LocalizationStore { localization_client: LocalizationHTTPClient, } impl LocalizationStore { pub fn new(client: BaseHTTPClient) -> Result<LocalizationStore, ServiceError> { Ok(Self { localization_client: LocalizationHTTPClient::new(client)?, }) } pub fn new_with_client( client: LocalizationHTTPClient, ) -> Result<LocalizationStore, ServiceError> { Ok(Self { localization_client: client, }) } /// Consume *v* and return its first element, or None. /// This is similar to VecDeque::pop_front but it consumes the whole Vec. fn chestburster(mut v: Vec<String>) -> Option<String> { if v.is_empty() { None } else { Some(v.swap_remove(0)) } } pub async fn load(&self) -> Result<LocalizationSettings, ServiceError> { let config = self.localization_client.get_config().await?; let opt_language = config.locales.and_then(Self::chestburster); let opt_keyboard = config.keymap; let opt_timezone = config.timezone; Ok(LocalizationSettings { language: opt_language, keyboard: opt_keyboard, timezone: opt_timezone, }) } pub async fn store(&self, settings: &LocalizationSettings) -> Result<(), ServiceError> { // clones are necessary as we have different structs owning their data let opt_language = settings.language.clone(); let opt_keymap = settings.keyboard.clone(); let opt_timezone = settings.timezone.clone(); let config = LocaleConfig { locales: opt_language.map(|s| vec![s]), keymap: opt_keymap, timezone: opt_timezone, ui_locale: None, ui_keymap: None, }; self.localization_client.set_config(&config).await } } #[cfg(test)] mod test { use super::*; use crate::base_http_client::BaseHTTPClient; use httpmock::prelude::*; use httpmock::Method::PATCH; use std::error::Error; use tokio::test; // without this, "error: async functions cannot be used for tests" async fn localization_store( mock_server_url: String, ) -> Result<LocalizationStore, ServiceError> { let mut bhc = BaseHTTPClient::default(); bhc.base_url = mock_server_url; let client = LocalizationHTTPClient::new(bhc)?; LocalizationStore::new_with_client(client) } #[test] async fn test_getting_l10n() -> Result<(), Box<dyn Error>> { let server = MockServer::start(); let l10n_mock = server.mock(|when, then| { when.method(GET).path("/api/l10n/config"); then.status(200) .header("content-type", "application/json") .body( r#"{ "locales": ["fr_FR.UTF-8"], "keymap": "fr(dvorak)", "timezone": "Europe/Paris" }"#, ); }); let url = server.url("/api"); let store = localization_store(url).await?; let settings = store.load().await?; let expected = LocalizationSettings { language: Some("fr_FR.UTF-8".to_owned()), keyboard: Some("fr(dvorak)".to_owned()), timezone: Some("Europe/Paris".to_owned()), }; // main assertion assert_eq!(settings, expected); // Ensure the specified mock was called exactly one time (or fail with a detailed error description). l10n_mock.assert(); Ok(()) } #[test] async fn test_setting_l10n() -> Result<(), Box<dyn Error>> { let server = MockServer::start(); let l10n_mock = server.mock(|when, then| { when.method(PATCH) .path("/api/l10n/config") .header("content-type", "application/json") .body( r#"{"locales":["fr_FR.UTF-8"],"keymap":"fr(dvorak)","timezone":"Europe/Paris","uiLocale":null,"uiKeymap":null}"# ); then.status(204); }); let url = server.url("/api"); let store = localization_store(url).await?; let settings = LocalizationSettings { language: Some("fr_FR.UTF-8".to_owned()), keyboard: Some("fr(dvorak)".to_owned()), timezone: Some("Europe/Paris".to_owned()), }; let result = store.store(&settings).await; // main assertion result?; // Ensure the specified mock was called exactly one time (or fail with a detailed error description). l10n_mock.assert(); Ok(()) } } 07070100000039000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001C00000000agama/agama-lib/src/manager0707010000003A000081A4000000000000000000000001671F5A6400001221000000000000000000000000000000000000001F00000000agama/agama-lib/src/manager.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module implements the web API for the manager module. pub mod http_client; pub use http_client::ManagerHTTPClient; use crate::error::ServiceError; use crate::proxies::ServiceStatusProxy; use crate::{ progress::Progress, proxies::{Manager1Proxy, ProgressProxy}, }; use serde_repr::Serialize_repr; use tokio_stream::StreamExt; use zbus::Connection; /// D-Bus client for the manager service #[derive(Clone)] pub struct ManagerClient<'a> { manager_proxy: Manager1Proxy<'a>, progress_proxy: ProgressProxy<'a>, status_proxy: ServiceStatusProxy<'a>, } /// Represents the installation phase. /// NOTE: does this conversion have any value? #[derive(Clone, Copy, Debug, PartialEq, Serialize_repr, utoipa::ToSchema)] #[repr(u32)] pub enum InstallationPhase { /// Start up phase. Startup, /// Configuration phase. Config, /// Installation phase. Install, } impl TryFrom<u32> for InstallationPhase { type Error = ServiceError; fn try_from(value: u32) -> Result<Self, Self::Error> { match value { 0 => Ok(Self::Startup), 1 => Ok(Self::Config), 2 => Ok(Self::Install), _ => Err(ServiceError::UnknownInstallationPhase(value)), } } } impl<'a> ManagerClient<'a> { pub async fn new(connection: Connection) -> zbus::Result<ManagerClient<'a>> { Ok(Self { manager_proxy: Manager1Proxy::new(&connection).await?, progress_proxy: ProgressProxy::new(&connection).await?, status_proxy: ServiceStatusProxy::new(&connection).await?, }) } /// Returns the list of busy services. pub async fn busy_services(&self) -> Result<Vec<String>, ServiceError> { Ok(self.manager_proxy.busy_services().await?) } /// Returns the current installation phase. pub async fn current_installation_phase(&self) -> Result<InstallationPhase, ServiceError> { let phase = self.manager_proxy.current_installation_phase().await?; phase.try_into() } /// Starts the probing process. pub async fn probe(&self) -> Result<(), ServiceError> { self.wait().await?; Ok(self.manager_proxy.probe().await?) } /// Starts the installation. pub async fn install(&self) -> Result<(), ServiceError> { Ok(self.manager_proxy.commit().await?) } /// Executes the after installation tasks. pub async fn finish(&self) -> Result<(), ServiceError> { Ok(self.manager_proxy.finish().await?) } /// Determines whether it is possible to start the installation. pub async fn can_install(&self) -> Result<bool, ServiceError> { Ok(self.manager_proxy.can_install().await?) } /// Determines whether the installer is running on Iguana. pub async fn use_iguana(&self) -> Result<bool, ServiceError> { Ok(self.manager_proxy.iguana_backend().await?) } /// Returns the current progress. pub async fn progress(&self) -> zbus::Result<Progress> { Progress::from_proxy(&self.progress_proxy).await } /// Returns whether the service is busy or not /// /// TODO: move this code to a trait with functions related to the service status. pub async fn is_busy(&self) -> bool { if let Ok(status) = self.status_proxy.current().await { return status != 0; } true } /// Waits until the manager is idle. pub async fn wait(&self) -> Result<(), ServiceError> { let mut stream = self.status_proxy.receive_current_changed().await; if !self.is_busy().await { return Ok(()); } while let Some(change) = stream.next().await { if change.get().await? == 0 { return Ok(()); } } Ok(()) } } 0707010000003B000081A4000000000000000000000001671F5A640000052A000000000000000000000000000000000000002B00000000agama/agama-lib/src/manager/http_client.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; pub struct ManagerHTTPClient { client: BaseHTTPClient, } impl ManagerHTTPClient { pub fn new(base: BaseHTTPClient) -> Self { Self { client: base } } pub async fn probe(&self) -> Result<(), ServiceError> { // BaseHTTPClient did not anticipate POST without request body // so we pass () which is rendered as `null` self.client.post_void("/manager/probe_sync", &()).await } } 0707010000003C000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001C00000000agama/agama-lib/src/network0707010000003D000081A4000000000000000000000001671F5A6400000419000000000000000000000000000000000000001F00000000agama/agama-lib/src/network.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements support for handling the network settings mod client; mod proxies; pub mod settings; mod store; pub mod types; pub use client::NetworkClient; pub use settings::NetworkSettings; pub use store::NetworkStore; 0707010000003E000081A4000000000000000000000001671F5A6400000BFA000000000000000000000000000000000000002600000000agama/agama-lib/src/network/client.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use super::{settings::NetworkConnection, types::Device}; use crate::base_http_client::BaseHTTPClient; use crate::error::ServiceError; /// HTTP/JSON client for the network service pub struct NetworkClient { pub client: BaseHTTPClient, } impl NetworkClient { pub async fn new(client: BaseHTTPClient) -> Result<NetworkClient, ServiceError> { Ok(Self { client }) } /// Returns an array of network devices pub async fn devices(&self) -> Result<Vec<Device>, ServiceError> { let json = self.client.get::<Vec<Device>>("/network/devices").await?; Ok(json) } /// Returns an array of network connections pub async fn connections(&self) -> Result<Vec<NetworkConnection>, ServiceError> { let json = self .client .get::<Vec<NetworkConnection>>("/network/connections") .await?; Ok(json) } /// Returns an array of network connections pub async fn connection(&self, id: &str) -> Result<NetworkConnection, ServiceError> { let json = self .client .get::<NetworkConnection>(format!("/network/connections/{id}").as_str()) .await?; Ok(json) } /// Returns an array of network connections pub async fn add_or_update_connection( &self, connection: NetworkConnection, ) -> Result<(), ServiceError> { let id = connection.id.clone(); let response = self.connection(id.as_str()).await; if response.is_ok() { let path = format!("/network/connections/{id}"); self.client.put_void(&path.as_str(), &connection).await? } else { self.client .post_void(format!("/network/connections").as_str(), &connection) .await? } Ok(()) } /// Returns an array of network connections pub async fn apply(&self) -> Result<(), ServiceError> { // trying to be tricky here. If something breaks then we need a put method on // BaseHTTPClient which doesn't require a serialiable object for the body self.client .post_void(&format!("/network/system/apply").as_str(), &()) .await?; Ok(()) } } 0707010000003F000081A4000000000000000000000001671F5A640000202C000000000000000000000000000000000000002700000000agama/agama-lib/src/network/proxies.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! D-Bus interface proxies for: `org.opensuse.Agama*.**.*` //! //! This code was generated by `zbus-xmlgen` `3.1.0` from DBus introspection data.`. use zbus::dbus_proxy; #[dbus_proxy( interface = "org.opensuse.Agama1.Network.Devices", default_service = "org.opensuse.Agama1", default_path = "/org/opensuse/Agama1/Network/devices" )] trait Devices { /// GetDevices method fn get_devices(&self) -> zbus::Result<Vec<zbus::zvariant::OwnedObjectPath>>; } #[dbus_proxy( interface = "org.opensuse.Agama1.Network.Device", default_service = "org.opensuse.Agama1", default_path = "/org/opensuse/Agama1/Network/devices/1" )] trait Device { /// Name property #[dbus_proxy(property)] fn name(&self) -> zbus::Result<String>; /// Type property #[dbus_proxy(property)] fn type_(&self) -> zbus::Result<u8>; /// State property #[dbus_proxy(property)] fn state(&self) -> zbus::Result<u8>; } #[dbus_proxy( interface = "org.opensuse.Agama1.Network.Connections", default_service = "org.opensuse.Agama1", default_path = "/org/opensuse/Agama1/Network/connections" )] trait Connections { /// AddConnection method fn add_connection(&self, id: &str, ty: u8) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// Apply method fn apply(&self) -> zbus::Result<()>; /// GetConnection method fn get_connection(&self, uuid: &str) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// GetConnectionById method fn get_connection_by_id(&self, id: &str) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// GetConnections method fn get_connections(&self) -> zbus::Result<Vec<zbus::zvariant::OwnedObjectPath>>; /// RemoveConnection method fn remove_connection(&self, uuid: &str) -> zbus::Result<()>; /// ConnectionAdded signal #[dbus_proxy(signal)] fn connection_added(&self, id: &str, path: zbus::zvariant::ObjectPath<'_>) -> zbus::Result<()>; } #[dbus_proxy( interface = "org.opensuse.Agama1.Network.Connection.Wireless", default_service = "org.opensuse.Agama1", default_path = "/org/opensuse/Agama1/Network" )] trait Wireless { /// Returns the operating mode of the Wireless device /// /// Possible values are 'unknown', 'adhoc', 'infrastructure', 'ap' or 'mesh' #[dbus_proxy(property)] fn mode(&self) -> zbus::Result<String>; #[dbus_proxy(property)] fn set_mode(&self, value: &str) -> zbus::Result<()>; /// Password property #[dbus_proxy(property)] fn password(&self) -> zbus::Result<String>; #[dbus_proxy(property)] fn set_password(&self, value: &str) -> zbus::Result<()>; /// SSID property #[dbus_proxy(property, name = "SSID")] fn ssid(&self) -> zbus::Result<Vec<u8>>; #[dbus_proxy(property, name = "SSID")] fn set_ssid(&self, value: &[u8]) -> zbus::Result<()>; /// Wireless Security property /// /// Possible values are 'none', 'owe', 'ieee8021x', 'wpa-psk', 'sae', /// 'wpa-eap', 'wpa-eap-suite-b-192' #[dbus_proxy(property)] fn security(&self) -> zbus::Result<String>; #[dbus_proxy(property)] fn set_security(&self, value: &str) -> zbus::Result<()>; } #[dbus_proxy( interface = "org.opensuse.Agama1.Network.Connection", default_service = "org.opensuse.Agama1", default_path = "/org/opensuse/Agama1/Network" )] trait Connection { /// Id property #[dbus_proxy(property)] fn id(&self) -> zbus::Result<String>; #[dbus_proxy(property)] fn interface(&self) -> zbus::Result<String>; #[dbus_proxy(property)] fn set_interface(&self, interface: &str) -> zbus::Result<()>; #[dbus_proxy(property)] fn mac_address(&self) -> zbus::Result<String>; #[dbus_proxy(property)] fn set_mac_address(&self, mac_address: &str) -> zbus::Result<()>; #[dbus_proxy(property)] fn mtu(&self) -> zbus::Result<u32>; #[dbus_proxy(property)] fn set_mtu(&self, mtu: u32) -> zbus::Result<()>; } #[dbus_proxy( interface = "org.opensuse.Agama1.Network.Connection.IP", default_service = "org.opensuse.Agama1", default_path = "/org/opensuse/Agama1/Network/connections/0" )] trait IP { /// Addresses property #[dbus_proxy(property)] fn addresses(&self) -> zbus::Result<Vec<String>>; #[dbus_proxy(property)] fn set_addresses(&self, value: &[&str]) -> zbus::Result<()>; /// Gateway4 property #[dbus_proxy(property)] fn gateway4(&self) -> zbus::Result<String>; #[dbus_proxy(property)] fn set_gateway4(&self, value: &str) -> zbus::Result<()>; /// Gateway6 property #[dbus_proxy(property)] fn gateway6(&self) -> zbus::Result<String>; #[dbus_proxy(property)] fn set_gateway6(&self, value: &str) -> zbus::Result<()>; /// Method4 property #[dbus_proxy(property)] fn method4(&self) -> zbus::Result<String>; #[dbus_proxy(property)] fn set_method4(&self, value: &str) -> zbus::Result<()>; /// Method6 property #[dbus_proxy(property)] fn method6(&self) -> zbus::Result<String>; #[dbus_proxy(property)] fn set_method6(&self, value: &str) -> zbus::Result<()>; /// Nameservers property #[dbus_proxy(property)] fn nameservers(&self) -> zbus::Result<Vec<String>>; #[dbus_proxy(property)] fn set_nameservers(&self, value: &[&str]) -> zbus::Result<()>; /// DNS searchlist property #[dbus_proxy(property)] fn dns_searchlist(&self) -> zbus::Result<Vec<String>>; #[dbus_proxy(property)] fn set_dns_searchlist(&self, value: &[&str]) -> zbus::Result<()>; /// Ignore auto DNS property #[dbus_proxy(property)] fn ignore_auto_dns(&self) -> zbus::Result<bool>; #[dbus_proxy(property)] fn set_ignore_auto_dns(&self, value: bool) -> zbus::Result<()>; } #[dbus_proxy( interface = "org.opensuse.Agama1.Network.Connection.Match", default_service = "org.opensuse.Agama1", default_path = "/org/opensuse/Agama1/Network" )] trait Match { /// Driver property #[dbus_proxy(property)] fn driver(&self) -> zbus::Result<Vec<String>>; fn set_driver(&self, value: &[&str]) -> zbus::Result<()>; /// Interface property #[dbus_proxy(property)] fn interface(&self) -> zbus::Result<Vec<String>>; fn set_interface(&self, value: &[&str]) -> zbus::Result<()>; /// Path property #[dbus_proxy(property)] fn path(&self) -> zbus::Result<Vec<String>>; #[dbus_proxy(property)] fn set_path(&self, value: &[&str]) -> zbus::Result<()>; /// Path property #[dbus_proxy(property)] fn kernel(&self) -> zbus::Result<Vec<String>>; fn set_kernel(&self, value: &[&str]) -> zbus::Result<()>; } #[dbus_proxy( interface = "org.opensuse.Agama1.Network.Connection.Bond", default_service = "org.opensuse.Agama1", default_path = "/org/opensuse/Agama1/Network" )] trait Bond { /// Mode property #[dbus_proxy(property)] fn mode(&self) -> zbus::Result<String>; #[dbus_proxy(property)] fn set_mode(&self, value: &str) -> zbus::Result<()>; /// Ports property #[dbus_proxy(property)] fn options(&self) -> zbus::Result<String>; #[dbus_proxy(property)] fn set_options(&self, value: &str) -> zbus::Result<()>; /// Ports property #[dbus_proxy(property)] fn ports(&self) -> zbus::Result<Vec<String>>; #[dbus_proxy(property)] fn set_ports(&self, value: &[&str]) -> zbus::Result<()>; } 07070100000040000081A4000000000000000000000001671F5A64000021C9000000000000000000000000000000000000002800000000agama/agama-lib/src/network/settings.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Representation of the network settings use super::types::{DeviceState, DeviceType, Status}; use cidr::IpInet; use serde::{Deserialize, Serialize}; use std::default::Default; use std::net::IpAddr; /// Network settings for installation #[derive(Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct NetworkSettings { /// Connections to use in the installation pub connections: Vec<NetworkConnection>, } #[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct MatchSettings { #[serde(skip_serializing_if = "Vec::is_empty", default)] pub driver: Vec<String>, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub path: Vec<String>, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub kernel: Vec<String>, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub interface: Vec<String>, } impl MatchSettings { pub fn is_empty(&self) -> bool { self.path.is_empty() && self.driver.is_empty() && self.kernel.is_empty() && self.interface.is_empty() } } /// Wireless configuration #[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct WirelessSettings { /// Password of the wireless network #[serde(skip_serializing_if = "Option::is_none")] pub password: Option<String>, /// Security method/key management pub security: String, /// SSID of the wireless network pub ssid: String, /// Wireless network mode pub mode: String, /// Frequency band of the wireless network #[serde(skip_serializing_if = "Option::is_none")] pub band: Option<String>, /// Wireless channel of the wireless network #[serde(skip_serializing_if = "is_zero", default)] pub channel: u32, /// Only allow connection to this mac address #[serde(skip_serializing_if = "Option::is_none")] pub bssid: Option<String>, /// Indicates that the wireless network is not broadcasting its SSID #[serde(skip_serializing_if = "std::ops::Not::not", default)] pub hidden: bool, /// A list of group/broadcast encryption algorithms #[serde(skip_serializing_if = "Vec::is_empty", default)] pub group_algorithms: Vec<String>, /// A list of pairwise encryption algorithms #[serde(skip_serializing_if = "Vec::is_empty", default)] pub pairwise_algorithms: Vec<String>, /// A list of allowed WPA protocol versions #[serde(skip_serializing_if = "Vec::is_empty", default)] pub wpa_protocol_versions: Vec<String>, /// Indicates whether Protected Management Frames must be enabled for the connection #[serde(skip_serializing_if = "is_zero", default)] pub pmf: i32, } #[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct BondSettings { pub mode: String, #[serde(skip_serializing_if = "Option::is_none")] pub options: Option<String>, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub ports: Vec<String>, } impl Default for BondSettings { fn default() -> Self { Self { mode: "balance-rr".to_string(), options: None, ports: vec![], } } } /// IEEE 802.1x (EAP) settings #[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct IEEE8021XSettings { /// List of EAP methods used #[serde(skip_serializing_if = "Vec::is_empty")] pub eap: Vec<String>, /// Phase 2 inner auth method #[serde(skip_serializing_if = "Option::is_none")] pub phase2_auth: Option<String>, /// Identity string, often for example the user's login name #[serde(skip_serializing_if = "Option::is_none")] pub identity: Option<String>, /// Password string used for EAP authentication #[serde(skip_serializing_if = "Option::is_none")] pub password: Option<String>, /// Path to CA certificate #[serde(skip_serializing_if = "Option::is_none")] pub ca_cert: Option<String>, /// Password string for CA certificate if it is encrypted #[serde(skip_serializing_if = "Option::is_none")] pub ca_cert_password: Option<String>, /// Path to client certificate #[serde(skip_serializing_if = "Option::is_none")] pub client_cert: Option<String>, /// Password string for client certificate if it is encrypted #[serde(skip_serializing_if = "Option::is_none")] pub client_cert_password: Option<String>, /// Path to private key #[serde(skip_serializing_if = "Option::is_none")] pub private_key: Option<String>, /// Password string for private key if it is encrypted #[serde(skip_serializing_if = "Option::is_none")] pub private_key_password: Option<String>, /// Anonymous identity string for EAP authentication methods #[serde(skip_serializing_if = "Option::is_none")] pub anonymous_identity: Option<String>, /// Which PEAP version is used when PEAP is set as the EAP method in the 'eap' property #[serde(skip_serializing_if = "Option::is_none")] pub peap_version: Option<String>, /// Force the use of the new PEAP label during key derivation #[serde(skip_serializing_if = "std::ops::Not::not", default)] pub peap_label: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct NetworkDevice { pub id: String, pub type_: DeviceType, pub state: DeviceState, } #[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct NetworkConnection { pub id: String, #[serde(skip_serializing_if = "Option::is_none")] pub method4: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub gateway4: Option<IpAddr>, #[serde(skip_serializing_if = "Option::is_none")] pub method6: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub gateway6: Option<IpAddr>, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub addresses: Vec<IpInet>, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub nameservers: Vec<IpAddr>, #[serde(skip_serializing_if = "Vec::is_empty", default)] pub dns_searchlist: Vec<String>, #[serde(skip_serializing_if = "Option::is_none")] pub ignore_auto_dns: Option<bool>, #[serde(skip_serializing_if = "Option::is_none")] pub wireless: Option<WirelessSettings>, #[serde(skip_serializing_if = "Option::is_none")] pub interface: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub match_settings: Option<MatchSettings>, #[serde(skip_serializing_if = "Option::is_none")] pub parent: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub bond: Option<BondSettings>, #[serde(rename = "mac-address", skip_serializing_if = "Option::is_none")] pub mac_address: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub status: Option<Status>, #[serde(skip_serializing_if = "is_zero", default)] pub mtu: u32, #[serde(rename = "ieee-8021x", skip_serializing_if = "Option::is_none")] pub ieee_8021x: Option<IEEE8021XSettings>, } fn is_zero<T: PartialEq + From<u16>>(u: &T) -> bool { *u == T::from(0) } impl NetworkConnection { /// Device type expected for the network connection. /// /// Which device type to use is inferred from the included settings. For instance, if it has /// wireless settings, it should be applied to a wireless device. pub fn device_type(&self) -> DeviceType { if self.wireless.is_some() { DeviceType::Wireless } else if self.bond.is_some() { DeviceType::Bond } else { DeviceType::Ethernet } } } 07070100000041000081A4000000000000000000000001671F5A640000133D000000000000000000000000000000000000002500000000agama/agama-lib/src/network/store.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use super::settings::NetworkConnection; use crate::base_http_client::BaseHTTPClient; use crate::error::ServiceError; use crate::network::{NetworkClient, NetworkSettings}; /// Loads and stores the network settings from/to the D-Bus service. pub struct NetworkStore { network_client: NetworkClient, } impl NetworkStore { pub async fn new(client: BaseHTTPClient) -> Result<NetworkStore, ServiceError> { Ok(Self { network_client: NetworkClient::new(client).await?, }) } // TODO: read the settings from the service pub async fn load(&self) -> Result<NetworkSettings, ServiceError> { let connections = self.network_client.connections().await?; Ok(NetworkSettings { connections }) } pub async fn store(&self, settings: &NetworkSettings) -> Result<(), ServiceError> { for id in ordered_connections(&settings.connections) { let id = id.as_str(); let fallback = default_connection(id); let conn = find_connection(id, &settings.connections).unwrap_or(&fallback); self.network_client .add_or_update_connection(conn.clone()) .await?; } self.network_client.apply().await?; Ok(()) } } /// Returns the list of connections in the order they should be written to the D-Bus service. /// /// * `conns`: connections to write. fn ordered_connections(conns: &Vec<NetworkConnection>) -> Vec<String> { let mut ordered: Vec<String> = Vec::with_capacity(conns.len()); for conn in conns { add_ordered_connection(conn, conns, &mut ordered); } ordered } /// Adds a connections and its dependencies to the list. /// /// * `conn`: connection to add. /// * `conns`: existing connections. /// * `ordered`: ordered list of connections. fn add_ordered_connection( conn: &NetworkConnection, conns: &Vec<NetworkConnection>, ordered: &mut Vec<String>, ) { if let Some(bond) = &conn.bond { for port in &bond.ports { if let Some(conn) = find_connection(port, conns) { add_ordered_connection(conn, conns, ordered); } else if !ordered.contains(&conn.id) { ordered.push(port.clone()); } } } if !ordered.contains(&conn.id) { ordered.push(conn.id.to_owned()) } } /// Finds a connection by id in the list. /// /// * `id`: connection ID. fn find_connection<'a>(id: &str, conns: &'a [NetworkConnection]) -> Option<&'a NetworkConnection> { conns .iter() .find(|c| c.id == id || c.interface == Some(id.to_string())) } fn default_connection(id: &str) -> NetworkConnection { NetworkConnection { id: id.to_string(), interface: Some(id.to_string()), ..Default::default() } } #[cfg(test)] mod tests { use super::ordered_connections; use crate::network::settings::{BondSettings, NetworkConnection}; #[test] fn test_ordered_connections() { let bond = NetworkConnection { id: "bond0".to_string(), bond: Some(BondSettings { ports: vec!["eth0".to_string(), "eth1".to_string(), "eth3".to_string()], ..Default::default() }), ..Default::default() }; let eth0 = NetworkConnection { id: "eth0".to_string(), ..Default::default() }; let eth1 = NetworkConnection { id: "Wired connection".to_string(), interface: Some("eth1".to_string()), ..Default::default() }; let eth2 = NetworkConnection { id: "eth2".to_string(), ..Default::default() }; let conns = vec![bond, eth0, eth1, eth2]; let ordered = ordered_connections(&conns); assert_eq!( ordered, vec![ "eth0".to_string(), "Wired connection".to_string(), "eth3".to_string(), "bond0".to_string(), "eth2".to_string() ] ) } } 07070100000042000081A4000000000000000000000001671F5A6400002532000000000000000000000000000000000000002500000000agama/agama-lib/src/network/types.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use cidr::errors::NetworkParseError; use serde::{Deserialize, Serialize}; use std::{ fmt, str::{self, FromStr}, }; use thiserror::Error; use zbus; /// Network device #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub struct Device { pub name: String, pub type_: DeviceType, pub state: DeviceState, } #[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct SSID(pub Vec<u8>); impl SSID { pub fn to_vec(&self) -> &Vec<u8> { &self.0 } } impl fmt::Display for SSID { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", str::from_utf8(&self.0).unwrap()) } } impl FromStr for SSID { type Err = NetworkParseError; fn from_str(s: &str) -> Result<Self, Self::Err> { Ok(SSID(s.as_bytes().into())) } } impl From<SSID> for Vec<u8> { fn from(value: SSID) -> Self { value.0 } } #[derive(Default, Debug, PartialEq, Copy, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub enum DeviceType { Loopback = 0, #[default] Ethernet = 1, Wireless = 2, Dummy = 3, Bond = 4, Vlan = 5, Bridge = 6, } // For now this mirrors NetworkManager, because it was less mental work than coming up with // what exactly Agama needs. Expected to be adapted. #[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub enum DeviceState { #[default] Unknown = 0, Unmanaged = 10, Unavailable = 20, Disconnected = 30, Prepare = 40, Config = 50, NeedAuth = 60, IpConfig = 70, IpCheck = 80, Secondaries = 90, Activated = 100, Deactivating = 110, Failed = 120, } #[derive(Debug, Error, PartialEq)] #[error("Invalid state: {0}")] pub struct InvalidDeviceState(String); impl TryFrom<u8> for DeviceState { type Error = InvalidDeviceState; fn try_from(value: u8) -> Result<Self, Self::Error> { match value { 0 => Ok(DeviceState::Unknown), 10 => Ok(DeviceState::Unmanaged), 20 => Ok(DeviceState::Unavailable), 30 => Ok(DeviceState::Disconnected), 40 => Ok(DeviceState::Prepare), 50 => Ok(DeviceState::Config), 60 => Ok(DeviceState::NeedAuth), 70 => Ok(DeviceState::IpConfig), 80 => Ok(DeviceState::IpCheck), 90 => Ok(DeviceState::Secondaries), 100 => Ok(DeviceState::Activated), 110 => Ok(DeviceState::Deactivating), 120 => Ok(DeviceState::Failed), _ => Err(InvalidDeviceState(value.to_string())), } } } impl fmt::Display for DeviceState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let name = match &self { DeviceState::Unknown => "unknown", DeviceState::Unmanaged => "unmanaged", DeviceState::Unavailable => "unavailable", DeviceState::Disconnected => "disconnected", DeviceState::Prepare => "prepare", DeviceState::Config => "config", DeviceState::NeedAuth => "need_auth", DeviceState::IpConfig => "ip_config", DeviceState::IpCheck => "ip_check", DeviceState::Secondaries => "secondaries", DeviceState::Activated => "activated", DeviceState::Deactivating => "deactivating", DeviceState::Failed => "failed", }; write!(f, "{}", name) } } #[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub enum Status { #[default] Up, Down, Removed, } impl fmt::Display for Status { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let name = match &self { Status::Up => "up", Status::Down => "down", Status::Removed => "removed", }; write!(f, "{}", name) } } #[derive(Debug, Error, PartialEq)] #[error("Invalid status: {0}")] pub struct InvalidStatus(String); impl TryFrom<&str> for Status { type Error = InvalidStatus; fn try_from(value: &str) -> Result<Self, Self::Error> { match value { "up" => Ok(Status::Up), "down" => Ok(Status::Down), "removed" => Ok(Status::Removed), _ => Err(InvalidStatus(value.to_string())), } } } /// Bond mode #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, utoipa::ToSchema)] pub enum BondMode { #[serde(rename = "balance-rr")] RoundRobin = 0, #[serde(rename = "active-backup")] ActiveBackup = 1, #[serde(rename = "balance-xor")] BalanceXOR = 2, #[serde(rename = "broadcast")] Broadcast = 3, #[serde(rename = "802.3ad")] LACP = 4, #[serde(rename = "balance-tlb")] BalanceTLB = 5, #[serde(rename = "balance-alb")] BalanceALB = 6, } impl Default for BondMode { fn default() -> Self { Self::RoundRobin } } impl std::fmt::Display for BondMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{}", match self { BondMode::RoundRobin => "balance-rr", BondMode::ActiveBackup => "active-backup", BondMode::BalanceXOR => "balance-xor", BondMode::Broadcast => "broadcast", BondMode::LACP => "802.3ad", BondMode::BalanceTLB => "balance-tlb", BondMode::BalanceALB => "balance-alb", } ) } } #[derive(Debug, Error, PartialEq)] #[error("Invalid bond mode: {0}")] pub struct InvalidBondMode(String); impl TryFrom<&str> for BondMode { type Error = InvalidBondMode; fn try_from(value: &str) -> Result<Self, Self::Error> { match value { "balance-rr" => Ok(BondMode::RoundRobin), "active-backup" => Ok(BondMode::ActiveBackup), "balance-xor" => Ok(BondMode::BalanceXOR), "broadcast" => Ok(BondMode::Broadcast), "802.3ad" => Ok(BondMode::LACP), "balance-tlb" => Ok(BondMode::BalanceTLB), "balance-alb" => Ok(BondMode::BalanceALB), _ => Err(InvalidBondMode(value.to_string())), } } } impl TryFrom<u8> for BondMode { type Error = InvalidBondMode; fn try_from(value: u8) -> Result<Self, Self::Error> { match value { 0 => Ok(BondMode::RoundRobin), 1 => Ok(BondMode::ActiveBackup), 2 => Ok(BondMode::BalanceXOR), 3 => Ok(BondMode::Broadcast), 4 => Ok(BondMode::LACP), 5 => Ok(BondMode::BalanceTLB), 6 => Ok(BondMode::BalanceALB), _ => Err(InvalidBondMode(value.to_string())), } } } impl From<InvalidBondMode> for zbus::fdo::Error { fn from(value: InvalidBondMode) -> zbus::fdo::Error { zbus::fdo::Error::Failed(format!("Network error: {value}")) } } #[derive(Debug, Error, PartialEq)] #[error("Invalid device type: {0}")] pub struct InvalidDeviceType(u8); impl TryFrom<u8> for DeviceType { type Error = InvalidDeviceType; fn try_from(value: u8) -> Result<Self, Self::Error> { match value { 0 => Ok(DeviceType::Loopback), 1 => Ok(DeviceType::Ethernet), 2 => Ok(DeviceType::Wireless), 3 => Ok(DeviceType::Dummy), 4 => Ok(DeviceType::Bond), 5 => Ok(DeviceType::Vlan), 6 => Ok(DeviceType::Bridge), _ => Err(InvalidDeviceType(value)), } } } impl From<InvalidDeviceType> for zbus::fdo::Error { fn from(value: InvalidDeviceType) -> zbus::fdo::Error { zbus::fdo::Error::Failed(format!("Network error: {value}")) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_display_ssid() { let ssid = SSID(vec![97, 103, 97, 109, 97]); assert_eq!(format!("{}", ssid), "agama"); } #[test] fn test_ssid_to_vec() { let vec = vec![97, 103, 97, 109, 97]; let ssid = SSID(vec.clone()); assert_eq!(ssid.to_vec(), &vec); } #[test] fn test_device_type_from_u8() { let dtype = DeviceType::try_from(0); assert_eq!(dtype, Ok(DeviceType::Loopback)); let dtype = DeviceType::try_from(128); assert_eq!(dtype, Err(InvalidDeviceType(128))); } #[test] fn test_display_bond_mode() { let mode = BondMode::try_from(1).unwrap(); assert_eq!(format!("{}", mode), "active-backup"); } } 07070100000043000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001C00000000agama/agama-lib/src/product07070100000044000081A4000000000000000000000001671F5A6400000487000000000000000000000000000000000000001F00000000agama/agama-lib/src/product.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements support for handling the product settings mod client; mod http_client; pub mod proxies; mod settings; mod store; pub use crate::software::model::RegistrationRequirement; pub use client::{Product, ProductClient}; pub use http_client::ProductHTTPClient; pub use settings::ProductSettings; pub use store::ProductStore; 07070100000045000081A4000000000000000000000001671F5A640000138D000000000000000000000000000000000000002600000000agama/agama-lib/src/product/client.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use std::collections::HashMap; use crate::error::ServiceError; use crate::software::model::RegistrationRequirement; use crate::software::proxies::SoftwareProductProxy; use serde::Serialize; use zbus::Connection; use super::proxies::RegistrationProxy; /// Represents a software product #[derive(Default, Debug, Serialize, utoipa::ToSchema)] pub struct Product { /// Product ID (eg., "ALP", "Tumbleweed", etc.) pub id: String, /// Product name (e.g., "openSUSE Tumbleweed") pub name: String, /// Product description pub description: String, /// Product icon (e.g., "default.svg") pub icon: String, } /// D-Bus client for the software service #[derive(Clone)] pub struct ProductClient<'a> { product_proxy: SoftwareProductProxy<'a>, registration_proxy: RegistrationProxy<'a>, } impl<'a> ProductClient<'a> { pub async fn new(connection: Connection) -> Result<ProductClient<'a>, ServiceError> { Ok(Self { product_proxy: SoftwareProductProxy::new(&connection).await?, registration_proxy: RegistrationProxy::new(&connection).await?, }) } /// Returns the available products pub async fn products(&self) -> Result<Vec<Product>, ServiceError> { let products: Vec<Product> = self .product_proxy .available_products() .await? .into_iter() .map(|(id, name, data)| { let description = match data.get("description") { Some(value) => value.try_into().unwrap(), None => "", }; let icon = match data.get("icon") { Some(value) => value.try_into().unwrap(), None => "default.svg", }; Product { id, name, description: description.to_string(), icon: icon.to_string(), } }) .collect(); Ok(products) } /// Returns the id of the selected product to install pub async fn product(&self) -> Result<String, ServiceError> { Ok(self.product_proxy.selected_product().await?) } /// Selects the product to install pub async fn select_product(&self, product_id: &str) -> Result<(), ServiceError> { let result = self.product_proxy.select_product(product_id).await?; match result { (0, _) => Ok(()), (3, description) => { let products = self.products().await?; let ids: Vec<String> = products.into_iter().map(|p| p.id).collect(); let error = format!("{0}. Available products: '{1:?}'", description, ids); Err(ServiceError::UnsuccessfulAction(error)) } (_, description) => Err(ServiceError::UnsuccessfulAction(description)), } } /// registration code used to register product pub async fn registration_code(&self) -> Result<String, ServiceError> { Ok(self.registration_proxy.reg_code().await?) } /// email used to register product pub async fn email(&self) -> Result<String, ServiceError> { Ok(self.registration_proxy.email().await?) } pub async fn registration_requirement(&self) -> Result<RegistrationRequirement, ServiceError> { let requirement = self.registration_proxy.requirement().await?; // unknown number can happen only if we do programmer mistake let result: RegistrationRequirement = requirement.try_into().unwrap(); Ok(result) } /// register product pub async fn register(&self, code: &str, email: &str) -> Result<(u32, String), ServiceError> { let mut options: HashMap<&str, zbus::zvariant::Value> = HashMap::new(); if !email.is_empty() { options.insert("Email", zbus::zvariant::Value::new(email)); } Ok(self.registration_proxy.register(code, options).await?) } /// de-register product pub async fn deregister(&self) -> Result<(u32, String), ServiceError> { Ok(self.registration_proxy.deregister().await?) } } 07070100000046000081A4000000000000000000000001671F5A6400000A52000000000000000000000000000000000000002B00000000agama/agama-lib/src/product/http_client.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use crate::software::model::RegistrationInfo; use crate::software::model::RegistrationParams; use crate::software::model::SoftwareConfig; use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; pub struct ProductHTTPClient { client: BaseHTTPClient, } impl ProductHTTPClient { pub fn new(base: BaseHTTPClient) -> Self { Self { client: base } } pub async fn get_software(&self) -> Result<SoftwareConfig, ServiceError> { self.client.get("/software/config").await } pub async fn set_software(&self, config: &SoftwareConfig) -> Result<(), ServiceError> { self.client.put_void("/software/config", config).await } /// Returns the id of the selected product to install pub async fn product(&self) -> Result<String, ServiceError> { let config = self.get_software().await?; if let Some(product) = config.product { Ok(product) } else { Ok("".to_owned()) } } /// Selects the product to install pub async fn select_product(&self, product_id: &str) -> Result<(), ServiceError> { let config = SoftwareConfig { product: Some(product_id.to_owned()), patterns: None, }; self.set_software(&config).await } pub async fn get_registration(&self) -> Result<RegistrationInfo, ServiceError> { self.client.get("/software/registration").await } /// register product pub async fn register(&self, key: &str, email: &str) -> Result<(u32, String), ServiceError> { // note RegistrationParams != RegistrationInfo, fun! let params = RegistrationParams { key: key.to_owned(), email: email.to_owned(), }; self.client.post("/software/registration", ¶ms).await } } 07070100000047000081A4000000000000000000000001671F5A6400000700000000000000000000000000000000000000002700000000agama/agama-lib/src/product/proxies.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! # DBus interface proxy for: `org.opensuse.Agama1.Registration` //! //! This code was generated by `zbus-xmlgen` `3.1.1` from DBus introspection data. use zbus::dbus_proxy; #[dbus_proxy( interface = "org.opensuse.Agama1.Registration", default_service = "org.opensuse.Agama.Software1", default_path = "/org/opensuse/Agama/Software1/Product" )] trait Registration { /// Deregister method fn deregister(&self) -> zbus::Result<(u32, String)>; /// Register method fn register( &self, reg_code: &str, options: std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, ) -> zbus::Result<(u32, String)>; /// Email property #[dbus_proxy(property)] fn email(&self) -> zbus::Result<String>; /// RegCode property #[dbus_proxy(property)] fn reg_code(&self) -> zbus::Result<String>; /// Requirement property #[dbus_proxy(property)] fn requirement(&self) -> zbus::Result<u32>; } 07070100000048000081A4000000000000000000000001671F5A64000004EC000000000000000000000000000000000000002800000000agama/agama-lib/src/product/settings.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Representation of the product settings use serde::{Deserialize, Serialize}; /// Software settings for installation #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ProductSettings { /// ID of the product to install (e.g., "ALP", "Tumbleweed", etc.) pub id: Option<String>, pub registration_code: Option<String>, pub registration_email: Option<String>, } 07070100000049000081A4000000000000000000000001671F5A6400001C1F000000000000000000000000000000000000002500000000agama/agama-lib/src/product/store.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements the store for the product settings. use super::{ProductHTTPClient, ProductSettings}; use crate::base_http_client::BaseHTTPClient; use crate::error::ServiceError; use crate::manager::http_client::ManagerHTTPClient; /// Loads and stores the product settings from/to the D-Bus service. pub struct ProductStore { product_client: ProductHTTPClient, manager_client: ManagerHTTPClient, } impl ProductStore { pub fn new(client: BaseHTTPClient) -> Result<ProductStore, ServiceError> { Ok(Self { product_client: ProductHTTPClient::new(client.clone()), manager_client: ManagerHTTPClient::new(client.clone()), }) } fn non_empty_string(s: String) -> Option<String> { if s.is_empty() { None } else { Some(s) } } pub async fn load(&self) -> Result<ProductSettings, ServiceError> { let product = self.product_client.product().await?; let registration_info = self.product_client.get_registration().await?; Ok(ProductSettings { id: Some(product), registration_code: Self::non_empty_string(registration_info.key), registration_email: Self::non_empty_string(registration_info.email), }) } pub async fn store(&self, settings: &ProductSettings) -> Result<(), ServiceError> { let mut probe = false; if let Some(product) = &settings.id { let existing_product = self.product_client.product().await?; if *product != existing_product { // avoid selecting same product and unnecessary probe self.product_client.select_product(product).await?; probe = true; } } if let Some(reg_code) = &settings.registration_code { let (result, message); if let Some(email) = &settings.registration_email { (result, message) = self.product_client.register(reg_code, email).await?; } else { (result, message) = self.product_client.register(reg_code, "").await?; } // FIXME: name the magic numbers. 3 is Registration not required // FIXME: well don't register when not required (no regcode in profile) if result != 0 && result != 3 { return Err(ServiceError::FailedRegistration(message)); } probe = true; } if probe { self.manager_client.probe().await?; } Ok(()) } } #[cfg(test)] mod test { use super::*; use crate::base_http_client::BaseHTTPClient; use httpmock::prelude::*; use std::error::Error; use tokio::test; // without this, "error: async functions cannot be used for tests" fn product_store(mock_server_url: String) -> ProductStore { let mut bhc = BaseHTTPClient::default(); bhc.base_url = mock_server_url; let p_client = ProductHTTPClient::new(bhc.clone()); let m_client = ManagerHTTPClient::new(bhc); ProductStore { product_client: p_client, manager_client: m_client, } } #[test] async fn test_getting_product() -> Result<(), Box<dyn Error>> { let server = MockServer::start(); let software_mock = server.mock(|when, then| { when.method(GET).path("/api/software/config"); then.status(200) .header("content-type", "application/json") .body( r#"{ "patterns": {"xfce":true}, "product": "Tumbleweed" }"#, ); }); let registration_mock = server.mock(|when, then| { when.method(GET).path("/api/software/registration"); then.status(200) .header("content-type", "application/json") .body( r#"{ "key": "", "email": "", "requirement": "NotRequired" }"#, ); }); let url = server.url("/api"); let store = product_store(url); let settings = store.load().await?; let expected = ProductSettings { id: Some("Tumbleweed".to_owned()), registration_code: None, registration_email: None, }; // main assertion assert_eq!(settings, expected); // Ensure the specified mock was called exactly one time (or fail with a detailed error description). software_mock.assert(); registration_mock.assert(); Ok(()) } #[test] async fn test_setting_product_ok() -> Result<(), Box<dyn Error>> { let server = MockServer::start(); // no product selected at first let get_software_mock = server.mock(|when, then| { when.method(GET).path("/api/software/config"); then.status(200) .header("content-type", "application/json") .body( r#"{ "patterns": {}, "product": "" }"#, ); }); let software_mock = server.mock(|when, then| { when.method(PUT) .path("/api/software/config") .header("content-type", "application/json") .body(r#"{"patterns":null,"product":"Tumbleweed"}"#); then.status(200); }); let manager_mock = server.mock(|when, then| { when.method(POST) .path("/api/manager/probe_sync") .header("content-type", "application/json") .body("null"); then.status(200); }); let url = server.url("/api"); let store = product_store(url); let settings = ProductSettings { id: Some("Tumbleweed".to_owned()), registration_code: None, registration_email: None, }; let result = store.store(&settings).await; // main assertion result?; // Ensure the specified mock was called exactly one time (or fail with a detailed error description). get_software_mock.assert(); software_mock.assert(); manager_mock.assert(); Ok(()) } } 0707010000004A000081A4000000000000000000000001671F5A64000019BE000000000000000000000000000000000000001F00000000agama/agama-lib/src/profile.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use crate::error::ProfileError; use anyhow::Context; use jsonschema::JSONSchema; use log::info; use serde_json; use std::{fs, io::Write, path::Path, process::Command}; use tempfile::{tempdir, TempDir}; use url::Url; /// Downloads and converts autoyast profile. pub struct AutoyastProfile { url: Url, } impl AutoyastProfile { pub fn new(url: &Url) -> anyhow::Result<Self> { Ok(Self { url: url.clone() }) } pub fn read_into(&self, mut out_fd: impl Write) -> anyhow::Result<()> { let path = self.url.path(); if path.ends_with(".xml") || path.ends_with(".erb") || path.ends_with('/') { let content = self.read_from_autoyast()?; out_fd.write_all(content.as_bytes())?; Ok(()) } else { let msg = format!("Unsupported AutoYaST format at {}", self.url); Err(anyhow::Error::msg(msg)) } } fn read_from_autoyast(&self) -> anyhow::Result<String> { const TMP_DIR_PREFIX: &str = "autoyast"; const AUTOINST_JSON: &str = "autoinst.json"; let tmp_dir = TempDir::with_prefix(TMP_DIR_PREFIX)?; Command::new("agama-autoyast") .args([self.url.as_str(), &tmp_dir.path().to_string_lossy()]) .status()?; let autoinst_json = tmp_dir.path().join(AUTOINST_JSON); Ok(fs::read_to_string(autoinst_json)?) } } #[derive(Debug)] pub enum ValidationResult { Valid, NotValid(Vec<String>), } /// Checks whether an autoinstallation profile is valid /// /// ``` /// # use agama_lib::profile::{ProfileValidator, ValidationResult}; /// # use std::path::Path; /// let validator = ProfileValidator::new( /// Path::new("share/profile.schema.json") /// ).expect("the default validator"); /// /// // you can validate a &str /// let wrong_profile = r#" /// { "product": { "name": "Tumbleweed" } } /// "#; /// let result = validator.validate_str(&wrong_profile).unwrap(); /// assert!(matches!(ValidationResult::NotValid, result)); /// /// // or a file /// validator.validate_file(Path::new("share/examples/profile.json")); /// assert!(matches!(ValidationResult::Valid, result)); /// ``` pub struct ProfileValidator { schema: JSONSchema, } impl ProfileValidator { pub fn default_schema() -> Result<Self, ProfileError> { let relative_path = Path::new("agama-lib/share/profile.schema.json"); let path = if relative_path.exists() { relative_path } else { Path::new("/usr/share/agama-cli/profile.schema.json") }; info!("Validation with path {:?}", path); Self::new(path) } pub fn new(schema_path: &Path) -> Result<Self, ProfileError> { let contents = fs::read_to_string(schema_path) .context(format!("Failed to read schema at {:?}", schema_path))?; let schema = serde_json::from_str(&contents)?; let schema = JSONSchema::compile(&schema).expect("A valid schema"); Ok(Self { schema }) } pub fn validate_file(&self, profile_path: &Path) -> Result<ValidationResult, ProfileError> { let contents = fs::read_to_string(profile_path)?; self.validate_str(&contents) } pub fn validate_str(&self, profile: &str) -> Result<ValidationResult, ProfileError> { let contents = serde_json::from_str(profile)?; let result = self.schema.validate(&contents); if let Err(errors) = result { let messages: Vec<String> = errors.map(|e| format!("{e}. {e:?}")).collect(); return Ok(ValidationResult::NotValid(messages)); } Ok(ValidationResult::Valid) } } /// Evaluates a profile /// /// Evaluating a profile means injecting the hardware information (coming from D-Bus) /// and running the jsonnet code to generate a plain JSON file. For this struct to /// work, the `/usr/bin/jsonnet` command must be available. pub struct ProfileEvaluator {} impl ProfileEvaluator { pub fn evaluate(&self, profile_path: &Path, mut out_fd: impl Write) -> anyhow::Result<()> { let dir = tempdir()?; let working_path = dir.path().join("profile.jsonnet"); fs::copy(profile_path, working_path)?; let hwinfo_path = dir.path().join("hw.libsonnet"); self.write_hwinfo(&hwinfo_path) .context("Failed to read system's hardware information")?; let result = Command::new("/usr/bin/jsonnet") .arg("profile.jsonnet") .current_dir(&dir) .output() .context("Failed to run jsonnet")?; if !result.status.success() { let message = String::from_utf8(result.stderr).context("Invalid UTF-8 sequence from jsonnet")?; return Err(ProfileError::EvaluationError(message).into()); } out_fd.write_all(&result.stdout)?; Ok(()) } // Write the hardware information in JSON format to a given path and also helpers to help with it // // TODO: we need a better way to generate this information, as lshw and hwinfo are not usable // out of the box. fn write_hwinfo(&self, path: &Path) -> anyhow::Result<()> { let result = Command::new("/usr/sbin/lshw") .args(["-json"]) .output() .context("Failed to run lshw")?; let helpers = fs::read_to_string("agama.libsonnet") .or_else(|_| fs::read_to_string("/usr/share/agama-cli/agama.libsonnet")) .context("Failed to read agama.libsonnet")?; let mut file = fs::File::create(path)?; file.write_all(b"{\n")?; file.write_all(helpers.as_bytes())?; file.write_all(b"\n\"lshw\":\n")?; file.write_all(&result.stdout)?; file.write_all(b"\n}")?; Ok(()) } } 0707010000004B000081A4000000000000000000000001671F5A6400001D3C000000000000000000000000000000000000002000000000agama/agama-lib/src/progress.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module offers a mechanism to report the installation progress in Agama's command-line //! interface. //! //! The library does not prescribe any way to present that information to the user. As shown in the //! example below, you can build your own presenter and implement the [ProgressPresenter] trait. //! //! ```no_run //! # use agama_lib::progress::{Progress, ProgressMonitor, ProgressPresenter}; //! # use async_trait::async_trait; //! # use tokio::{runtime::Handle, task}; //! # use zbus; //! //! // Custom presenter //! struct SimplePresenter {} //! //! impl SimplePresenter { //! fn report_progress(&self, progress: &Progress) { //! println!("{}/{} {}", &progress.current_step, &progress.max_steps, &progress.current_title); //! } //! } //! //! #[async_trait] //! impl ProgressPresenter for SimplePresenter { //! async fn start(&mut self, progress: &Progress) { //! println!("Starting..."); //! self.report_progress(progress); //! } //! //! async fn update_main(&mut self, progress: &Progress) { //! self.report_progress(progress); //! } //! //! async fn update_detail(&mut self, progress: &Progress) { //! self.report_progress(progress); //! } //! //! async fn finish(&mut self) { //! println!("Done"); //! } //! } //! //! async fn run_monitor() { //! let connection = zbus::Connection::system().await.unwrap(); //! let mut monitor = ProgressMonitor::new(connection).await.unwrap(); //! monitor.run(SimplePresenter {}).await; //!} //! ``` use crate::{error::ServiceError, proxies::ProgressProxy}; use async_trait::async_trait; use serde::Serialize; use tokio_stream::{StreamExt, StreamMap}; use zbus::Connection; /// Represents the progress for an Agama service. #[derive(Clone, Default, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct Progress { /// Current step pub current_step: u32, /// Number of steps pub max_steps: u32, /// Title of the current step pub current_title: String, /// Whether the progress reporting is finished pub finished: bool, } impl Progress { pub async fn from_proxy(proxy: &crate::proxies::ProgressProxy<'_>) -> zbus::Result<Progress> { let (current_step, max_steps, finished) = tokio::join!(proxy.current_step(), proxy.total_steps(), proxy.finished()); let (current_step, current_title) = current_step?; Ok(Self { current_step, current_title, max_steps: max_steps?, finished: finished?, }) } pub fn from_cached_proxy(proxy: &crate::proxies::ProgressProxy<'_>) -> Option<Progress> { let (current_step, current_title) = proxy.cached_current_step().ok()??; let max_steps = proxy.cached_total_steps().ok()??; let finished = proxy.cached_finished().ok()??; Some(Progress { current_step, current_title, max_steps, finished, }) } } /// Monitorizes and reports the progress of Agama's current operation. /// /// It implements a main/details reporter by listening to the manager and software services, /// similar to Agama's web UI. How this information is displayed depends on the presenter (see /// [ProgressMonitor.run]). pub struct ProgressMonitor<'a> { manager_proxy: ProgressProxy<'a>, software_proxy: ProgressProxy<'a>, } impl<'a> ProgressMonitor<'a> { pub async fn new(connection: Connection) -> Result<ProgressMonitor<'a>, ServiceError> { let manager_proxy = ProgressProxy::builder(&connection) .path("/org/opensuse/Agama/Manager1")? .destination("org.opensuse.Agama.Manager1")? .build() .await?; let software_proxy = ProgressProxy::builder(&connection) .path("/org/opensuse/Agama/Software1")? .destination("org.opensuse.Agama.Software1")? .build() .await?; Ok(Self { manager_proxy, software_proxy, }) } /// Runs the monitor until the current operation finishes. pub async fn run(&mut self, mut presenter: impl ProgressPresenter) -> Result<(), ServiceError> { presenter.start(&self.main_progress().await?).await; let mut changes = self.build_stream().await; while let Some(stream) = changes.next().await { match stream { ("/org/opensuse/Agama/Manager1", _) => { let progress = self.main_progress().await?; if progress.finished { presenter.finish().await; return Ok(()); } presenter.update_main(&progress).await; } ("/org/opensuse/Agama/Software1", _) => { let progress = &self.detail_progress().await?; presenter.update_detail(progress).await; } _ => eprintln!("Unknown"), }; } Ok(()) } /// Proxy that reports the progress. async fn main_progress(&self) -> Result<Progress, ServiceError> { Ok(Progress::from_proxy(&self.manager_proxy).await?) } /// Proxy that reports the progress detail. async fn detail_progress(&self) -> Result<Progress, ServiceError> { Ok(Progress::from_proxy(&self.software_proxy).await?) } /// Builds an stream of progress changes. /// /// It listens for changes in the `Current` property and generates a stream identifying the /// proxy where the change comes from. async fn build_stream(&self) -> StreamMap<&str, zbus::PropertyStream<'_, (u32, String)>> { let mut streams = StreamMap::new(); let proxies = [&self.manager_proxy, &self.software_proxy]; for proxy in proxies.iter() { let stream = proxy.receive_current_step_changed().await; let path = proxy.path().as_str(); streams.insert(path, stream); } streams } } /// Presents the progress to the user. #[async_trait] pub trait ProgressPresenter { /// Starts the progress reporting. /// /// * `progress`: current main progress. async fn start(&mut self, progress: &Progress); /// Updates the progress. /// /// * `progress`: current progress. async fn update_main(&mut self, progress: &Progress); /// Updates the progress detail. /// /// * `progress`: current progress detail. async fn update_detail(&mut self, progress: &Progress); /// Finishes the progress reporting. async fn finish(&mut self); } 0707010000004C000081A4000000000000000000000001671F5A6400001B9B000000000000000000000000000000000000001F00000000agama/agama-lib/src/proxies.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! D-Bus interface proxies for: `org.opensuse.Agama*.**.*` //! //! This code was generated by `zbus-xmlgen` `3.1.0` from DBus introspection data.`. use zbus::dbus_proxy; /// Progress1Proxy can be used also with Software and Storage object. /// /// TODO: example #[dbus_proxy( interface = "org.opensuse.Agama1.Progress", default_service = "org.opensuse.Agama.Manager1", default_path = "/org/opensuse/Agama/Manager1" )] trait Progress { /// CurrentStep property #[dbus_proxy(property)] fn current_step(&self) -> zbus::Result<(u32, String)>; /// Finished property #[dbus_proxy(property)] fn finished(&self) -> zbus::Result<bool>; /// TotalSteps property #[dbus_proxy(property)] fn total_steps(&self) -> zbus::Result<u32>; /// Steps property #[dbus_proxy(property)] fn steps(&self) -> zbus::Result<Vec<String>>; } #[dbus_proxy( interface = "org.opensuse.Agama1.ServiceStatus", default_service = "org.opensuse.Agama.Manager1", default_path = "/org/opensuse/Agama/Manager1" )] trait ServiceStatus { /// All property #[dbus_proxy(property)] fn all( &self, ) -> zbus::Result<Vec<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>>; /// Current property #[dbus_proxy(property)] fn current(&self) -> zbus::Result<u32>; } #[dbus_proxy( interface = "org.opensuse.Agama.Manager1", default_service = "org.opensuse.Agama.Manager1", default_path = "/org/opensuse/Agama/Manager1" )] trait Manager1 { /// CanInstall method fn can_install(&self) -> zbus::Result<bool>; /// CollectLogs method fn collect_logs(&self) -> zbus::Result<String>; /// Commit method fn commit(&self) -> zbus::Result<()>; /// Finish method fn finish(&self) -> zbus::Result<()>; /// Probe method fn probe(&self) -> zbus::Result<()>; /// BusyServices property #[dbus_proxy(property)] fn busy_services(&self) -> zbus::Result<Vec<String>>; /// CurrentInstallationPhase property #[dbus_proxy(property)] fn current_installation_phase(&self) -> zbus::Result<u32>; /// IguanaBackend property #[dbus_proxy(property)] fn iguana_backend(&self) -> zbus::Result<bool>; /// InstallationPhases property #[dbus_proxy(property)] fn installation_phases( &self, ) -> zbus::Result<Vec<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>>; } #[dbus_proxy( interface = "org.opensuse.Agama1.Questions", default_service = "org.opensuse.Agama1", default_path = "/org/opensuse/Agama1/Questions" )] trait Questions1 { /// AddAnswerFile method fn add_answer_file(&self, path: &str) -> zbus::Result<()>; /// Delete method fn delete(&self, question: &zbus::zvariant::ObjectPath<'_>) -> zbus::Result<()>; /// New method #[dbus_proxy(name = "New")] fn new_question( &self, class: &str, text: &str, options: &[&str], default_option: &str, data: std::collections::HashMap<&str, &str>, ) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// NewWithPassword method fn new_with_password( &self, class: &str, text: &str, options: &[&str], default_option: &str, data: std::collections::HashMap<&str, &str>, ) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// Interactive property #[dbus_proxy(property)] fn interactive(&self) -> zbus::Result<bool>; #[dbus_proxy(property)] fn set_interactive(&self, value: bool) -> zbus::Result<()>; } #[dbus_proxy( interface = "org.opensuse.Agama1.Questions.Generic", default_service = "org.opensuse.Agama1", default_path = "/org/opensuse/Agama1/Questions" )] trait GenericQuestion { /// Answer property #[dbus_proxy(property)] fn answer(&self) -> zbus::Result<String>; #[dbus_proxy(property)] fn set_answer(&self, value: &str) -> zbus::Result<()>; /// Class property #[dbus_proxy(property)] fn class(&self) -> zbus::Result<String>; /// Data property #[dbus_proxy(property)] fn data(&self) -> zbus::Result<std::collections::HashMap<String, String>>; /// DefaultOption property #[dbus_proxy(property)] fn default_option(&self) -> zbus::Result<String>; /// Id property #[dbus_proxy(property)] fn id(&self) -> zbus::Result<u32>; /// Options property #[dbus_proxy(property)] fn options(&self) -> zbus::Result<Vec<String>>; /// Text property #[dbus_proxy(property)] fn text(&self) -> zbus::Result<String>; } #[dbus_proxy( interface = "org.opensuse.Agama1.Questions.WithPassword", default_service = "org.opensuse.Agama1", default_path = "/org/opensuse/Agama1/Questions" )] trait QuestionWithPassword { /// Password property #[dbus_proxy(property)] fn password(&self) -> zbus::Result<String>; #[dbus_proxy(property)] fn set_password(&self, value: &str) -> zbus::Result<()>; } #[dbus_proxy(interface = "org.opensuse.Agama1.Issues", assume_defaults = true)] trait Issues { /// All property #[dbus_proxy(property)] fn all(&self) -> zbus::Result<Vec<(String, String, u32, u32)>>; } #[dbus_proxy( interface = "org.opensuse.Agama1.Locale", default_service = "org.opensuse.Agama.Manager1", default_path = "/org/opensuse/Agama/Manager1" )] trait Locale { /// SetLocale method fn set_locale(&self, locale: &str) -> zbus::Result<()>; } #[dbus_proxy( interface = "org.opensuse.Agama.Storage1.Job", default_service = "org.opensuse.Agama.Storage1", default_path = "/org/opensuse/Agama/Storage1/jobs" )] trait Job { #[dbus_proxy(property)] fn running(&self) -> zbus::Result<bool>; #[dbus_proxy(property)] fn exit_code(&self) -> zbus::Result<u32>; #[dbus_proxy(signal)] fn finished(&self, exit_code: u32) -> zbus::Result<()>; } #[dbus_proxy( interface = "org.opensuse.Agama.Storage1.DASD.Format", default_service = "org.opensuse.Agama.Storage1", default_path = "/org/opensuse/Agama/Storage1/jobs/1" )] trait FormatJob { #[dbus_proxy(property)] fn summary(&self) -> zbus::Result<std::collections::HashMap<String, (u32, u32, bool)>>; } 0707010000004D000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001E00000000agama/agama-lib/src/questions0707010000004E000081A4000000000000000000000001671F5A640000102F000000000000000000000000000000000000002100000000agama/agama-lib/src/questions.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Data model for Agama questions use std::collections::HashMap; pub mod http_client; pub mod model; /// Basic generic question that fits question without special needs /// /// structs living directly under questions namespace is for D-Bus usage and holds complete questions data /// for user side data model see questions::model #[derive(Clone, Debug)] pub struct GenericQuestion { /// numeric id used to identify question on D-Bus pub id: u32, /// class of questions. Similar kinds of questions share same class. /// It is dot separated list of elements. Examples are /// `storage.luks.actication` or `software.repositories.unknown_gpg` pub class: String, /// Textual representation of question. Expected to be read by people pub text: String, /// possible answers for question pub options: Vec<String>, /// default answer. Can be used as hint or preselection and it is used as answer for unattended questions. pub default_option: String, /// additional data to help identify questions. Useful for automatic answers. It is question specific. pub data: HashMap<String, String>, /// Confirmed answer. If empty then not answered yet. pub answer: String, } impl GenericQuestion { pub fn new( id: u32, class: String, text: String, options: Vec<String>, default_option: String, data: HashMap<String, String>, ) -> Self { Self { id, class, text, options, default_option, data, answer: String::from(""), } } /// Gets object path of given question. It is useful as parameter /// for deleting it. /// /// # Examples /// /// ``` /// use std::collections::HashMap; /// use agama_lib::questions::GenericQuestion; /// let question = GenericQuestion::new( /// 2, /// "test_class".to_string(), /// "Really?".to_string(), /// vec!["Yes".to_string(), "No".to_string()], /// "No".to_string(), /// HashMap::new() /// ); /// assert_eq!(question.object_path(), "/org/opensuse/Agama1/Questions/2".to_string()); /// ``` pub fn object_path(&self) -> String { format!("/org/opensuse/Agama1/Questions/{}", self.id) } } /// Composition for questions which include password. /// /// ## Extension /// If there is need to provide more mixins, then this structure does not work /// well as it is hard do various combinations. Idea is when need for more /// mixins arise to convert it to Question Struct that have optional mixins /// inside like /// /// ```no_compile /// struct Question { /// base: GenericQuestion, /// with_password: Option<WithPassword>, /// another_mixin: Option<AnotherMixin> /// } /// ``` /// /// This way all handling code can check if given mixin is used and /// act appropriate. #[derive(Clone, Debug)] pub struct WithPassword { /// Luks password. Empty means no password set. pub password: String, /// rest of question data that is same as for other questions pub base: GenericQuestion, } impl WithPassword { pub fn new(base: GenericQuestion) -> Self { Self { password: "".to_string(), base, } } } 0707010000004F000081A4000000000000000000000001671F5A6400002129000000000000000000000000000000000000002D00000000agama/agama-lib/src/questions/http_client.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use std::time::Duration; use reqwest::StatusCode; use tokio::time::sleep; use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; use super::model::{self, Answer, Question}; pub struct HTTPClient { client: BaseHTTPClient, } impl HTTPClient { pub fn new(client: BaseHTTPClient) -> Result<Self, ServiceError> { Ok(Self { client: client }) } pub async fn list_questions(&self) -> Result<Vec<model::Question>, ServiceError> { self.client.get("/questions").await } /// Creates question and return newly created question including id pub async fn create_question(&self, question: &Question) -> Result<Question, ServiceError> { self.client.post("/questions", question).await } /// non blocking varient of checking if question has already answer pub async fn try_answer(&self, question_id: u32) -> Result<Option<Answer>, ServiceError> { let path = format!("/questions/{}/answer", question_id); let result: Result<Option<Answer>, _> = self.client.get(path.as_str()).await; match result { Err(ServiceError::BackendError(code, ref _body_s)) => { if code == StatusCode::NOT_FOUND { Ok(None) // no answer yet, fine } else { result // pass error } } _ => result, // pass answer } } /// Blocking variant of getting answer for given question. pub async fn get_answer(&self, question_id: u32) -> Result<Answer, ServiceError> { loop { let answer = self.try_answer(question_id).await?; if let Some(result) = answer { return Ok(result); } let duration = Duration::from_secs(1); sleep(duration).await; // TODO: use websocket to get events instead of polling, but be aware of race condition that // auto answer can answer faster before we connect to socket. So ask for answer // and meanwhile start checking for events } } pub async fn delete_question(&self, question_id: u32) -> Result<(), ServiceError> { let path = format!("/questions/{}", question_id); self.client.delete_void(path.as_str()).await } } #[cfg(test)] mod test { use super::model::{GenericAnswer, GenericQuestion}; use super::*; use crate::base_http_client::BaseHTTPClient; use httpmock::prelude::*; use std::collections::HashMap; use std::error::Error; use tokio::test; // without this, "error: async functions cannot be used for tests" fn questions_client(mock_server_url: String) -> HTTPClient { let mut bhc = BaseHTTPClient::default(); bhc.base_url = mock_server_url; HTTPClient { client: bhc } } #[test] async fn test_list_questions() -> Result<(), Box<dyn Error>> { let server = MockServer::start(); let client = questions_client(server.url("/api")); let mock = server.mock(|when, then| { when.method(GET).path("/api/questions"); then.status(200) .header("content-type", "application/json") .body( r#"[ { "generic": { "id": 42, "class": "foo", "text": "Shape", "options": ["bouba","kiki"], "defaultOption": "bouba", "data": { "a": "A" } }, "withPassword":null } ]"#, ); }); let expected: Vec<model::Question> = vec![Question { generic: GenericQuestion { id: Some(42), class: "foo".to_owned(), text: "Shape".to_owned(), options: vec!["bouba".to_owned(), "kiki".to_owned()], default_option: "bouba".to_owned(), data: HashMap::from([("a".to_owned(), "A".to_owned())]), }, with_password: None, }]; let actual = client.list_questions().await?; assert_eq!(actual, expected); mock.assert(); Ok(()) } #[test] async fn test_create_question() -> Result<(), Box<dyn Error>> { let server = MockServer::start(); let mock = server.mock(|when, then| { when.method(POST) .path("/api/questions") .header("content-type", "application/json") .body( r#"{"generic":{"id":null,"class":"fiction.hamlet","text":"To be or not to be","options":["to be","not to be"],"defaultOption":"to be","data":{"a":"A"}},"withPassword":null}"# ); then.status(200) .header("content-type", "application/json") .body( r#"{ "generic": { "id": 7, "class": "fiction.hamlet", "text": "To be or not to be", "options": ["to be","not to be"], "defaultOption": "to be", "data": { "a": "A" } }, "withPassword":null }"#, ); }); let client = questions_client(server.url("/api")); let posted_question = Question { generic: GenericQuestion { id: None, class: "fiction.hamlet".to_owned(), text: "To be or not to be".to_owned(), options: vec!["to be".to_owned(), "not to be".to_owned()], default_option: "to be".to_owned(), data: HashMap::from([("a".to_owned(), "A".to_owned())]), }, with_password: None, }; let mut expected_question = posted_question.clone(); expected_question.generic.id = Some(7); let actual = client.create_question(&posted_question).await?; assert_eq!(actual, expected_question); // Ensure the specified mock was called exactly one time (or fail with a detailed error description). mock.assert(); Ok(()) } #[test] async fn test_try_answer() -> Result<(), Box<dyn Error>> { let server = MockServer::start(); let client = questions_client(server.url("/api")); let mock = server.mock(|when, then| { when.method(GET).path("/api/questions/42/answer"); then.status(200) .header("content-type", "application/json") .body( r#"{ "generic": { "answer": "maybe" }, "withPassword":null }"#, ); }); let expected = Some(Answer { generic: GenericAnswer { answer: "maybe".to_owned(), }, with_password: None, }); let actual = client.try_answer(42).await?; assert_eq!(actual, expected); let mock2 = server.mock(|when, then| { when.method(GET).path("/api/questions/666/answer"); then.status(404); }); let actual = client.try_answer(666).await?; assert_eq!(actual, None); mock.assert(); mock2.assert(); Ok(()) } } 07070100000050000081A4000000000000000000000001671F5A6400000CEC000000000000000000000000000000000000002700000000agama/agama-lib/src/questions/model.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use std::collections::HashMap; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Question { pub generic: GenericQuestion, pub with_password: Option<QuestionWithPassword>, } /// Facade of agama_lib::questions::GenericQuestion /// For fields details see it. /// Reason why it does not use directly GenericQuestion from lib /// is that it contain both question and answer. It works for dbus /// API which has both as attributes, but web API separate /// question and its answer. So here it is split into GenericQuestion /// and GenericAnswer #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct GenericQuestion { /// id is optional as newly created questions does not have it assigned pub id: Option<u32>, pub class: String, pub text: String, pub options: Vec<String>, pub default_option: String, pub data: HashMap<String, String>, } /// Facade of agama_lib::questions::WithPassword /// For fields details see it. /// Reason why it does not use directly WithPassword from lib /// is that it is not composition as used here, but more like /// child of generic question and contain reference to Base. /// Here for web API we want to have in json that separation that would /// allow to compose any possible future specialization of question. /// Also note that question is empty as QuestionWithPassword does not /// provide more details for question, but require additional answer. /// Can be potentionally extended in future e.g. with list of allowed characters? #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct QuestionWithPassword {} #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Answer { pub generic: GenericAnswer, pub with_password: Option<PasswordAnswer>, } /// Answer needed for GenericQuestion #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct GenericAnswer { pub answer: String, } /// Answer needed for Password specific questions. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct PasswordAnswer { pub password: String, } 07070100000051000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001C00000000agama/agama-lib/src/scripts07070100000052000081A4000000000000000000000001671F5A6400000415000000000000000000000000000000000000001F00000000agama/agama-lib/src/scripts.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements support for handling the user-defined scripts. mod client; mod error; mod model; mod settings; mod store; pub use error::ScriptError; pub use model::*; pub use settings::*; pub use store::ScriptsStore; 07070100000053000081A4000000000000000000000001671F5A64000007B2000000000000000000000000000000000000002600000000agama/agama-lib/src/scripts/client.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; use super::{Script, ScriptsGroup}; /// HTTP client to interact with scripts. pub struct ScriptsClient { client: BaseHTTPClient, } impl ScriptsClient { pub fn new(base: BaseHTTPClient) -> Self { Self { client: base } } /// Adds a script to the given group. /// /// * `script`: script's definition. pub async fn add_script(&self, script: &Script) -> Result<(), ServiceError> { self.client.post_void("/scripts", &script).await } /// Runs user-defined scripts of the given group. /// /// * `group`: group of the scripts to run pub async fn run_scripts(&self, group: ScriptsGroup) -> Result<(), ServiceError> { self.client.post_void("/scripts/run", &group).await } /// Returns the user-defined scripts. pub async fn scripts(&self) -> Result<Vec<Script>, ServiceError> { self.client.get("/scripts").await } /// Remove all the user-defined scripts. pub async fn delete_scripts(&self) -> Result<(), ServiceError> { self.client.delete_void("/scripts").await } } 07070100000054000081A4000000000000000000000001671F5A6400000459000000000000000000000000000000000000002500000000agama/agama-lib/src/scripts/error.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use std::io; use thiserror::Error; use crate::transfer::TransferError; #[derive(Error, Debug)] pub enum ScriptError { #[error("Could not fetch the profile: '{0}'")] Unreachable(#[from] TransferError), #[error("I/O error: '{0}'")] InputOutputError(#[from] io::Error), } 07070100000055000081A4000000000000000000000001671F5A64000019B7000000000000000000000000000000000000002500000000agama/agama-lib/src/scripts/model.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use std::{ fs, io::Write, os::unix::fs::OpenOptionsExt, path::{Path, PathBuf}, process, }; use serde::{Deserialize, Serialize}; use crate::transfer::Transfer; use super::ScriptError; #[derive(Debug, Clone, Copy, PartialEq, strum::Display, Serialize, Deserialize)] #[strum(serialize_all = "camelCase")] #[serde(rename_all = "camelCase")] pub enum ScriptsGroup { Pre, Post, } #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] pub enum ScriptSource { /// Script's body. Text { body: String }, /// URL to get the script from. Remote { url: String }, } /// Represents a script to run as part of the installation process. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Script { /// Script's name. pub name: String, #[serde(flatten)] pub source: ScriptSource, /// Script's group pub group: ScriptsGroup, } impl Script { /// Runs the script and returns the output. /// /// * `workdir`: where to write assets (script, logs and exit code). pub async fn run(&self, workdir: &Path) -> Result<(), ScriptError> { let dir = workdir.join(self.group.to_string()); let path = dir.join(&self.name); self.write(&path).await?; let output = process::Command::new(&path).output()?; let stdout_log = dir.join(format!("{}.log", &self.name)); fs::write(stdout_log, output.stdout)?; let stderr_log = dir.join(format!("{}.err", &self.name)); fs::write(stderr_log, output.stderr)?; let status_file = dir.join(format!("{}.out", &self.name)); fs::write(status_file, output.status.to_string())?; Ok(()) } /// Writes the script to the file system. /// /// * `path`: path to write the script to. async fn write<P: AsRef<Path>>(&self, path: P) -> Result<(), ScriptError> { let mut file = fs::OpenOptions::new() .create(true) .write(true) .truncate(true) .mode(0o500) .open(&path)?; match &self.source { ScriptSource::Text { body } => write!(file, "{}", &body)?, ScriptSource::Remote { url } => Transfer::get(&url, file)?, }; Ok(()) } } /// Manages a set of installation scripts. /// /// It offers an API to add and execute installation scripts. pub struct ScriptsRepository { workdir: PathBuf, pub scripts: Vec<Script>, } impl ScriptsRepository { /// Builds a new repository. /// /// * `workdir`: directory to store the scripts. pub fn new<P: AsRef<Path>>(workdir: P) -> ScriptsRepository { ScriptsRepository { workdir: PathBuf::from(workdir.as_ref()), ..Default::default() } } /// Adds a new script to the repository. /// /// * `script`: script to add. pub fn add(&mut self, script: Script) { self.scripts.push(script); } /// Removes all the scripts from the repository. pub fn clear(&mut self) { self.scripts.clear(); } /// Runs the scripts in the given group. /// /// They run in the order they were added to the repository. If does not return an error /// if running a script fails, although it logs the problem. pub async fn run(&self, group: ScriptsGroup) -> Result<(), ScriptError> { let workdir = self.workdir.join(group.to_string()); std::fs::create_dir_all(&workdir)?; let scripts: Vec<_> = self.scripts.iter().filter(|s| s.group == group).collect(); for script in scripts { if let Err(error) = script.run(&self.workdir).await { log::error!( "Failed to run user-defined script '{}': {:?}", &script.name, error ); } } Ok(()) } } impl Default for ScriptsRepository { fn default() -> Self { Self { workdir: PathBuf::from("/run/agama/scripts"), scripts: vec![], } } } #[cfg(test)] mod test { use tempfile::TempDir; use tokio::test; use crate::scripts::{Script, ScriptSource}; use super::{ScriptsGroup, ScriptsRepository}; #[test] async fn test_add_script() { let tmp_dir = TempDir::with_prefix("scripts-").expect("a temporary directory"); let mut repo = ScriptsRepository::new(&tmp_dir); let script = Script { name: "test".to_string(), source: ScriptSource::Text { body: "".to_string(), }, group: ScriptsGroup::Pre, }; repo.add(script); let script = repo.scripts.first().unwrap(); assert_eq!("test".to_string(), script.name); } #[test] async fn test_run_scripts() { let tmp_dir = TempDir::with_prefix("scripts-").expect("a temporary directory"); let mut repo = ScriptsRepository::new(&tmp_dir); let body = "#!/bin/bash\necho hello\necho error >&2".to_string(); let script = Script { name: "test".to_string(), source: ScriptSource::Text { body }, group: ScriptsGroup::Pre, }; repo.add(script); repo.run(ScriptsGroup::Pre).await.unwrap(); repo.scripts.first().unwrap(); let path = &tmp_dir.path().join("pre").join("test.log"); let body: Vec<u8> = std::fs::read(&path).unwrap(); let body = String::from_utf8(body).unwrap(); assert_eq!("hello\n", body); let path = &tmp_dir.path().join("pre").join("test.err"); let body: Vec<u8> = std::fs::read(&path).unwrap(); let body = String::from_utf8(body).unwrap(); assert_eq!("error\n", body); } } 07070100000056000081A4000000000000000000000001671F5A64000006AB000000000000000000000000000000000000002800000000agama/agama-lib/src/scripts/settings.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use serde::{Deserialize, Serialize}; use super::{Script, ScriptSource}; #[derive(Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScriptsConfig { /// User-defined pre-installation scripts #[serde(skip_serializing_if = "Vec::is_empty")] pub pre: Vec<ScriptConfig>, /// User-defined post-installation scripts #[serde(skip_serializing_if = "Vec::is_empty")] pub post: Vec<ScriptConfig>, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ScriptConfig { /// Script's name. pub name: String, /// Script's source #[serde(flatten)] pub source: ScriptSource, } impl From<&Script> for ScriptConfig { fn from(value: &Script) -> Self { ScriptConfig { name: value.name.clone(), source: value.source.clone(), } } } 07070100000057000081A4000000000000000000000001671F5A6400000A08000000000000000000000000000000000000002500000000agama/agama-lib/src/scripts/store.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; use super::{client::ScriptsClient, settings::ScriptsConfig, Script, ScriptConfig, ScriptsGroup}; pub struct ScriptsStore { client: ScriptsClient, } impl ScriptsStore { pub fn new(client: BaseHTTPClient) -> Self { Self { client: ScriptsClient::new(client), } } pub async fn load(&self) -> Result<ScriptsConfig, ServiceError> { let scripts = self.client.scripts().await?; Ok(ScriptsConfig { pre: Self::to_script_configs(&scripts, ScriptsGroup::Pre), post: Self::to_script_configs(&scripts, ScriptsGroup::Post), }) } pub async fn store(&self, settings: &ScriptsConfig) -> Result<(), ServiceError> { self.client.delete_scripts().await?; for pre in &settings.pre { self.client .add_script(&Self::to_script(pre, ScriptsGroup::Pre)) .await?; } for post in &settings.post { self.client .add_script(&Self::to_script(post, ScriptsGroup::Post)) .await?; } // TODO: find a better play to run the scripts (before probing). self.client.run_scripts(ScriptsGroup::Pre).await?; Ok(()) } fn to_script(config: &ScriptConfig, group: ScriptsGroup) -> Script { Script { name: config.name.clone(), source: config.source.clone(), group, } } fn to_script_configs(scripts: &[Script], group: ScriptsGroup) -> Vec<ScriptConfig> { scripts .iter() .filter(|s| s.group == group) .map(|s| s.into()) .collect() } } 07070100000058000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001D00000000agama/agama-lib/src/software07070100000059000081A4000000000000000000000001671F5A6400000481000000000000000000000000000000000000002000000000agama/agama-lib/src/software.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements support for handling the software settings mod client; mod http_client; pub mod model; pub mod proxies; mod settings; mod store; pub use client::{Pattern, SelectedBy, SoftwareClient, UnknownSelectedBy}; pub use http_client::SoftwareHTTPClient; pub use settings::SoftwareSettings; pub use store::SoftwareStore; 0707010000005A000081A4000000000000000000000001671F5A6400001649000000000000000000000000000000000000002700000000agama/agama-lib/src/software/client.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use super::proxies::Software1Proxy; use crate::error::ServiceError; use serde::Serialize; use serde_repr::Serialize_repr; use std::collections::HashMap; use zbus::Connection; /// Represents a software product #[derive(Debug, Serialize, utoipa::ToSchema)] pub struct Pattern { /// Pattern name (eg., "aaa_base", "gnome") pub name: String, /// Pattern category (e.g., "Production") pub category: String, /// Pattern icon path locally on system pub icon: String, /// Pattern description pub description: String, /// Pattern summary pub summary: String, /// Pattern order pub order: String, } /// Represents the reason why a pattern is selected. #[derive(Clone, Copy, Debug, PartialEq, Serialize_repr, utoipa::ToSchema)] #[repr(u8)] pub enum SelectedBy { /// The pattern was selected by the user. User = 0, /// The pattern was selected automatically. Auto = 1, /// The pattern has not be selected. None = 2, } #[derive(Debug, thiserror::Error)] #[error("Unknown selected by value: '{0}'")] pub struct UnknownSelectedBy(u8); impl TryFrom<u8> for SelectedBy { type Error = UnknownSelectedBy; fn try_from(value: u8) -> Result<Self, Self::Error> { match value { 0 => Ok(Self::User), 1 => Ok(Self::Auto), _ => Err(UnknownSelectedBy(value)), } } } /// D-Bus client for the software service #[derive(Clone)] pub struct SoftwareClient<'a> { software_proxy: Software1Proxy<'a>, } impl<'a> SoftwareClient<'a> { pub async fn new(connection: Connection) -> Result<SoftwareClient<'a>, ServiceError> { Ok(Self { software_proxy: Software1Proxy::new(&connection).await?, }) } /// Returns the available patterns pub async fn patterns(&self, filtered: bool) -> Result<Vec<Pattern>, ServiceError> { let patterns: Vec<Pattern> = self .software_proxy .list_patterns(filtered) .await? .into_iter() .map( |(name, (category, description, icon, summary, order))| Pattern { name, category, icon, description, summary, order, }, ) .collect(); Ok(patterns) } /// Returns the ids of patterns selected by user pub async fn user_selected_patterns(&self) -> Result<Vec<String>, ServiceError> { let patterns: Vec<String> = self .software_proxy .selected_patterns() .await? .into_iter() .filter_map(|(id, reason)| match SelectedBy::try_from(reason) { Ok(SelectedBy::User) => Some(id), Ok(_reason) => None, Err(e) => { log::warn!("Ignoring pattern {}. Error: {}", &id, e); None } }) .collect(); Ok(patterns) } /// Returns the selected pattern and the reason each one selected. pub async fn selected_patterns(&self) -> Result<HashMap<String, SelectedBy>, ServiceError> { let patterns = self.software_proxy.selected_patterns().await?; let patterns = patterns .into_iter() .filter_map(|(id, reason)| match SelectedBy::try_from(reason) { Ok(reason) => Some((id, reason)), Err(e) => { log::warn!("Ignoring pattern {}. Error: {}", &id, e); None } }) .collect(); Ok(patterns) } /// Selects patterns by user pub async fn select_patterns( &self, patterns: HashMap<String, bool>, ) -> Result<(), ServiceError> { let (add, remove): (Vec<_>, Vec<_>) = patterns.into_iter().partition(|(_, install)| *install); let add: Vec<_> = add.iter().map(|(name, _)| name.as_ref()).collect(); let remove: Vec<_> = remove.iter().map(|(name, _)| name.as_ref()).collect(); let wrong_patterns = self .software_proxy .set_user_patterns(add.as_slice(), remove.as_slice()) .await?; if !wrong_patterns.is_empty() { Err(ServiceError::UnknownPatterns(wrong_patterns)) } else { Ok(()) } } /// Returns the required space for installing the selected patterns. /// /// It returns a formatted string including the size and the unit. pub async fn used_disk_space(&self) -> Result<String, ServiceError> { Ok(self.software_proxy.used_disk_space().await?) } /// Starts the process to read the repositories data. pub async fn probe(&self) -> Result<(), ServiceError> { Ok(self.software_proxy.probe().await?) } } 0707010000005B000081A4000000000000000000000001671F5A6400000AF8000000000000000000000000000000000000002C00000000agama/agama-lib/src/software/http_client.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use crate::software::model::SoftwareConfig; use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; use std::collections::HashMap; pub struct SoftwareHTTPClient { client: BaseHTTPClient, } impl SoftwareHTTPClient { pub fn new(base: BaseHTTPClient) -> Self { Self { client: base } } pub async fn get_config(&self) -> Result<SoftwareConfig, ServiceError> { self.client.get("/software/config").await } pub async fn set_config(&self, config: &SoftwareConfig) -> Result<(), ServiceError> { // FIXME: test how errors come out: // unknown pattern name, // D-Bus client returns // Err(ServiceError::UnknownPatterns(wrong_patterns)) // CLI prints: // Anyhow(Backend call failed with status 400 and text '{"error":"Agama service error: Failed to find these patterns: [\"no_such_pattern\"]"}') self.client.put_void("/software/config", config).await } /// Returns the ids of patterns selected by user pub async fn user_selected_patterns(&self) -> Result<Vec<String>, ServiceError> { // TODO: this way we unnecessarily ask D-Bus (via web.rs) also for the product and then ignore it let config = self.get_config().await?; let Some(patterns_map) = config.patterns else { return Ok(vec![]); }; let patterns: Vec<String> = patterns_map .into_iter() .filter_map(|(name, is_selected)| if is_selected { Some(name) } else { None }) .collect(); Ok(patterns) } /// Selects patterns by user pub async fn select_patterns( &self, patterns: HashMap<String, bool>, ) -> Result<(), ServiceError> { let config = SoftwareConfig { product: None, // TODO: SoftwareStore only passes true bools, false branch is untested patterns: Some(patterns), }; self.set_config(&config).await } } 0707010000005C000081A4000000000000000000000001671F5A6400000BBA000000000000000000000000000000000000002600000000agama/agama-lib/src/software/model.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Software service configuration (product, patterns, etc.). #[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct SoftwareConfig { /// A map where the keys are the pattern names and the values whether to install them or not. pub patterns: Option<HashMap<String, bool>>, /// Name of the product to install. pub product: Option<String>, } /// Software service configuration (product, patterns, etc.). #[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct RegistrationParams { /// Registration key. pub key: String, /// Registration email. pub email: String, } /// Information about registration configuration (product, patterns, etc.). #[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct RegistrationInfo { /// Registration key. Empty value mean key not used or not registered. pub key: String, /// Registration email. Empty value mean email not used or not registered. pub email: String, /// if registration is required, optional or not needed for current product. /// Change only if selected product is changed. pub requirement: RegistrationRequirement, } #[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] pub enum RegistrationRequirement { /// Product does not require registration NotRequired = 0, /// Product has optional registration Optional = 1, /// It is mandatory to register the product Mandatory = 2, } impl TryFrom<u32> for RegistrationRequirement { type Error = (); fn try_from(v: u32) -> Result<Self, Self::Error> { match v { x if x == RegistrationRequirement::NotRequired as u32 => { Ok(RegistrationRequirement::NotRequired) } x if x == RegistrationRequirement::Optional as u32 => { Ok(RegistrationRequirement::Optional) } x if x == RegistrationRequirement::Mandatory as u32 => { Ok(RegistrationRequirement::Mandatory) } _ => Err(()), } } } 0707010000005D000081A4000000000000000000000001671F5A640000110D000000000000000000000000000000000000002800000000agama/agama-lib/src/software/proxies.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! D-Bus interface proxies for: `org.opensuse.Agama.Software1.*` //! //! This code was generated by `zbus-xmlgen` `3.1.1` from DBus introspection data. use zbus::dbus_proxy; /// Software patterns map. /// /// It uses the pattern name as key and a tuple containing the following information as value: /// /// * Category. /// * Description. /// * Icon. /// * Summary. /// * Order. pub type PatternsMap = std::collections::HashMap<String, (String, String, String, String, String)>; #[dbus_proxy( interface = "org.opensuse.Agama.Software1", default_service = "org.opensuse.Agama.Software1", default_path = "/org/opensuse/Agama/Software1" )] trait Software1 { /// AddPattern method fn add_pattern(&self, id: &str) -> zbus::Result<bool>; /// Finish method fn finish(&self) -> zbus::Result<()>; /// Install method fn install(&self) -> zbus::Result<()>; /// IsPackageInstalled method fn is_package_installed(&self, name: &str) -> zbus::Result<bool>; /// ListPatterns method fn list_patterns(&self, filtered: bool) -> zbus::Result<PatternsMap>; /// Probe method fn probe(&self) -> zbus::Result<()>; /// Propose method fn propose(&self) -> zbus::Result<()>; /// ProvisionsSelected method fn provisions_selected(&self, provisions: &[&str]) -> zbus::Result<Vec<bool>>; /// RemovePattern method fn remove_pattern(&self, id: &str) -> zbus::Result<bool>; /// SetUserPatterns method fn set_user_patterns(&self, add: &[&str], remove: &[&str]) -> zbus::Result<Vec<String>>; /// UsedDiskSpace method fn used_disk_space(&self) -> zbus::Result<String>; /// SelectedPatterns property #[dbus_proxy(property)] fn selected_patterns(&self) -> zbus::Result<std::collections::HashMap<String, u8>>; } /// Product definition. /// /// It is composed of the following elements: /// /// * Product ID. /// * Display name. /// * Some additional data which includes a "description" key. pub type Product = ( String, String, std::collections::HashMap<String, zbus::zvariant::OwnedValue>, ); #[dbus_proxy( interface = "org.opensuse.Agama.Software1.Product", default_service = "org.opensuse.Agama.Software1", default_path = "/org/opensuse/Agama/Software1/Product" )] trait SoftwareProduct { /// SelectProduct method fn select_product(&self, id: &str) -> zbus::Result<(u32, String)>; /// AvailableProducts method fn available_products(&self) -> zbus::Result<Vec<Product>>; /// SelectedProduct property #[dbus_proxy(property)] fn selected_product(&self) -> zbus::Result<String>; } #[dbus_proxy( interface = "org.opensuse.Agama.Software1.Proposal", default_service = "org.opensuse.Agama.Software1", default_path = "/org/opensuse/Agama/Software1/Proposal" )] trait SoftwareProposal { /// AddResolvables method fn add_resolvables( &self, id: &str, r#type: u8, resolvables: &[&str], optional: bool, ) -> zbus::Result<()>; /// GetResolvables method fn get_resolvables(&self, id: &str, r#type: u8, optional: bool) -> zbus::Result<Vec<String>>; /// RemoveResolvables method fn remove_resolvables( &self, id: &str, r#type: u8, resolvables: &[&str], optional: bool, ) -> zbus::Result<()>; /// SetResolvables method fn set_resolvables( &self, id: &str, r#type: u8, resolvables: &[&str], optional: bool, ) -> zbus::Result<()>; } 0707010000005E000081A4000000000000000000000001671F5A640000048E000000000000000000000000000000000000002900000000agama/agama-lib/src/software/settings.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Representation of the software settings use serde::{Deserialize, Serialize}; /// Software settings for installation #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SoftwareSettings { /// List of patterns to install. If empty use default. pub patterns: Vec<String>, } 0707010000005F000081A4000000000000000000000001671F5A64000014C4000000000000000000000000000000000000002600000000agama/agama-lib/src/software/store.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements the store for the software settings. use std::collections::HashMap; use super::{SoftwareHTTPClient, SoftwareSettings}; use crate::base_http_client::BaseHTTPClient; use crate::error::ServiceError; /// Loads and stores the software settings from/to the D-Bus service. pub struct SoftwareStore { software_client: SoftwareHTTPClient, } impl SoftwareStore { pub fn new(client: BaseHTTPClient) -> Result<SoftwareStore, ServiceError> { Ok(Self { software_client: SoftwareHTTPClient::new(client), }) } pub async fn load(&self) -> Result<SoftwareSettings, ServiceError> { let patterns = self.software_client.user_selected_patterns().await?; Ok(SoftwareSettings { patterns }) } pub async fn store(&self, settings: &SoftwareSettings) -> Result<(), ServiceError> { let patterns: HashMap<String, bool> = settings .patterns .iter() .map(|name| (name.to_owned(), true)) .collect(); self.software_client.select_patterns(patterns).await?; Ok(()) } } #[cfg(test)] mod test { use super::*; use crate::base_http_client::BaseHTTPClient; use httpmock::prelude::*; use std::error::Error; use tokio::test; // without this, "error: async functions cannot be used for tests" fn software_store(mock_server_url: String) -> SoftwareStore { let mut bhc = BaseHTTPClient::default(); bhc.base_url = mock_server_url; let client = SoftwareHTTPClient::new(bhc); SoftwareStore { software_client: client, } } #[test] async fn test_getting_software() -> Result<(), Box<dyn Error>> { let server = MockServer::start(); let software_mock = server.mock(|when, then| { when.method(GET).path("/api/software/config"); then.status(200) .header("content-type", "application/json") .body( r#"{ "patterns": {"xfce":true}, "product": "Tumbleweed" }"#, ); }); let url = server.url("/api"); let store = software_store(url); let settings = store.load().await?; let expected = SoftwareSettings { patterns: vec!["xfce".to_owned()], }; // main assertion assert_eq!(settings, expected); // Ensure the specified mock was called exactly one time (or fail with a detailed error description). software_mock.assert(); Ok(()) } #[test] async fn test_setting_software_ok() -> Result<(), Box<dyn Error>> { let server = MockServer::start(); let software_mock = server.mock(|when, then| { when.method(PUT) .path("/api/software/config") .header("content-type", "application/json") .body(r#"{"patterns":{"xfce":true},"product":null}"#); then.status(200); }); let url = server.url("/api"); let store = software_store(url); let settings = SoftwareSettings { patterns: vec!["xfce".to_owned()], }; let result = store.store(&settings).await; // main assertion result?; // Ensure the specified mock was called exactly one time (or fail with a detailed error description). software_mock.assert(); Ok(()) } #[test] async fn test_setting_software_err() -> Result<(), Box<dyn Error>> { let server = MockServer::start(); let software_mock = server.mock(|when, then| { when.method(PUT) .path("/api/software/config") .header("content-type", "application/json") .body(r#"{"patterns":{"no_such_pattern":true},"product":null}"#); then.status(400) .body(r#"'{"error":"Agama service error: Failed to find these patterns: [\"no_such_pattern\"]"}"#); }); let url = server.url("/api"); let store = software_store(url); let settings = SoftwareSettings { patterns: vec!["no_such_pattern".to_owned()], }; let result = store.store(&settings).await; // main assertion assert!(result.is_err()); // Ensure the specified mock was called exactly one time (or fail with a detailed error description). software_mock.assert(); Ok(()) } } 07070100000060000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001C00000000agama/agama-lib/src/storage07070100000061000081A4000000000000000000000001671F5A6400000491000000000000000000000000000000000000001F00000000agama/agama-lib/src/storage.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements support for handling the storage settings pub mod client; pub mod http_client; pub mod model; pub mod proxies; mod settings; mod store; pub use client::{ iscsi::{ISCSIAuth, ISCSIClient, ISCSIInitiator, ISCSINode}, zfcp::ZFCPClient, StorageClient, }; pub use settings::StorageSettings; pub use store::StorageStore; 07070100000062000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000002300000000agama/agama-lib/src/storage/client07070100000063000081A4000000000000000000000001671F5A640000385C000000000000000000000000000000000000002600000000agama/agama-lib/src/storage/client.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements a client to access Agama's storage service. use super::model::{ Action, BlockDevice, Component, Device, DeviceInfo, DeviceSid, Drive, Filesystem, LvmLv, LvmVg, Md, Multipath, Partition, PartitionTable, ProposalSettings, ProposalSettingsPatch, Raid, Volume, }; use super::proxies::{ProposalCalculatorProxy, ProposalProxy, Storage1Proxy}; use super::StorageSettings; use crate::dbus::get_property; use crate::error::ServiceError; use std::collections::HashMap; use zbus::fdo::ObjectManagerProxy; use zbus::names::{InterfaceName, OwnedInterfaceName}; use zbus::zvariant::{OwnedObjectPath, OwnedValue}; use zbus::Connection; pub mod dasd; pub mod iscsi; pub mod zfcp; type DBusObject = ( OwnedObjectPath, HashMap<OwnedInterfaceName, HashMap<std::string::String, OwnedValue>>, ); /// D-Bus client for the storage service #[derive(Clone)] pub struct StorageClient<'a> { pub connection: Connection, calculator_proxy: ProposalCalculatorProxy<'a>, storage_proxy: Storage1Proxy<'a>, object_manager_proxy: ObjectManagerProxy<'a>, proposal_proxy: ProposalProxy<'a>, } impl<'a> StorageClient<'a> { pub async fn new(connection: Connection) -> Result<StorageClient<'a>, ServiceError> { Ok(Self { calculator_proxy: ProposalCalculatorProxy::new(&connection).await?, storage_proxy: Storage1Proxy::new(&connection).await?, object_manager_proxy: ObjectManagerProxy::builder(&connection) .destination("org.opensuse.Agama.Storage1")? .path("/org/opensuse/Agama/Storage1")? .build() .await?, // Do not cache the D-Bus proposal proxy because the proposal object is reexported with // every new call to calculate. proposal_proxy: ProposalProxy::builder(&connection) .cache_properties(zbus::CacheProperties::No) .build() .await?, connection, }) } /// Whether the devices have changed. pub async fn devices_dirty_bit(&self) -> Result<bool, ServiceError> { Ok(self.storage_proxy.deprecated_system().await?) } /// Actions to perform in the storage devices. pub async fn actions(&self) -> Result<Vec<Action>, ServiceError> { let actions = self.proposal_proxy.actions().await?; let mut result: Vec<Action> = Vec::with_capacity(actions.len()); for i in actions { result.push(i.try_into()?); } Ok(result) } /// SIDs of the devices available for the installation. pub async fn available_devices(&self) -> Result<Vec<DeviceSid>, ServiceError> { let paths: Vec<zbus::zvariant::ObjectPath> = self .calculator_proxy .available_devices() .await? .into_iter() .map(|p| p.into_inner()) .collect(); let result: Result<Vec<DeviceSid>, _> = paths.into_iter().map(|v| v.try_into()).collect(); Ok(result?) } /// Default values for a volume with the given mount path. pub async fn volume_for(&self, mount_path: &str) -> Result<Volume, ServiceError> { let volume_hash = self.calculator_proxy.default_volume(mount_path).await?; Ok(volume_hash.try_into()?) } /// Mount points of the volumes pre-defined by the product. pub async fn product_mount_points(&self) -> Result<Vec<String>, ServiceError> { Ok(self.calculator_proxy.product_mount_points().await?) } /// Encryption methods allowed by the product. pub async fn encryption_methods(&self) -> Result<Vec<String>, ServiceError> { Ok(self.calculator_proxy.encryption_methods().await?) } /// Settings used for calculating the proposal. pub async fn proposal_settings(&self) -> Result<ProposalSettings, ServiceError> { Ok(self.proposal_proxy.settings().await?.try_into()?) } /// Runs the probing process pub async fn probe(&self) -> Result<(), ServiceError> { Ok(self.storage_proxy.probe().await?) } /// Set the storage config according to the JSON schema pub async fn set_config(&self, settings: StorageSettings) -> Result<u32, ServiceError> { Ok(self .storage_proxy .set_config(serde_json::to_string(&settings).unwrap().as_str()) .await?) } /// Get the storage config according to the JSON schema pub async fn get_config(&self) -> Result<StorageSettings, ServiceError> { let serialized_settings = self.storage_proxy.get_config().await?; let settings = serde_json::from_str(serialized_settings.as_str()).unwrap(); Ok(settings) } pub async fn calculate(&self, settings: ProposalSettingsPatch) -> Result<u32, ServiceError> { Ok(self.calculator_proxy.calculate(settings.into()).await?) } /// Probed devices. pub async fn system_devices(&self) -> Result<Vec<Device>, ServiceError> { let objects = self.object_manager_proxy.get_managed_objects().await?; let mut result = vec![]; for object in objects { let path = &object.0; if !path.as_str().contains("Storage1/system") { continue; } result.push(self.build_device(&object).await?) } Ok(result) } /// Resulting devices after calculating a proposal. pub async fn staging_devices(&self) -> Result<Vec<Device>, ServiceError> { let objects = self.object_manager_proxy.get_managed_objects().await?; let mut result = vec![]; for object in objects { let path = &object.0; if !path.as_str().contains("Storage1/staging") { continue; } result.push(self.build_device(&object).await?) } Ok(result) } fn get_interface<'b>( &'b self, object: &'b DBusObject, name: &str, ) -> Option<&HashMap<String, OwnedValue>> { let interface: OwnedInterfaceName = InterfaceName::from_str_unchecked(name).into(); let interfaces = &object.1; interfaces.get(&interface) } async fn build_device(&self, object: &DBusObject) -> Result<Device, ServiceError> { Ok(Device { block_device: self.build_block_device(object).await?, component: self.build_component(object).await?, device_info: self.build_device_info(object).await?, drive: self.build_drive(object).await?, filesystem: self.build_filesystem(object).await?, lvm_lv: self.build_lvm_lv(object).await?, lvm_vg: self.build_lvm_vg(object).await?, md: self.build_md(object).await?, multipath: self.build_multipath(object).await?, partition: self.build_partition(object).await?, partition_table: self.build_partition_table(object).await?, raid: self.build_raid(object).await?, }) } async fn build_device_info(&self, object: &DBusObject) -> Result<DeviceInfo, ServiceError> { let iface = self.get_interface(object, "org.opensuse.Agama.Storage1.Device"); // All devices have to implement the Device interface, so report error if it is not there. let Some(properties) = iface else { return Err(zbus::zvariant::Error::Message(format!( "Storage device {} is missing the Device interface", object.0 )) .into()); }; Ok(DeviceInfo { sid: get_property(properties, "SID")?, name: get_property(properties, "Name")?, description: get_property(properties, "Description")?, }) } async fn build_block_device( &self, object: &DBusObject, ) -> Result<Option<BlockDevice>, ServiceError> { let iface = self.get_interface(object, "org.opensuse.Agama.Storage1.Block"); let Some(properties) = iface else { return Ok(None); }; Ok(Some(BlockDevice { active: get_property(properties, "Active")?, encrypted: get_property(properties, "Encrypted")?, size: get_property(properties, "Size")?, shrinking: get_property(properties, "Shrinking")?, start: get_property(properties, "Start")?, systems: get_property(properties, "Systems")?, udev_ids: get_property(properties, "UdevIds")?, udev_paths: get_property(properties, "UdevPaths")?, })) } async fn build_component( &self, object: &DBusObject, ) -> Result<Option<Component>, ServiceError> { let iface = self.get_interface(object, "org.opensuse.Agama.Storage1.Component"); let Some(properties) = iface else { return Ok(None); }; Ok(Some(Component { component_type: get_property(properties, "Type")?, device_names: get_property(properties, "DeviceNames")?, devices: get_property(properties, "Devices")?, })) } async fn build_drive(&self, object: &DBusObject) -> Result<Option<Drive>, ServiceError> { let iface = self.get_interface(object, "org.opensuse.Agama.Storage1.Drive"); let Some(properties) = iface else { return Ok(None); }; Ok(Some(Drive { drive_type: get_property(properties, "Type")?, vendor: get_property(properties, "Vendor")?, model: get_property(properties, "Model")?, bus: get_property(properties, "Bus")?, bus_id: get_property(properties, "BusId")?, driver: get_property(properties, "Driver")?, transport: get_property(properties, "Transport")?, info: get_property(properties, "Info")?, })) } async fn build_filesystem( &self, object: &DBusObject, ) -> Result<Option<Filesystem>, ServiceError> { let iface = self.get_interface(object, "org.opensuse.Agama.Storage1.Filesystem"); let Some(properties) = iface else { return Ok(None); }; Ok(Some(Filesystem { sid: get_property(properties, "SID")?, fs_type: get_property(properties, "Type")?, mount_path: get_property(properties, "MountPath")?, label: get_property(properties, "Label")?, })) } async fn build_lvm_lv(&self, object: &DBusObject) -> Result<Option<LvmLv>, ServiceError> { let iface = self.get_interface(object, "org.opensuse.Agama.Storage1.LVM.LogicalVolume"); let Some(properties) = iface else { return Ok(None); }; Ok(Some(LvmLv { volume_group: get_property(properties, "VolumeGroup")?, })) } async fn build_lvm_vg(&self, object: &DBusObject) -> Result<Option<LvmVg>, ServiceError> { let iface = self.get_interface(object, "org.opensuse.Agama.Storage1.LVM.VolumeGroup"); let Some(properties) = iface else { return Ok(None); }; Ok(Some(LvmVg { size: get_property(properties, "Size")?, physical_volumes: get_property(properties, "PhysicalVolumes")?, logical_volumes: get_property(properties, "LogicalVolumes")?, })) } async fn build_md(&self, object: &DBusObject) -> Result<Option<Md>, ServiceError> { let iface = self.get_interface(object, "org.opensuse.Agama.Storage1.MD"); let Some(properties) = iface else { return Ok(None); }; Ok(Some(Md { uuid: get_property(properties, "UUID")?, level: get_property(properties, "Level")?, devices: get_property(properties, "Devices")?, })) } async fn build_multipath( &self, object: &DBusObject, ) -> Result<Option<Multipath>, ServiceError> { let iface = self.get_interface(object, "org.opensuse.Agama.Storage1.Multipath"); let Some(properties) = iface else { return Ok(None); }; Ok(Some(Multipath { wires: get_property(properties, "Wires")?, })) } async fn build_partition( &self, object: &DBusObject, ) -> Result<Option<Partition>, ServiceError> { let iface = self.get_interface(object, "org.opensuse.Agama.Storage1.Partition"); let Some(properties) = iface else { return Ok(None); }; Ok(Some(Partition { device: get_property(properties, "Device")?, efi: get_property(properties, "EFI")?, })) } async fn build_partition_table( &self, object: &DBusObject, ) -> Result<Option<PartitionTable>, ServiceError> { let iface = self.get_interface(object, "org.opensuse.Agama.Storage1.PartitionTable"); let Some(properties) = iface else { return Ok(None); }; Ok(Some(PartitionTable { ptable_type: get_property(properties, "Type")?, partitions: get_property(properties, "Partitions")?, unused_slots: get_property(properties, "UnusedSlots")?, })) } async fn build_raid(&self, object: &DBusObject) -> Result<Option<Raid>, ServiceError> { let iface = self.get_interface(object, "org.opensuse.Agama.Storage1.RAID"); let Some(properties) = iface else { return Ok(None); }; Ok(Some(Raid { devices: get_property(properties, "Devices")?, })) } } 07070100000064000081A4000000000000000000000001671F5A64000012C3000000000000000000000000000000000000002B00000000agama/agama-lib/src/storage/client/dasd.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements a client to access Agama's D-Bus API related to DASD management. use zbus::{ fdo::ObjectManagerProxy, zvariant::{ObjectPath, OwnedObjectPath}, Connection, }; use crate::{ error::ServiceError, storage::{model::dasd::DASDDevice, proxies::DASDManagerProxy}, }; /// Client to connect to Agama's D-Bus API for DASD management. #[derive(Clone)] pub struct DASDClient<'a> { manager_proxy: DASDManagerProxy<'a>, object_manager_proxy: ObjectManagerProxy<'a>, } impl<'a> DASDClient<'a> { pub async fn new(connection: Connection) -> Result<DASDClient<'a>, ServiceError> { let manager_proxy = DASDManagerProxy::new(&connection).await?; let object_manager_proxy = ObjectManagerProxy::builder(&connection) .destination("org.opensuse.Agama.Storage1")? .path("/org/opensuse/Agama/Storage1")? .build() .await?; Ok(Self { manager_proxy, object_manager_proxy, }) } pub async fn supported(&self) -> Result<bool, ServiceError> { let introspect = self.manager_proxy.introspect().await?; // simply check if introspection contain given interface Ok(introspect.contains("org.opensuse.Agama.Storage1.DASD.Manager")) } pub async fn probe(&self) -> Result<(), ServiceError> { Ok(self.manager_proxy.probe().await?) } pub async fn devices(&self) -> Result<Vec<(OwnedObjectPath, DASDDevice)>, ServiceError> { let managed_objects = self.object_manager_proxy.get_managed_objects().await?; let mut devices: Vec<(OwnedObjectPath, DASDDevice)> = vec![]; for (path, ifaces) in managed_objects { if let Some(properties) = ifaces.get("org.opensuse.Agama.Storage1.DASD.Device") { match DASDDevice::try_from(properties) { Ok(device) => { devices.push((path, device)); } Err(error) => { log::warn!("Not a valid DASD device: {}", error); } } } } Ok(devices) } pub async fn format(&self, ids: &[&str]) -> Result<String, ServiceError> { let selected = self.find_devices(ids).await?; let references = selected.iter().collect::<Vec<&ObjectPath<'_>>>(); let (exit_code, job_path) = self.manager_proxy.format(&references).await?; if exit_code != 0 { return Err(ServiceError::UnsuccessfulAction("DASD format".to_string())); } Ok(job_path.to_string()) } pub async fn enable(&self, ids: &[&str]) -> Result<(), ServiceError> { let selected = self.find_devices(ids).await?; let references = selected.iter().collect::<Vec<&ObjectPath<'_>>>(); self.manager_proxy.enable(&references).await?; Ok(()) } pub async fn disable(&self, ids: &[&str]) -> Result<(), ServiceError> { let selected = self.find_devices(ids).await?; let references = selected.iter().collect::<Vec<&ObjectPath<'_>>>(); self.manager_proxy.disable(&references).await?; Ok(()) } pub async fn set_diag(&self, ids: &[&str], diag: bool) -> Result<(), ServiceError> { let selected = self.find_devices(ids).await?; let references = selected.iter().collect::<Vec<&ObjectPath<'_>>>(); self.manager_proxy.set_diag(&references, diag).await?; Ok(()) } async fn find_devices(&self, ids: &[&str]) -> Result<Vec<ObjectPath<'_>>, ServiceError> { let devices = self.devices().await?; let selected: Vec<ObjectPath> = devices .into_iter() .filter_map(|(path, device)| { if ids.contains(&device.id.as_str()) { Some(path.into_inner()) } else { None } }) .collect(); Ok(selected) } } 07070100000065000081A4000000000000000000000001671F5A640000290E000000000000000000000000000000000000002C00000000agama/agama-lib/src/storage/client/iscsi.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use core::fmt; use std::collections::HashMap; use crate::{ dbus::{extract_id_from_path, get_property}, error::ServiceError, storage::proxies::{InitiatorProxy, NodeProxy}, }; use serde::{Deserialize, Serialize}; use thiserror::Error; use zbus::{ fdo::ObjectManagerProxy, zvariant::{self, ObjectPath, OwnedValue, Value}, Connection, }; #[derive(Serialize, utoipa::ToSchema)] pub struct ISCSIInitiator { name: String, ibft: bool, } #[derive(Clone, Debug, Default, Serialize, utoipa::ToSchema)] /// ISCSI node pub struct ISCSINode { /// Artificial ID to match it against the D-Bus backend. pub id: u32, /// Target name. pub target: String, /// Target IP address (in string-like form). pub address: String, /// Target port. pub port: u32, /// Interface name. pub interface: String, /// Whether the node was initiated by iBFT pub ibft: bool, /// Whether the node is connected (there is a session). pub connected: bool, /// Startup status (TODO: document better) pub startup: String, } impl TryFrom<&HashMap<String, OwnedValue>> for ISCSINode { type Error = ServiceError; fn try_from(value: &HashMap<String, OwnedValue>) -> Result<Self, Self::Error> { Ok(ISCSINode { id: 0, target: get_property(value, "Target")?, address: get_property(value, "Address")?, interface: get_property(value, "Interface")?, port: get_property(value, "Port")?, ibft: get_property(value, "IBFT")?, connected: get_property(value, "Connected")?, startup: get_property(value, "Startup")?, }) } } #[derive(Clone, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct ISCSIAuth { /// Username for authentication by target. pub username: Option<String>, /// Password for authentication by target. pub password: Option<String>, /// Username for authentication by initiator. pub reverse_username: Option<String>, /// Password for authentication by initiator. pub reverse_password: Option<String>, } impl From<ISCSIAuth> for HashMap<String, OwnedValue> { fn from(value: ISCSIAuth) -> Self { let mut hash = HashMap::new(); if let Some(username) = value.username { hash.insert("Username".to_string(), Value::new(username).to_owned()); } if let Some(password) = value.password { hash.insert("Password".to_string(), Value::new(password).to_owned()); } if let Some(reverse_username) = value.reverse_username { hash.insert( "ReverseUsername".to_string(), Value::new(reverse_username).to_owned(), ); } if let Some(reverse_password) = value.reverse_password { hash.insert( "ReversePassword".to_string(), Value::new(reverse_password).to_owned(), ); } hash } } /// D-Bus client for the ISCSI part of the storage service. #[derive(Clone)] pub struct ISCSIClient<'a> { connection: zbus::Connection, initiator_proxy: InitiatorProxy<'a>, object_manager_proxy: ObjectManagerProxy<'a>, } impl<'a> ISCSIClient<'a> { pub async fn new(connection: Connection) -> Result<ISCSIClient<'a>, ServiceError> { let initiator_proxy = InitiatorProxy::builder(&connection) .destination("org.opensuse.Agama.Storage1")? .path("/org/opensuse/Agama/Storage1")? .build() .await?; let object_manager_proxy = ObjectManagerProxy::builder(&connection) .destination("org.opensuse.Agama.Storage1")? .path("/org/opensuse/Agama/Storage1")? .build() .await?; Ok(Self { connection, initiator_proxy, object_manager_proxy, }) } /// Performs an iSCSI discovery. /// /// It returns true when the discovery was successful. /// /// * `address`: target address in string-like form. /// * `port`: target port. /// * `auth`: authentication options. pub async fn discover<'b>( &self, address: &str, port: u32, auth: ISCSIAuth, ) -> Result<bool, ServiceError> { let mut options_hash: HashMap<&str, zvariant::Value> = HashMap::new(); if let (Some(ref username), Some(ref password)) = (auth.username, auth.password) { options_hash.insert("Username", username.to_string().into()); options_hash.insert("Password", password.to_string().into()); } if let (Some(ref username), Some(ref password)) = (auth.reverse_username, auth.reverse_password) { options_hash.insert("ReverseUsername", username.to_string().into()); options_hash.insert("ReversePassword", password.to_string().into()); } let mut options_ref: HashMap<&str, &zvariant::Value<'_>> = HashMap::new(); for (key, value) in options_hash.iter() { options_ref.insert(key, value); } let result = self .initiator_proxy .discover(address, port, options_ref) .await?; Ok(result == 0) } /// Returns the initiator data. pub async fn get_initiator(&self) -> Result<ISCSIInitiator, ServiceError> { let ibft = self.initiator_proxy.ibft().await?; let name = self.initiator_proxy.initiator_name().await?; Ok(ISCSIInitiator { name, ibft }) } /// Sets the initiator name. /// /// * `name`: new name. pub async fn set_initiator_name(&self, name: &str) -> Result<(), ServiceError> { Ok(self.initiator_proxy.set_initiator_name(name).await?) } /// Returns the iSCSI nodes. pub async fn get_nodes(&self) -> Result<Vec<ISCSINode>, ServiceError> { let managed_objects = self.object_manager_proxy.get_managed_objects().await?; let mut nodes: Vec<ISCSINode> = vec![]; for (path, ifaces) in managed_objects { if let Some(properties) = ifaces.get("org.opensuse.Agama.Storage1.ISCSI.Node") { let id = extract_id_from_path(&path).unwrap_or(0); match ISCSINode::try_from(properties) { Ok(mut node) => { node.id = id; nodes.push(node); } Err(error) => { log::warn!("Not a valid iSCSI node: {}", error); } } } } Ok(nodes) } /// Sets the startup for a ISCSI node. /// /// * `id`: node ID. /// * `startup`: new startup value. pub async fn set_startup(&self, id: u32, startup: &str) -> Result<(), ServiceError> { let proxy = self.get_node_proxy(id).await?; Ok(proxy.set_startup(startup).await?) } pub async fn login( &self, id: u32, auth: ISCSIAuth, startup: String, ) -> Result<LoginResult, ServiceError> { let proxy = self.get_node_proxy(id).await?; let mut options: HashMap<String, OwnedValue> = auth.into(); options.insert("Startup".to_string(), Value::new(startup).to_owned()); // FIXME: duplicated code (see discover) let mut options_ref: HashMap<&str, &zvariant::Value<'_>> = HashMap::new(); for (key, value) in options.iter() { options_ref.insert(key, value); } let result = proxy.login(options_ref).await?; let result = LoginResult::try_from(result).map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; Ok(result) } pub async fn logout(&self, id: u32) -> Result<bool, ServiceError> { let proxy = self.get_node_proxy(id).await?; let result = proxy.logout().await?; Ok(result == 0) } pub async fn delete_node(&self, id: u32) -> Result<(), ServiceError> { let path = format!("/org/opensuse/Agama/Storage1/iscsi_nodes/{}", id); let path = ObjectPath::from_string_unchecked(path); self.initiator_proxy.delete(&path).await?; Ok(()) } pub async fn get_node_proxy(&self, id: u32) -> Result<NodeProxy, ServiceError> { let proxy = NodeProxy::builder(&self.connection) .path(format!("/org/opensuse/Agama/Storage1/iscsi_nodes/{}", id))? .build() .await?; Ok(proxy) } } #[derive(Serialize, utoipa::ToSchema)] pub enum LoginResult { /// Successful login. Success = 0, /// Invalid startup value. InvalidStartup = 1, /// Failed login. Failed = 2, } #[derive(Debug, Error, PartialEq)] #[error("Invalid iSCSI login result: {0}")] pub struct InvalidLoginResult(u32); impl TryFrom<u32> for LoginResult { type Error = InvalidLoginResult; fn try_from(value: u32) -> Result<Self, Self::Error> { match value { v if v == Self::Success as u32 => Ok(Self::Success), v if v == Self::InvalidStartup as u32 => Ok(Self::InvalidStartup), v if v == Self::Failed as u32 => Ok(Self::Failed), _ => Err(InvalidLoginResult(value)), } } } // TODO: the error description should come from the backend (as in deregister) impl fmt::Display for LoginResult { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Success => write!(f, "Success"), Self::InvalidStartup => write!(f, "Invalid startup value"), Self::Failed => write!(f, "Could not login into the iSCSI node"), } } } 07070100000066000081A4000000000000000000000001671F5A6400001D49000000000000000000000000000000000000002B00000000agama/agama-lib/src/storage/client/zfcp.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements a client to access Agama's D-Bus API related to zFCP management. use std::collections::HashMap; use futures_util::future::join_all; use zbus::{fdo::ObjectManagerProxy, zvariant::OwnedObjectPath, Connection}; use crate::{ dbus::{extract_id_from_path, get_property}, error::ServiceError, storage::{ model::zfcp::{ZFCPController, ZFCPDisk}, proxies::{ZFCPControllerProxy, ZFCPManagerProxy}, }, }; const ZFCP_CONTROLLER_PREFIX: &'static str = "/org/opensuse/Agama/Storage1/zfcp_controllers"; /// Client to connect to Agama's D-Bus API for zFCP management. #[derive(Clone)] pub struct ZFCPClient<'a> { manager_proxy: ZFCPManagerProxy<'a>, object_manager_proxy: ObjectManagerProxy<'a>, connection: Connection, } impl<'a> ZFCPClient<'a> { pub async fn new(connection: Connection) -> Result<Self, ServiceError> { let manager_proxy = ZFCPManagerProxy::new(&connection).await?; let object_manager_proxy = ObjectManagerProxy::builder(&connection) .destination("org.opensuse.Agama.Storage1")? .path("/org/opensuse/Agama/Storage1")? .build() .await?; Ok(Self { manager_proxy, object_manager_proxy, connection, }) } pub async fn supported(&self) -> Result<bool, ServiceError> { let introspect = self.manager_proxy.introspect().await?; // simply check if introspection contain given interface Ok(introspect.contains("org.opensuse.Agama.Storage1.ZFCP.Manager")) } pub async fn is_lun_scan_allowed(&self) -> Result<bool, ServiceError> { let allowed = self.manager_proxy.allow_lunscan().await?; // simply check if introspection contain given interface Ok(allowed) } pub async fn probe(&self) -> Result<(), ServiceError> { Ok(self.manager_proxy.probe().await?) } pub async fn get_disks(&self) -> Result<Vec<(OwnedObjectPath, ZFCPDisk)>, ServiceError> { let managed_objects = self.object_manager_proxy.get_managed_objects().await?; let mut devices: Vec<(OwnedObjectPath, ZFCPDisk)> = vec![]; for (path, ifaces) in managed_objects { if let Some(properties) = ifaces.get("org.opensuse.Agama.Storage1.ZFCP.Disk") { match ZFCPDisk::try_from(properties) { Ok(device) => { devices.push((path, device)); } Err(error) => { log::warn!("Not a valid zFCP disk: {}", error); } } } } Ok(devices) } pub async fn get_controllers( &self, ) -> Result<Vec<(OwnedObjectPath, ZFCPController)>, ServiceError> { let managed_objects = self.object_manager_proxy.get_managed_objects().await?; let mut devices: Vec<(OwnedObjectPath, ZFCPController)> = vec![]; for (path, ifaces) in managed_objects { if let Some(properties) = ifaces.get("org.opensuse.Agama.Storage1.ZFCP.Controller") { let id = extract_id_from_path(&path)?.to_string(); devices.push(( path, ZFCPController { id: id.clone(), channel: get_property(properties, "Channel")?, lun_scan: get_property(properties, "LUNScan")?, active: get_property(properties, "Active")?, luns_map: self.get_luns_map(id.as_str()).await?, }, )) } } Ok(devices) } async fn get_controller_proxy( &self, controller_id: &str, ) -> Result<ZFCPControllerProxy, ServiceError> { let dbus = ZFCPControllerProxy::builder(&self.connection) .path(ZFCP_CONTROLLER_PREFIX.to_string() + "/" + controller_id)? .build() .await?; Ok(dbus) } pub async fn activate_controller(&self, controller_id: &str) -> Result<(), ServiceError> { let controller = self.get_controller_proxy(controller_id).await?; controller.activate().await?; Ok(()) } pub async fn get_wwpns(&self, controller_id: &str) -> Result<Vec<String>, ServiceError> { let controller = self.get_controller_proxy(controller_id).await?; let result = controller.get_wwpns().await?; Ok(result) } pub async fn get_luns( &self, controller_id: &str, wwpn: &str, ) -> Result<Vec<String>, ServiceError> { let controller = self.get_controller_proxy(controller_id).await?; let result = controller.get_luns(wwpn).await?; Ok(result) } /// Obtains a LUNs map for the given controller /// /// Given a controller id it returns a HashMap with each of its WWPNs as keys and the list of /// LUNS corresponding to that specific WWPN as values. /// /// Arguments: /// /// `controller_id`: controller id pub async fn get_luns_map( &self, controller_id: &str, ) -> Result<HashMap<String, Vec<String>>, ServiceError> { let wwpns = self.get_wwpns(controller_id).await?; let aresult = wwpns.into_iter().map(|wwpn| async move { Ok(( wwpn.clone(), self.get_luns(controller_id, wwpn.as_str()).await?, )) }); let sresult = join_all(aresult).await; sresult .into_iter() .collect::<Result<HashMap<String, Vec<String>>, _>>() } pub async fn activate_disk( &self, controller_id: &str, wwpn: &str, lun: &str, ) -> Result<(), ServiceError> { let controller = self.get_controller_proxy(controller_id).await?; let result = controller.activate_disk(wwpn, lun).await?; if result == 0 { Ok(()) } else { let text = format!("Failed to activate disk. chzdev exit code {}", result); Err(ServiceError::UnsuccessfulAction(text)) } } pub async fn deactivate_disk( &self, controller_id: &str, wwpn: &str, lun: &str, ) -> Result<(), ServiceError> { let controller = self.get_controller_proxy(controller_id).await?; let result = controller.deactivate_disk(wwpn, lun).await?; if result == 0 { Ok(()) } else { let text = format!("Failed to deactivate disk. chzdev exit code {}", result); Err(ServiceError::UnsuccessfulAction(text)) } } } 07070100000067000081A4000000000000000000000001671F5A64000005B3000000000000000000000000000000000000002B00000000agama/agama-lib/src/storage/http_client.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements a client to access Agama's storage service. use crate::base_http_client::BaseHTTPClient; use crate::storage::StorageSettings; use crate::ServiceError; pub struct StorageHTTPClient { client: BaseHTTPClient, } impl StorageHTTPClient { pub fn new(base: BaseHTTPClient) -> Self { Self { client: base } } pub async fn get_config(&self) -> Result<StorageSettings, ServiceError> { self.client.get("/storage/config").await } pub async fn set_config(&self, config: &StorageSettings) -> Result<(), ServiceError> { self.client.put_void("/storage/config", config).await } } 07070100000068000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000002200000000agama/agama-lib/src/storage/model07070100000069000081A4000000000000000000000001671F5A64000058A8000000000000000000000000000000000000002500000000agama/agama-lib/src/storage/model.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use std::collections::HashMap; use serde::{Deserialize, Serialize}; use zbus::zvariant::{OwnedValue, Value}; use crate::dbus::{get_optional_property, get_property}; pub mod dasd; pub mod zfcp; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct DeviceSid(u32); impl From<u32> for DeviceSid { fn from(sid: u32) -> Self { DeviceSid(sid) } } impl TryFrom<i32> for DeviceSid { type Error = zbus::zvariant::Error; fn try_from(value: i32) -> Result<Self, Self::Error> { u32::try_from(value) .map(|v| v.into()) .map_err(|_| Self::Error::Message(format!("Cannot convert sid from {}", value))) } } impl TryFrom<zbus::zvariant::Value<'_>> for DeviceSid { type Error = zbus::zvariant::Error; fn try_from(value: Value) -> Result<Self, Self::Error> { match value { Value::ObjectPath(path) => path.try_into(), Value::U32(v) => Ok(v.into()), Value::I32(v) => v.try_into(), _ => Err(Self::Error::Message(format!( "Cannot convert sid from {}", value ))), } } } impl TryFrom<zbus::zvariant::ObjectPath<'_>> for DeviceSid { type Error = zbus::zvariant::Error; fn try_from(path: zbus::zvariant::ObjectPath) -> Result<Self, Self::Error> { path.as_str() .rsplit_once('/') .and_then(|(_, sid)| sid.parse::<u32>().ok()) .ok_or_else(|| Self::Error::Message(format!("Cannot parse sid from {}", path))) .map(DeviceSid) } } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct DeviceSize(u64); impl From<u64> for DeviceSize { fn from(value: u64) -> Self { DeviceSize(value) } } impl TryFrom<i64> for DeviceSize { type Error = zbus::zvariant::Error; fn try_from(value: i64) -> Result<Self, Self::Error> { u64::try_from(value) .map(|v| v.into()) .map_err(|_| Self::Error::Message(format!("Cannot convert size from {}", value))) } } impl TryFrom<zbus::zvariant::Value<'_>> for DeviceSize { type Error = zbus::zvariant::Error; fn try_from(value: Value) -> Result<Self, Self::Error> { match value { Value::U32(v) => Ok(u64::from(v).into()), Value::U64(v) => Ok(v.into()), Value::I32(v) => i64::from(v).try_into(), Value::I64(v) => v.try_into(), _ => Err(Self::Error::Message(format!( "Cannot convert size from {}", value ))), } } } impl<'a> From<DeviceSize> for zbus::zvariant::Value<'a> { fn from(val: DeviceSize) -> Self { Value::new(val.0) } } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] // note that dbus use camelCase for proposalTarget values and snake_case for volumeTarget #[serde(rename_all = "camelCase")] pub enum ProposalTarget { Disk, NewLvmVg, ReusedLvmVg, } impl TryFrom<zbus::zvariant::Value<'_>> for ProposalTarget { type Error = zbus::zvariant::Error; fn try_from(value: zbus::zvariant::Value) -> Result<Self, zbus::zvariant::Error> { let svalue: String = value.try_into()?; match svalue.as_str() { "disk" => Ok(Self::Disk), "newLvmVg" => Ok(Self::NewLvmVg), "reusedLvmVg" => Ok(Self::ReusedLvmVg), _ => Err(zbus::zvariant::Error::Message( format!("Wrong value for Target: {}", svalue).to_string(), )), } } } impl ProposalTarget { pub fn as_dbus_string(&self) -> String { match &self { ProposalTarget::Disk => "disk", ProposalTarget::NewLvmVg => "newLvmVg", ProposalTarget::ReusedLvmVg => "reusedLvmVg", } .to_string() } } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "snake_case")] pub enum SpaceAction { ForceDelete, Resize, } impl SpaceAction { pub fn as_dbus_string(&self) -> String { match &self { Self::ForceDelete => "force_delete", Self::Resize => "resize", } .to_string() } } impl TryFrom<zbus::zvariant::Value<'_>> for SpaceAction { type Error = zbus::zvariant::Error; fn try_from(value: zbus::zvariant::Value) -> Result<Self, zbus::zvariant::Error> { let svalue: String = value.try_into()?; match svalue.as_str() { "force_delete" => Ok(Self::ForceDelete), "resize" => Ok(Self::Resize), _ => Err(zbus::zvariant::Error::Message( format!("Wrong value for SpacePolicy: {}", svalue).to_string(), )), } } } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct SpaceActionSettings { pub device: String, pub action: SpaceAction, } impl TryFrom<zbus::zvariant::Value<'_>> for SpaceActionSettings { type Error = zbus::zvariant::Error; fn try_from(value: zbus::zvariant::Value) -> Result<Self, zbus::zvariant::Error> { let mvalue: HashMap<String, OwnedValue> = value.try_into()?; let res = SpaceActionSettings { device: get_property(&mvalue, "Device")?, action: get_property(&mvalue, "Action")?, }; Ok(res) } } impl<'a> From<SpaceActionSettings> for zbus::zvariant::Value<'a> { fn from(val: SpaceActionSettings) -> Self { let result: HashMap<&str, Value> = HashMap::from([ ("Device", Value::new(val.device)), ("Action", Value::new(val.action.as_dbus_string())), ]); Value::new(result) } } /// Represents a proposal patch -> change of proposal configuration that can be partial #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct ProposalSettingsPatch { pub target: Option<ProposalTarget>, pub target_device: Option<String>, #[serde(rename = "targetPVDevices")] pub target_pv_devices: Option<Vec<String>>, pub configure_boot: Option<bool>, pub boot_device: Option<String>, pub encryption_password: Option<String>, pub encryption_method: Option<String>, #[serde(rename = "encryptionPBKDFunction")] pub encryption_pbkd_function: Option<String>, pub space_policy: Option<String>, pub space_actions: Option<Vec<SpaceActionSettings>>, pub volumes: Option<Vec<Volume>>, } impl<'a> From<ProposalSettingsPatch> for HashMap<&'static str, Value<'a>> { fn from(val: ProposalSettingsPatch) -> Self { let mut result = HashMap::new(); if let Some(target) = val.target { result.insert("Target", Value::new(target.as_dbus_string())); } if let Some(dev) = val.target_device { result.insert("TargetDevice", Value::new(dev)); } if let Some(devs) = val.target_pv_devices { result.insert("TargetPVDevices", Value::new(devs)); } if let Some(value) = val.configure_boot { result.insert("ConfigureBoot", Value::new(value)); } if let Some(value) = val.boot_device { result.insert("BootDevice", Value::new(value)); } if let Some(value) = val.encryption_password { result.insert("EncryptionPassword", Value::new(value)); } if let Some(value) = val.encryption_method { result.insert("EncryptionMethod", Value::new(value)); } if let Some(value) = val.encryption_pbkd_function { result.insert("EncryptionPBKDFunction", Value::new(value)); } if let Some(value) = val.space_policy { result.insert("SpacePolicy", Value::new(value)); } if let Some(value) = val.space_actions { let list: Vec<Value> = value.into_iter().map(|a| a.into()).collect(); result.insert("SpaceActions", Value::new(list)); } if let Some(value) = val.volumes { let list: Vec<Value> = value.into_iter().map(|a| a.into()).collect(); result.insert("Volumes", Value::new(list)); } result } } /// Represents a proposal configuration #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct ProposalSettings { pub target: ProposalTarget, pub target_device: Option<String>, #[serde(rename = "targetPVDevices")] pub target_pv_devices: Option<Vec<String>>, pub configure_boot: bool, pub boot_device: String, pub default_boot_device: String, pub encryption_password: String, pub encryption_method: String, #[serde(rename = "encryptionPBKDFunction")] pub encryption_pbkd_function: String, pub space_policy: String, pub space_actions: Vec<SpaceActionSettings>, pub volumes: Vec<Volume>, } impl TryFrom<HashMap<String, OwnedValue>> for ProposalSettings { type Error = zbus::zvariant::Error; fn try_from(hash: HashMap<String, OwnedValue>) -> Result<Self, zbus::zvariant::Error> { let res = ProposalSettings { target: get_property(&hash, "Target")?, target_device: get_optional_property(&hash, "TargetDevice")?, target_pv_devices: get_optional_property(&hash, "TargetPVDevices")?, configure_boot: get_property(&hash, "ConfigureBoot")?, boot_device: get_property(&hash, "BootDevice")?, default_boot_device: get_property(&hash, "DefaultBootDevice")?, encryption_password: get_property(&hash, "EncryptionPassword")?, encryption_method: get_property(&hash, "EncryptionMethod")?, encryption_pbkd_function: get_property(&hash, "EncryptionPBKDFunction")?, space_policy: get_property(&hash, "SpacePolicy")?, space_actions: get_property(&hash, "SpaceActions")?, volumes: get_property(&hash, "Volumes")?, }; Ok(res) } } /// Represents a single change action done to storage #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Action { device: DeviceSid, text: String, subvol: bool, delete: bool, resize: bool, } impl TryFrom<HashMap<String, OwnedValue>> for Action { type Error = zbus::zvariant::Error; fn try_from(hash: HashMap<String, OwnedValue>) -> Result<Self, zbus::zvariant::Error> { let res = Action { device: get_property(&hash, "Device")?, text: get_property(&hash, "Text")?, subvol: get_property(&hash, "Subvol")?, delete: get_property(&hash, "Delete")?, resize: get_property(&hash, "Resize")?, }; Ok(res) } } /// Represents value for target key of Volume /// It is snake cased when serializing to be compatible with yast2-storage-ng. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "snake_case")] pub enum VolumeTarget { Default, NewPartition, NewVg, Device, Filesystem, } impl<'a> From<VolumeTarget> for zbus::zvariant::Value<'a> { fn from(val: VolumeTarget) -> Self { let str = match val { VolumeTarget::Default => "default", VolumeTarget::NewPartition => "new_partition", VolumeTarget::NewVg => "new_vg", VolumeTarget::Device => "device", VolumeTarget::Filesystem => "filesystem", }; Value::new(str) } } impl TryFrom<zbus::zvariant::Value<'_>> for VolumeTarget { type Error = zbus::zvariant::Error; fn try_from(value: zbus::zvariant::Value) -> Result<Self, zbus::zvariant::Error> { let svalue: String = value.try_into()?; match svalue.as_str() { "default" => Ok(VolumeTarget::Default), "new_partition" => Ok(VolumeTarget::NewPartition), "new_vg" => Ok(VolumeTarget::NewVg), "device" => Ok(VolumeTarget::Device), "filesystem" => Ok(VolumeTarget::Filesystem), _ => Err(zbus::zvariant::Error::Message( format!("Wrong value for Target: {}", svalue).to_string(), )), } } } /// Represents volume outline aka requirements for volume #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct VolumeOutline { required: bool, fs_types: Vec<String>, support_auto_size: bool, adjust_by_ram: bool, snapshots_configurable: bool, snapshots_affect_sizes: bool, size_relevant_volumes: Vec<String>, } impl TryFrom<zbus::zvariant::Value<'_>> for VolumeOutline { type Error = zbus::zvariant::Error; fn try_from(value: zbus::zvariant::Value) -> Result<Self, zbus::zvariant::Error> { let mvalue: HashMap<String, OwnedValue> = value.try_into()?; let res = VolumeOutline { required: get_property(&mvalue, "Required")?, fs_types: get_property(&mvalue, "FsTypes")?, support_auto_size: get_property(&mvalue, "SupportAutoSize")?, adjust_by_ram: get_property(&mvalue, "AdjustByRam")?, snapshots_configurable: get_property(&mvalue, "SnapshotsConfigurable")?, snapshots_affect_sizes: get_property(&mvalue, "SnapshotsAffectSizes")?, size_relevant_volumes: get_property(&mvalue, "SizeRelevantVolumes")?, }; Ok(res) } } /// Represents a single volume #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Volume { mount_path: String, mount_options: Vec<String>, target: VolumeTarget, target_device: Option<String>, fs_type: String, min_size: Option<DeviceSize>, max_size: Option<DeviceSize>, auto_size: bool, snapshots: bool, transactional: Option<bool>, outline: Option<VolumeOutline>, } impl<'a> From<Volume> for zbus::zvariant::Value<'a> { fn from(val: Volume) -> Self { let mut result: HashMap<&str, Value> = HashMap::from([ ("MountPath", Value::new(val.mount_path)), ("MountOptions", Value::new(val.mount_options)), ("Target", val.target.into()), ("FsType", Value::new(val.fs_type)), ("AutoSize", Value::new(val.auto_size)), ("Snapshots", Value::new(val.snapshots)), ]); if let Some(dev) = val.target_device { result.insert("TargetDevice", Value::new(dev)); } if let Some(value) = val.min_size { result.insert("MinSize", value.into()); } if let Some(value) = val.max_size { result.insert("MaxSize", value.into()); } // intentionally skip outline as it is not send to dbus and act as read only parameter Value::new(result) } } impl TryFrom<zbus::zvariant::Value<'_>> for Volume { type Error = zbus::zvariant::Error; fn try_from(object: zbus::zvariant::Value) -> Result<Self, zbus::zvariant::Error> { let hash: HashMap<String, OwnedValue> = object.try_into()?; hash.try_into() } } impl TryFrom<HashMap<String, OwnedValue>> for Volume { type Error = zbus::zvariant::Error; fn try_from(volume_hash: HashMap<String, OwnedValue>) -> Result<Self, zbus::zvariant::Error> { let res = Volume { mount_path: get_property(&volume_hash, "MountPath")?, mount_options: get_property(&volume_hash, "MountOptions")?, target: get_property(&volume_hash, "Target")?, target_device: get_optional_property(&volume_hash, "TargetDevice")?, fs_type: get_property(&volume_hash, "FsType")?, min_size: get_optional_property(&volume_hash, "MinSize")?, max_size: get_optional_property(&volume_hash, "MaxSize")?, auto_size: get_property(&volume_hash, "AutoSize")?, snapshots: get_property(&volume_hash, "Snapshots")?, transactional: get_optional_property(&volume_hash, "Transactional")?, outline: get_optional_property(&volume_hash, "Outline")?, }; Ok(res) } } /// Information about system device created by composition to reflect different devices on system // FIXME Device schema is not generated because it collides with the network Device. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Device { pub device_info: DeviceInfo, pub block_device: Option<BlockDevice>, pub component: Option<Component>, pub drive: Option<Drive>, pub filesystem: Option<Filesystem>, pub lvm_lv: Option<LvmLv>, pub lvm_vg: Option<LvmVg>, pub md: Option<Md>, pub multipath: Option<Multipath>, pub partition: Option<Partition>, pub partition_table: Option<PartitionTable>, pub raid: Option<Raid>, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct DeviceInfo { pub sid: DeviceSid, pub name: String, pub description: String, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct BlockDevice { pub active: bool, pub encrypted: bool, pub size: DeviceSize, pub shrinking: ShrinkingInfo, pub start: u64, pub systems: Vec<String>, pub udev_ids: Vec<String>, pub udev_paths: Vec<String>, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub enum ShrinkingInfo { Supported(DeviceSize), Unsupported(Vec<String>), } impl TryFrom<zbus::zvariant::Value<'_>> for ShrinkingInfo { type Error = zbus::zvariant::Error; fn try_from(value: zbus::zvariant::Value) -> Result<Self, zbus::zvariant::Error> { let hash: HashMap<String, OwnedValue> = value.clone().try_into()?; let mut info: Option<Self> = None; if let Some(size) = get_optional_property(&hash, "Supported")? { info = Some(Self::Supported(size)); } if let Some(reasons) = get_optional_property(&hash, "Unsupported")? { info = Some(Self::Unsupported(reasons)); } info.ok_or(Self::Error::Message(format!( "Wrong value for Shrinking: {}", value ))) } } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Component { #[serde(rename = "type")] pub component_type: String, pub device_names: Vec<String>, pub devices: Vec<DeviceSid>, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Drive { #[serde(rename = "type")] pub drive_type: String, pub vendor: String, pub model: String, pub bus: String, pub bus_id: String, pub driver: Vec<String>, pub transport: String, pub info: DriveInfo, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct DriveInfo { pub sd_card: bool, #[serde(rename = "dellBOSS")] pub dell_boss: bool, } impl TryFrom<zbus::zvariant::Value<'_>> for DriveInfo { type Error = zbus::zvariant::Error; fn try_from(object: zbus::zvariant::Value) -> Result<Self, zbus::zvariant::Error> { let hash: HashMap<String, OwnedValue> = object.try_into()?; hash.try_into() } } impl TryFrom<HashMap<String, OwnedValue>> for DriveInfo { type Error = zbus::zvariant::Error; fn try_from(info_hash: HashMap<String, OwnedValue>) -> Result<Self, zbus::zvariant::Error> { let res = DriveInfo { sd_card: get_property(&info_hash, "SDCard")?, dell_boss: get_property(&info_hash, "DellBOSS")?, }; Ok(res) } } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Filesystem { pub sid: DeviceSid, #[serde(rename = "type")] pub fs_type: String, pub mount_path: String, pub label: String, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct LvmLv { pub volume_group: DeviceSid, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct LvmVg { pub size: DeviceSize, pub physical_volumes: Vec<DeviceSid>, pub logical_volumes: Vec<DeviceSid>, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Md { pub uuid: String, pub level: String, pub devices: Vec<DeviceSid>, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Multipath { pub wires: Vec<String>, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Partition { pub device: DeviceSid, pub efi: bool, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct PartitionTable { #[serde(rename = "type")] pub ptable_type: String, pub partitions: Vec<DeviceSid>, pub unused_slots: Vec<UnusedSlot>, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct UnusedSlot { pub start: u64, pub size: DeviceSize, } impl TryFrom<zbus::zvariant::Value<'_>> for UnusedSlot { type Error = zbus::zvariant::Error; fn try_from(value: Value) -> Result<Self, Self::Error> { let slot_info: (u64, u64) = value.try_into()?; Ok(UnusedSlot { start: slot_info.0, size: slot_info.1.into(), }) } } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Raid { pub devices: Vec<String>, } 0707010000006A000081A4000000000000000000000001671F5A6400000907000000000000000000000000000000000000002A00000000agama/agama-lib/src/storage/model/dasd.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements a data model for DASD devices management. use std::collections::HashMap; use serde::Serialize; use zbus::zvariant::OwnedValue; use crate::{dbus::get_property, error::ServiceError}; /// Represents a DASD device (specific to s390x systems). #[derive(Clone, Debug, Serialize, Default, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct DASDDevice { pub id: String, pub enabled: bool, pub device_name: String, pub formatted: bool, pub diag: bool, pub status: String, pub device_type: String, pub access_type: String, pub partition_info: String, } #[derive(Clone, Debug, Serialize, Default, utoipa::ToSchema)] pub struct DASDFormatSummary { pub total: u32, pub step: u32, pub done: bool, } impl TryFrom<&HashMap<String, OwnedValue>> for DASDDevice { type Error = ServiceError; fn try_from(value: &HashMap<String, OwnedValue>) -> Result<Self, Self::Error> { Ok(DASDDevice { id: get_property(value, "Id")?, enabled: get_property(value, "Enabled")?, device_name: get_property(value, "DeviceName")?, formatted: get_property(value, "Formatted")?, diag: get_property(value, "Diag")?, status: get_property(value, "Status")?, device_type: get_property(value, "Type")?, access_type: get_property(value, "AccessType")?, partition_info: get_property(value, "PartitionInfo")?, }) } } 0707010000006B000081A4000000000000000000000001671F5A64000009BD000000000000000000000000000000000000002A00000000agama/agama-lib/src/storage/model/zfcp.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements a data model for zFCP devices management. use std::collections::HashMap; use serde::Serialize; use zbus::zvariant::OwnedValue; use crate::{dbus::get_property, error::ServiceError}; /// Represents a zFCP disk (specific to s390x systems). #[derive(Clone, Debug, Serialize, Default, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct ZFCPDisk { /// Name of the zFCP device (e.g., /dev/sda) pub name: String, /// zFCP controller channel id (e.g., 0.0.fa00) pub channel: String, /// WWPN of the targer port (e.g., 0x500507630300c562) pub wwpn: String, /// LUN of the SCSI device (e.g. 0x4010403300000000) pub lun: String, } impl TryFrom<&HashMap<String, OwnedValue>> for ZFCPDisk { type Error = ServiceError; fn try_from(value: &HashMap<String, OwnedValue>) -> Result<Self, Self::Error> { Ok(ZFCPDisk { name: get_property(value, "Name")?, channel: get_property(value, "Channel")?, wwpn: get_property(value, "WWPN")?, lun: get_property(value, "LUN")?, }) } } /// Represents a zFCP controller (specific to s390x systems). #[derive(Clone, Debug, Serialize, Default, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct ZFCPController { /// unique internal ID for given controller pub id: String, /// zFCP controller channel id (e.g., 0.0.fa00) pub channel: String, /// flag whenever channel is performing LUN auto scan pub lun_scan: bool, /// flag whenever channel is active pub active: bool, /// map of associated WWPNs and its LUNs pub luns_map: HashMap<String, Vec<String>>, } 0707010000006C000081A4000000000000000000000001671F5A64000024A0000000000000000000000000000000000000002700000000agama/agama-lib/src/storage/proxies.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! D-Bus interface proxies for interfaces implemented by objects in the storage service. //! //! This code was generated by `zbus-xmlgen` `3.1.1` from DBus introspection data. use zbus::dbus_proxy; #[dbus_proxy( interface = "org.opensuse.Agama.Storage1", default_service = "org.opensuse.Agama.Storage1", default_path = "/org/opensuse/Agama/Storage1" )] trait Storage1 { /// Finish method fn finish(&self) -> zbus::Result<()>; /// Install method fn install(&self) -> zbus::Result<()>; /// Probe method fn probe(&self) -> zbus::Result<()>; /// Set the storage config according to the JSON schema fn set_config(&self, settings: &str) -> zbus::Result<u32>; /// Get the current storage config according to the JSON schema fn get_config(&self) -> zbus::Result<String>; /// DeprecatedSystem property #[dbus_proxy(property)] fn deprecated_system(&self) -> zbus::Result<bool>; } #[dbus_proxy( interface = "org.opensuse.Agama.Storage1.Proposal.Calculator", default_service = "org.opensuse.Agama.Storage1", default_path = "/org/opensuse/Agama/Storage1" )] trait ProposalCalculator { /// Calculate guided proposal fn calculate( &self, settings: std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, ) -> zbus::Result<u32>; /// DefaultVolume method fn default_volume( &self, mount_path: &str, ) -> zbus::Result<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>; /// AvailableDevices property #[dbus_proxy(property)] fn available_devices(&self) -> zbus::Result<Vec<zbus::zvariant::OwnedObjectPath>>; /// EncryptionMethods property #[dbus_proxy(property)] fn encryption_methods(&self) -> zbus::Result<Vec<String>>; /// ProductMountPoints property #[dbus_proxy(property)] fn product_mount_points(&self) -> zbus::Result<Vec<String>>; /// Proposal result fn result(&self) -> zbus::Result<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>; } #[dbus_proxy( interface = "org.opensuse.Agama.Storage1.Proposal", default_service = "org.opensuse.Agama.Storage1", default_path = "/org/opensuse/Agama/Storage1/Proposal" )] trait Proposal { /// Actions property #[dbus_proxy(property)] fn actions( &self, ) -> zbus::Result<Vec<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>>; /// Settings property #[dbus_proxy(property)] fn settings( &self, ) -> zbus::Result<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>; } #[dbus_proxy( interface = "org.opensuse.Agama.Storage1.ISCSI.Initiator", default_service = "org.opensuse.Agama.Storage1", assume_defaults = true )] trait Initiator { /// Delete method fn delete(&self, node: &zbus::zvariant::ObjectPath<'_>) -> zbus::Result<u32>; /// Discover method fn discover( &self, address: &str, port: u32, options: std::collections::HashMap<&str, &zbus::zvariant::Value<'_>>, ) -> zbus::Result<u32>; /// IBFT property #[dbus_proxy(property, name = "IBFT")] fn ibft(&self) -> zbus::Result<bool>; /// InitiatorName property #[dbus_proxy(property)] fn initiator_name(&self) -> zbus::Result<String>; #[dbus_proxy(property)] fn set_initiator_name(&self, value: &str) -> zbus::Result<()>; } #[dbus_proxy( interface = "org.opensuse.Agama.Storage1.ISCSI.Node", default_service = "org.opensuse.Agama.Storage1", assume_defaults = true )] trait Node { /// Login method fn login( &self, options: std::collections::HashMap<&str, &zbus::zvariant::Value<'_>>, ) -> zbus::Result<u32>; /// Logout method fn logout(&self) -> zbus::Result<u32>; /// Address property #[dbus_proxy(property)] fn address(&self) -> zbus::Result<String>; /// Connected property #[dbus_proxy(property)] fn connected(&self) -> zbus::Result<bool>; /// IBFT property #[dbus_proxy(property, name = "IBFT")] fn ibft(&self) -> zbus::Result<bool>; /// Interface property #[dbus_proxy(property)] fn interface(&self) -> zbus::Result<String>; /// Port property #[dbus_proxy(property)] fn port(&self) -> zbus::Result<u32>; /// Startup property #[dbus_proxy(property)] fn startup(&self) -> zbus::Result<String>; #[dbus_proxy(property)] fn set_startup(&self, value: &str) -> zbus::Result<()>; /// Target property #[dbus_proxy(property)] fn target(&self) -> zbus::Result<String>; } #[dbus_proxy( interface = "org.opensuse.Agama.Storage1.DASD.Manager", default_service = "org.opensuse.Agama.Storage1", default_path = "/org/opensuse/Agama/Storage1" )] trait DASDManager { /// Disable method fn disable(&self, devices: &[&zbus::zvariant::ObjectPath<'_>]) -> zbus::Result<u32>; /// Enable method fn enable(&self, devices: &[&zbus::zvariant::ObjectPath<'_>]) -> zbus::Result<u32>; /// Format method fn format( &self, devices: &[&zbus::zvariant::ObjectPath<'_>], ) -> zbus::Result<(u32, zbus::zvariant::OwnedObjectPath)>; /// Probe method fn probe(&self) -> zbus::Result<()>; /// SetDiag method fn set_diag( &self, devices: &[&zbus::zvariant::ObjectPath<'_>], diag: bool, ) -> zbus::Result<u32>; } #[dbus_proxy( interface = "org.opensuse.Agama.Storage1.DASD.Device", default_service = "org.opensuse.Agama.Storage1", assume_defaults = true )] trait DASDDevice { /// AccessType property #[dbus_proxy(property)] fn access_type(&self) -> zbus::Result<String>; /// DeviceName property #[dbus_proxy(property)] fn device_name(&self) -> zbus::Result<String>; /// Diag property #[dbus_proxy(property)] fn diag(&self) -> zbus::Result<bool>; /// Enabled property #[dbus_proxy(property)] fn enabled(&self) -> zbus::Result<bool>; /// Formatted property #[dbus_proxy(property)] fn formatted(&self) -> zbus::Result<bool>; /// Id property #[dbus_proxy(property)] fn id(&self) -> zbus::Result<String>; /// PartitionInfo property #[dbus_proxy(property)] fn partition_info(&self) -> zbus::Result<String>; /// Status property #[dbus_proxy(property)] fn status(&self) -> zbus::Result<String>; /// Type property #[dbus_proxy(property)] fn type_(&self) -> zbus::Result<String>; } #[dbus_proxy( interface = "org.opensuse.Agama.Storage1.ZFCP.Manager", default_service = "org.opensuse.Agama.Storage1", default_path = "/org/opensuse/Agama/Storage1" )] trait ZFCPManager { /// Probe method fn probe(&self) -> zbus::Result<()>; /// AllowLUNScan property #[dbus_proxy(property, name = "AllowLUNScan")] fn allow_lunscan(&self) -> zbus::Result<bool>; } #[dbus_proxy( interface = "org.opensuse.Agama.Storage1.ZFCP.Controller", default_service = "org.opensuse.Agama.Storage1", default_path = "/org/opensuse/Agama/Storage1" )] trait ZFCPController { /// Activate method fn activate(&self) -> zbus::Result<u32>; /// ActivateDisk method fn activate_disk(&self, wwpn: &str, lun: &str) -> zbus::Result<u32>; /// DeactivateDisk method fn deactivate_disk(&self, wwpn: &str, lun: &str) -> zbus::Result<u32>; /// GetLUNs method #[dbus_proxy(name = "GetLUNs")] fn get_luns(&self, wwpn: &str) -> zbus::Result<Vec<String>>; /// GetWWPNs method #[dbus_proxy(name = "GetWWPNs")] fn get_wwpns(&self) -> zbus::Result<Vec<String>>; /// Active property #[dbus_proxy(property)] fn active(&self) -> zbus::Result<bool>; /// Channel property #[dbus_proxy(property)] fn channel(&self) -> zbus::Result<String>; /// LUNScan property #[dbus_proxy(property, name = "LUNScan")] fn lunscan(&self) -> zbus::Result<bool>; } #[dbus_proxy( interface = "org.opensuse.Agama.Storage1.ZFCP.Disk", default_service = "org.opensuse.Agama.Storage1", default_path = "/org/opensuse/Agama/Storage1" )] trait Disk { /// Channel property #[dbus_proxy(property)] fn channel(&self) -> zbus::Result<String>; /// LUN property #[dbus_proxy(property, name = "LUN")] fn lun(&self) -> zbus::Result<String>; /// Name property #[dbus_proxy(property)] fn name(&self) -> zbus::Result<String>; /// WWPN property #[dbus_proxy(property, name = "WWPN")] fn wwpn(&self) -> zbus::Result<String>; } 0707010000006D000081A4000000000000000000000001671F5A64000006A1000000000000000000000000000000000000002800000000agama/agama-lib/src/storage/settings.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Representation of the storage settings use crate::install_settings::InstallSettings; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; /// Storage settings for installation #[derive(Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StorageSettings { #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] pub storage: Option<Box<RawValue>>, #[serde(default, rename = "legacyAutoyastStorage")] #[serde(skip_serializing_if = "Option::is_none")] pub storage_autoyast: Option<Box<RawValue>>, } impl From<&InstallSettings> for StorageSettings { fn from(install_settings: &InstallSettings) -> Self { StorageSettings { storage: install_settings.storage.clone(), storage_autoyast: install_settings.storage_autoyast.clone(), } } } 0707010000006E000081A4000000000000000000000001671F5A640000101B000000000000000000000000000000000000002500000000agama/agama-lib/src/storage/store.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements the store for the storage settings. use super::StorageSettings; use crate::base_http_client::BaseHTTPClient; use crate::error::ServiceError; use crate::storage::http_client::StorageHTTPClient; /// Loads and stores the storage settings from/to the HTTP service. pub struct StorageStore { storage_client: StorageHTTPClient, } impl StorageStore { pub fn new(client: BaseHTTPClient) -> Result<StorageStore, ServiceError> { Ok(Self { storage_client: StorageHTTPClient::new(client), }) } pub async fn load(&self) -> Result<StorageSettings, ServiceError> { self.storage_client.get_config().await } pub async fn store(&self, settings: &StorageSettings) -> Result<(), ServiceError> { self.storage_client.set_config(settings).await?; Ok(()) } } #[cfg(test)] mod test { use super::*; use crate::base_http_client::BaseHTTPClient; use httpmock::prelude::*; use std::error::Error; use tokio::test; // without this, "error: async functions cannot be used for tests" fn storage_store(mock_server_url: String) -> StorageStore { let mut bhc = BaseHTTPClient::default(); bhc.base_url = mock_server_url; let client = StorageHTTPClient::new(bhc); StorageStore { storage_client: client, } } #[test] async fn test_getting_storage() -> Result<(), Box<dyn Error>> { let server = MockServer::start(); let storage_mock = server.mock(|when, then| { when.method(GET).path("/api/storage/config"); then.status(200) .header("content-type", "application/json") .body( r#"{ "storage": { "some": "stuff" } }"#, ); }); let url = server.url("/api"); let store = storage_store(url); let settings = store.load().await?; // main assertion assert_eq!(settings.storage.unwrap().get(), r#"{ "some": "stuff" }"#); assert!(settings.storage_autoyast.is_none()); // Ensure the specified mock was called exactly one time (or fail with a detailed error description). storage_mock.assert(); Ok(()) } #[test] async fn test_setting_storage_ok() -> Result<(), Box<dyn Error>> { let server = MockServer::start(); let storage_mock = server.mock(|when, then| { when.method(PUT) .path("/api/storage/config") .header("content-type", "application/json") .body(r#"{"legacyAutoyastStorage":{ "some" : "data" }}"#); then.status(200); }); let url = server.url("/api"); let store = storage_store(url); let boxed_raw_value = serde_json::value::RawValue::from_string(r#"{ "some" : "data" }"#.to_owned())?; let settings = StorageSettings { storage: None, storage_autoyast: Some(boxed_raw_value), }; let result = store.store(&settings).await; // main assertion result?; // Ensure the specified mock was called exactly one time (or fail with a detailed error description). storage_mock.assert(); Ok(()) } } 0707010000006F000081A4000000000000000000000001671F5A640000112C000000000000000000000000000000000000001D00000000agama/agama-lib/src/store.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Load/store the settings from/to the D-Bus services. // TODO: quickly explain difference between FooSettings and FooStore, with an example use crate::base_http_client::BaseHTTPClient; use crate::error::ServiceError; use crate::install_settings::InstallSettings; use crate::{ localization::LocalizationStore, network::NetworkStore, product::ProductStore, scripts::ScriptsStore, software::SoftwareStore, storage::StorageStore, users::UsersStore, }; /// Struct that loads/stores the settings from/to the D-Bus services. /// /// It is composed by a set of "stores" that are able to load/store the /// settings for each service. /// /// This struct uses the default connection built by [connection function](super::connection). pub struct Store { users: UsersStore, network: NetworkStore, product: ProductStore, software: SoftwareStore, storage: StorageStore, localization: LocalizationStore, scripts: ScriptsStore, } impl Store { pub async fn new(http_client: BaseHTTPClient) -> Result<Store, ServiceError> { Ok(Self { localization: LocalizationStore::new(http_client.clone())?, users: UsersStore::new(http_client.clone())?, network: NetworkStore::new(http_client.clone()).await?, product: ProductStore::new(http_client.clone())?, software: SoftwareStore::new(http_client.clone())?, storage: StorageStore::new(http_client.clone())?, scripts: ScriptsStore::new(http_client), }) } /// Loads the installation settings from the HTTP interface. pub async fn load(&self) -> Result<InstallSettings, ServiceError> { let mut settings = InstallSettings { network: Some(self.network.load().await?), software: Some(self.software.load().await?), user: Some(self.users.load().await?), product: Some(self.product.load().await?), localization: Some(self.localization.load().await?), scripts: Some(self.scripts.load().await?), ..Default::default() }; let storage_settings = self.storage.load().await?; settings.storage = storage_settings.storage; settings.storage_autoyast = storage_settings.storage_autoyast; // TODO: use try_join here Ok(settings) } /// Stores the given installation settings in the D-Bus service pub async fn store(&self, settings: &InstallSettings) -> Result<(), ServiceError> { if let Some(network) = &settings.network { self.network.store(network).await?; } if let Some(scripts) = &settings.scripts { self.scripts.store(scripts).await?; } // order is important here as network can be critical for connection // to registration server and selecting product is important for rest if let Some(product) = &settings.product { self.product.store(product).await?; } // ordering: localization after product as some product may miss some locales if let Some(localization) = &settings.localization { self.localization.store(localization).await?; } if let Some(software) = &settings.software { self.software.store(software).await?; } if let Some(user) = &settings.user { self.users.store(user).await?; } if settings.storage.is_some() || settings.storage_autoyast.is_some() { self.storage.store(&settings.into()).await? } Ok(()) } } 07070100000070000081A4000000000000000000000001671F5A640000044A000000000000000000000000000000000000002000000000agama/agama-lib/src/transfer.rs//! File transfer API for Agama. //! //! Implement a file transfer API which, in the future, will support Agama specific URLs. Check the //! YaST document about [URL handling in the //! installer](https://github.com/yast/yast-installation/blob/master/doc/url.md) for further //! information. //! //! At this point, it only supports those schemes supported by CURL. use std::io::Write; use curl::easy::Easy; use thiserror::Error; #[derive(Error, Debug)] #[error(transparent)] pub struct TransferError(#[from] curl::Error); pub type TransferResult<T> = Result<T, TransferError>; /// File transfer API pub struct Transfer {} impl Transfer { /// Retrieves and writes the data from an URL /// /// * `url`: URL to get the data from. /// * `out_fd`: where to write the data. pub fn get(url: &str, mut out_fd: impl Write) -> TransferResult<()> { let mut handle = Easy::new(); handle.url(url)?; let mut transfer = handle.transfer(); transfer.write_function(|buf| Ok(out_fd.write(buf).unwrap()))?; transfer.perform()?; Ok(()) } } 07070100000071000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001A00000000agama/agama-lib/src/users07070100000072000081A4000000000000000000000001671F5A640000047B000000000000000000000000000000000000001D00000000agama/agama-lib/src/users.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements support for handling the users settings mod client; mod http_client; pub mod model; pub mod proxies; mod settings; mod store; pub use client::{FirstUser, UsersClient}; pub use http_client::UsersHTTPClient; pub use settings::{FirstUserSettings, RootUserSettings, UserSettings}; pub use store::UsersStore; 07070100000073000081A4000000000000000000000001671F5A6400000F8A000000000000000000000000000000000000002400000000agama/agama-lib/src/users/client.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements a client to access Agama's users service. use super::proxies::{FirstUser as FirstUserFromDBus, Users1Proxy}; use crate::error::ServiceError; use serde::{Deserialize, Serialize}; use zbus::Connection; /// Represents the settings for the first user #[derive(Serialize, Deserialize, Clone, Debug, Default, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct FirstUser { /// First user's full name pub full_name: String, /// First user's username pub user_name: String, /// First user's password (in clear text) pub password: String, /// Whether auto-login should enabled or not pub autologin: bool, /// Additional data coming from the D-Bus service pub data: std::collections::HashMap<String, zbus::zvariant::OwnedValue>, } impl FirstUser { pub fn from_dbus(dbus_data: zbus::Result<FirstUserFromDBus>) -> zbus::Result<Self> { let data = dbus_data?; Ok(Self { full_name: data.0, user_name: data.1, password: data.2, autologin: data.3, data: data.4, }) } } /// D-Bus client for the users service #[derive(Clone)] pub struct UsersClient<'a> { users_proxy: Users1Proxy<'a>, } impl<'a> UsersClient<'a> { pub async fn new(connection: Connection) -> zbus::Result<UsersClient<'a>> { Ok(Self { users_proxy: Users1Proxy::new(&connection).await?, }) } /// Returns the settings for first non admin user pub async fn first_user(&self) -> zbus::Result<FirstUser> { FirstUser::from_dbus(self.users_proxy.first_user().await) } /// SetRootPassword method pub async fn set_root_password( &self, value: &str, encrypted: bool, ) -> Result<u32, ServiceError> { Ok(self.users_proxy.set_root_password(value, encrypted).await?) } pub async fn remove_root_password(&self) -> Result<u32, ServiceError> { Ok(self.users_proxy.remove_root_password().await?) } /// Whether the root password is set or not pub async fn is_root_password(&self) -> Result<bool, ServiceError> { Ok(self.users_proxy.root_password_set().await?) } /// Returns the SSH key for the root user pub async fn root_ssh_key(&self) -> zbus::Result<String> { self.users_proxy.root_sshkey().await } /// SetRootSSHKey method pub async fn set_root_sshkey(&self, value: &str) -> Result<u32, ServiceError> { Ok(self.users_proxy.set_root_sshkey(value).await?) } /// Set the configuration for the first user pub async fn set_first_user( &self, first_user: &FirstUser, ) -> zbus::Result<(bool, Vec<String>)> { self.users_proxy .set_first_user( &first_user.full_name, &first_user.user_name, &first_user.password, first_user.autologin, std::collections::HashMap::new(), ) .await } pub async fn remove_first_user(&self) -> zbus::Result<bool> { Ok(self.users_proxy.remove_first_user().await? == 0) } } 07070100000074000081A4000000000000000000000001671F5A6400000CBD000000000000000000000000000000000000002900000000agama/agama-lib/src/users/http_client.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use super::client::FirstUser; use crate::users::model::{RootConfig, RootPatchSettings}; use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; pub struct UsersHTTPClient { client: BaseHTTPClient, } impl UsersHTTPClient { pub fn new(client: BaseHTTPClient) -> Result<Self, ServiceError> { Ok(Self { client }) } /// Returns the settings for first non admin user pub async fn first_user(&self) -> Result<FirstUser, ServiceError> { self.client.get("/users/first").await } /// Set the configuration for the first user pub async fn set_first_user(&self, first_user: &FirstUser) -> Result<(), ServiceError> { let result = self.client.put_void("/users/first", first_user).await; if let Err(ServiceError::BackendError(422, ref issues_s)) = result { let issues: Vec<String> = serde_json::from_str(issues_s)?; return Err(ServiceError::WrongUser(issues)); } result } async fn root_config(&self) -> Result<RootConfig, ServiceError> { self.client.get("/users/root").await } /// Whether the root password is set or not pub async fn is_root_password(&self) -> Result<bool, ServiceError> { let root_config = self.root_config().await?; Ok(root_config.password) } /// SetRootPassword method. /// Returns 0 if successful (always, for current backend) pub async fn set_root_password( &self, value: &str, encrypted: bool, ) -> Result<u32, ServiceError> { let rps = RootPatchSettings { sshkey: None, password: Some(value.to_owned()), password_encrypted: Some(encrypted), }; let ret = self.client.patch("/users/root", &rps).await?; Ok(ret) } /// Returns the SSH key for the root user pub async fn root_ssh_key(&self) -> Result<String, ServiceError> { let root_config = self.root_config().await?; Ok(root_config.sshkey) } /// SetRootSSHKey method. /// Returns 0 if successful (always, for current backend) pub async fn set_root_sshkey(&self, value: &str) -> Result<u32, ServiceError> { let rps = RootPatchSettings { sshkey: Some(value.to_owned()), password: None, password_encrypted: None, }; let ret = self.client.patch("/users/root", &rps).await?; Ok(ret) } } 07070100000075000081A4000000000000000000000001671F5A6400000608000000000000000000000000000000000000002300000000agama/agama-lib/src/users/model.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct RootConfig { /// returns if password for root is set or not pub password: bool, /// empty string mean no sshkey is specified pub sshkey: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct RootPatchSettings { /// empty string here means remove ssh key for root pub sshkey: Option<String>, /// empty string here means remove password for root pub password: Option<String>, /// specify if patched password is provided in encrypted form pub password_encrypted: Option<bool>, } 07070100000076000081A4000000000000000000000001671F5A6400000A56000000000000000000000000000000000000002500000000agama/agama-lib/src/users/proxies.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! D-Bus interface proxies for: `org.opensuse.Agama.Users1.*` //! //! This code was generated by `zbus-xmlgen` `3.1.0` from DBus introspection data.`. use zbus::dbus_proxy; /// First user as it comes from D-Bus. /// /// It is composed of: /// /// * full name /// * user name /// * password /// * auto-login (enabled or not) /// * some optional and additional data pub type FirstUser = ( String, String, String, bool, std::collections::HashMap<String, zbus::zvariant::OwnedValue>, ); #[dbus_proxy( interface = "org.opensuse.Agama.Users1", default_service = "org.opensuse.Agama.Manager1", default_path = "/org/opensuse/Agama/Users1" )] trait Users1 { /// RemoveFirstUser method fn remove_first_user(&self) -> zbus::Result<u32>; /// RemoveRootPassword method fn remove_root_password(&self) -> zbus::Result<u32>; /// SetFirstUser method fn set_first_user( &self, full_name: &str, user_name: &str, password: &str, auto_login: bool, data: std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, ) -> zbus::Result<(bool, Vec<String>)>; /// SetRootPassword method fn set_root_password(&self, value: &str, encrypted: bool) -> zbus::Result<u32>; /// SetRootSSHKey method #[dbus_proxy(name = "SetRootSSHKey")] fn set_root_sshkey(&self, value: &str) -> zbus::Result<u32>; /// Write method fn write(&self) -> zbus::Result<u32>; /// FirstUser property #[dbus_proxy(property)] fn first_user(&self) -> zbus::Result<FirstUser>; /// RootPasswordSet property #[dbus_proxy(property)] fn root_password_set(&self) -> zbus::Result<bool>; /// RootSSHKey property #[dbus_proxy(property, name = "RootSSHKey")] fn root_sshkey(&self) -> zbus::Result<String>; } 07070100000077000081A4000000000000000000000001671F5A6400000801000000000000000000000000000000000000002600000000agama/agama-lib/src/users/settings.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use serde::{Deserialize, Serialize}; /// User settings /// /// Holds the user settings for the installation. #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct UserSettings { #[serde(rename = "user")] pub first_user: Option<FirstUserSettings>, pub root: Option<RootUserSettings>, } /// First user settings /// /// Holds the settings for the first user. #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct FirstUserSettings { /// First user's full name pub full_name: Option<String>, /// First user's username pub user_name: Option<String>, /// First user's password (in clear text) pub password: Option<String>, /// Whether auto-login should enabled or not pub autologin: Option<bool>, } /// Root user settings /// /// Holds the settings for the root user. #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct RootUserSettings { /// Root's password (in clear text) #[serde(skip_serializing)] pub password: Option<String>, /// Root SSH public key pub ssh_public_key: Option<String>, } 07070100000078000081A4000000000000000000000001671F5A6400001FC9000000000000000000000000000000000000002300000000agama/agama-lib/src/users/store.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use super::{FirstUser, FirstUserSettings, RootUserSettings, UserSettings, UsersHTTPClient}; use crate::base_http_client::BaseHTTPClient; use crate::error::ServiceError; /// Loads and stores the users settings from/to the D-Bus service. pub struct UsersStore { users_client: UsersHTTPClient, } impl UsersStore { pub fn new(client: BaseHTTPClient) -> Result<Self, ServiceError> { Ok(Self { users_client: UsersHTTPClient::new(client)?, }) } pub fn new_with_client(client: UsersHTTPClient) -> Result<Self, ServiceError> { Ok(Self { users_client: client, }) } pub async fn load(&self) -> Result<UserSettings, ServiceError> { let first_user = self.users_client.first_user().await?; let first_user = FirstUserSettings { user_name: Some(first_user.user_name), autologin: Some(first_user.autologin), full_name: Some(first_user.full_name), password: Some(first_user.password), }; let mut root_user = RootUserSettings::default(); let ssh_public_key = self.users_client.root_ssh_key().await?; if !ssh_public_key.is_empty() { root_user.ssh_public_key = Some(ssh_public_key) } Ok(UserSettings { first_user: Some(first_user), root: Some(root_user), }) } pub async fn store(&self, settings: &UserSettings) -> Result<(), ServiceError> { // fixme: improve if let Some(settings) = &settings.first_user { self.store_first_user(settings).await?; } if let Some(settings) = &settings.root { self.store_root_user(settings).await?; } Ok(()) } async fn store_first_user(&self, settings: &FirstUserSettings) -> Result<(), ServiceError> { let first_user = FirstUser { user_name: settings.user_name.clone().unwrap_or_default(), full_name: settings.full_name.clone().unwrap_or_default(), autologin: settings.autologin.unwrap_or_default(), password: settings.password.clone().unwrap_or_default(), ..Default::default() }; self.users_client.set_first_user(&first_user).await?; Ok(()) } async fn store_root_user(&self, settings: &RootUserSettings) -> Result<(), ServiceError> { if let Some(root_password) = &settings.password { self.users_client .set_root_password(root_password, false) .await?; } if let Some(ssh_public_key) = &settings.ssh_public_key { self.users_client.set_root_sshkey(ssh_public_key).await?; } Ok(()) } } #[cfg(test)] mod test { use super::*; use crate::base_http_client::BaseHTTPClient; use httpmock::prelude::*; use httpmock::Method::PATCH; use std::error::Error; use tokio::test; // without this, "error: async functions cannot be used for tests" fn users_store(mock_server_url: String) -> Result<UsersStore, ServiceError> { let mut bhc = BaseHTTPClient::default(); bhc.base_url = mock_server_url; let client = UsersHTTPClient::new(bhc)?; UsersStore::new_with_client(client) } #[test] async fn test_getting_users() -> Result<(), Box<dyn Error>> { let server = MockServer::start(); let user_mock = server.mock(|when, then| { when.method(GET).path("/api/users/first"); then.status(200) .header("content-type", "application/json") .body( r#"{ "fullName": "Tux", "userName": "tux", "password": "fish", "autologin": true, "data": {} }"#, ); }); let root_mock = server.mock(|when, then| { when.method(GET).path("/api/users/root"); then.status(200) .header("content-type", "application/json") .body( r#"{ "sshkey": "keykeykey", "password": true }"#, ); }); let url = server.url("/api"); let store = users_store(url)?; let settings = store.load().await?; let first_user = FirstUserSettings { full_name: Some("Tux".to_owned()), user_name: Some("tux".to_owned()), password: Some("fish".to_owned()), autologin: Some(true), }; let root_user = RootUserSettings { // FIXME this is weird: no matter what HTTP reports, we end up with None password: None, ssh_public_key: Some("keykeykey".to_owned()), }; let expected = UserSettings { first_user: Some(first_user), root: Some(root_user), }; // main assertion assert_eq!(settings, expected); // Ensure the specified mock was called exactly one time (or fail with a detailed error description). user_mock.assert(); root_mock.assert(); Ok(()) } #[test] async fn test_setting_users() -> Result<(), Box<dyn Error>> { let server = MockServer::start(); let user_mock = server.mock(|when, then| { when.method(PUT) .path("/api/users/first") .header("content-type", "application/json") .body( r#"{"fullName":"Tux","userName":"tux","password":"fish","autologin":true,"data":{}}"# ); then.status(200); }); // note that we use 2 requests for root let root_mock = server.mock(|when, then| { when.method(PATCH) .path("/api/users/root") .header("content-type", "application/json") .body(r#"{"sshkey":null,"password":"1234","passwordEncrypted":false}"#); then.status(200).body("0"); }); let root_mock2 = server.mock(|when, then| { when.method(PATCH) .path("/api/users/root") .header("content-type", "application/json") .body(r#"{"sshkey":"keykeykey","password":null,"passwordEncrypted":null}"#); then.status(200).body("0"); }); let url = server.url("/api"); let store = users_store(url)?; let first_user = FirstUserSettings { full_name: Some("Tux".to_owned()), user_name: Some("tux".to_owned()), password: Some("fish".to_owned()), autologin: Some(true), }; let root_user = RootUserSettings { password: Some("1234".to_owned()), ssh_public_key: Some("keykeykey".to_owned()), }; let settings = UserSettings { first_user: Some(first_user), root: Some(root_user), }; let result = store.store(&settings).await; // main assertion result?; // Ensure the specified mock was called exactly one time (or fail with a detailed error description). user_mock.assert(); root_mock.assert(); root_mock2.assert(); Ok(()) } } 07070100000079000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001800000000agama/agama-locale-data0707010000007A000081A4000000000000000000000001671F5A6400000195000000000000000000000000000000000000002300000000agama/agama-locale-data/Cargo.toml[package] name = "agama-locale-data" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1.0" serde = { version = "1.0.210", features = ["derive"] } quick-xml = { version = "0.28.2", features = ["serialize"] } flate2 = "1.0.34" chrono-tz = "0.8.6" regex = "1" thiserror = "1.0.64" utoipa = "4.2.3" 0707010000007B000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001C00000000agama/agama-locale-data/src0707010000007C000081A4000000000000000000000001671F5A6400001365000000000000000000000000000000000000003400000000agama/agama-locale-data/src/deprecated_timezones.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. /// List of timezones which are deprecated and langtables missing translations for it /// /// Filtering it out also helps with returning smaller list of real timezones. /// Sadly many libraries facing issues with deprecated timezones, see e.g. /// pub(crate) const DEPRECATED_TIMEZONES: &[&str] = &[ "Africa/Asmera", // replaced by Africa/Asmara "Africa/Timbuktu", // replaced by Africa/Bamako "America/Argentina/ComodRivadavia", // replaced by America/Argentina/Catamarca "America/Atka", // replaced by America/Adak "America/Ciudad_Juarez", // failed to find replacement "America/Coral_Harbour", // replaced by America/Atikokan "America/Ensenada", // replaced by America/Tijuana "America/Fort_Nelson", "America/Fort_Wayne", // replaced by America/Indiana/Indianapolis "America/Knox_IN", // replaced by America/Indiana/Knox "America/Nuuk", "America/Porto_Acre", // replaced by America/Rio_Branco "America/Punta_Arenas", "America/Rosario", "America/Virgin", "Antarctica/Troll", "Asia/Ashkhabad", // looks like typo/wrong transcript, it should be Asia/Ashgabat "Asia/Atyrau", "Asia/Barnaul", "Asia/Calcutta", // renamed to Asia/Kolkata "Asia/Chita", "Asia/Chungking", "Asia/Dacca", "Asia/Famagusta", "Asia/Katmandu", "Asia/Macao", "Asia/Qostanay", "Asia/Saigon", "Asia/Srednekolymsk", "Asia/Tel_Aviv", "Asia/Thimbu", "Asia/Tomsk", "Asia/Ujung_Pandang", "Asia/Ulan_Bator", "Asia/Yangon", "Atlantic/Faeroe", "Atlantic/Jan_Mayen", "Australia/ACT", "Australia/Canberra", "Australia/LHI", "Australia/NSW", "Australia/North", "Australia/Queensland", "Australia/South", "Australia/Tasmania", "Australia/Victoria", "Australia/West", "Australia/Yancowinna", "Brazil/Acre", "Brazil/DeNoronha", "Brazil/East", "Brazil/West", "CET", "CST6CDT", "Canada/Atlantic", // all canada TZ was replaced by America ones "Canada/Central", "Canada/Eastern", "Canada/Mountain", "Canada/Newfoundland", "Canada/Pacific", "Canada/Saskatchewan", "Canada/Yukon", "Chile/Continental", // all Chile was replaced by continental America tz "Chile/EasterIsland", "Cuba", "EET", "EST", // not sure why it is not in langtable "EST5EDT", "Egypt", "Eire", "Etc/GMT", "Etc/GMT+0", "Etc/GMT+1", "Etc/GMT+2", "Etc/GMT+3", "Etc/GMT+4", "Etc/GMT+5", "Etc/GMT+6", "Etc/GMT+7", "Etc/GMT+8", "Etc/GMT+9", "Etc/GMT+10", "Etc/GMT+11", "Etc/GMT+12", "Etc/GMT-0", "Etc/GMT-1", "Etc/GMT-2", "Etc/GMT-3", "Etc/GMT-4", "Etc/GMT-5", "Etc/GMT-6", "Etc/GMT-7", "Etc/GMT-8", "Etc/GMT-9", "Etc/GMT-10", "Etc/GMT-11", "Etc/GMT-12", "Etc/GMT-13", "Etc/GMT-14", "Etc/GMT0", "Etc/Greenwich", "Etc/UCT", "Etc/UTC", "Etc/Universal", "Etc/Zulu", "Europe/Astrakhan", "Europe/Belfast", "Europe/Kirov", "Europe/Kyiv", "Europe/Saratov", "Europe/Tiraspol", "Europe/Ulyanovsk", "GB", "GB-Eire", "GMT", "GMT+0", "GMT-0", "GMT0", "Greenwich", "HST", "Hongkong", "Iceland", "Iran", "Israel", "Jamaica", "Japan", "Kwajalein", "Libya", "MET", "Mexico/BajaNorte", "Mexico/BajaSur", "Mexico/General", "MST", "MST7MDT", "NZ", "NZ-CHAT", "Navajo", "Pacific/Bougainville", "Pacific/Kanton", "Pacific/Ponape", "Pacific/Samoa", "Pacific/Truk", "Pacific/Yap", "PRC", "PST8PDT", "Poland", "Portugal", "ROC", "ROK", "Singapore", "Turkey", "UCT", "Universal", "US/Aleutian", // all US/ replaced by America "US/Central", "US/East-Indiana", "US/Eastern", "US/Hawaii", "US/Indiana-Starke", "US/Michigan", "US/Mountain", "US/Pacific", "US/Samoa", "W-SU", "WET", "Zulu", ]; 0707010000007D000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000002500000000agama/agama-locale-data/src/keyboard0707010000007E000081A4000000000000000000000001671F5A64000003BA000000000000000000000000000000000000002800000000agama/agama-locale-data/src/keyboard.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. pub mod xkb_config_registry; pub mod xkeyboard; pub use xkb_config_registry::XkbConfigRegistry; pub use xkeyboard::XKeyboards; 0707010000007F000081A4000000000000000000000001671F5A64000009F7000000000000000000000000000000000000003C00000000agama/agama-locale-data/src/keyboard/xkb_config_registry.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module aims to read the information in the X Keyboard Configuration Database. //! //! <https://freedesktop.org/Software/XKeyboardConfig> use quick_xml::de::from_str; use serde::Deserialize; use std::{error::Error, fs}; const DB_PATH: &str = "/usr/share/X11/xkb/rules/base.xml"; /// X Keyboard Configuration Database #[derive(Deserialize, Debug)] pub struct XkbConfigRegistry { #[serde(rename = "layoutList")] pub layout_list: LayoutList, } impl XkbConfigRegistry { /// Reads the database from the given file /// /// - `path`: database path. pub fn from(path: &str) -> Result<Self, Box<dyn Error>> { let contents = fs::read_to_string(path)?; Ok(from_str(&contents)?) } /// Reads the database from the default path. pub fn from_system() -> Result<Self, Box<dyn Error>> { Self::from(DB_PATH) } } #[derive(Deserialize, Debug)] pub struct LayoutList { #[serde(rename = "layout")] pub layouts: Vec<Layout>, } #[derive(Deserialize, Debug)] pub struct Layout { #[serde(rename = "configItem")] pub config_item: ConfigItem, #[serde(rename = "variantList", default)] pub variants_list: VariantList, } #[derive(Deserialize, Debug)] pub struct ConfigItem { pub name: String, #[serde(rename = "description")] pub description: String, } #[derive(Deserialize, Debug, Default)] pub struct VariantList { #[serde(rename = "variant", default)] pub variants: Vec<Variant>, } #[derive(Deserialize, Debug)] pub struct Variant { #[serde(rename = "configItem")] pub config_item: VariantConfigItem, } #[derive(Deserialize, Debug)] pub struct VariantConfigItem { pub name: String, pub description: String, } 07070100000080000081A4000000000000000000000001671F5A640000056B000000000000000000000000000000000000003200000000agama/agama-locale-data/src/keyboard/xkeyboard.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use serde::Deserialize; use crate::ranked::{RankedLanguages, RankedTerritories}; #[derive(Debug, Deserialize)] pub struct XKeyboard { #[serde(rename(deserialize = "keyboardId"))] /// like "layout(variant)", for example "us" or "ua(phonetic)" pub id: String, /// like "Ukrainian (phonetic)" pub description: String, pub ascii: bool, pub comment: Option<String>, pub languages: RankedLanguages, pub territories: RankedTerritories, } #[derive(Debug, Deserialize)] pub struct XKeyboards { pub keyboard: Vec<XKeyboard>, } 07070100000081000081A4000000000000000000000001671F5A6400000563000000000000000000000000000000000000002800000000agama/agama-locale-data/src/language.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use serde::Deserialize; use crate::ranked::{RankedLocales, RankedTerritories}; #[derive(Debug, Deserialize)] pub struct Language { #[serde(rename(deserialize = "languageId"))] pub id: String, pub territories: RankedTerritories, pub locales: RankedLocales, pub names: crate::localization::Localization, } #[derive(Debug, Deserialize)] pub struct Languages { pub language: Vec<Language>, } impl Languages { pub fn find_by_id(&self, id: &str) -> Option<&Language> { self.language.iter().find(|t| t.id == id) } } 07070100000082000081A4000000000000000000000001671F5A6400001C3D000000000000000000000000000000000000002300000000agama/agama-locale-data/src/lib.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use anyhow::Context; use flate2::bufread::GzDecoder; use quick_xml::de::Deserializer; use serde::Deserialize; use std::collections::HashMap; use std::fs::File; use std::io::BufRead; use std::io::BufReader; use std::process::Command; pub mod deprecated_timezones; pub mod keyboard; pub mod language; mod locale; pub mod localization; pub mod ranked; pub mod territory; pub mod timezone_part; use keyboard::xkeyboard; pub use locale::{InvalidKeymap, InvalidLocaleCode, KeymapId, LocaleId}; fn file_reader(file_path: &str) -> anyhow::Result<impl BufRead> { let file = File::open(file_path) .with_context(|| format!("Failed to read langtable-data ({})", file_path))?; let reader = BufReader::new(GzDecoder::new(BufReader::new(file))); Ok(reader) } /// Gets list of X11 keyboards structs pub fn get_xkeyboards() -> anyhow::Result<xkeyboard::XKeyboards> { const FILE_PATH: &str = "/usr/share/langtable/data/keyboards.xml.gz"; let reader = file_reader(FILE_PATH)?; let mut deserializer = Deserializer::from_reader(reader); let ret = xkeyboard::XKeyboards::deserialize(&mut deserializer) .context("Failed to deserialize keyboard entry")?; Ok(ret) } /// Gets list of available keymaps /// /// ## Examples /// Requires working localectl. /// /// ```no_run /// use agama_locale_data::KeymapId; /// /// let key_maps = agama_locale_data::get_localectl_keymaps().unwrap(); /// let us: KeymapId = "us".parse().unwrap(); /// assert!(key_maps.contains(&us)); /// ``` pub fn get_localectl_keymaps() -> anyhow::Result<Vec<KeymapId>> { let output = Command::new("localectl") .arg("list-keymaps") .output() .context("failed to execute localectl list-maps")? .stdout; let output = String::from_utf8(output).context("Strange localectl output formatting")?; let ret: Vec<_> = output.lines().flat_map(|l| l.parse().ok()).collect(); Ok(ret) } /// Returns struct which contain list of known languages pub fn get_languages() -> anyhow::Result<language::Languages> { const FILE_PATH: &str = "/usr/share/langtable/data/languages.xml.gz"; let reader = file_reader(FILE_PATH)?; let mut deserializer = Deserializer::from_reader(reader); let ret = language::Languages::deserialize(&mut deserializer) .context("Failed to deserialize language entry")?; Ok(ret) } /// Returns struct which contain list of known territories pub fn get_territories() -> anyhow::Result<territory::Territories> { const FILE_PATH: &str = "/usr/share/langtable/data/territories.xml.gz"; let reader = file_reader(FILE_PATH)?; let mut deserializer = Deserializer::from_reader(reader); let ret = territory::Territories::deserialize(&mut deserializer) .context("Failed to deserialize territory entry")?; Ok(ret) } /// Returns struct which contain list of known parts of timezones. Useful for translation pub fn get_timezone_parts() -> anyhow::Result<timezone_part::TimezoneIdParts> { const FILE_PATH: &str = "/usr/share/langtable/data/timezoneidparts.xml.gz"; let reader = file_reader(FILE_PATH)?; let mut deserializer = Deserializer::from_reader(reader); let ret = timezone_part::TimezoneIdParts::deserialize(&mut deserializer) .context("Failed to deserialize timezone part entry")?; Ok(ret) } /// Returns a hash mapping timezones to its main country (typically, the country of /// the city that is used to name the timezone). The information is read from the /// file /usr/share/zoneinfo/zone.tab. pub fn get_timezone_countries() -> anyhow::Result<HashMap<String, String>> { const FILE_PATH: &str = "/usr/share/zoneinfo/zone.tab"; let content = std::fs::read_to_string(FILE_PATH) .with_context(|| format!("Failed to read {}", FILE_PATH))?; let countries = content .lines() .filter_map(|line| { if line.starts_with('#') { return None; } let fields: Vec<&str> = line.split('\t').collect(); Some((fields.get(2)?.to_string(), fields.first()?.to_string())) }) .collect(); Ok(countries) } /// Gets list of non-deprecated timezones pub fn get_timezones() -> Vec<String> { chrono_tz::TZ_VARIANTS .iter() .filter(|&tz| !crate::deprecated_timezones::DEPRECATED_TIMEZONES.contains(&tz.name())) // Filter out deprecated asmera .map(|e| e.name().to_string()) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn test_get_keyboards() { let result = get_xkeyboards().unwrap(); let first = result.keyboard.first().expect("no keyboards"); assert_eq!(first.id, "ad") } #[test] fn test_get_languages() { let result = get_languages().unwrap(); let first = result.language.first().expect("no languages"); assert_eq!(first.id, "aa") } #[test] fn test_get_territories() { let result = get_territories().unwrap(); let first = result.territory.first().expect("no territories"); assert_eq!(first.id, "001") // looks strange, but it is meta id for whole world } #[test] fn test_get_timezone_parts() { let result = get_timezone_parts().unwrap(); let first = result.timezone_part.first().expect("no timezone parts"); assert_eq!(first.id, "Abidjan") } #[test] fn test_get_timezones() { let result = get_timezones(); assert_eq!(result.len(), 430); let first = result.first().expect("no keyboards"); assert_eq!(first, "Africa/Abidjan"); // test that we filter out deprecates Asmera ( there is already recent Asmara) let asmera = result.iter().find(|&t| t == "Africa/Asmera"); assert_eq!(asmera, None); let asmara = result.iter().find(|&t| t == "Africa/Asmara"); assert_eq!(asmara, Some(&"Africa/Asmara".to_string())); // here test that timezones from timezones matches ones in langtable ( as timezones can contain deprecated ones) // so this test catch if there is new zone that is not translated or if a zone is become deprecated let timezones = get_timezones(); let localized = get_timezone_parts() .unwrap() .localize_timezones("de", &timezones); let _res: Vec<(String, String)> = timezones.into_iter().zip(localized).collect(); } } 07070100000083000081A4000000000000000000000001671F5A6400001BC3000000000000000000000000000000000000002600000000agama/agama-locale-data/src/locale.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Defines useful types to deal with localization values use regex::Regex; use serde::Serialize; use std::sync::OnceLock; use std::{fmt::Display, str::FromStr}; use thiserror::Error; #[derive(Clone, Debug, PartialEq, Serialize, utoipa::ToSchema)] pub struct LocaleId { // ISO-639 pub language: String, // ISO-3166 pub territory: String, pub encoding: String, } impl Display for LocaleId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{}_{}.{}", &self.language, &self.territory, &self.encoding ) } } impl Default for LocaleId { fn default() -> Self { Self { language: "en".to_string(), territory: "US".to_string(), encoding: "UTF-8".to_string(), } } } #[derive(Error, Debug)] #[error("Not a valid locale string: {0}")] pub struct InvalidLocaleCode(String); impl TryFrom<&str> for LocaleId { type Error = InvalidLocaleCode; fn try_from(value: &str) -> Result<Self, Self::Error> { let locale_regexp: Regex = Regex::new(r"^([[:alpha:]]+)_([[:alpha:]]+)(?:\.(.+))?").unwrap(); let captures = locale_regexp .captures(value) .ok_or_else(|| InvalidLocaleCode(value.to_string()))?; let encoding = captures .get(3) .map(|e| e.as_str()) .unwrap_or("UTF-8") .to_string(); Ok(Self { language: captures.get(1).unwrap().as_str().to_string(), territory: captures.get(2).unwrap().as_str().to_string(), encoding, }) } } static KEYMAP_ID_REGEX: OnceLock<Regex> = OnceLock::new(); /// Keymap layout identifier /// /// ``` /// use agama_locale_data::KeymapId; /// use std::str::FromStr; /// /// let id: KeymapId = "es(ast)".parse().unwrap(); /// assert_eq!(id.layout, "es"); /// assert_eq!(id.variant, Some("ast".to_string())); /// assert_eq!(id.dashed(), "es-ast".to_string()); /// /// let id_with_dashes: KeymapId = "es-ast".parse().unwrap(); /// assert_eq!(id, id_with_dashes); /// ``` #[derive(Clone, Debug, PartialEq, Serialize, utoipa::ToSchema)] pub struct KeymapId { /// Keyboard layout (e.g., "es" in "es(ast)") pub layout: String, /// Keyboard variante (e.g., "ast" in "es(ast)") pub variant: Option<String>, } impl Default for KeymapId { fn default() -> Self { Self { layout: "us".to_string(), variant: None, } } } #[derive(Error, Debug, PartialEq)] #[error("Invalid keymap ID: {0}")] pub struct InvalidKeymap(String); impl KeymapId { pub fn dashed(&self) -> String { if let Some(variant) = &self.variant { format!("{}-{}", &self.layout, variant) } else { self.layout.to_owned() } } } impl Display for KeymapId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if let Some(variant) = &self.variant { write!(f, "{}({})", &self.layout, variant) } else { write!(f, "{}", &self.layout) } } } impl FromStr for KeymapId { type Err = InvalidKeymap; fn from_str(s: &str) -> Result<Self, Self::Err> { let re = KEYMAP_ID_REGEX // https://docs.rs/regex/latest/regex/#example-verbose-mode .get_or_init(|| { Regex::new( r"(?x) ^ ([\w.]+) # layout part ( # optional variant: \( (?<var1>.+) \) # in parentheses, X11 style | - (?<var2>.+) # or after a minus, console style )? $ # must match whole input, no substring allowed ", ) .unwrap() }); if let Some(parts) = re.captures(s) { let mut variant = None; if let Some(var1) = parts.name("var1") { variant = Some(var1.as_str().to_string()); } if let Some(var2) = parts.name("var2") { variant = Some(var2.as_str().to_string()); } Ok(KeymapId { layout: parts[1].to_string(), variant, }) } else { Err(InvalidKeymap(s.to_string())) } } } #[cfg(test)] mod test { use super::KeymapId; use std::str::FromStr; #[test] fn test_parse_keymap_id() { let keymap_id0 = KeymapId::from_str("es").unwrap(); assert_eq!( KeymapId { layout: "es".to_string(), variant: None }, keymap_id0 ); let keymap_id1 = KeymapId::from_str("es(ast)").unwrap(); assert_eq!( KeymapId { layout: "es".to_string(), variant: Some("ast".to_string()) }, keymap_id1 ); let keymap_id2 = KeymapId::from_str("es-ast").unwrap(); assert_eq!( KeymapId { layout: "es".to_string(), variant: Some("ast".to_string()) }, keymap_id2 ); let keymap_id3 = KeymapId::from_str("pt-nativo-us").unwrap(); assert_eq!( KeymapId { layout: "pt".to_string(), variant: Some("nativo-us".to_string()) }, keymap_id3 ); let keymap_id4 = KeymapId::from_str("lt.std").unwrap(); assert_eq!( KeymapId { layout: "lt.std".to_string(), variant: None }, keymap_id4 ); } #[test] fn test_parse_keymap_id_err() { // no word characters for layout let result = KeymapId::from_str("$%&"); assert!(result.is_err()); // layout is there but with trailing garbage let result = KeymapId::from_str("cz@"); assert!(result.is_err()); // variant but then another variant let result = KeymapId::from_str("cz(qwerty)-yeah"); assert!(result.is_err()); } } 07070100000084000081A4000000000000000000000001671F5A6400000548000000000000000000000000000000000000002C00000000agama/agama-locale-data/src/localization.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct Localization { pub name: Vec<LocalizationEntry>, } impl Localization { pub fn name_for(&self, language: &str) -> Option<String> { let entry = self.name.iter().find(|n| n.language == language)?; Some(entry.value.clone()) } } #[derive(Debug, Deserialize)] pub struct LocalizationEntry { #[serde(rename(deserialize = "languageId"))] pub language: String, #[serde(rename(deserialize = "trName"))] pub value: String, } 07070100000085000081A4000000000000000000000001671F5A6400000707000000000000000000000000000000000000002600000000agama/agama-locale-data/src/ranked.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Bigger rank means it is more important use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct RankedLanguage { #[serde(rename(deserialize = "languageId"))] pub id: String, /// Bigger rank means it is more important pub rank: u16, } #[derive(Debug, Deserialize)] pub struct RankedLanguages { #[serde(default)] pub language: Vec<RankedLanguage>, } #[derive(Debug, Deserialize)] pub struct RankedTerritory { #[serde(rename(deserialize = "territoryId"))] pub id: String, /// Bigger rank means it is more important pub rank: u16, } #[derive(Debug, Deserialize)] pub struct RankedTerritories { #[serde(default)] pub territory: Vec<RankedTerritory>, } #[derive(Debug, Deserialize)] pub struct RankedLocale { #[serde(rename(deserialize = "localeId"))] pub id: String, pub rank: u16, } #[derive(Debug, Deserialize)] pub struct RankedLocales { #[serde(default)] pub locale: Vec<RankedLocale>, } 07070100000086000081A4000000000000000000000001671F5A6400000520000000000000000000000000000000000000002900000000agama/agama-locale-data/src/territory.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct Territory { #[serde(rename(deserialize = "territoryId"))] pub id: String, pub languages: crate::ranked::RankedLanguages, pub names: crate::localization::Localization, } #[derive(Debug, Deserialize)] pub struct Territories { pub territory: Vec<Territory>, } impl Territories { pub fn find_by_id(&self, id: &str) -> Option<&Territory> { self.territory.iter().find(|t| t.id == id) } } 07070100000087000081A4000000000000000000000001671F5A6400000D60000000000000000000000000000000000000002D00000000agama/agama-locale-data/src/timezone_part.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use std::collections::HashMap; use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct TimezoneIdPart { #[serde(rename(deserialize = "timezoneIdPartId"))] /// "Prague" pub id: String, /// [{language: "cs", value: "Praha"}, {"language": "de", value: "Prag"} ...] pub names: crate::localization::Localization, } // Timezone id parts are useful mainly for localization of timezones // Just search each part of timezone for translation #[derive(Debug, Deserialize)] pub struct TimezoneIdParts { #[serde(rename(deserialize = "timezoneIdPart"))] pub timezone_part: Vec<TimezoneIdPart>, } impl TimezoneIdParts { // TODO: Implement a caching mechanism pub fn localize_part(&self, part_id: &str, language: &str) -> Option<String> { self.timezone_part .iter() .find(|p| p.id == part_id) .and_then(|p| p.names.name_for(language)) } /// Localized given list of timezones to given language /// # Examples /// /// ``` /// let parts = agama_locale_data::get_timezone_parts().expect("missing timezone parts"); /// let timezones = vec!["Europe/Prague".to_string(), "Europe/Berlin".to_string()]; /// let result = vec!["Evropa/Praha".to_string(), "Evropa/Berlín".to_string()]; /// assert_eq!(parts.localize_timezones("cs", &timezones), result); /// ``` pub fn localize_timezones(&self, language: &str, timezones: &[String]) -> Vec<String> { let mapping = self.construct_mapping(language); timezones .iter() .map(|tz| self.translate_timezone(&mapping, tz)) .collect() } fn construct_mapping(&self, language: &str) -> HashMap<String, String> { let mut res: HashMap<String, String> = HashMap::with_capacity(self.timezone_part.len()); self.timezone_part .iter() .map(|part| (part.id.clone(), part.names.name_for(language))) .for_each(|(time_id, names)| { // skip missing translations if let Some(trans) = names { res.insert(time_id, trans); } }); res } fn translate_timezone(&self, mapping: &HashMap<String, String>, timezone: &str) -> String { timezone .split('/') .map(|tzp| { mapping .get(&tzp.to_string()) .unwrap_or_else(|| panic!("Unknown timezone part {tzp}")) .to_owned() }) .collect::<Vec<String>>() .join("/") } } 07070100000088000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001300000000agama/agama-server07070100000089000081A4000000000000000000000001671F5A6400000754000000000000000000000000000000000000001E00000000agama/agama-server/Cargo.toml[package] name = "agama-server" version = "0.1.0" edition = "2021" rust-version.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] anyhow = "1.0" agama-locale-data = { path = "../agama-locale-data" } agama-lib = { path = "../agama-lib" } log = "0.4" zbus = { version = "3", default-features = false, features = ["tokio"] } uuid = { version = "1.10.0", features = ["v4"] } thiserror = "1.0.64" serde = { version = "1.0.210", features = ["derive"] } cidr = { version = "0.2.3", features = ["serde"] } tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } tokio-stream = "0.1.16" gettext-rs = { version = "0.7.1", features = ["gettext-system"] } regex = "1.11.0" macaddr = { version = "1.0", features = ["serde_std"] } async-trait = "0.1.83" axum = { version = "0.7.7", features = ["ws"] } serde_json = "1.0.128" tower-http = { version = "0.5.2", features = ["compression-br", "fs", "trace"] } tracing-subscriber = "0.3.18" tracing-journald = "0.3.0" tracing = "0.1.40" clap = { version = "4.5.19", features = ["derive", "wrap_help"] } tower = { version = "0.4.13", features = ["util"] } utoipa = { version = "4.2.0", features = ["axum_extras", "uuid"] } config = "0.14.0" rand = "0.8.5" axum-extra = { version = "0.9.4", features = ["cookie", "typed-header"] } pam = "0.8.0" serde_with = "3.10.0" pin-project = "1.1.5" openssl = "0.10.66" sd-notify = "0.4.2" hyper = "1.4.1" hyper-util = "0.1.9" tokio-openssl = "0.6.5" futures-util = { version = "0.3.30", default-features = false, features = [ "alloc", ] } libsystemd = "0.7.0" subprocess = "0.2.9" gethostname = "0.4.3" [[bin]] name = "agama-dbus-server" path = "src/agama-dbus-server.rs" [[bin]] name = "agama-web-server" path = "src/agama-web-server.rs" [dev-dependencies] http-body-util = "0.1.2" tokio-test = "0.4.4" 0707010000008A000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001900000000agama/agama-server/share0707010000008B000081A4000000000000000000000001671F5A6400000030000000000000000000000000000000000000002D00000000agama/agama-server/share/server-example.yaml--- jwt_secret: "UhLgulLqwi8fKSVez3Mrc8HYFXEnB" 0707010000008C000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001700000000agama/agama-server/src0707010000008D000081A4000000000000000000000001671F5A640000075F000000000000000000000000000000000000002C00000000agama/agama-server/src/agama-dbus-server.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use agama_server::{ l10n::{self, helpers}, logs::init_logging, questions, }; use agama_lib::connection_to; use anyhow::Context; use std::future::pending; const ADDRESS: &str = "unix:path=/run/agama/bus"; const SERVICE_NAME: &str = "org.opensuse.Agama1"; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let locale = helpers::init_locale()?; init_logging().context("Could not initialize the logger")?; let connection = connection_to(ADDRESS) .await .expect("Could not connect to the D-Bus daemon"); // When adding more services here, the order might be important. questions::export_dbus_objects(&connection).await?; log::info!("Started questions interface"); l10n::export_dbus_objects(&connection, &locale).await?; log::info!("Started locale interface"); connection .request_name(SERVICE_NAME) .await .context(format!("Requesting name {SERVICE_NAME}"))?; // Do other things or go to wait forever pending::<()>().await; Ok(()) } 0707010000008E000081A4000000000000000000000001671F5A6400003769000000000000000000000000000000000000002B00000000agama/agama-server/src/agama-web-server.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use std::{ path::{Path, PathBuf}, pin::Pin, process::{ExitCode, Termination}, }; use agama_lib::{auth::AuthToken, connection_to}; use agama_server::{ cert::Certificate, l10n::helpers, logs::init_logging, web::{self, run_monitor}, }; use anyhow::Context; use axum::{ extract::Request as AxumRequest, http::{Request, Response}, Router, }; use clap::{Args, Parser, Subcommand}; use futures_util::pin_mut; use hyper::body::Incoming; use hyper_util::rt::{TokioExecutor, TokioIo}; use hyper_util::server::conn::auto::Builder; use openssl::ssl::{Ssl, SslAcceptor, SslMethod}; use tokio::sync::broadcast::channel; use tokio_openssl::SslStream; use tower::Service; const DEFAULT_WEB_UI_DIR: &str = "/usr/share/agama/web_ui"; const TOKEN_FILE: &str = "/run/agama/token"; #[derive(Subcommand, Debug)] enum Commands { /// Starts the API server. /// /// This command starts the server in the given ports. The secondary port, if enabled, uses SSL. /// If no certificate is specified, agama-web-server generates a self-signed one. Serve(ServeArgs), } /// Manage Agama's HTTP/JSON API. /// /// Agama's public interface is composed by an HTTP/JSON API and a WebSocket. Using this API is /// possible to inspect or change the configuration, start the installation process and monitor /// changes and progress. This program, agama-web-server, implements such an API. /// /// To start the API, use the "serve" command. If you want to get an OpenAPI representation, just go /// for the "doc" command. #[derive(Parser, Debug)] #[command(max_term_width = 100)] struct Cli { #[command(subcommand)] pub command: Commands, } fn find_web_ui_dir() -> PathBuf { if let Ok(home) = std::env::var("HOME") { let path = Path::new(&home).join(".local/share/agama"); if path.exists() { return path; } } Path::new(DEFAULT_WEB_UI_DIR).into() } #[derive(Args, Debug)] struct ServeArgs { // Address/port to listen on. ":::80" listens for both IPv6 and IPv4 // connections unless manually disabled in /proc/sys/net/ipv6/bindv6only. /// Primary port to listen on #[arg(long, default_value = ":::80")] address: String, /// Optional secondary address to listen on #[arg(long, default_value = None)] address2: Option<String>, #[arg(long, default_value = "/etc/agama.d/ssl/key.pem")] key: Option<PathBuf>, #[arg(long, default_value = "/etc/agama.d/ssl/cert.pem")] cert: Option<PathBuf>, // Agama D-Bus address #[arg(long, default_value = "unix:path=/run/agama/bus")] dbus_address: String, // Directory containing the web UI code #[arg(long)] web_ui_dir: Option<PathBuf>, } impl ServeArgs { /// Returns true of given path to certificate points to an existing file fn valid_cert_path(&self) -> bool { self.cert.as_ref().is_some_and(|c| Path::new(&c).exists()) } /// Returns true of given path to key points to an existing file fn valid_key_path(&self) -> bool { self.key.as_ref().is_some_and(|k| Path::new(&k).exists()) } /// Takes options provided by user and loads / creates Certificate struct according to them fn to_certificate(&self) -> anyhow::Result<Certificate> { if self.valid_cert_path() && self.valid_key_path() { let cert = self.cert.clone().unwrap(); let key = self.key.clone().unwrap(); // read the provided certificate Certificate::read(cert.as_path(), key.as_path()) } else { // ask for self-signed certificate let certificate = Certificate::new()?; // write the certificate for the later use // for now do not care if writing self generated certificate failed or not, in the // worst case we will generate new one ... which will surely be better let _ = certificate.write(); Ok(certificate) } } } /// Builds an SSL acceptor using a provided SSL certificate or generates a self-signed one fn ssl_acceptor(certificate: &Certificate) -> Result<SslAcceptor, openssl::error::ErrorStack> { let mut tls_builder = SslAcceptor::mozilla_modern_v5(SslMethod::tls_server())?; tls_builder.set_private_key(&certificate.key)?; tls_builder.set_certificate(&certificate.cert)?; // check that the key belongs to the certificate tls_builder.check_private_key()?; Ok(tls_builder.build()) } /// Checks whether the connection uses SSL or not /// `stream`: the TCP stream containing a request from client async fn is_ssl_stream(stream: &tokio::net::TcpStream) -> bool { // a buffer for reading the first byte from the TCP connection let mut buf = [0u8; 1]; // peek() receives the data without removing it from the stream, // the data is not consumed, it will be read from the stream again later stream .peek(&mut buf) .await // SSL3.0/TLS1.x starts with byte 0x16 // SSL2 starts with 0x80 (but should not be used as it is considered insecure) // see https://stackoverflow.com/q/3897883 // otherwise consider the stream as a plain HTTP stream possibly starting with // "GET ... HTTP/1.1" or "POST ... HTTP/1.1" or a similar line .is_ok_and(|_| buf[0] == 0x16u8 || buf[0] == 0x80u8) } /// Builds a response for the HTTP -> HTTPS redirection /// returns (HTTP response status code) 308 permanent redirect fn redirect_https(host: &str, uri: &hyper::Uri) -> Response<String> { let builder = Response::builder() // build the redirection target URL .header("Location", format!("https://{}{}", host, uri)) .status(hyper::StatusCode::PERMANENT_REDIRECT); // according to documentation this can fail only if builder was previosly fed with data // which failed to parse into an internal representation (e.g. invalid header) builder .body(String::from("")) .expect("Failed to create redirection request") } /// Builds an error response for the HTTP -> HTTPS redirection when we cannot build /// the redirect response from the original request /// returns error 400 fn redirect_error() -> Response<String> { let builder = Response::builder().status(hyper::StatusCode::BAD_REQUEST); let msg = "HTTP protocol is not allowed for external requests, please use HTTPS.\n"; // according to documentation this can fail only if builder was previosly fed with data // which failed to parse into an internal representation (e.g. invalid header) builder .body(String::from(msg)) .expect("Failed to create an error response") } /// Builds a router for the HTTP -> HTTPS redirection /// if the redirection URL cannot be built from the original request it returns error 400 /// instead of the redirection fn https_redirect() -> Router { // see https://docs.rs/axum/latest/axum/routing/struct.Router.html#example let redirect_service = tower::service_fn(|req: AxumRequest| async move { if let Some(host) = req.headers().get("host").and_then(|h| h.to_str().ok()) { Ok(redirect_https(host, req.uri())) } else { Ok(redirect_error()) } }); Router::new() // the wildcard path below does not match an empty path, we need to match it explicitly .route_service("/", redirect_service) .route_service("/*path", redirect_service) } /// handle the HTTPS connection async fn handle_https_stream( tls_acceptor: SslAcceptor, addr: std::net::SocketAddr, tcp_stream: tokio::net::TcpStream, service: axum::Router, ) { // handle HTTPS connection let ssl = Ssl::new(tls_acceptor.context()).unwrap(); let mut tls_stream = SslStream::new(ssl, tcp_stream).unwrap(); if let Err(err) = SslStream::accept(Pin::new(&mut tls_stream)).await { tracing::error!("Error during TSL handshake from {}: {}", addr, err); } else { let stream = TokioIo::new(tls_stream); let hyper_service = hyper::service::service_fn(move |request: Request<Incoming>| { service.clone().call(request) }); let ret = Builder::new(TokioExecutor::new()) .serve_connection_with_upgrades(stream, hyper_service) .await; if let Err(err) = ret { tracing::error!("Error serving connection from {}: {}", addr, err); } } } /// handle the HTTP connection async fn handle_http_stream( addr: std::net::SocketAddr, tcp_stream: tokio::net::TcpStream, service: axum::Router, redirector_service: axum::Router, ) { let stream = TokioIo::new(tcp_stream); let hyper_service = hyper::service::service_fn(move |request: Request<Incoming>| { // check if it is local connection or external // the to_canonical() converts IPv4-mapped IPv6 addresses // to plain IPv4, then is_loopback() works correctly for the IPv4 connections if addr.ip().to_canonical().is_loopback() { // accept plain HTTP on the local connection service.clone().call(request) } else { // redirect external connections to HTTPS redirector_service.clone().call(request) } }); let ret = Builder::new(TokioExecutor::new()) .serve_connection_with_upgrades(stream, hyper_service) .await; if let Err(err) = ret { tracing::error!("Error serving connection from {}: {}", addr, err); } } /// Starts the web server async fn start_server(address: String, service: Router, ssl_acceptor: SslAcceptor) { tracing::info!("Starting Agama web server at {}", address); // see https://github.com/tokio-rs/axum/blob/main/examples/low-level-openssl/src/main.rs // how to use axum with openSSL let listener = tokio::net::TcpListener::bind(&address) .await .unwrap_or_else(|error| { let msg = format!("Error: could not listen on {}: {}", &address, error); tracing::error!(msg); panic!("{}", msg) }); pin_mut!(listener); let redirector = https_redirect(); loop { let tower_service = service.clone(); let redirector_service = redirector.clone(); let tls_acceptor = ssl_acceptor.clone(); // Wait for a new tcp connection; if it fails we cannot do much, so print an error and die let (tcp_stream, addr) = listener .accept() .await .expect("Failed to open port for listening"); tokio::spawn(async move { if is_ssl_stream(&tcp_stream).await { // handle HTTPS connection handle_https_stream(tls_acceptor, addr, tcp_stream, tower_service).await; } else { // handle HTTP connection handle_http_stream(addr, tcp_stream, tower_service, redirector_service).await; } }); } } /// Start serving the API. /// `options`: command-line arguments. async fn serve_command(args: ServeArgs) -> anyhow::Result<()> { _ = helpers::init_locale(); init_logging().context("Could not initialize the logger")?; let (tx, _) = channel(16); run_monitor(tx.clone()).await?; let config = web::ServiceConfig::load()?; write_token(TOKEN_FILE, &config.jwt_secret).context("could not create the token file")?; let dbus = connection_to(&args.dbus_address).await?; let web_ui_dir = args.web_ui_dir.clone().unwrap_or(find_web_ui_dir()); let service = web::service(config, tx, dbus, web_ui_dir).await?; // TODO: Move elsewhere? Use a singleton? (It would be nice to use the same // generated self-signed certificate on both ports.) let ssl_acceptor = if let Ok(ssl_acceptor) = ssl_acceptor(&args.to_certificate()?) { ssl_acceptor } else { return Err(anyhow::anyhow!("SSL initialization failed")); }; let mut addresses = vec![args.address]; if let Some(a) = args.address2 { addresses.push(a) } let servers: Vec<_> = addresses .iter() .map(|a| { tokio::spawn(start_server( a.clone(), service.clone(), ssl_acceptor.clone(), )) }) .collect(); // notify systemd that web server start serving if let Ok(true) = sd_notify::booted() { sd_notify::notify(true, &[sd_notify::NotifyState::Ready]) .context("Failed to notify systemd")?; } futures_util::future::join_all(servers).await; Ok(()) } async fn run_command(cli: Cli) -> anyhow::Result<()> { match cli.command { Commands::Serve(options) => serve_command(options).await, } } fn write_token(path: &str, secret: &str) -> anyhow::Result<()> { let token = AuthToken::generate(secret)?; Ok(token.write(path)?) } /// Represents the result of execution. pub enum CliResult { /// Successful execution. Ok = 0, /// Something went wrong. Error = 1, } impl Termination for CliResult { fn report(self) -> ExitCode { ExitCode::from(self as u8) } } #[tokio::main] async fn main() -> CliResult { let cli = Cli::parse(); if let Err(error) = run_command(cli).await { eprintln!("{:?}", error); return CliResult::Error; } CliResult::Ok } 0707010000008F000081A4000000000000000000000001671F5A6400001419000000000000000000000000000000000000001F00000000agama/agama-server/src/cert.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use anyhow; use gethostname::gethostname; use openssl::asn1::Asn1Time; use openssl::bn::{BigNum, MsbOption}; use openssl::hash::MessageDigest; use openssl::pkey::{PKey, Private}; use openssl::rsa::Rsa; use openssl::x509::extension::{BasicConstraints, SubjectAlternativeName, SubjectKeyIdentifier}; use openssl::x509::{X509NameBuilder, X509}; use std::{ fs, io::{self, Write}, os::unix::fs::OpenOptionsExt, path::Path, }; const DEFAULT_CERT_DIR: &str = "/etc/agama.d/ssl"; /// Structure to handle and store certificate and private key which is later /// used for establishing HTTPS connection pub struct Certificate { pub cert: X509, pub key: PKey<Private>, } impl Certificate { /// Writes cert, key to (for now well known) location(s) pub fn write(&self) -> anyhow::Result<()> { // check and create default dir if needed if !Path::new(DEFAULT_CERT_DIR).is_dir() { std::fs::create_dir_all(DEFAULT_CERT_DIR)?; } if let Ok(bytes) = self.cert.to_pem() { write_and_restrict(Path::new(DEFAULT_CERT_DIR).join("cert.pem"), &bytes)?; } if let Ok(bytes) = self.key.private_key_to_pem_pkcs8() { write_and_restrict(Path::new(DEFAULT_CERT_DIR).join("key.pem"), &bytes)?; } Ok(()) } /// Reads certificate and corresponding private key from given paths pub fn read<T: AsRef<Path>>(cert: T, key: T) -> anyhow::Result<Self> { let cert_bytes = std::fs::read(cert)?; let key_bytes = std::fs::read(key)?; let cert = X509::from_pem(&cert_bytes); let key = PKey::private_key_from_pem(&key_bytes); match (cert, key) { (Ok(c), Ok(k)) => Ok(Certificate { cert: c, key: k }), _ => Err(anyhow::anyhow!("Failed to read certificate")), } } /// Creates a self-signed certificate pub fn new() -> anyhow::Result<Self> { let rsa = Rsa::generate(2048)?; let key = PKey::from_rsa(rsa)?; let hostname = gethostname() .into_string() .unwrap_or(String::from("localhost")); let mut x509_name = X509NameBuilder::new()?; x509_name.append_entry_by_text("O", "Agama")?; x509_name.append_entry_by_text("CN", hostname.as_str())?; let x509_name = x509_name.build(); let mut builder = X509::builder()?; builder.set_version(2)?; let serial_number = { let mut serial = BigNum::new()?; serial.rand(159, MsbOption::MAYBE_ZERO, false)?; serial.to_asn1_integer()? }; builder.set_serial_number(&serial_number)?; builder.set_subject_name(&x509_name)?; builder.set_issuer_name(&x509_name)?; builder.set_pubkey(&key)?; let not_before = Asn1Time::days_from_now(0)?; builder.set_not_before(¬_before)?; let not_after = Asn1Time::days_from_now(365)?; builder.set_not_after(¬_after)?; builder.append_extension(BasicConstraints::new().critical().ca().build()?)?; builder.append_extension( SubjectAlternativeName::new() // use the default Agama host name // TODO: use the gethostname crate and use the current real hostname .dns("agama") // use the default name for the mDNS/Avahi // TODO: check which name is actually used by mDNS, to avoid // conflicts it might actually use something like agama-2.local .dns("agama.local") .build(&builder.x509v3_context(None, None))?, )?; let subject_key_identifier = SubjectKeyIdentifier::new().build(&builder.x509v3_context(None, None))?; builder.append_extension(subject_key_identifier)?; builder.sign(&key, MessageDigest::sha256())?; let cert = builder.build(); Ok(Certificate { cert, key }) } } /// Writes buf into a file at path and sets the file permissions for the root only access fn write_and_restrict<T: AsRef<Path>>(path: T, buf: &[u8]) -> io::Result<()> { let mut file = fs::OpenOptions::new() .create(true) .truncate(true) .write(true) .mode(0o400) .open(path)?; file.write_all(buf)?; Ok(()) } 07070100000090000081A4000000000000000000000001671F5A640000221B000000000000000000000000000000000000001F00000000agama/agama-server/src/dbus.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module defines some reusable functions/structs related to D-Bus that might be useful when //! implementing agama-server features. use std::{ collections::{hash_map::Entry, HashMap}, pin::Pin, sync::Arc, task::{Context, Poll}, }; use agama_lib::{dbus::to_owned_hash, error::ServiceError}; use futures_util::{ready, Stream}; use pin_project::pin_project; use tokio_stream::StreamMap; use zbus::{ fdo::{InterfacesAdded, InterfacesRemoved, PropertiesChanged}, zvariant::{ObjectPath, OwnedObjectPath}, MatchRule, Message, MessageStream, MessageType, }; #[derive(Debug)] pub enum DBusObjectChange { Added(OwnedObjectPath, HashMap<String, zbus::zvariant::OwnedValue>), Changed(OwnedObjectPath, HashMap<String, zbus::zvariant::OwnedValue>), Removed(OwnedObjectPath), } const PROPERTIES_CHANGED: &str = "properties_changed"; const OBJECTS_MANAGER: &str = "objects_manager"; /// This stream listens for changes in a collection of D-Bus objects and emits /// an [DBusObjectChange] when an object is added, updated or removed. It is required /// that the collection implements the ObjectManager interface. /// /// Initially, it was intended to emit the proxy representing the object. However, as /// retrieving the proxy is an async operation too, it might lead to a deadlock. /// See the [zbus::MessageStream](https://docs.rs/zbus/4.2.0/zbus/struct.MessageStream.html) /// and [issue#350](https://github.com/dbus2/zbus/issues/350). /// /// TODO: allow filtering by multiple D-Bus interfaces and properties. #[pin_project] pub struct DBusObjectChangesStream { connection: zbus::Connection, manager_path: OwnedObjectPath, namespace: OwnedObjectPath, interface: String, #[pin] inner: StreamMap<&'static str, MessageStream>, } impl DBusObjectChangesStream { /// Creates a new stream. /// /// * `connection`: D-Bus connection to listen on. /// * `manager_path`: D-Bus path of the object implementing the ObjectManager. /// * `namespace`: namespace to watch (corresponds to a "path_namespace" in the MatchRule). /// * `interface`: name of the interface to watch. pub async fn new( connection: &zbus::Connection, manager_path: &ObjectPath<'_>, namespace: &ObjectPath<'_>, interface: &str, ) -> Result<Self, ServiceError> { let manager_path = OwnedObjectPath::from(manager_path.to_owned()); let namespace = OwnedObjectPath::from(namespace.to_owned()); let connection = connection.clone(); let mut inner = StreamMap::new(); inner.insert( OBJECTS_MANAGER, Self::build_added_and_removed_stream(&connection, &manager_path).await?, ); inner.insert( PROPERTIES_CHANGED, Self::build_properties_changed_stream(&connection, &namespace).await?, ); Ok(Self { connection, manager_path, namespace, interface: interface.to_string(), inner, }) } /// Handles the case where a property changes. /// /// * message: property change message. /// * interface: name of the interface to watch. fn handle_properties_changed( message: Result<Arc<Message>, zbus::Error>, interface: &str, ) -> Option<DBusObjectChange> { let Ok(message) = message else { return None; }; let properties = PropertiesChanged::from_message(message)?; let args = properties.args().ok()?; if args.interface_name.as_str() == interface { let path = OwnedObjectPath::from(properties.path().unwrap().clone()); let data = to_owned_hash(&args.changed_properties); Some(DBusObjectChange::Changed(path, data)) } else { None } } /// Handles the addition or removal of an object. /// /// * message: add/remove message. /// * interface: name of the interface to watch. fn handle_added_or_removed( message: Result<Arc<Message>, zbus::Error>, interface: &str, ) -> Option<DBusObjectChange> { let Ok(message) = message else { return None; }; if let Some(added) = InterfacesAdded::from_message(message.clone()) { let args = added.args().ok()?; let data = args.interfaces_and_properties.get(&interface)?; let data = to_owned_hash(data); let path = OwnedObjectPath::from(args.object_path().clone()); return Some(DBusObjectChange::Added(path, data)); } if let Some(removed) = InterfacesRemoved::from_message(message) { let args = removed.args().ok()?; if args.interfaces.contains(&interface) { let path = OwnedObjectPath::from(args.object_path().clone()); return Some(DBusObjectChange::Removed(path)); } } None } /// Builds a stream of added/removed objects within the collection. /// /// * `connection`: D-Bus connection. /// * `manager_path`: path of the object implementing the ObjectManager interface. async fn build_added_and_removed_stream( connection: &zbus::Connection, manager_path: &OwnedObjectPath, ) -> Result<MessageStream, ServiceError> { let rule = MatchRule::builder() .msg_type(MessageType::Signal) .path(manager_path.clone())? .interface("org.freedesktop.DBus.ObjectManager")? .build(); let stream = MessageStream::for_match_rule(rule, connection, None).await?; Ok(stream) } /// Builds a stream of properties changed within the collection. /// /// * `connection`: D-Bus connection. /// * `namespace`: namespace to watch for. async fn build_properties_changed_stream( connection: &zbus::Connection, namespace: &OwnedObjectPath, ) -> Result<MessageStream, ServiceError> { let rule = MatchRule::builder() .msg_type(MessageType::Signal) .path_namespace(namespace.clone())? .interface("org.freedesktop.DBus.Properties")? .member("PropertiesChanged")? .build(); let stream = MessageStream::for_match_rule(rule, connection, None).await?; Ok(stream) } } impl Stream for DBusObjectChangesStream { type Item = DBusObjectChange; fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { let mut pinned = self.project(); Poll::Ready(loop { let item = ready!(pinned.inner.as_mut().poll_next(cx)); let next_value = match item { Some((PROPERTIES_CHANGED, message)) => { Self::handle_properties_changed(message, pinned.interface) } Some((OBJECTS_MANAGER, message)) => { Self::handle_added_or_removed(message, pinned.interface) } _ => None, }; if next_value.is_some() { break next_value; } }) } } pub struct ObjectsCache<T> { objects: HashMap<OwnedObjectPath, T>, } impl<T> ObjectsCache<T> where T: Default, { pub fn add(&mut self, path: OwnedObjectPath, object: T) { _ = self.objects.insert(path, object) } pub fn find_or_create(&mut self, path: &OwnedObjectPath) -> &mut T { match self.objects.entry(path.clone()) { Entry::Vacant(entry) => entry.insert(T::default()), Entry::Occupied(entry) => entry.into_mut(), } } pub fn remove(&mut self, path: &OwnedObjectPath) -> Option<T> { self.objects.remove(path) } } impl<T> Default for ObjectsCache<T> { fn default() -> Self { Self { objects: HashMap::new(), } } } 07070100000091000081A4000000000000000000000001671F5A640000088C000000000000000000000000000000000000002000000000agama/agama-server/src/error.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use agama_lib::error::ServiceError; use axum::{ http::StatusCode, response::{IntoResponse, Response}, Json, }; use serde_json::json; use crate::{l10n::LocaleError, questions::QuestionsError}; #[derive(thiserror::Error, Debug)] pub enum Error { #[error("D-Bus error: {0}")] DBus(#[from] zbus::Error), #[error("Generic error: {0}")] Anyhow(String), #[error("Agama service error: {0}")] Service(#[from] ServiceError), #[error("Questions service error: {0}")] Questions(QuestionsError), #[error("Software service error: {0}")] Locale(#[from] LocaleError), } // This would be nice, but using it for a return type // results in a confusing error message about // error[E0277]: the trait bound `MyError: Serialize` is not satisfied //type MyResult<T> = Result<T, MyError>; impl From<anyhow::Error> for Error { fn from(e: anyhow::Error) -> Self { // {:#} includes causes Self::Anyhow(format!("{:#}", e)) } } impl From<Error> for zbus::fdo::Error { fn from(value: Error) -> zbus::fdo::Error { zbus::fdo::Error::Failed(format!("D-Bus error: {value}")) } } impl IntoResponse for Error { fn into_response(self) -> Response { let body = json!({ "error": self.to_string() }); (StatusCode::BAD_REQUEST, Json(body)).into_response() } } 07070100000092000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001C00000000agama/agama-server/src/l10n07070100000093000081A4000000000000000000000001671F5A6400000489000000000000000000000000000000000000001F00000000agama/agama-server/src/l10n.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. mod dbus; pub mod error; pub mod helpers; mod keyboard; pub mod l10n; mod locale; mod timezone; pub mod web; pub use agama_lib::localization::model::LocaleConfig; pub use dbus::export_dbus_objects; pub use error::LocaleError; pub use keyboard::Keymap; pub use l10n::L10n; pub use locale::LocaleEntry; pub use timezone::TimezoneEntry; 07070100000094000081A4000000000000000000000001671F5A6400001103000000000000000000000000000000000000002400000000agama/agama-server/src/l10n/dbus.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use std::sync::{Arc, RwLock}; use agama_locale_data::{KeymapId, LocaleId}; use zbus::{dbus_interface, Connection}; use super::L10n; struct L10nInterface { backend: Arc<RwLock<L10n>>, } #[dbus_interface(name = "org.opensuse.Agama1.Locale")] impl L10nInterface { #[dbus_interface(property)] pub fn locales(&self) -> Vec<String> { let backend = self.backend.read().unwrap(); backend.locales.to_owned() } #[dbus_interface(property)] pub fn set_locales(&mut self, locales: Vec<String>) -> zbus::fdo::Result<()> { let mut backend = self.backend.write().unwrap(); if locales.is_empty() { return Err(zbus::fdo::Error::Failed( "The locales list cannot be empty".to_string(), )); } backend .set_locales(&locales) .map_err(|e| zbus::fdo::Error::Failed(format!("Could not set the locales: {}", e)))?; Ok(()) } #[dbus_interface(property, name = "UILocale")] pub fn ui_locale(&self) -> String { let backend = self.backend.read().unwrap(); backend.ui_locale.to_string() } #[dbus_interface(property, name = "UILocale")] pub fn set_ui_locale(&mut self, locale: &str) -> zbus::fdo::Result<()> { let mut backend = self.backend.write().unwrap(); let locale: LocaleId = locale .try_into() .map_err(|_e| zbus::fdo::Error::Failed(format!("Invalid locale value '{locale}'")))?; Ok(backend.translate(&locale)?) } #[dbus_interface(property)] pub fn keymap(&self) -> String { let backend = self.backend.read().unwrap(); backend.keymap.to_string() } #[dbus_interface(property)] fn set_keymap(&mut self, keymap_id: &str) -> Result<(), zbus::fdo::Error> { let mut backend = self.backend.write().unwrap(); let keymap_id: KeymapId = keymap_id .parse() .map_err(|_e| zbus::fdo::Error::InvalidArgs("Cannot parse keymap ID".to_string()))?; backend .set_keymap(keymap_id) .map_err(|e| zbus::fdo::Error::Failed(format!("Could not set the keymap: {}", e)))?; Ok(()) } #[dbus_interface(property)] pub fn timezone(&self) -> String { let backend = self.backend.read().unwrap(); backend.timezone.clone() } #[dbus_interface(property)] pub fn set_timezone(&mut self, timezone: &str) -> Result<(), zbus::fdo::Error> { let mut backend = self.backend.write().unwrap(); backend .set_timezone(timezone) .map_err(|e| zbus::fdo::Error::Failed(format!("Could not set the timezone: {}", e)))?; Ok(()) } // TODO: what should be returned value for commit? pub fn commit(&mut self) -> zbus::fdo::Result<()> { let backend = self.backend.read().unwrap(); backend.commit().map_err(|e| { zbus::fdo::Error::Failed(format!("Could not apply the l10n configuration: {e}")) })?; Ok(()) } } pub async fn export_dbus_objects( connection: &Connection, locale: &LocaleId, ) -> Result<(), Box<dyn std::error::Error>> { const PATH: &str = "/org/opensuse/Agama1/Locale"; // When serving, request the service name _after_ exposing the main object let backend = L10n::new_with_locale(locale)?; let locale_iface = L10nInterface { backend: Arc::new(RwLock::new(backend)), }; connection.object_server().at(PATH, locale_iface).await?; Ok(()) } 07070100000095000081A4000000000000000000000001671F5A640000050F000000000000000000000000000000000000002500000000agama/agama-server/src/l10n/error.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use agama_locale_data::{InvalidKeymap, KeymapId}; #[derive(thiserror::Error, Debug)] pub enum LocaleError { #[error("Unknown locale code: {0}")] UnknownLocale(String), #[error("Unknown timezone: {0}")] UnknownTimezone(String), #[error("Unknown keymap: {0}")] UnknownKeymap(KeymapId), #[error("Invalid keymap: {0}")] InvalidKeymap(#[from] InvalidKeymap), #[error("Could not apply the changes")] Commit(#[from] std::io::Error), } 07070100000096000081A4000000000000000000000001671F5A64000006B0000000000000000000000000000000000000002700000000agama/agama-server/src/l10n/helpers.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Helpers functions //! //! FIXME: find a better place for the localization function use agama_locale_data::LocaleId; use gettextrs::{bind_textdomain_codeset, setlocale, textdomain, LocaleCategory}; use std::env; /// Initializes the service locale. /// /// It returns the used locale. Defaults to `en_US.UTF-8`. pub fn init_locale() -> Result<LocaleId, Box<dyn std::error::Error>> { let lang = env::var("LANG").unwrap_or("en_US.UTF-8".to_string()); let locale: LocaleId = lang.as_str().try_into().unwrap_or_default(); set_service_locale(&locale); textdomain("xkeyboard-config")?; bind_textdomain_codeset("xkeyboard-config", "UTF-8")?; Ok(locale) } /// Sets the service locale. /// pub fn set_service_locale(locale: &LocaleId) { if setlocale(LocaleCategory::LcAll, locale.to_string()).is_none() { log::warn!("Could not set the locale"); } } 07070100000097000081A4000000000000000000000001671F5A6400000FAD000000000000000000000000000000000000002800000000agama/agama-server/src/l10n/keyboard.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use agama_locale_data::{get_localectl_keymaps, keyboard::XkbConfigRegistry, KeymapId}; use gettextrs::*; use serde::ser::{Serialize, SerializeStruct}; use std::collections::HashMap; // Minimal representation of a keymap #[derive(Clone, Debug, utoipa::ToSchema)] pub struct Keymap { /// Keymap identifier (e.g., "us") pub id: KeymapId, /// Keymap description description: String, } impl Keymap { pub fn new(id: KeymapId, description: &str) -> Self { Self { id, description: description.to_string(), } } pub fn localized_description(&self) -> String { gettext(&self.description) } } impl Serialize for Keymap { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: serde::Serializer, { let mut state = serializer.serialize_struct("Keymap", 2)?; state.serialize_field("id", &self.id.to_string())?; state.serialize_field("description", &self.localized_description())?; state.end() } } /// Represents the keymaps database. /// /// The list of supported keymaps is read from `systemd-localed` and the /// descriptions from the X Keyboard Configuraiton Database (see /// `agama_locale_data::XkbConfigRegistry`). #[derive(Default)] pub struct KeymapsDatabase { keymaps: Vec<Keymap>, } impl KeymapsDatabase { pub fn new() -> Self { Self::default() } /// Reads the list of keymaps. pub fn read(&mut self) -> anyhow::Result<()> { self.keymaps = get_keymaps()?; Ok(()) } pub fn exists(&self, id: &KeymapId) -> bool { self.keymaps.iter().any(|k| &k.id == id) } /// Returns the list of keymaps. pub fn entries(&self) -> &Vec<Keymap> { &self.keymaps } } /// Returns the list of keymaps to offer. /// /// It only includes the keyboards supported by `localectl` but getting /// the description from the X Keyboard Configuration Database. fn get_keymaps() -> anyhow::Result<Vec<Keymap>> { let mut keymaps: Vec<Keymap> = vec![]; let xkb_descriptions = get_keymap_descriptions(); let keymap_ids = get_localectl_keymaps()?; for keymap_id in keymap_ids { let keymap_id_str = keymap_id.to_string(); if let Some(description) = xkb_descriptions.get(&keymap_id_str) { keymaps.push(Keymap::new(keymap_id, description)); } else { log::debug!("Keyboard '{}' not found in xkb database", keymap_id_str); } } Ok(keymaps) } /// Returns a map of keymaps ids and its descriptions from the X Keyboard /// Configuration Database. fn get_keymap_descriptions() -> HashMap<String, String> { let layouts = XkbConfigRegistry::from_system().unwrap(); let mut keymaps = HashMap::new(); for layout in layouts.layout_list.layouts { let name = layout.config_item.name; keymaps.insert(name.to_string(), layout.config_item.description.to_string()); for variant in layout.variants_list.variants { let id = format!("{}({})", &name, &variant.config_item.name); keymaps.insert(id, variant.config_item.description); } } keymaps } 07070100000098000081A4000000000000000000000001671F5A6400002159000000000000000000000000000000000000002400000000agama/agama-server/src/l10n/l10n.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use std::env; use std::io; use std::process::Command; use std::time::Duration; use crate::error::Error; use agama_locale_data::{KeymapId, LocaleId}; use regex::Regex; use subprocess::{Popen, PopenConfig, PopenError, Redirection}; use super::keyboard::KeymapsDatabase; use super::locale::LocalesDatabase; use super::timezone::TimezonesDatabase; use super::{helpers, LocaleError}; pub struct L10n { pub timezone: String, pub timezones_db: TimezonesDatabase, pub locales: Vec<String>, pub locales_db: LocalesDatabase, pub keymap: KeymapId, pub keymaps_db: KeymapsDatabase, pub ui_locale: LocaleId, pub ui_keymap: KeymapId, } // timeout for the setxkbmap call (in seconds), when there is an authentication // problem when accessing the X server then it enters an infinite loop const SETXKBMAP_TIMEOUT: u64 = 3; // helper function which runs a command with timeout and collects it's standard // output fn run_with_timeout(cmd: &[&str], timeout: u64) -> Result<Option<String>, PopenError> { // start the subprocess let mut process = Popen::create( cmd, PopenConfig { stdout: Redirection::Pipe, stderr: Redirection::Pipe, ..Default::default() }, )?; // wait for it to finish or until the timeout is reached if process .wait_timeout(Duration::from_secs(timeout))? .is_none() { tracing::warn!("Command {:?} timed out!", cmd); // if the process is still running after the timeout then terminate it, // ignore errors, there is another attempt later to kill the process let _ = process.terminate(); // give the process some time to react to SIGTERM if process.wait_timeout(Duration::from_secs(1))?.is_none() { // process still running, kill it with SIGKILL process.kill()?; } return Err(PopenError::LogicError("Timeout reached")); } // get the collected stdout/stderr let (out, err) = process.communicate(None)?; if let Some(err_str) = err { if !err_str.is_empty() { tracing::warn!("Error output size: {}", err_str.len()); } } Ok(out) } // the default X display to use if not configured or when X forwarding is used fn default_display() -> String { String::from(":0") } // helper function to get the X display name, if not set it returns the default display fn display() -> String { let display = env::var("DISPLAY"); match display { Ok(display) => { // use the $DISPLAY value only when it is a local X server if display.starts_with(':') { display } else { // when using SSH X forwarding (e.g. "localhost:10.0") we could // accidentally change the configuration of the remote X server, // in that case try using the local X server if it is available default_display() } } Err(_) => default_display(), } } impl L10n { pub fn new_with_locale(ui_locale: &LocaleId) -> Result<Self, Error> { const DEFAULT_TIMEZONE: &str = "Europe/Berlin"; let locale = ui_locale.to_string(); let mut locales_db = LocalesDatabase::new(); locales_db.read(&locale)?; let mut default_locale = ui_locale.to_string(); if !locales_db.exists(locale.as_str()) { // TODO: handle the case where the database is empty (not expected!) default_locale = locales_db.entries().first().unwrap().id.to_string(); }; let mut timezones_db = TimezonesDatabase::new(); timezones_db.read(&ui_locale.language)?; let mut default_timezone = DEFAULT_TIMEZONE.to_string(); if !timezones_db.exists(&default_timezone) { default_timezone = timezones_db.entries().first().unwrap().code.to_string(); }; let mut keymaps_db = KeymapsDatabase::new(); keymaps_db.read()?; let ui_keymap = Self::x11_keymap().unwrap_or("us".to_string()); let locale = Self { keymap: "us".parse().unwrap(), timezone: default_timezone, locales: vec![default_locale], locales_db, timezones_db, keymaps_db, ui_locale: ui_locale.clone(), ui_keymap: ui_keymap.parse().unwrap_or_default(), }; Ok(locale) } pub fn set_locales(&mut self, locales: &Vec<String>) -> Result<(), LocaleError> { for loc in locales { if !self.locales_db.exists(loc.as_str()) { return Err(LocaleError::UnknownLocale(loc.to_string()))?; } } self.locales.clone_from(locales); Ok(()) } pub fn set_timezone(&mut self, timezone: &str) -> Result<(), LocaleError> { // TODO: modify exists() to receive an `&str` if !self.timezones_db.exists(&timezone.to_string()) { return Err(LocaleError::UnknownTimezone(timezone.to_string()))?; } timezone.clone_into(&mut self.timezone); Ok(()) } pub fn set_keymap(&mut self, keymap_id: KeymapId) -> Result<(), LocaleError> { if !self.keymaps_db.exists(&keymap_id) { return Err(LocaleError::UnknownKeymap(keymap_id)); } self.keymap = keymap_id; Ok(()) } // TODO: use LocaleError pub fn translate(&mut self, locale: &LocaleId) -> Result<(), Error> { helpers::set_service_locale(locale); self.timezones_db.read(&locale.language)?; self.locales_db.read(&locale.language)?; self.ui_locale = locale.clone(); Ok(()) } // TODO: use LocaleError pub fn set_ui_keymap(&mut self, keymap_id: KeymapId) -> Result<(), LocaleError> { if !self.keymaps_db.exists(&keymap_id) { return Err(LocaleError::UnknownKeymap(keymap_id)); } let keymap = keymap_id.to_string(); self.ui_keymap = keymap_id; Command::new("/usr/bin/localectl") .args(["set-x11-keymap", &keymap]) .output() .map_err(LocaleError::Commit)?; let output = run_with_timeout( &["setxkbmap", "-display", &display(), &keymap], SETXKBMAP_TIMEOUT, ); output.map_err(|e| { LocaleError::Commit(io::Error::new(io::ErrorKind::Other, e.to_string())) })?; Ok(()) } // TODO: what should be returned value for commit? pub fn commit(&self) -> Result<(), LocaleError> { const ROOT: &str = "/mnt"; Command::new("/usr/bin/systemd-firstboot") .args([ "--root", ROOT, "--force", "--locale", self.locales.first().unwrap_or(&"en_US.UTF-8".to_string()), "--keymap", &self.keymap.to_string(), "--timezone", &self.timezone, ]) .status()?; Ok(()) } fn x11_keymap() -> Result<String, io::Error> { let output = run_with_timeout( &["setxkbmap", "-query", "-display", &display()], SETXKBMAP_TIMEOUT, ); let output = output.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; let output = output.unwrap_or(String::new()); let keymap_regexp = Regex::new(r"(?m)^layout: (.+)$").unwrap(); let captures = keymap_regexp.captures(&output); let keymap = captures .and_then(|c| c.get(1).map(|e| e.as_str())) .unwrap_or("us") .to_string(); Ok(keymap) } } 07070100000099000081A4000000000000000000000001671F5A64000017E5000000000000000000000000000000000000002600000000agama/agama-server/src/l10n/locale.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module provides support for reading the locales database. use crate::error::Error; use agama_locale_data::{InvalidLocaleCode, LocaleId}; use anyhow::Context; use serde::Serialize; use serde_with::{serde_as, DisplayFromStr}; use std::{fs, process::Command}; /// Represents a locale, including the localized language and territory. #[serde_as] #[derive(Debug, Serialize, Clone, utoipa::ToSchema)] pub struct LocaleEntry { /// The locale code (e.g., "es_ES.UTF-8"). #[serde_as(as = "DisplayFromStr")] pub id: LocaleId, /// Localized language name (e.g., "Spanish", "Español", etc.) pub language: String, /// Localized territory name (e.g., "Spain", "España", etc.) pub territory: String, } /// Represents the locales database. /// /// The list of supported locales is read from `systemd-localed`. However, the /// translations are obtained from the `agama_locale_data` crate. #[derive(Default)] pub struct LocalesDatabase { known_locales: Vec<LocaleId>, locales: Vec<LocaleEntry>, } impl LocalesDatabase { pub fn new() -> Self { Self::default() } /// Loads the list of locales. /// /// It checks for a file in /etc/agama.d/locales containing the list of supported locales (one per line). /// It it does not exists, calls `localectl list-locales`. /// /// * `ui_language`: language to translate the descriptions (e.g., "en"). pub fn read(&mut self, ui_language: &str) -> Result<(), Error> { self.known_locales = Self::get_locales_list()?; self.locales = self.get_locales(ui_language)?; Ok(()) } /// Determines whether a locale exists in the database. pub fn exists<T>(&self, locale: T) -> bool where T: TryInto<LocaleId>, T::Error: Into<InvalidLocaleCode>, { if let Ok(locale) = TryInto::<LocaleId>::try_into(locale) { return self.known_locales.contains(&locale); } false } /// Returns the list of locales. pub fn entries(&self) -> &Vec<LocaleEntry> { &self.locales } /// Gets the supported locales information. /// /// * `ui_language`: language to use in the translations. fn get_locales(&self, ui_language: &str) -> Result<Vec<LocaleEntry>, Error> { const DEFAULT_LANG: &str = "en"; let mut result = Vec::with_capacity(self.known_locales.len()); let languages = agama_locale_data::get_languages()?; let territories = agama_locale_data::get_territories()?; for code in self.known_locales.as_slice() { let language = languages .find_by_id(&code.language) .context("language not found")?; let names = &language.names; let language_label = names .name_for(ui_language) .or_else(|| names.name_for(DEFAULT_LANG)) .unwrap_or(language.id.to_string()); let territory = territories .find_by_id(&code.territory) .context("territory not found")?; let names = &territory.names; let territory_label = names .name_for(ui_language) .or_else(|| names.name_for(DEFAULT_LANG)) .unwrap_or(territory.id.to_string()); let entry = LocaleEntry { id: code.clone(), language: language_label, territory: territory_label, }; result.push(entry) } Ok(result) } fn get_locales_list() -> Result<Vec<LocaleId>, Error> { const LOCALES_LIST_PATH: &str = "/etc/agama.d/locales"; let locales = fs::read_to_string(LOCALES_LIST_PATH).map(Self::get_locales_from_string); if let Ok(locales) = locales { if !locales.is_empty() { return Ok(locales); } } let result = Command::new("localectl") .args(["list-locales"]) .output() .context("Failed to get the list of locales")?; let locales = String::from_utf8(result.stdout) .map(Self::get_locales_from_string) .context("Invalid UTF-8 sequence from list-locales")?; Ok(locales) } fn get_locales_from_string(locales: String) -> Vec<LocaleId> { locales .lines() .filter_map(|line| TryInto::<LocaleId>::try_into(line).ok()) .collect() } } #[cfg(test)] mod tests { use super::LocalesDatabase; use agama_locale_data::LocaleId; #[test] fn test_read_locales() { let mut db = LocalesDatabase::new(); db.read("de").unwrap(); let found_locales = db.entries(); let spanish: LocaleId = "es_ES".try_into().unwrap(); let found = found_locales .iter() .find(|l| l.id == spanish) .expect("Spanish locale not found?! Suggestion: zypper in glibc-locale"); assert_eq!(&found.language, "Spanisch"); assert_eq!(&found.territory, "Spanien"); } #[test] fn test_locale_exists() { let mut db = LocalesDatabase::new(); db.read("en").unwrap(); assert!(db.exists("en_US")); assert!(!db.exists("unknown_UNKNOWN")); } } 0707010000009A000081A4000000000000000000000001671F5A6400001696000000000000000000000000000000000000002800000000agama/agama-server/src/l10n/timezone.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module provides support for reading the timezones database. use crate::error::Error; use agama_locale_data::territory::Territories; use agama_locale_data::timezone_part::TimezoneIdParts; use serde::Serialize; use std::collections::HashMap; /// Represents a timezone, including each part as localized. #[derive(Clone, Debug, Serialize, utoipa::ToSchema)] pub struct TimezoneEntry { /// Timezone identifier (e.g. "Atlantic/Canary"). pub code: String, /// Localized parts (e.g., "Atlántico", "Canarias"). pub parts: Vec<String>, /// Localized name of the territory this timezone is associated to pub country: Option<String>, } #[derive(Default)] pub struct TimezonesDatabase { timezones: Vec<TimezoneEntry>, } impl TimezonesDatabase { pub fn new() -> Self { Self::default() } /// Initializes the list of known timezones. /// /// * `ui_language`: language to translate the descriptions (e.g., "en"). pub fn read(&mut self, ui_language: &str) -> Result<(), Error> { self.timezones = self.get_timezones(ui_language)?; Ok(()) } /// Determines whether a timezone exists in the database. pub fn exists(&self, timezone: &String) -> bool { self.timezones.iter().any(|t| &t.code == timezone) } /// Returns the list of timezones. pub fn entries(&self) -> &Vec<TimezoneEntry> { &self.timezones } /// Returns a list of the supported timezones. /// /// Each element of the list contains a timezone identifier and a vector /// containing the translation of each part of the language. /// /// * `ui_language`: language to translate the descriptions (e.g., "en"). fn get_timezones(&self, ui_language: &str) -> Result<Vec<TimezoneEntry>, Error> { let timezones = agama_locale_data::get_timezones(); let tz_parts = agama_locale_data::get_timezone_parts()?; let territories = agama_locale_data::get_territories()?; let tz_countries = agama_locale_data::get_timezone_countries()?; const COUNTRYLESS: [&str; 2] = ["UTC", "Antarctica/South_Pole"]; let ret = timezones .into_iter() .filter_map(|tz| { let parts = translate_parts(&tz, ui_language, &tz_parts); let country = translate_country(&tz, ui_language, &tz_countries, &territories); match country { None if !COUNTRYLESS.contains(&tz.as_str()) => None, _ => Some(TimezoneEntry { code: tz, parts, country, }), } }) .collect(); Ok(ret) } } fn translate_parts(timezone: &str, ui_language: &str, tz_parts: &TimezoneIdParts) -> Vec<String> { timezone .split('/') .map(|part| { tz_parts .localize_part(part, ui_language) .unwrap_or(part.to_owned()) }) .collect() } fn translate_country( timezone: &str, lang: &str, countries: &HashMap<String, String>, territories: &Territories, ) -> Option<String> { let tz = match timezone { "Asia/Rangoon" => "Asia/Yangon", "Europe/Kiev" => "Europe/Kyiv", _ => timezone, }; let country_id = countries.get(tz)?; let territory = territories.find_by_id(country_id)?; let name = territory.names.name_for(lang)?; Some(name) } #[cfg(test)] mod tests { use super::TimezonesDatabase; #[test] fn test_read_timezones() { let mut db = TimezonesDatabase::new(); db.read("es").unwrap(); let found_timezones = db.entries(); let found = found_timezones .iter() .find(|tz| tz.code == "Europe/Berlin") .unwrap(); assert_eq!(&found.code, "Europe/Berlin"); assert_eq!( found.parts, vec!["Europa".to_string(), "Berlín".to_string()] ); assert_eq!(found.country, Some("Alemania".to_string())); } #[test] fn test_read_timezone_without_country() { let mut db = TimezonesDatabase::new(); db.read("es").unwrap(); let timezone = db.entries().iter().find(|tz| tz.code == "UTC").unwrap(); assert_eq!(timezone.country, None); } #[test] fn test_read_kiev_country() { let mut db = TimezonesDatabase::new(); db.read("en").unwrap(); let timezone = db .entries() .iter() .find(|tz| tz.code == "Europe/Kiev") .unwrap(); assert_eq!(timezone.country, Some("Ukraine".to_string())); } #[test] fn test_timezone_exists() { let mut db = TimezonesDatabase::new(); db.read("es").unwrap(); assert!(db.exists(&"Atlantic/Canary".to_string())); assert!(!db.exists(&"Unknown/Unknown".to_string())); } } 0707010000009B000081A4000000000000000000000001671F5A6400001AB5000000000000000000000000000000000000002300000000agama/agama-server/src/l10n/web.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module implements the web API for the localization module. use super::{ error::LocaleError, keyboard::Keymap, locale::LocaleEntry, timezone::TimezoneEntry, L10n, }; use crate::{ error::Error, web::{Event, EventsSender}, }; use agama_lib::{ error::ServiceError, localization::model::LocaleConfig, localization::LocaleProxy, proxies::LocaleProxy as ManagerLocaleProxy, }; use agama_locale_data::LocaleId; use axum::{ extract::State, http::StatusCode, response::IntoResponse, routing::{get, patch}, Json, Router, }; use std::sync::Arc; use tokio::sync::RwLock; #[derive(Clone)] struct LocaleState<'a> { locale: Arc<RwLock<L10n>>, proxy: LocaleProxy<'a>, manager_proxy: ManagerLocaleProxy<'a>, events: EventsSender, } /// Sets up and returns the axum service for the localization module. /// /// * `events`: channel to send the events to the main service. pub async fn l10n_service( dbus: zbus::Connection, events: EventsSender, ) -> Result<Router, ServiceError> { let id = LocaleId::default(); let locale = L10n::new_with_locale(&id).unwrap(); let proxy = LocaleProxy::new(&dbus).await?; let manager_proxy = ManagerLocaleProxy::new(&dbus).await?; let state = LocaleState { locale: Arc::new(RwLock::new(locale)), proxy, manager_proxy, events, }; let router = Router::new() .route("/keymaps", get(keymaps)) .route("/locales", get(locales)) .route("/timezones", get(timezones)) .route("/config", patch(set_config).get(get_config)) .with_state(state); Ok(router) } #[utoipa::path(get, path = "/l10n/locales", responses( (status = 200, description = "List of known locales", body = Vec<LocaleEntry>) ))] async fn locales(State(state): State<LocaleState<'_>>) -> Json<Vec<LocaleEntry>> { let data = state.locale.read().await; let locales = data.locales_db.entries().to_vec(); Json(locales) } #[utoipa::path( get, path = "/timezones", context_path = "/api/l10n", responses( (status = 200, description = "List of known timezones", body = Vec<TimezoneEntry>) ) )] async fn timezones(State(state): State<LocaleState<'_>>) -> Json<Vec<TimezoneEntry>> { let data = state.locale.read().await; let timezones = data.timezones_db.entries().to_vec(); Json(timezones) } #[utoipa::path( get, path = "/keymaps", context_path = "/api/l10n", responses( (status = 200, description = "List of known keymaps", body = Vec<Keymap>) ) )] async fn keymaps(State(state): State<LocaleState<'_>>) -> Json<Vec<Keymap>> { let data = state.locale.read().await; let keymaps = data.keymaps_db.entries().to_vec(); Json(keymaps) } // TODO: update all or nothing // TODO: send only the attributes that have changed #[utoipa::path( patch, path = "/config", context_path = "/api/l10n", operation_id = "set_l10n_config", responses( (status = 204, description = "Set the locale configuration", body = LocaleConfig) ) )] async fn set_config( State(state): State<LocaleState<'_>>, Json(value): Json<LocaleConfig>, ) -> Result<impl IntoResponse, Error> { let mut data = state.locale.write().await; let mut changes = LocaleConfig::default(); if let Some(locales) = &value.locales { data.set_locales(locales)?; changes.locales.clone_from(&value.locales); } if let Some(timezone) = &value.timezone { data.set_timezone(timezone)?; changes.timezone.clone_from(&value.timezone); } if let Some(keymap_id) = &value.keymap { let keymap_id = keymap_id.parse().map_err(LocaleError::InvalidKeymap)?; data.set_keymap(keymap_id)?; changes.keymap.clone_from(&value.keymap); } if let Some(ui_locale) = &value.ui_locale { let locale: LocaleId = ui_locale .as_str() .try_into() .map_err(|_e| LocaleError::UnknownLocale(ui_locale.to_string()))?; data.translate(&locale)?; let locale_string = locale.to_string(); state.manager_proxy.set_locale(&locale_string).await?; changes.ui_locale = Some(locale_string); _ = state.events.send(Event::LocaleChanged { locale: locale.to_string(), }); } if let Some(ui_keymap) = &value.ui_keymap { let ui_keymap = ui_keymap.parse().map_err(LocaleError::InvalidKeymap)?; data.set_ui_keymap(ui_keymap)?; } if let Err(e) = update_dbus(&state.proxy, &changes).await { log::warn!("Could not synchronize settings in the localization D-Bus service: {e}"); } _ = state.events.send(Event::L10nConfigChanged(changes)); Ok(StatusCode::NO_CONTENT) } #[utoipa::path( get, path = "/config", context_path = "/api/l10n", operation_id = "get_l10n_config", responses( (status = 200, description = "Localization configuration", body = LocaleConfig) ) )] async fn get_config(State(state): State<LocaleState<'_>>) -> Json<LocaleConfig> { let data = state.locale.read().await; Json(LocaleConfig { locales: Some(data.locales.clone()), keymap: Some(data.keymap.to_string()), timezone: Some(data.timezone.to_string()), ui_locale: Some(data.ui_locale.to_string()), ui_keymap: Some(data.ui_keymap.to_string()), }) } pub async fn update_dbus( client: &LocaleProxy<'_>, config: &LocaleConfig, ) -> Result<(), ServiceError> { if let Some(locales) = &config.locales { let locales: Vec<_> = locales.iter().map(|l| l.as_ref()).collect(); client.set_locales(&locales).await?; } if let Some(keymap) = &config.keymap { client.set_keymap(keymap.as_str()).await?; } if let Some(timezone) = &config.timezone { client.set_timezone(timezone).await?; } if let Some(ui_locale) = &config.ui_locale { client.set_uilocale(ui_locale).await?; } Ok(()) } 0707010000009C000081A4000000000000000000000001671F5A640000041C000000000000000000000000000000000000001E00000000agama/agama-server/src/lib.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. pub mod cert; pub mod dbus; pub mod error; pub mod l10n; pub mod logs; pub mod manager; pub mod network; pub mod questions; pub mod scripts; pub mod software; pub mod storage; pub mod users; pub mod web; pub use web::service; 0707010000009D000081A4000000000000000000000001671F5A640000062B000000000000000000000000000000000000001F00000000agama/agama-server/src/logs.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Functions to work with logs. use anyhow::Context; use libsystemd::logging; use tracing_subscriber::prelude::*; /// Initializes the logging mechanism. /// /// It is based on [Tracing](https://github.com/tokio-rs/tracing), part of the Tokio ecosystem. pub fn init_logging() -> anyhow::Result<()> { if logging::connected_to_journal() { let journald = tracing_journald::layer().context("could not connect to journald")?; tracing_subscriber::registry().with(journald).init(); } else { let subscriber = tracing_subscriber::fmt() .with_file(true) .with_line_number(true) .compact() .finish(); tracing::subscriber::set_global_default(subscriber)?; } Ok(()) } 0707010000009E000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001F00000000agama/agama-server/src/manager0707010000009F000081A4000000000000000000000001671F5A6400000365000000000000000000000000000000000000002200000000agama/agama-server/src/manager.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. pub mod web; pub use web::manager_service; 070701000000A0000081A4000000000000000000000001671F5A640000204A000000000000000000000000000000000000002600000000agama/agama-server/src/manager/web.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module implements the web API for the manager service. //! //! The module offers two public functions: //! //! * `manager_service` which returns the Axum service. //! * `manager_stream` which offers an stream that emits the manager events coming from D-Bus. use agama_lib::{ error::ServiceError, manager::{InstallationPhase, ManagerClient}, proxies::Manager1Proxy, }; use axum::{ extract::{Request, State}, http::StatusCode, response::IntoResponse, routing::{get, post}, Json, Router, }; use rand::distributions::{Alphanumeric, DistString}; use serde::Serialize; use std::pin::Pin; use tokio::process::Command; use tokio_stream::{Stream, StreamExt}; use tower_http::services::ServeFile; use crate::{ error::Error, web::{ common::{progress_router, service_status_router}, Event, }, }; #[derive(Clone)] pub struct ManagerState<'a> { dbus: zbus::Connection, manager: ManagerClient<'a>, } /// Holds information about the manager's status. #[derive(Clone, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct InstallerStatus { /// Current installation phase. phase: InstallationPhase, /// Whether the service is busy. is_busy: bool, /// Whether Agama is running on Iguana. use_iguana: bool, /// Whether it is possible to start the installation. can_install: bool, } /// Returns a stream that emits manager related events coming from D-Bus. /// /// It emits the Event::InstallationPhaseChanged event. /// /// * `connection`: D-Bus connection to listen for events. pub async fn manager_stream( dbus: zbus::Connection, ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Error> { let proxy = Manager1Proxy::new(&dbus).await?; let stream = proxy .receive_current_installation_phase_changed() .await .then(|change| async move { if let Ok(phase) = change.get().await { match InstallationPhase::try_from(phase) { Ok(phase) => Some(Event::InstallationPhaseChanged { phase }), Err(error) => { log::warn!("Ignoring the installation phase change. Error: {}", error); None } } } else { None } }) .filter_map(|e| e); Ok(Box::pin(stream)) } /// Sets up and returns the axum service for the manager module pub async fn manager_service(dbus: zbus::Connection) -> Result<Router, ServiceError> { const DBUS_SERVICE: &str = "org.opensuse.Agama.Manager1"; const DBUS_PATH: &str = "/org/opensuse/Agama/Manager1"; let status_router = service_status_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; let progress_router = progress_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; let manager = ManagerClient::new(dbus.clone()).await?; let state = ManagerState { manager, dbus }; Ok(Router::new() .route("/probe", post(probe_action)) .route("/probe_sync", post(probe_sync_action)) .route("/install", post(install_action)) .route("/finish", post(finish_action)) .route("/installer", get(installer_status)) .route("/logs.tar.gz", get(download_logs)) .merge(status_router) .merge(progress_router) .with_state(state)) } /// Starts the probing process. // The Probe D-Bus method is blocking and will not return until the probing is finished. To avoid a // long-lived HTTP connection, this method returns immediately (with a 200) and runs the request on // a separate task. #[utoipa::path( post, path = "/probe", context_path = "/api/manager", responses( ( status = 200, description = "The probing was requested but there is no way to know whether it succeeded." ) ) )] async fn probe_action(State(state): State<ManagerState<'_>>) -> Result<(), Error> { let dbus = state.dbus.clone(); tokio::spawn(async move { let result = dbus .call_method( Some("org.opensuse.Agama.Manager1"), "/org/opensuse/Agama/Manager1", Some("org.opensuse.Agama.Manager1"), "Probe", &(), ) .await; if let Err(error) = result { tracing::error!("Could not start probing: {:?}", error); } }); Ok(()) } /// Starts the probing process and waits until it is done. /// We need this because the CLI (agama_lib::Store) only does sync calls. #[utoipa::path( post, path = "/probe_sync", context_path = "/api/manager", responses( (status = 200, description = "Probing done.") ) )] async fn probe_sync_action(State(state): State<ManagerState<'_>>) -> Result<(), Error> { state.manager.probe().await?; Ok(()) } /// Starts the installation process. #[utoipa::path( post, path = "/install", context_path = "/api/manager", responses( (status = 200, description = "The installation process was started.") ) )] async fn install_action(State(state): State<ManagerState<'_>>) -> Result<(), Error> { state.manager.install().await?; Ok(()) } /// Executes the post installation tasks (e.g., rebooting the system). #[utoipa::path( post, path = "/install", context_path = "/api/manager", responses( (status = 200, description = "The installation tasks are executed.") ) )] async fn finish_action(State(state): State<ManagerState<'_>>) -> Result<(), Error> { state.manager.finish().await?; Ok(()) } /// Returns the manager status. #[utoipa::path( get, path = "/installer", context_path = "/api/manager", responses( (status = 200, description = "Installation status.", body = InstallerStatus) ) )] async fn installer_status( State(state): State<ManagerState<'_>>, ) -> Result<Json<InstallerStatus>, Error> { let phase = state.manager.current_installation_phase().await?; // CanInstall gets blocked during installation let can_install = match phase { InstallationPhase::Install => false, _ => state.manager.can_install().await?, }; let status = InstallerStatus { phase, can_install, is_busy: state.manager.is_busy().await, use_iguana: state.manager.use_iguana().await?, }; Ok(Json(status)) } /// Returns agama logs #[utoipa::path(get, path = "/api/manager/logs", responses( (status = 200, description = "Download logs blob.") ))] pub async fn download_logs() -> impl IntoResponse { let path = generate_logs().await; let Ok(path) = path else { return (StatusCode::INTERNAL_SERVER_ERROR).into_response(); }; match ServeFile::new(path) .try_call(Request::new(axum::body::Body::empty())) .await { Ok(res) => res.into_response(), Err(_) => (StatusCode::INTERNAL_SERVER_ERROR).into_response(), } } async fn generate_logs() -> Result<String, Error> { let random_name: String = Alphanumeric.sample_string(&mut rand::thread_rng(), 8); let path = format!("/run/agama/logs_{random_name}"); Command::new("agama") .args(["logs", "store", "-d", path.as_str()]) .status() .await .map_err(|e| ServiceError::CannotGenerateLogs(e.to_string()))?; let full_path = format!("{path}.tar.gz"); Ok(full_path) } 070701000000A1000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001F00000000agama/agama-server/src/network070701000000A2000081A4000000000000000000000001671F5A6400000C12000000000000000000000000000000000000002200000000agama/agama-server/src/network.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Network configuration service for Agama //! //! This library implements the network configuration service for Agama. //! //! ## Connections and devices //! //! The library is built around the concepts of network devices and connections, akin to //! NetworkManager approach. //! //! Each network device is exposed as a D-Bus object using a path like //! `/org/opensuse/Agama1/Network/devices/[0-9]+`. At this point, those objects expose a bit of //! information about network devices. The entry point for the devices is the //! `/org/opensuse/Agama1/Network/devices` object, that expose a `GetDevices` method that returns //! the paths for the devices objects. //! //! The network configuration is exposed through the connections objects as //! `/org/opensuse/Agama1/Network/connections/[0-9]+`. Those objects are composed of several //! D-Bus interfaces depending on its type: //! //! * `org.opensuse.Agama1.Network.Connection` exposes common information across all connection //! types. //! * `org.opensuse.Agama1.Network.Connection.IPv4` includes IPv4 settings, like the configuration method //! (DHCP, manual, etc.), IP addresses, name servers and so on. //! * `org.opensuse.Agama1.Network.Connection.Wireless` exposes the configuration for wireless //! connections. //! //! Analogous to the devices API, there is a special `/org/opensuse/Agama1/Network/connections` //! object that implements a few methods that are related to the collection of connections like //! `GetConnections`, `AddConnection` and `RemoveConnection`. Additionally, it implements an //! `Apply` method to write the changes to the NetworkManager service. //! //! ## Limitations //! //! We expect to address the following problems as we evolve the API, but it is noteworthy to have //! them in mind: //! //! * The devices list does not reflect the changes in the system. For instance, it is not updated //! when a device is connected to the system. //! * Many configuration types are still missing (bridges, bonding, etc.). mod action; mod adapter; pub mod error; pub mod model; mod nm; pub mod system; pub mod web; pub use action::Action; pub use adapter::{Adapter, NetworkAdapterError}; pub use model::NetworkState; pub use nm::NetworkManagerAdapter; pub use system::NetworkSystem; 070701000000A3000081A4000000000000000000000001671F5A6400000D1B000000000000000000000000000000000000002900000000agama/agama-server/src/network/action.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use crate::network::model::{AccessPoint, Connection, Device}; use agama_lib::network::types::DeviceType; use tokio::sync::oneshot; use uuid::Uuid; use super::{error::NetworkStateError, model::GeneralState, NetworkAdapterError}; pub type Responder<T> = oneshot::Sender<T>; pub type ControllerConnection = (Connection, Vec<String>); /// Networking actions, like adding, updating or removing connections. /// /// These actions are meant to be processed by [crate::network::system::NetworkSystem], updating the model /// and the D-Bus tree as needed. #[derive(Debug)] pub enum Action { /// Add a new connection with the given name and type. AddConnection(String, DeviceType, Responder<Result<(), NetworkStateError>>), /// Add a new connection NewConnection(Box<Connection>, Responder<Result<(), NetworkStateError>>), /// Gets a connection by its id GetConnection(String, Responder<Option<Connection>>), /// Gets a connection by its Uuid GetConnectionByUuid(Uuid, Responder<Option<Connection>>), /// Gets a connection GetConnections(Responder<Vec<Connection>>), /// Gets a controller connection GetController( Uuid, Responder<Result<ControllerConnection, NetworkStateError>>, ), /// Gets all scanned access points GetAccessPoints(Responder<Vec<AccessPoint>>), /// Adds a new device. AddDevice(Box<Device>), /// Updates a device by its `name`. UpdateDevice(String, Box<Device>), /// Removes a device by its `name`. RemoveDevice(String), /// Gets a device by its name GetDevice(String, Responder<Option<Device>>), /// Gets all the existent devices GetDevices(Responder<Vec<Device>>), GetGeneralState(Responder<GeneralState>), /// Sets a controller's ports. It uses the Uuid of the controller and the IDs or interface names /// of the ports. SetPorts( Uuid, Box<Vec<String>>, Responder<Result<(), NetworkStateError>>, ), /// Updates a connection (replacing the old one). UpdateConnection(Box<Connection>, Responder<Result<(), NetworkStateError>>), /// Updates the general network configuration UpdateGeneralState(GeneralState), /// Forces a wireless networks scan refresh RefreshScan(Responder<Result<(), NetworkAdapterError>>), /// Remove the connection with the given Uuid. RemoveConnection(String, Responder<Result<(), NetworkStateError>>), /// Apply the current configuration. Apply(Responder<Result<(), NetworkAdapterError>>), } 070701000000A4000081A4000000000000000000000001671F5A6400000998000000000000000000000000000000000000002A00000000agama/agama-server/src/network/adapter.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use crate::network::{model::StateConfig, Action, NetworkState}; use agama_lib::error::ServiceError; use async_trait::async_trait; use thiserror::Error; use tokio::sync::mpsc::UnboundedSender; #[derive(Error, Debug)] pub enum NetworkAdapterError { #[error("Could not read the network configuration: {0}")] Read(ServiceError), #[error("Could not update the network configuration: {0}")] Write(ServiceError), #[error("Checkpoint handling error: {0}")] Checkpoint(ServiceError), // only relevant for adapters that implement a checkpoint mechanism #[error("The network watcher cannot run: {0}")] Watcher(ServiceError), } /// A trait for the ability to read/write from/to a network service. #[async_trait] pub trait Adapter { async fn read(&self, config: StateConfig) -> Result<NetworkState, NetworkAdapterError>; async fn write(&self, network: &NetworkState) -> Result<(), NetworkAdapterError>; /// Returns the watcher, which is responsible for listening for network changes. fn watcher(&self) -> Option<Box<dyn Watcher + Send>> { None } } impl From<NetworkAdapterError> for zbus::fdo::Error { fn from(value: NetworkAdapterError) -> zbus::fdo::Error { zbus::fdo::Error::Failed(value.to_string()) } } #[async_trait] /// A trait for the ability to listen for network changes. pub trait Watcher { /// Listens for network changes and emit actions to update the state. /// /// * `actions`: channel to emit the actions. async fn run( self: Box<Self>, actions: UnboundedSender<Action>, ) -> Result<(), NetworkAdapterError>; } 070701000000A5000081A4000000000000000000000001671F5A6400000B72000000000000000000000000000000000000002800000000agama/agama-server/src/network/error.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Error types. use thiserror::Error; /// Errors that are related to the network configuration. #[derive(Error, Debug)] pub enum NetworkStateError { #[error("Unknown connection '{0}'")] UnknownConnection(String), #[error("Cannot update connection '{0}'")] CannotUpdateConnection(String), #[error("Unknown device '{0}'")] UnknownDevice(String), #[error("Invalid connection UUID: '{0}'")] InvalidUuid(String), #[error("Invalid IP address: '{0}'")] InvalidIpAddr(String), #[error("Invalid IP method: '{0}'")] InvalidIpMethod(u8), #[error("Invalid wireless mode: '{0}'")] InvalidWirelessMode(String), #[error("Connection '{0}' already exists")] ConnectionExists(String), #[error("Invalid security wireless protocol: '{0}'")] InvalidSecurityProtocol(String), #[error("Adapter error: '{0}'")] AdapterError(String), #[error("Invalid bond mode '{0}'")] InvalidBondMode(String), #[error("Invalid bond options")] InvalidBondOptions, #[error("Not a controller connection: '{0}'")] NotControllerConnection(String), #[error("Unexpected configuration")] UnexpectedConfiguration, #[error("Invalid WEP authentication algorithm: '{0}'")] InvalidWEPAuthAlg(String), #[error("Invalid WEP key type: '{0}'")] InvalidWEPKeyType(u32), #[error("Invalid EAP method: '{0}'")] InvalidEAPMethod(String), #[error("Invalid phase2 authentication method: '{0}'")] InvalidPhase2AuthMethod(String), #[error("Invalid group algorithm: '{0}'")] InvalidGroupAlgorithm(String), #[error("Invalid pairwise algorithm: '{0}'")] InvalidPairwiseAlgorithm(String), #[error("Invalid WPA protocol version: '{0}'")] InvalidWPAProtocolVersion(String), #[error("Invalid wireless band: '{0}'")] InvalidWirelessBand(String), #[error("Invalid bssid: '{0}'")] InvalidBssid(String), } impl From<NetworkStateError> for zbus::fdo::Error { fn from(value: NetworkStateError) -> zbus::fdo::Error { zbus::fdo::Error::Failed(format!("Network error: {value}")) } } 070701000000A6000081A4000000000000000000000001671F5A640000D200000000000000000000000000000000000000002800000000agama/agama-server/src/network/model.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Representation of the network configuration //! //! * This module contains the types that represent the network concepts. They are supposed to be //! agnostic from the real network service (e.g., NetworkManager). use crate::network::error::NetworkStateError; use agama_lib::network::settings::{ BondSettings, IEEE8021XSettings, NetworkConnection, WirelessSettings, }; use agama_lib::network::types::{BondMode, DeviceState, DeviceType, Status, SSID}; use cidr::IpInet; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, skip_serializing_none, DisplayFromStr}; use std::{ collections::HashMap, default::Default, fmt, net::IpAddr, str::{self, FromStr}, }; use thiserror::Error; use uuid::Uuid; use zbus::zvariant::Value; #[derive(PartialEq)] pub struct StateConfig { pub access_points: bool, pub devices: bool, pub connections: bool, pub general_state: bool, } impl Default for StateConfig { fn default() -> Self { Self { access_points: true, devices: true, connections: true, general_state: true, } } } #[derive(Default, Clone, Debug)] pub struct NetworkState { pub general_state: GeneralState, pub access_points: Vec<AccessPoint>, pub devices: Vec<Device>, pub connections: Vec<Connection>, } impl NetworkState { /// Returns a NetworkState struct with the given devices and connections. /// /// * `general_state`: General network configuration /// * `access_points`: Access points to include in the state. /// * `devices`: devices to include in the state. /// * `connections`: connections to include in the state. pub fn new( general_state: GeneralState, access_points: Vec<AccessPoint>, devices: Vec<Device>, connections: Vec<Connection>, ) -> Self { Self { general_state, access_points, devices, connections, } } /// Get device by name /// /// * `name`: device name pub fn get_device(&self, name: &str) -> Option<&Device> { self.devices.iter().find(|d| d.name == name) } /// Get connection by UUID /// /// * `uuid`: connection UUID pub fn get_connection_by_uuid(&self, uuid: Uuid) -> Option<&Connection> { self.connections.iter().find(|c| c.uuid == uuid) } /// Get connection by UUID as mutable /// /// * `uuid`: connection UUID pub fn get_connection_by_uuid_mut(&mut self, uuid: Uuid) -> Option<&mut Connection> { self.connections.iter_mut().find(|c| c.uuid == uuid) } /// Get connection by interface /// /// * `name`: connection interface name pub fn get_connection_by_interface(&self, name: &str) -> Option<&Connection> { let interface = Some(name); self.connections .iter() .find(|c| c.interface.as_deref() == interface) } /// Get connection by ID /// /// * `id`: connection ID pub fn get_connection(&self, id: &str) -> Option<&Connection> { self.connections.iter().find(|c| c.id == id) } /// Get connection by ID as mutable /// /// * `id`: connection ID pub fn get_connection_mut(&mut self, id: &str) -> Option<&mut Connection> { self.connections.iter_mut().find(|c| c.id == id) } /// Get a device by name as mutable /// /// * `name`: device name pub fn get_device_mut(&mut self, name: &str) -> Option<&mut Device> { self.devices.iter_mut().find(|c| c.name == name) } pub fn get_controlled_by(&mut self, uuid: Uuid) -> Vec<&Connection> { let uuid = Some(uuid); self.connections .iter() .filter(|c| c.controller == uuid) .collect() } /// Adds a new connection. /// /// It uses the `id` to decide whether the connection already exists. pub fn add_connection(&mut self, conn: Connection) -> Result<(), NetworkStateError> { if self.get_connection(&conn.id).is_some() { return Err(NetworkStateError::ConnectionExists(conn.id)); } self.connections.push(conn); Ok(()) } /// Updates a connection with a new one. /// /// It uses the `id` to decide which connection to update. /// /// Additionally, it registers the connection to be removed when the changes are applied. pub fn update_connection(&mut self, conn: Connection) -> Result<(), NetworkStateError> { let Some(old_conn) = self.get_connection_mut(&conn.id) else { return Err(NetworkStateError::UnknownConnection(conn.id.clone())); }; *old_conn = conn; Ok(()) } /// Removes a connection from the state. /// /// Additionally, it registers the connection to be removed when the changes are applied. pub fn remove_connection(&mut self, id: &str) -> Result<(), NetworkStateError> { let Some(conn) = self.get_connection_mut(id) else { return Err(NetworkStateError::UnknownConnection(id.to_string())); }; conn.remove(); Ok(()) } pub fn add_device(&mut self, device: Device) -> Result<(), NetworkStateError> { self.devices.push(device); Ok(()) } pub fn update_device(&mut self, name: &str, device: Device) -> Result<(), NetworkStateError> { let Some(old_device) = self.get_device_mut(name) else { return Err(NetworkStateError::UnknownDevice(device.name.clone())); }; *old_device = device; Ok(()) } pub fn remove_device(&mut self, name: &str) -> Result<(), NetworkStateError> { let Some(position) = self.devices.iter().position(|d| d.name == name) else { return Err(NetworkStateError::UnknownDevice(name.to_string())); }; self.devices.remove(position); Ok(()) } /// Sets a controller's ports. /// /// If the connection is not a controller, returns an error. /// /// * `controller`: controller to set ports on. /// * `ports`: list of port names (using the connection ID or the interface name). pub fn set_ports( &mut self, controller: &Connection, ports: Vec<String>, ) -> Result<(), NetworkStateError> { if let ConnectionConfig::Bond(_) = &controller.config { let mut controlled = vec![]; for port in ports { let connection = self .get_connection_by_interface(&port) .or_else(|| self.get_connection(&port)) .ok_or(NetworkStateError::UnknownConnection(port))?; controlled.push(connection.uuid); } for conn in self.connections.iter_mut() { if controlled.contains(&conn.uuid) { conn.controller = Some(controller.uuid); } else if conn.controller == Some(controller.uuid) { conn.controller = None; } } Ok(()) } else { Err(NetworkStateError::NotControllerConnection( controller.id.to_owned(), )) } } } #[cfg(test)] mod tests { use super::*; use crate::network::error::NetworkStateError; use uuid::Uuid; #[test] fn test_macaddress() { let mut val: Option<String> = None; assert!(matches!( MacAddress::try_from(&val).unwrap(), MacAddress::Unset )); val = Some(String::from("")); assert!(matches!( MacAddress::try_from(&val).unwrap(), MacAddress::Unset )); val = Some(String::from("preserve")); assert!(matches!( MacAddress::try_from(&val).unwrap(), MacAddress::Preserve )); val = Some(String::from("permanent")); assert!(matches!( MacAddress::try_from(&val).unwrap(), MacAddress::Permanent )); val = Some(String::from("random")); assert!(matches!( MacAddress::try_from(&val).unwrap(), MacAddress::Random )); val = Some(String::from("stable")); assert!(matches!( MacAddress::try_from(&val).unwrap(), MacAddress::Stable )); val = Some(String::from("This is not a MACAddr")); assert!(matches!( MacAddress::try_from(&val), Err(InvalidMacAddress(_)) )); val = Some(String::from("de:ad:be:ef:2b:ad")); assert_eq!( MacAddress::try_from(&val).unwrap().to_string(), String::from("de:ad:be:ef:2b:ad").to_uppercase() ); } #[test] fn test_add_connection() { let mut state = NetworkState::default(); let uuid = Uuid::new_v4(); let conn0 = Connection { id: "eth0".to_string(), uuid, ..Default::default() }; state.add_connection(conn0).unwrap(); let found = state.get_connection("eth0").unwrap(); assert_eq!(found.uuid, uuid); } #[test] fn test_add_duplicated_connection() { let mut state = NetworkState::default(); let mut conn0 = Connection::new("eth0".to_string(), DeviceType::Ethernet); conn0.uuid = Uuid::new_v4(); state.add_connection(conn0.clone()).unwrap(); let error = state.add_connection(conn0).unwrap_err(); assert!(matches!(error, NetworkStateError::ConnectionExists(_))); } #[test] fn test_update_connection() { let mut state = NetworkState::default(); let conn0 = Connection { id: "eth0".to_string(), uuid: Uuid::new_v4(), ..Default::default() }; state.add_connection(conn0).unwrap(); let uuid = Uuid::new_v4(); let conn1 = Connection { id: "eth0".to_string(), uuid, ..Default::default() }; state.update_connection(conn1).unwrap(); let found = state.get_connection("eth0").unwrap(); assert_eq!(found.uuid, uuid); } #[test] fn test_update_unknown_connection() { let mut state = NetworkState::default(); let conn0 = Connection::new("eth0".to_string(), DeviceType::Ethernet); let error = state.update_connection(conn0).unwrap_err(); assert!(matches!(error, NetworkStateError::UnknownConnection(_))); } #[test] fn test_remove_connection() { let mut state = NetworkState::default(); let conn0 = Connection::new("eth0".to_string(), DeviceType::Ethernet); state.add_connection(conn0).unwrap(); state.remove_connection("eth0".as_ref()).unwrap(); let found = state.get_connection("eth0").unwrap(); assert!(found.is_removed()); } #[test] fn test_remove_unknown_connection() { let mut state = NetworkState::default(); let error = state.remove_connection("unknown".as_ref()).unwrap_err(); assert!(matches!(error, NetworkStateError::UnknownConnection(_))); } #[test] fn test_is_loopback() { let conn = Connection::new("eth0".to_string(), DeviceType::Ethernet); assert!(!conn.is_loopback()); let conn = Connection::new("eth0".to_string(), DeviceType::Loopback); assert!(conn.is_loopback()); } #[test] fn test_set_bonding_ports() { let mut state = NetworkState::default(); let eth0 = Connection { id: "eth0".to_string(), interface: Some("eth0".to_string()), ..Default::default() }; let eth1 = Connection { id: "eth1".to_string(), interface: Some("eth1".to_string()), ..Default::default() }; let bond0 = Connection { id: "bond0".to_string(), interface: Some("bond0".to_string()), config: ConnectionConfig::Bond(Default::default()), ..Default::default() }; state.add_connection(eth0).unwrap(); state.add_connection(eth1).unwrap(); state.add_connection(bond0.clone()).unwrap(); state.set_ports(&bond0, vec!["eth1".to_string()]).unwrap(); let eth1_found = state.get_connection("eth1").unwrap(); assert_eq!(eth1_found.controller, Some(bond0.uuid)); let eth0_found = state.get_connection("eth0").unwrap(); assert_eq!(eth0_found.controller, None); } #[test] fn test_set_bonding_missing_port() { let mut state = NetworkState::default(); let bond0 = Connection { id: "bond0".to_string(), interface: Some("bond0".to_string()), config: ConnectionConfig::Bond(Default::default()), ..Default::default() }; state.add_connection(bond0.clone()).unwrap(); let error = state .set_ports(&bond0, vec!["eth0".to_string()]) .unwrap_err(); assert!(matches!(error, NetworkStateError::UnknownConnection(_))); } #[test] fn test_set_non_controller_ports() { let mut state = NetworkState::default(); let eth0 = Connection { id: "eth0".to_string(), ..Default::default() }; state.add_connection(eth0.clone()).unwrap(); let error = state .set_ports(ð0, vec!["eth1".to_string()]) .unwrap_err(); assert!(matches!( error, NetworkStateError::NotControllerConnection(_), )); } } /// Network state #[serde_as] #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] pub struct GeneralState { pub hostname: String, pub connectivity: bool, pub wireless_enabled: bool, pub networking_enabled: bool, // pub network_state: NMSTATE } /// Access Point #[serde_as] #[derive(Default, Debug, Clone, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct AccessPoint { #[serde_as(as = "DisplayFromStr")] pub ssid: SSID, pub hw_address: String, pub strength: u8, pub flags: u32, pub rsn_flags: u32, pub wpa_flags: u32, } /// Network device #[serde_as] #[skip_serializing_none] #[derive(Default, Debug, Clone, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Device { pub name: String, #[serde(rename = "type")] pub type_: DeviceType, #[serde_as(as = "DisplayFromStr")] pub mac_address: MacAddress, pub ip_config: Option<IpConfig>, // Connection.id pub connection: Option<String>, pub state: DeviceState, pub state_reason: u8, } /// Represents a known network connection. #[serde_as] #[skip_serializing_none] #[derive(Debug, Clone, PartialEq, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Connection { pub id: String, pub uuid: Uuid, #[serde_as(as = "DisplayFromStr")] pub mac_address: MacAddress, pub firewall_zone: Option<String>, pub mtu: u32, pub ip_config: IpConfig, pub status: Status, pub interface: Option<String>, pub controller: Option<Uuid>, pub port_config: PortConfig, pub match_config: MatchConfig, pub config: ConnectionConfig, pub ieee_8021x_config: Option<IEEE8021XConfig>, } impl Connection { pub fn new(id: String, device_type: DeviceType) -> Self { let config = match device_type { DeviceType::Ethernet => ConnectionConfig::Ethernet, DeviceType::Wireless => ConnectionConfig::Wireless(Default::default()), DeviceType::Loopback => ConnectionConfig::Loopback, DeviceType::Dummy => ConnectionConfig::Dummy, DeviceType::Bond => ConnectionConfig::Bond(Default::default()), DeviceType::Vlan => ConnectionConfig::Vlan(Default::default()), DeviceType::Bridge => ConnectionConfig::Bridge(Default::default()), }; Self { id, config, ..Default::default() } } pub fn remove(&mut self) { self.status = Status::Removed; } pub fn is_removed(&self) -> bool { self.status == Status::Removed } pub fn is_up(&self) -> bool { self.status == Status::Up } pub fn set_up(&mut self) { self.status = Status::Up } pub fn set_down(&mut self) { self.status = Status::Down } /// Determines whether it is a loopback interface. pub fn is_loopback(&self) -> bool { matches!(self.config, ConnectionConfig::Loopback) } pub fn is_ethernet(&self) -> bool { matches!(self.config, ConnectionConfig::Loopback) || matches!(self.config, ConnectionConfig::Ethernet) || matches!(self.config, ConnectionConfig::Dummy) || matches!(self.config, ConnectionConfig::Bond(_)) || matches!(self.config, ConnectionConfig::Vlan(_)) || matches!(self.config, ConnectionConfig::Bridge(_)) } } impl Default for Connection { fn default() -> Self { Self { id: Default::default(), uuid: Uuid::new_v4(), mac_address: Default::default(), firewall_zone: Default::default(), mtu: Default::default(), ip_config: Default::default(), status: Default::default(), interface: Default::default(), controller: Default::default(), port_config: Default::default(), match_config: Default::default(), config: Default::default(), ieee_8021x_config: Default::default(), } } } impl TryFrom<NetworkConnection> for Connection { type Error = NetworkStateError; fn try_from(conn: NetworkConnection) -> Result<Self, Self::Error> { let id = conn.clone().id; let mut connection = Connection::new(id, conn.device_type()); if let Some(method) = conn.clone().method4 { let method: Ipv4Method = method.parse().unwrap(); connection.ip_config.method4 = method; } if let Some(method) = conn.method6 { let method: Ipv6Method = method.parse().unwrap(); connection.ip_config.method6 = method; } if let Some(status) = conn.status { connection.status = status; } if let Some(ignore_auto_dns) = conn.ignore_auto_dns { connection.ip_config.ignore_auto_dns = ignore_auto_dns; } if let Some(wireless_config) = conn.wireless { let config = WirelessConfig::try_from(wireless_config)?; connection.config = config.into(); } if let Some(bond_config) = conn.bond { let config = BondConfig::try_from(bond_config)?; connection.config = config.into(); } if let Some(ieee_8021x_config) = conn.ieee_8021x { connection.ieee_8021x_config = Some(IEEE8021XConfig::try_from(ieee_8021x_config)?); } connection.ip_config.addresses = conn.addresses; connection.ip_config.nameservers = conn.nameservers; connection.ip_config.dns_searchlist = conn.dns_searchlist; connection.ip_config.gateway4 = conn.gateway4; connection.ip_config.gateway6 = conn.gateway6; connection.interface = conn.interface; connection.mtu = conn.mtu; Ok(connection) } } impl TryFrom<Connection> for NetworkConnection { type Error = NetworkStateError; fn try_from(conn: Connection) -> Result<Self, Self::Error> { let id = conn.clone().id; let mac = conn.mac_address.to_string(); let method4 = Some(conn.ip_config.method4.to_string()); let method6 = Some(conn.ip_config.method6.to_string()); let mac_address = (!mac.is_empty()).then_some(mac); let nameservers = conn.ip_config.nameservers; let dns_searchlist = conn.ip_config.dns_searchlist; let ignore_auto_dns = Some(conn.ip_config.ignore_auto_dns); let addresses = conn.ip_config.addresses; let gateway4 = conn.ip_config.gateway4; let gateway6 = conn.ip_config.gateway6; let interface = conn.interface; let status = Some(conn.status); let mtu = conn.mtu; let ieee_8021x: Option<IEEE8021XSettings> = conn .ieee_8021x_config .and_then(|x| IEEE8021XSettings::try_from(x).ok()); let mut connection = NetworkConnection { id, status, method4, method6, gateway4, gateway6, nameservers, dns_searchlist, ignore_auto_dns, mac_address, interface, addresses, mtu, ieee_8021x, ..Default::default() }; match conn.config { ConnectionConfig::Wireless(config) => { connection.wireless = Some(WirelessSettings::try_from(config)?); } ConnectionConfig::Bond(config) => { connection.bond = Some(BondSettings::try_from(config)?); } _ => {} } Ok(connection) } } #[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum ConnectionConfig { #[default] Ethernet, Wireless(WirelessConfig), Loopback, Dummy, Bond(BondConfig), Vlan(VlanConfig), Bridge(BridgeConfig), Infiniband(InfinibandConfig), Tun(TunConfig), } #[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum PortConfig { #[default] None, Bridge(BridgePortConfig), } impl From<BondConfig> for ConnectionConfig { fn from(value: BondConfig) -> Self { Self::Bond(value) } } impl From<WirelessConfig> for ConnectionConfig { fn from(value: WirelessConfig) -> Self { Self::Wireless(value) } } #[derive(Debug, Error)] #[error("Invalid MAC address: {0}")] pub struct InvalidMacAddress(String); #[derive(Debug, Default, Clone, PartialEq, Serialize, utoipa::ToSchema)] pub enum MacAddress { MacAddress(macaddr::MacAddr6), Preserve, Permanent, Random, Stable, #[default] Unset, } impl FromStr for MacAddress { type Err = InvalidMacAddress; fn from_str(s: &str) -> Result<Self, Self::Err> { match s { "preserve" => Ok(Self::Preserve), "permanent" => Ok(Self::Permanent), "random" => Ok(Self::Random), "stable" => Ok(Self::Stable), "" => Ok(Self::Unset), _ => Ok(Self::MacAddress(match macaddr::MacAddr6::from_str(s) { Ok(mac) => mac, Err(e) => return Err(InvalidMacAddress(e.to_string())), })), } } } impl TryFrom<&Option<String>> for MacAddress { type Error = InvalidMacAddress; fn try_from(value: &Option<String>) -> Result<Self, Self::Error> { match &value { Some(str) => MacAddress::from_str(str), None => Ok(Self::Unset), } } } impl fmt::Display for MacAddress { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let output = match &self { Self::MacAddress(mac) => mac.to_string(), Self::Preserve => "preserve".to_string(), Self::Permanent => "permanent".to_string(), Self::Random => "random".to_string(), Self::Stable => "stable".to_string(), Self::Unset => "".to_string(), }; write!(f, "{}", output) } } impl From<InvalidMacAddress> for zbus::fdo::Error { fn from(value: InvalidMacAddress) -> Self { zbus::fdo::Error::Failed(value.to_string()) } } #[skip_serializing_none] #[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct IpConfig { pub method4: Ipv4Method, pub method6: Ipv6Method, #[serde(skip_serializing_if = "Vec::is_empty")] pub addresses: Vec<IpInet>, #[serde(skip_serializing_if = "Vec::is_empty")] pub nameservers: Vec<IpAddr>, #[serde(skip_serializing_if = "Vec::is_empty")] pub dns_searchlist: Vec<String>, pub ignore_auto_dns: bool, pub gateway4: Option<IpAddr>, pub gateway6: Option<IpAddr>, pub routes4: Option<Vec<IpRoute>>, pub routes6: Option<Vec<IpRoute>>, } #[skip_serializing_none] #[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct MatchConfig { #[serde(skip_serializing_if = "Vec::is_empty")] pub driver: Vec<String>, #[serde(skip_serializing_if = "Vec::is_empty")] pub interface: Vec<String>, #[serde(skip_serializing_if = "Vec::is_empty")] pub path: Vec<String>, #[serde(skip_serializing_if = "Vec::is_empty")] pub kernel: Vec<String>, } #[derive(Debug, Error)] #[error("Unknown IP configuration method name: {0}")] pub struct UnknownIpMethod(String); #[derive(Debug, Default, Copy, Clone, PartialEq, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub enum Ipv4Method { #[default] Disabled = 0, Auto = 1, Manual = 2, LinkLocal = 3, } impl fmt::Display for Ipv4Method { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let name = match &self { Ipv4Method::Disabled => "disabled", Ipv4Method::Auto => "auto", Ipv4Method::Manual => "manual", Ipv4Method::LinkLocal => "link-local", }; write!(f, "{}", name) } } impl FromStr for Ipv4Method { type Err = UnknownIpMethod; fn from_str(s: &str) -> Result<Self, Self::Err> { match s { "disabled" => Ok(Ipv4Method::Disabled), "auto" => Ok(Ipv4Method::Auto), "manual" => Ok(Ipv4Method::Manual), "link-local" => Ok(Ipv4Method::LinkLocal), _ => Err(UnknownIpMethod(s.to_string())), } } } #[derive(Debug, Default, Copy, Clone, PartialEq, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub enum Ipv6Method { #[default] Disabled = 0, Auto = 1, Manual = 2, LinkLocal = 3, Ignore = 4, Dhcp = 5, } impl fmt::Display for Ipv6Method { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let name = match &self { Ipv6Method::Disabled => "disabled", Ipv6Method::Auto => "auto", Ipv6Method::Manual => "manual", Ipv6Method::LinkLocal => "link-local", Ipv6Method::Ignore => "ignore", Ipv6Method::Dhcp => "dhcp", }; write!(f, "{}", name) } } impl FromStr for Ipv6Method { type Err = UnknownIpMethod; fn from_str(s: &str) -> Result<Self, Self::Err> { match s { "disabled" => Ok(Ipv6Method::Disabled), "auto" => Ok(Ipv6Method::Auto), "manual" => Ok(Ipv6Method::Manual), "link-local" => Ok(Ipv6Method::LinkLocal), "ignore" => Ok(Ipv6Method::Ignore), "dhcp" => Ok(Ipv6Method::Dhcp), _ => Err(UnknownIpMethod(s.to_string())), } } } impl From<UnknownIpMethod> for zbus::fdo::Error { fn from(value: UnknownIpMethod) -> zbus::fdo::Error { zbus::fdo::Error::Failed(value.to_string()) } } #[derive(Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct IpRoute { pub destination: IpInet, #[serde(skip_serializing_if = "Option::is_none")] pub next_hop: Option<IpAddr>, #[serde(skip_serializing_if = "Option::is_none")] pub metric: Option<u32>, } impl From<&IpRoute> for HashMap<&str, Value<'_>> { fn from(route: &IpRoute) -> Self { let mut map: HashMap<&str, Value> = HashMap::from([ ("dest", Value::new(route.destination.address().to_string())), ( "prefix", Value::new(route.destination.network_length() as u32), ), ]); if let Some(next_hop) = route.next_hop { map.insert("next-hop", Value::new(next_hop.to_string())); } if let Some(metric) = route.metric { map.insert("metric", Value::new(metric)); } map } } #[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum VlanProtocol { #[default] IEEE802_1Q, IEEE802_1ad, } #[derive(Debug, Error)] #[error("Invalid VlanProtocol: {0}")] pub struct InvalidVlanProtocol(String); impl std::str::FromStr for VlanProtocol { type Err = InvalidVlanProtocol; fn from_str(s: &str) -> Result<VlanProtocol, Self::Err> { match s { "802.1Q" => Ok(VlanProtocol::IEEE802_1Q), "802.1ad" => Ok(VlanProtocol::IEEE802_1ad), _ => Err(InvalidVlanProtocol(s.to_string())), } } } impl fmt::Display for VlanProtocol { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let name = match &self { VlanProtocol::IEEE802_1Q => "802.1Q", VlanProtocol::IEEE802_1ad => "802.1ad", }; write!(f, "{}", name) } } #[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct VlanConfig { pub parent: String, pub id: u32, pub protocol: VlanProtocol, } #[serde_as] #[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct WirelessConfig { pub mode: WirelessMode, #[serde_as(as = "DisplayFromStr")] pub ssid: SSID, #[serde(skip_serializing_if = "Option::is_none")] pub password: Option<String>, pub security: SecurityProtocol, #[serde(skip_serializing_if = "Option::is_none")] pub band: Option<WirelessBand>, pub channel: u32, #[serde(skip_serializing_if = "Option::is_none")] pub bssid: Option<macaddr::MacAddr6>, #[serde(skip_serializing_if = "Option::is_none")] pub wep_security: Option<WEPSecurity>, pub hidden: bool, #[serde(skip_serializing_if = "Vec::is_empty")] pub group_algorithms: Vec<GroupAlgorithm>, #[serde(skip_serializing_if = "Vec::is_empty")] pub pairwise_algorithms: Vec<PairwiseAlgorithm>, #[serde(skip_serializing_if = "Vec::is_empty")] pub wpa_protocol_versions: Vec<WPAProtocolVersion>, pub pmf: i32, } impl TryFrom<ConnectionConfig> for WirelessConfig { type Error = NetworkStateError; fn try_from(value: ConnectionConfig) -> Result<Self, Self::Error> { match value { ConnectionConfig::Wireless(config) => Ok(config), _ => Err(NetworkStateError::UnexpectedConfiguration), } } } impl TryFrom<WirelessSettings> for WirelessConfig { type Error = NetworkStateError; fn try_from(settings: WirelessSettings) -> Result<Self, Self::Error> { let ssid = SSID(settings.ssid.as_bytes().into()); let mode = WirelessMode::try_from(settings.mode.as_str())?; let security = SecurityProtocol::try_from(settings.security.as_str())?; let band = if let Some(band) = &settings.band { Some( WirelessBand::try_from(band.as_str()) .map_err(|_| NetworkStateError::InvalidWirelessBand(band.to_string()))?, ) } else { None }; let bssid = if let Some(bssid) = &settings.bssid { Some( macaddr::MacAddr6::from_str(bssid) .map_err(|_| NetworkStateError::InvalidBssid(bssid.to_string()))?, ) } else { None }; let group_algorithms = settings .group_algorithms .iter() .map(|x| { GroupAlgorithm::from_str(x) .map_err(|_| NetworkStateError::InvalidGroupAlgorithm(x.to_string())) }) .collect::<Result<Vec<GroupAlgorithm>, NetworkStateError>>()?; let pairwise_algorithms = settings .pairwise_algorithms .iter() .map(|x| { PairwiseAlgorithm::from_str(x) .map_err(|_| NetworkStateError::InvalidGroupAlgorithm(x.to_string())) }) .collect::<Result<Vec<PairwiseAlgorithm>, NetworkStateError>>()?; let wpa_protocol_versions = settings .wpa_protocol_versions .iter() .map(|x| { WPAProtocolVersion::from_str(x) .map_err(|_| NetworkStateError::InvalidGroupAlgorithm(x.to_string())) }) .collect::<Result<Vec<WPAProtocolVersion>, NetworkStateError>>()?; Ok(WirelessConfig { ssid, mode, security, password: settings.password, band, channel: settings.channel, bssid, hidden: settings.hidden, group_algorithms, pairwise_algorithms, wpa_protocol_versions, pmf: settings.pmf, ..Default::default() }) } } impl TryFrom<WirelessConfig> for WirelessSettings { type Error = NetworkStateError; fn try_from(wireless: WirelessConfig) -> Result<Self, Self::Error> { let band = wireless.band.map(|x| x.to_string()); let bssid = wireless.bssid.map(|x| x.to_string()); let group_algorithms = wireless .group_algorithms .iter() .map(|x| x.to_string()) .collect::<Vec<String>>(); let pairwise_algorithms = wireless .pairwise_algorithms .iter() .map(|x| x.to_string()) .collect::<Vec<String>>(); let wpa_protocol_versions = wireless .wpa_protocol_versions .iter() .map(|x| x.to_string()) .collect::<Vec<String>>(); Ok(WirelessSettings { ssid: wireless.ssid.to_string(), mode: wireless.mode.to_string(), security: wireless.security.to_string(), password: wireless.password, band, channel: wireless.channel, bssid, hidden: wireless.hidden, group_algorithms, pairwise_algorithms, wpa_protocol_versions, pmf: wireless.pmf, }) } } #[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, utoipa::ToSchema)] pub enum WirelessMode { Unknown = 0, AdHoc = 1, #[default] Infra = 2, AP = 3, Mesh = 4, } impl TryFrom<&str> for WirelessMode { type Error = NetworkStateError; fn try_from(value: &str) -> Result<Self, Self::Error> { match value { "unknown" => Ok(WirelessMode::Unknown), "adhoc" => Ok(WirelessMode::AdHoc), "infrastructure" => Ok(WirelessMode::Infra), "ap" => Ok(WirelessMode::AP), "mesh" => Ok(WirelessMode::Mesh), _ => Err(NetworkStateError::InvalidWirelessMode(value.to_string())), } } } impl fmt::Display for WirelessMode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let name = match &self { WirelessMode::Unknown => "unknown", WirelessMode::AdHoc => "adhoc", WirelessMode::Infra => "infrastructure", WirelessMode::AP => "ap", WirelessMode::Mesh => "mesh", }; write!(f, "{}", name) } } #[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, utoipa::ToSchema)] pub enum SecurityProtocol { #[default] WEP, // No encryption or WEP ("none") OWE, // Opportunistic Wireless Encryption ("owe") DynamicWEP, // Dynamic WEP ("ieee8021x") WPA2, // WPA2 + WPA3 personal ("wpa-psk") WPA3Personal, // WPA3 personal only ("sae") WPA2Enterprise, // WPA2 + WPA3 Enterprise ("wpa-eap") WPA3Only, // WPA3 only ("wpa-eap-suite-b-192") } impl fmt::Display for SecurityProtocol { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let value = match &self { SecurityProtocol::WEP => "none", SecurityProtocol::OWE => "owe", SecurityProtocol::DynamicWEP => "ieee8021x", SecurityProtocol::WPA2 => "wpa-psk", SecurityProtocol::WPA3Personal => "sae", SecurityProtocol::WPA2Enterprise => "wpa-eap", SecurityProtocol::WPA3Only => "wpa-eap-suite-b-192", }; write!(f, "{}", value) } } impl TryFrom<&str> for SecurityProtocol { type Error = NetworkStateError; fn try_from(value: &str) -> Result<Self, Self::Error> { match value { "none" => Ok(SecurityProtocol::WEP), "owe" => Ok(SecurityProtocol::OWE), "ieee8021x" => Ok(SecurityProtocol::DynamicWEP), "wpa-psk" => Ok(SecurityProtocol::WPA2), "sae" => Ok(SecurityProtocol::WPA3Personal), "wpa-eap" => Ok(SecurityProtocol::WPA2Enterprise), "wpa-eap-suite-b-192" => Ok(SecurityProtocol::WPA3Only), _ => Err(NetworkStateError::InvalidSecurityProtocol( value.to_string(), )), } } } #[derive(Debug, Clone, Copy, PartialEq, Serialize, utoipa::ToSchema)] pub enum GroupAlgorithm { Wep40, Wep104, Tkip, Ccmp, } #[derive(Debug, Error)] #[error("Invalid group algorithm: {0}")] pub struct InvalidGroupAlgorithm(String); impl FromStr for GroupAlgorithm { type Err = InvalidGroupAlgorithm; fn from_str(value: &str) -> Result<Self, Self::Err> { match value { "wep40" => Ok(GroupAlgorithm::Wep40), "wep104" => Ok(GroupAlgorithm::Wep104), "tkip" => Ok(GroupAlgorithm::Tkip), "ccmp" => Ok(GroupAlgorithm::Ccmp), _ => Err(InvalidGroupAlgorithm(value.to_string())), } } } impl fmt::Display for GroupAlgorithm { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let name = match &self { GroupAlgorithm::Wep40 => "wep40", GroupAlgorithm::Wep104 => "wep104", GroupAlgorithm::Tkip => "tkip", GroupAlgorithm::Ccmp => "ccmp", }; write!(f, "{}", name) } } #[derive(Debug, Clone, Copy, PartialEq, Serialize, utoipa::ToSchema)] pub enum PairwiseAlgorithm { Tkip, Ccmp, } #[derive(Debug, Error)] #[error("Invalid pairwise algorithm: {0}")] pub struct InvalidPairwiseAlgorithm(String); impl FromStr for PairwiseAlgorithm { type Err = InvalidPairwiseAlgorithm; fn from_str(value: &str) -> Result<Self, Self::Err> { match value { "tkip" => Ok(PairwiseAlgorithm::Tkip), "ccmp" => Ok(PairwiseAlgorithm::Ccmp), _ => Err(InvalidPairwiseAlgorithm(value.to_string())), } } } impl fmt::Display for PairwiseAlgorithm { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let name = match &self { PairwiseAlgorithm::Tkip => "tkip", PairwiseAlgorithm::Ccmp => "ccmp", }; write!(f, "{}", name) } } #[derive(Debug, Clone, Copy, PartialEq, Serialize, utoipa::ToSchema)] pub enum WPAProtocolVersion { Wpa, Rsn, } #[derive(Debug, Error)] #[error("Invalid WPA protocol version: {0}")] pub struct InvalidWPAProtocolVersion(String); impl FromStr for WPAProtocolVersion { type Err = InvalidWPAProtocolVersion; fn from_str(value: &str) -> Result<Self, Self::Err> { match value { "wpa" => Ok(WPAProtocolVersion::Wpa), "rsn" => Ok(WPAProtocolVersion::Rsn), _ => Err(InvalidWPAProtocolVersion(value.to_string())), } } } impl fmt::Display for WPAProtocolVersion { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let name = match &self { WPAProtocolVersion::Wpa => "wpa", WPAProtocolVersion::Rsn => "rsn", }; write!(f, "{}", name) } } #[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct WEPSecurity { pub auth_alg: WEPAuthAlg, pub wep_key_type: WEPKeyType, #[serde(skip_serializing_if = "Vec::is_empty")] pub keys: Vec<String>, pub wep_key_index: u32, } #[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum WEPKeyType { #[default] Unknown = 0, Key = 1, Passphrase = 2, } impl TryFrom<u32> for WEPKeyType { type Error = NetworkStateError; fn try_from(value: u32) -> Result<Self, Self::Error> { match value { 0 => Ok(WEPKeyType::Unknown), 1 => Ok(WEPKeyType::Key), 2 => Ok(WEPKeyType::Passphrase), _ => Err(NetworkStateError::InvalidWEPKeyType(value)), } } } #[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum WEPAuthAlg { #[default] Unset, Open, Shared, Leap, } impl TryFrom<&str> for WEPAuthAlg { type Error = NetworkStateError; fn try_from(value: &str) -> Result<Self, Self::Error> { match value { "open" => Ok(WEPAuthAlg::Open), "shared" => Ok(WEPAuthAlg::Shared), "leap" => Ok(WEPAuthAlg::Leap), "" => Ok(WEPAuthAlg::Unset), _ => Err(NetworkStateError::InvalidWEPAuthAlg(value.to_string())), } } } impl fmt::Display for WEPAuthAlg { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let name = match &self { WEPAuthAlg::Open => "open", WEPAuthAlg::Shared => "shared", WEPAuthAlg::Leap => "shared", WEPAuthAlg::Unset => "", }; write!(f, "{}", name) } } #[derive(Debug, Clone, Copy, PartialEq, Serialize, utoipa::ToSchema)] pub enum WirelessBand { A, // 5GHz BG, // 2.4GHz } impl fmt::Display for WirelessBand { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let value = match &self { WirelessBand::A => "a", WirelessBand::BG => "bg", }; write!(f, "{}", value) } } impl TryFrom<&str> for WirelessBand { type Error = anyhow::Error; fn try_from(value: &str) -> Result<Self, Self::Error> { match value { "a" => Ok(WirelessBand::A), "bg" => Ok(WirelessBand::BG), _ => Err(anyhow::anyhow!("Invalid band: {}", value)), } } } #[derive(Debug, Default, Clone, PartialEq, Serialize, utoipa::ToSchema)] pub struct BondOptions(pub HashMap<String, String>); impl TryFrom<&str> for BondOptions { type Error = NetworkStateError; fn try_from(value: &str) -> Result<Self, Self::Error> { let mut options = HashMap::new(); for opt in value.split_whitespace() { let (key, value) = opt .trim() .split_once('=') .ok_or(NetworkStateError::InvalidBondOptions)?; options.insert(key.to_string(), value.to_string()); } Ok(BondOptions(options)) } } impl fmt::Display for BondOptions { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let opts = &self .0 .iter() .map(|(key, value)| format!("{key}={value}")) .collect::<Vec<_>>(); write!(f, "{}", opts.join(" ")) } } #[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct BondConfig { pub mode: BondMode, pub options: BondOptions, } impl TryFrom<ConnectionConfig> for BondConfig { type Error = NetworkStateError; fn try_from(value: ConnectionConfig) -> Result<Self, Self::Error> { match value { ConnectionConfig::Bond(config) => Ok(config), _ => Err(NetworkStateError::UnexpectedConfiguration), } } } impl TryFrom<BondSettings> for BondConfig { type Error = NetworkStateError; fn try_from(settings: BondSettings) -> Result<Self, Self::Error> { let mode = BondMode::try_from(settings.mode.as_str()) .map_err(|_| NetworkStateError::InvalidBondMode(settings.mode))?; let mut options = BondOptions::default(); if let Some(setting_options) = settings.options { options = BondOptions::try_from(setting_options.as_str())?; } Ok(BondConfig { mode, options }) } } impl TryFrom<BondConfig> for BondSettings { type Error = NetworkStateError; fn try_from(bond: BondConfig) -> Result<Self, Self::Error> { Ok(BondSettings { mode: bond.mode.to_string(), options: Some(bond.options.to_string()), ..Default::default() }) } } #[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct BridgeConfig { pub stp: bool, #[serde(skip_serializing_if = "Option::is_none")] pub priority: Option<u32>, #[serde(skip_serializing_if = "Option::is_none")] pub forward_delay: Option<u32>, #[serde(skip_serializing_if = "Option::is_none")] pub hello_time: Option<u32>, #[serde(skip_serializing_if = "Option::is_none")] pub max_age: Option<u32>, #[serde(skip_serializing_if = "Option::is_none")] pub ageing_time: Option<u32>, } #[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct BridgePortConfig { #[serde(skip_serializing_if = "Option::is_none")] pub priority: Option<u32>, #[serde(skip_serializing_if = "Option::is_none")] pub path_cost: Option<u32>, } #[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct InfinibandConfig { pub p_key: Option<i32>, pub parent: Option<String>, pub transport_mode: InfinibandTransportMode, } #[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum InfinibandTransportMode { #[default] Datagram, Connected, } #[derive(Debug, Error)] #[error("Invalid infiniband transport-mode: {0}")] pub struct InvalidInfinibandTransportMode(String); impl FromStr for InfinibandTransportMode { type Err = InvalidInfinibandTransportMode; fn from_str(s: &str) -> Result<Self, Self::Err> { match s { "datagram" => Ok(Self::Datagram), "connected" => Ok(Self::Connected), _ => Err(InvalidInfinibandTransportMode(s.to_string())), } } } impl fmt::Display for InfinibandTransportMode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let name = match &self { InfinibandTransportMode::Datagram => "datagram", InfinibandTransportMode::Connected => "connected", }; write!(f, "{}", name) } } #[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum TunMode { #[default] Tun = 1, Tap = 2, } #[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct TunConfig { pub mode: TunMode, pub group: Option<String>, pub owner: Option<String>, } /// Represents a network change. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub enum NetworkChange { /// A new device has been added. DeviceAdded(Device), /// A device has been removed. DeviceRemoved(String), /// The device has been updated. The String corresponds to the /// original device name, which is especially useful if the /// device gets renamed. DeviceUpdated(String, Device), } #[derive(Default, Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct IEEE8021XConfig { pub eap: Vec<EAPMethod>, pub phase2_auth: Option<Phase2AuthMethod>, pub identity: Option<String>, pub password: Option<String>, pub ca_cert: Option<String>, pub ca_cert_password: Option<String>, pub client_cert: Option<String>, pub client_cert_password: Option<String>, pub private_key: Option<String>, pub private_key_password: Option<String>, pub anonymous_identity: Option<String>, pub peap_version: Option<String>, pub peap_label: bool, } impl TryFrom<IEEE8021XSettings> for IEEE8021XConfig { type Error = NetworkStateError; fn try_from(value: IEEE8021XSettings) -> Result<Self, Self::Error> { let eap = value .eap .iter() .map(|x| { EAPMethod::from_str(x) .map_err(|_| NetworkStateError::InvalidEAPMethod(x.to_string())) }) .collect::<Result<Vec<EAPMethod>, NetworkStateError>>()?; let phase2_auth = if let Some(phase2_auth) = &value.phase2_auth { Some(Phase2AuthMethod::from_str(phase2_auth).map_err(|_| { NetworkStateError::InvalidPhase2AuthMethod(phase2_auth.to_string()) })?) } else { None }; Ok(IEEE8021XConfig { eap, phase2_auth, identity: value.identity, password: value.password, ca_cert: value.ca_cert, ca_cert_password: value.ca_cert_password, client_cert: value.client_cert, client_cert_password: value.client_cert_password, private_key: value.private_key, private_key_password: value.private_key_password, anonymous_identity: value.anonymous_identity, peap_version: value.peap_version, peap_label: value.peap_label, }) } } impl TryFrom<IEEE8021XConfig> for IEEE8021XSettings { type Error = NetworkStateError; fn try_from(value: IEEE8021XConfig) -> Result<Self, Self::Error> { let eap = value .eap .iter() .map(|x| x.to_string()) .collect::<Vec<String>>(); let phase2_auth = value.phase2_auth.map(|phase2_auth| phase2_auth.to_string()); Ok(IEEE8021XSettings { eap, phase2_auth, identity: value.identity, password: value.password, ca_cert: value.ca_cert, ca_cert_password: value.ca_cert_password, client_cert: value.client_cert, client_cert_password: value.client_cert_password, private_key: value.private_key, private_key_password: value.private_key_password, anonymous_identity: value.anonymous_identity, peap_version: value.peap_version, peap_label: value.peap_label, }) } } #[derive(Debug, Error)] #[error("Invalid eap method: {0}")] pub struct InvalidEAPMethod(String); #[derive(Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum EAPMethod { LEAP, MD5, TLS, PEAP, TTLS, PWD, FAST, } impl FromStr for EAPMethod { type Err = InvalidEAPMethod; fn from_str(s: &str) -> Result<Self, Self::Err> { match s { "leap" => Ok(Self::LEAP), "md5" => Ok(Self::MD5), "tls" => Ok(Self::TLS), "peap" => Ok(Self::PEAP), "ttls" => Ok(Self::TTLS), "pwd" => Ok(Self::PWD), "fast" => Ok(Self::FAST), _ => Err(InvalidEAPMethod(s.to_string())), } } } impl fmt::Display for EAPMethod { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let value = match &self { Self::LEAP => "leap", Self::MD5 => "md5", Self::TLS => "tls", Self::PEAP => "peap", Self::TTLS => "ttls", Self::PWD => "pwd", Self::FAST => "fast", }; write!(f, "{}", value) } } #[derive(Debug, Error)] #[error("Invalid phase2-auth method: {0}")] pub struct InvalidPhase2AuthMethod(String); #[derive(Debug, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum Phase2AuthMethod { PAP, CHAP, MSCHAP, MSCHAPV2, GTC, OTP, MD5, TLS, } impl FromStr for Phase2AuthMethod { type Err = InvalidPhase2AuthMethod; fn from_str(s: &str) -> Result<Self, Self::Err> { match s { "pap" => Ok(Self::PAP), "chap" => Ok(Self::CHAP), "mschap" => Ok(Self::MSCHAP), "mschapv2" => Ok(Self::MSCHAPV2), "gtc" => Ok(Self::GTC), "otp" => Ok(Self::OTP), "md5" => Ok(Self::MD5), "tls" => Ok(Self::TLS), _ => Err(InvalidPhase2AuthMethod(s.to_string())), } } } impl fmt::Display for Phase2AuthMethod { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let value = match self { Self::PAP => "pap", Self::CHAP => "chap", Self::MSCHAP => "mschap", Self::MSCHAPV2 => "mschapv2", Self::GTC => "gtc", Self::OTP => "otp", Self::MD5 => "md5", Self::TLS => "tls", }; write!(f, "{}", value) } } 070701000000A7000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000002200000000agama/agama-server/src/network/nm070701000000A8000081A4000000000000000000000001671F5A640000055A000000000000000000000000000000000000002500000000agama/agama-server/src/network/nm.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Support for interacting with [NetworkManager](https://networkmanager.dev/). //! //! This module defines [a NetworkManager client](client::NetworkManagerClient) and a set of //! structs and enums to work with NetworkManager configuration. It is intended to be used //! internally, so the API is focused on Agama's use cases. mod adapter; mod builder; mod client; mod dbus; mod error; mod model; mod proxies; mod watcher; pub use adapter::NetworkManagerAdapter; pub use client::NetworkManagerClient; pub use watcher::NetworkManagerWatcher; 070701000000A9000081A4000000000000000000000001671F5A6400001A03000000000000000000000000000000000000002D00000000agama/agama-server/src/network/nm/adapter.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use crate::network::{ adapter::Watcher, model::{Connection, NetworkState, StateConfig}, nm::{NetworkManagerClient, NetworkManagerWatcher}, Adapter, NetworkAdapterError, }; use agama_lib::error::ServiceError; use async_trait::async_trait; use core::time; use log; use std::thread; /// An adapter for NetworkManager pub struct NetworkManagerAdapter<'a> { client: NetworkManagerClient<'a>, connection: zbus::Connection, } impl<'a> NetworkManagerAdapter<'a> { /// Returns the adapter for system's NetworkManager. pub async fn from_system() -> Result<NetworkManagerAdapter<'a>, ServiceError> { let connection = zbus::Connection::system().await?; let client = NetworkManagerClient::new(connection.clone()).await?; Ok(Self { client, connection }) } } #[async_trait] impl<'a> Adapter for NetworkManagerAdapter<'a> { async fn read(&self, config: StateConfig) -> Result<NetworkState, NetworkAdapterError> { let general_state = self .client .general_state() .await .map_err(NetworkAdapterError::Read)?; let mut state = NetworkState::default(); if config.general_state { state.general_state = general_state.clone(); } if config.devices { state.devices = self .client .devices() .await .map_err(NetworkAdapterError::Read)?; } if config.connections { state.connections = self .client .connections() .await .map_err(NetworkAdapterError::Read)?; } if config.access_points && general_state.wireless_enabled { if !config.devices && !config.connections { self.client .request_scan() .await .map_err(NetworkAdapterError::Read)?; thread::sleep(time::Duration::from_secs(1)); }; state.access_points = self .client .access_points() .await .map_err(NetworkAdapterError::Read)?; } Ok(state) } /// Writes the connections to NetworkManager. /// /// Internally, it creates an ordered list of connections before processing them. The reason is /// that using async recursive functions is giving us some troubles, so we decided to go with a /// simpler approach. /// /// * `network`: network model. async fn write(&self, network: &NetworkState) -> Result<(), NetworkAdapterError> { let old_state = self.read(StateConfig::default()).await?; let checkpoint = self .client .create_checkpoint() .await .map_err(NetworkAdapterError::Checkpoint)?; log::info!("Updating the general state {:?}", &network.general_state); let result = self .client .update_general_state(&network.general_state) .await; if let Err(e) = result { self.client .rollback_checkpoint(&checkpoint.as_ref()) .await .map_err(NetworkAdapterError::Checkpoint)?; log::error!( "Could not update the general state {:?}: {}", &network.general_state, &e ); return Err(NetworkAdapterError::Write(e)); } for conn in ordered_connections(network) { if let Some(old_conn) = old_state.get_connection_by_uuid(conn.uuid) { if old_conn == conn { continue; } } else if conn.is_removed() { log::info!( "Connection {} ({}) does not need to be removed", conn.id, conn.uuid ); continue; } log::info!("Updating connection {} ({})", conn.id, conn.uuid); let result = if conn.is_removed() { self.client.remove_connection(conn.uuid).await } else { let ctrl = conn .controller .and_then(|uuid| network.get_connection_by_uuid(uuid)); self.client.add_or_update_connection(conn, ctrl).await }; if let Err(e) = result { self.client .rollback_checkpoint(&checkpoint.as_ref()) .await .map_err(NetworkAdapterError::Checkpoint)?; log::error!("Could not process the connection {}: {}", conn.id, &e); return Err(NetworkAdapterError::Write(e)); } } self.client .destroy_checkpoint(&checkpoint.as_ref()) .await .map_err(NetworkAdapterError::Checkpoint)?; Ok(()) } fn watcher(&self) -> Option<Box<dyn Watcher + Send>> { Some(Box::new(NetworkManagerWatcher::new(&self.connection))) } } /// Returns the connections in the order they should be processed. /// /// * `network`: network model. fn ordered_connections(network: &NetworkState) -> Vec<&Connection> { let mut conns: Vec<&Connection> = vec![]; for conn in &network.connections { add_ordered_connections(conn, network, &mut conns); } conns } fn add_ordered_connections<'b>( conn: &'b Connection, network: &'b NetworkState, conns: &mut Vec<&'b Connection>, ) { if let Some(uuid) = conn.controller { let controller = network.get_connection_by_uuid(uuid).unwrap(); add_ordered_connections(controller, network, conns); } if !conns.contains(&conn) { conns.push(conn); } } 070701000000AA000081A4000000000000000000000001671F5A6400002168000000000000000000000000000000000000002D00000000agama/agama-server/src/network/nm/builder.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Conversion mechanism between proxies and model structs. use crate::network::{ model::{Device, IpConfig, IpRoute, MacAddress}, nm::{ model::NmDeviceType, proxies::{DeviceProxy, IP4ConfigProxy, IP6ConfigProxy}, }, }; use agama_lib::{ error::ServiceError, network::types::{DeviceState, DeviceType}, }; use anyhow::Context; use cidr::IpInet; use std::{collections::HashMap, net::IpAddr, str::FromStr}; /// Builder to create a [Device] from its corresponding NetworkManager D-Bus representation. pub struct DeviceFromProxyBuilder<'a> { connection: zbus::Connection, proxy: &'a DeviceProxy<'a>, } impl<'a> DeviceFromProxyBuilder<'a> { pub fn new(connection: &zbus::Connection, proxy: &'a DeviceProxy<'a>) -> Self { Self { connection: connection.clone(), proxy, } } /// Creates a [Device] starting on the [DeviceProxy]. pub async fn build(&self) -> Result<Device, ServiceError> { let device_type = NmDeviceType(self.proxy.device_type().await?); // TODO: we need to check the errors hierarchy to not abuse ServiceError. let type_: DeviceType = device_type .try_into() .context("Unsupported device type: {device_type}")?; let state = self.proxy.state().await? as u8; let (_, state_reason) = self.proxy.state_reason().await?; let state: DeviceState = state .try_into() .context("Unsupported device state: {state}")?; let mut device = Device { name: self.proxy.interface().await?, type_, state, state_reason: state_reason as u8, ..Default::default() }; if state == DeviceState::Activated { device.ip_config = self.build_ip_config().await?; } device.mac_address = self.mac_address_from_dbus(self.proxy.hw_address().await?.as_str()); if let Ok((connection, _)) = self.proxy.get_applied_connection(0).await { device.connection = self.connection_id(connection); } Ok(device) } async fn build_ip_config(&self) -> Result<Option<IpConfig>, ServiceError> { let ip4_path = self.proxy.ip4_config().await?; let ip6_path = self.proxy.ip6_config().await?; let ip4_proxy = IP4ConfigProxy::builder(&self.connection) .path(ip4_path.as_str())? .build() .await; let Ok(ip4_proxy) = ip4_proxy else { return Ok(None); }; let ip6_proxy = IP6ConfigProxy::builder(&self.connection) .path(ip6_path.as_str())? .build() .await; let Ok(ip6_proxy) = ip6_proxy else { return Ok(None); }; let result = self .build_ip_config_from_proxies(ip4_proxy, ip6_proxy) .await .ok(); Ok(result) } async fn build_ip_config_from_proxies( &self, ip4_proxy: IP4ConfigProxy<'_>, ip6_proxy: IP6ConfigProxy<'_>, ) -> Result<IpConfig, ServiceError> { let address_data = ip4_proxy.address_data().await?; let nameserver_data = ip4_proxy.nameserver_data().await?; let mut addresses: Vec<IpInet> = vec![]; let mut nameservers: Vec<IpAddr> = vec![]; for addr in address_data { if let Some(address) = self.address_with_prefix_from_dbus(addr) { addresses.push(address) } } let address_data = ip6_proxy.address_data().await?; for addr in address_data { if let Some(address) = self.address_with_prefix_from_dbus(addr) { addresses.push(address) } } for nameserver in nameserver_data { if let Some(address) = self.nameserver_from_dbus(nameserver) { nameservers.push(address) } } // FIXME: Convert from Vec<u8> to [u8; 16] and take into account big vs little endian order, // in IP6Config there is no nameserver-data. // // let nameserver_data = ip6_proxy.nameservers().await?; let route_data = ip4_proxy.route_data().await?; let mut routes4: Vec<IpRoute> = vec![]; if !route_data.is_empty() { for route in route_data { if let Some(route) = self.route_from_dbus(route) { routes4.push(route) } } } let mut routes6: Vec<IpRoute> = vec![]; let route_data = ip6_proxy.route_data().await?; if !route_data.is_empty() { for route in route_data { if let Some(route) = self.route_from_dbus(route) { routes6.push(route) } } } let ip4_gateway = ip4_proxy.gateway().await?; let ip6_gateway = ip6_proxy.gateway().await?; let mut ip_config = IpConfig { addresses, nameservers, ..Default::default() }; if !ip4_gateway.is_empty() { ip_config.gateway4 = Some(ip4_gateway.parse().unwrap()); }; if !ip6_gateway.is_empty() { ip_config.gateway6 = Some(ip6_gateway.parse().unwrap()); }; if !routes4.is_empty() { ip_config.routes4 = Some(routes4); } if !routes6.is_empty() { ip_config.routes6 = Some(routes6); } Ok(ip_config) } pub fn address_with_prefix_from_dbus( &self, address_data: HashMap<String, zbus::zvariant::OwnedValue>, ) -> Option<IpInet> { let addr_str: &str = address_data.get("address")?.downcast_ref()?; let prefix: &u32 = address_data.get("prefix")?.downcast_ref()?; let prefix = *prefix as u8; let address = IpInet::new(addr_str.parse().unwrap(), prefix).ok()?; Some(address) } pub fn nameserver_from_dbus( &self, nameserver_data: HashMap<String, zbus::zvariant::OwnedValue>, ) -> Option<IpAddr> { let addr_str: &str = nameserver_data.get("address")?.downcast_ref()?; Some(addr_str.parse().unwrap()) } pub fn route_from_dbus( &self, route_data: HashMap<String, zbus::zvariant::OwnedValue>, ) -> Option<IpRoute> { let dest_str: &str = route_data.get("dest")?.downcast_ref()?; let prefix: u8 = *route_data.get("prefix")?.downcast_ref::<u32>()? as u8; let destination = IpInet::new(dest_str.parse().unwrap(), prefix).ok()?; let mut new_route = IpRoute { destination, next_hop: None, metric: None, }; if let Some(next_hop) = route_data.get("next-hop") { let next_hop_str: &str = next_hop.downcast_ref()?; new_route.next_hop = Some(IpAddr::from_str(next_hop_str).unwrap()); } if let Some(metric) = route_data.get("metric") { let metric: u32 = *metric.downcast_ref()?; new_route.metric = Some(metric); } Some(new_route) } fn mac_address_from_dbus(&self, mac: &str) -> MacAddress { match MacAddress::from_str(mac) { Ok(mac) => mac, Err(_) => { log::warn!("Unable to parse mac {}", &mac); MacAddress::Unset } } } pub fn connection_id( &self, connection_data: HashMap<String, HashMap<String, zbus::zvariant::OwnedValue>>, ) -> Option<String> { let connection = connection_data.get("connection")?; let id: &str = connection.get("id")?.downcast_ref()?; Some(id.to_string()) } } 070701000000AB000081A4000000000000000000000001671F5A6400003311000000000000000000000000000000000000002C00000000agama/agama-server/src/network/nm/client.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! NetworkManager client. use std::collections::HashMap; use super::builder::DeviceFromProxyBuilder; use super::dbus::{ cleanup_dbus_connection, connection_from_dbus, connection_to_dbus, controller_from_dbus, merge_dbus_connections, }; use super::model::NmDeviceType; use super::proxies::{ AccessPointProxy, ActiveConnectionProxy, ConnectionProxy, DeviceProxy, NetworkManagerProxy, SettingsProxy, WirelessProxy, }; use crate::network::model::{AccessPoint, Connection, Device, GeneralState}; use agama_lib::error::ServiceError; use agama_lib::network::types::{DeviceType, SSID}; use log; use uuid::Uuid; use zbus; use zbus::zvariant::{ObjectPath, OwnedObjectPath}; /// Simplified NetworkManager D-Bus client. /// /// Implements a minimal API to be used internally. At this point, it allows to query the list of /// network devices and connections, converting them to its own data types. pub struct NetworkManagerClient<'a> { connection: zbus::Connection, nm_proxy: NetworkManagerProxy<'a>, } impl<'a> NetworkManagerClient<'a> { /// Creates a NetworkManagerClient using the given D-Bus connection. /// /// * `connection`: D-Bus connection. pub async fn new( connection: zbus::Connection, ) -> Result<NetworkManagerClient<'a>, ServiceError> { Ok(Self { nm_proxy: NetworkManagerProxy::new(&connection).await?, connection, }) } /// Returns the general state pub async fn general_state(&self) -> Result<GeneralState, ServiceError> { let proxy = SettingsProxy::new(&self.connection).await?; let hostname = proxy.hostname().await?; let wireless_enabled = self.nm_proxy.wireless_enabled().await?; let networking_enabled = self.nm_proxy.networking_enabled().await?; // TODO:: Allow to set global DNS configuration // let global_dns_configuration = self.nm_proxy.global_dns_configuration().await?; // Fixme: save as NMConnectivityState enum let connectivity = self.nm_proxy.connectivity().await? == 4; Ok(GeneralState { hostname, wireless_enabled, networking_enabled, connectivity, }) } /// Updates the general state pub async fn update_general_state(&self, state: &GeneralState) -> Result<(), ServiceError> { let wireless_enabled = self.nm_proxy.wireless_enabled().await?; if wireless_enabled != state.wireless_enabled { self.nm_proxy .set_wireless_enabled(state.wireless_enabled) .await?; }; Ok(()) } /// Returns the list of access points. pub async fn request_scan(&self) -> Result<(), ServiceError> { for path in &self.nm_proxy.get_devices().await? { let proxy = DeviceProxy::builder(&self.connection) .path(path.as_str())? .build() .await?; let device_type = NmDeviceType(proxy.device_type().await?).try_into(); if let Ok(DeviceType::Wireless) = device_type { let wproxy = WirelessProxy::builder(&self.connection) .path(path.as_str())? .build() .await?; wproxy.request_scan(HashMap::new()).await?; } } Ok(()) } /// Returns the list of access points. pub async fn access_points(&self) -> Result<Vec<AccessPoint>, ServiceError> { let mut points = vec![]; for path in &self.nm_proxy.get_devices().await? { let proxy = DeviceProxy::builder(&self.connection) .path(path.as_str())? .build() .await?; let device_type = NmDeviceType(proxy.device_type().await?).try_into(); if let Ok(DeviceType::Wireless) = device_type { let wproxy = WirelessProxy::builder(&self.connection) .path(path.as_str())? .build() .await?; for ap_path in wproxy.access_points().await? { let wproxy = AccessPointProxy::builder(&self.connection) .path(ap_path.as_str())? .build() .await?; let ssid = SSID(wproxy.ssid().await?); let hw_address = wproxy.hw_address().await?; let strength = wproxy.strength().await?; let flags = wproxy.flags().await?; let rsn_flags = wproxy.rsn_flags().await?; let wpa_flags = wproxy.wpa_flags().await?; points.push(AccessPoint { ssid, hw_address, strength, flags, rsn_flags, wpa_flags, }) } } } Ok(points) } /// Returns the list of network devices. pub async fn devices(&self) -> Result<Vec<Device>, ServiceError> { let mut devs = vec![]; for path in &self.nm_proxy.get_all_devices().await? { let proxy = DeviceProxy::builder(&self.connection) .path(path.as_str())? .build() .await?; if let Ok(device) = DeviceFromProxyBuilder::new(&self.connection, &proxy) .build() .await { devs.push(device); } else { tracing::warn!("Ignoring network device on path {}", &path); } } Ok(devs) } /// Returns the list of network connections. pub async fn connections(&self) -> Result<Vec<Connection>, ServiceError> { let mut controlled_by: HashMap<Uuid, String> = HashMap::new(); let mut uuids_map: HashMap<String, Uuid> = HashMap::new(); let proxy = SettingsProxy::new(&self.connection).await?; let paths = proxy.list_connections().await?; let mut connections: Vec<Connection> = Vec::with_capacity(paths.len()); for path in paths { let proxy = ConnectionProxy::builder(&self.connection) .path(path.as_str())? .build() .await?; let flags = proxy.flags().await?; // https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMSettingsConnectionFlags if flags & 8 != 0 { log::warn!("Skipped connection because of flags: {}", flags); continue; } let settings = proxy.get_settings().await?; if let Some(mut connection) = connection_from_dbus(settings.clone()) { if let Some(controller) = controller_from_dbus(&settings) { controlled_by.insert(connection.uuid, controller.to_string()); } if let Some(iname) = &connection.interface { uuids_map.insert(iname.to_string(), connection.uuid); } if self.settings_active_connection(path).await?.is_none() { connection.set_down() } connections.push(connection); } } for conn in connections.iter_mut() { let Some(interface_name) = controlled_by.get(&conn.uuid) else { continue; }; if let Some(uuid) = uuids_map.get(interface_name) { conn.controller = Some(*uuid); } else { log::warn!( "Could not found a connection for the interface '{}' (required by connection '{}')", interface_name, conn.id ); } } Ok(connections) } /// Adds or updates a connection if it already exists. /// /// * `conn`: connection to add or update. pub async fn add_or_update_connection( &self, conn: &Connection, controller: Option<&Connection>, ) -> Result<(), ServiceError> { let mut new_conn = connection_to_dbus(conn, controller); let path = if let Ok(proxy) = self.get_connection_proxy(conn.uuid).await { let original = proxy.get_settings().await?; let merged = merge_dbus_connections(&original, &new_conn); proxy.update(merged).await?; OwnedObjectPath::from(proxy.path().to_owned()) } else { let proxy = SettingsProxy::new(&self.connection).await?; cleanup_dbus_connection(&mut new_conn); proxy.add_connection(new_conn).await? }; if conn.is_up() { self.activate_connection(path).await?; } else { self.deactivate_connection(path).await?; } Ok(()) } /// Removes a network connection. pub async fn remove_connection(&self, uuid: Uuid) -> Result<(), ServiceError> { let proxy = self.get_connection_proxy(uuid).await?; proxy.delete().await?; Ok(()) } /// Creates a checkpoint. pub async fn create_checkpoint(&self) -> Result<OwnedObjectPath, ServiceError> { let path = self.nm_proxy.checkpoint_create(&[], 0, 0).await?; Ok(path) } /// Destroys a checkpoint. /// /// * `checkpoint`: checkpoint's D-Bus path. pub async fn destroy_checkpoint( &self, checkpoint: &ObjectPath<'_>, ) -> Result<(), ServiceError> { self.nm_proxy.checkpoint_destroy(checkpoint).await?; Ok(()) } /// Rolls the configuration back to the given checkpoint. /// /// * `checkpoint`: checkpoint's D-Bus path. pub async fn rollback_checkpoint( &self, checkpoint: &ObjectPath<'_>, ) -> Result<(), ServiceError> { self.nm_proxy.checkpoint_rollback(checkpoint).await?; Ok(()) } /// Activates a NetworkManager connection. /// /// * `path`: D-Bus patch of the connection. async fn activate_connection(&self, path: OwnedObjectPath) -> Result<(), ServiceError> { let proxy = NetworkManagerProxy::new(&self.connection).await?; let root = ObjectPath::try_from("/").unwrap(); proxy .activate_connection(&path.as_ref(), &root, &root) .await?; Ok(()) } /// Deactivates a NetworkManager connection. /// /// * `path`: D-Bus patch of the connection. async fn deactivate_connection(&self, path: OwnedObjectPath) -> Result<(), ServiceError> { let proxy = NetworkManagerProxy::new(&self.connection).await?; if let Some(active_connection) = self.settings_active_connection(path.clone()).await? { if let Err(e) = proxy .deactivate_connection(&active_connection.as_ref()) .await { // Ignore ConnectionNotActive error since this just means the state is already correct if !e.to_string().contains("ConnectionNotActive") { return Err(ServiceError::DBus(e)); } } } Ok(()) } async fn get_connection_proxy(&self, uuid: Uuid) -> Result<ConnectionProxy, ServiceError> { let proxy = SettingsProxy::new(&self.connection).await?; let uuid_s = uuid.to_string(); let path = proxy.get_connection_by_uuid(uuid_s.as_str()).await?; let proxy = ConnectionProxy::builder(&self.connection) .path(path)? .build() .await?; Ok(proxy) } async fn settings_active_connection( &self, path: OwnedObjectPath, ) -> Result<Option<OwnedObjectPath>, ServiceError> { for active_path in &self.nm_proxy.active_connections().await? { let proxy = ActiveConnectionProxy::builder(&self.connection) .path(active_path.as_str())? .build() .await?; let connection = proxy.connection().await?; if path == connection { return Ok(Some(active_path.to_owned())); }; } Ok(None) } } 070701000000AC000081A4000000000000000000000001671F5A6400013868000000000000000000000000000000000000002A00000000agama/agama-server/src/network/nm/dbus.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module implements some functions to convert from/to D-Bus types //! //! Working with hash maps coming from D-Bus is rather tedious and it is even worse when working //! with nested hash maps (see [NestedHash] and [OwnedNestedHash]). use super::model::*; use crate::network::model::*; use agama_lib::{ dbus::{NestedHash, OwnedNestedHash}, network::types::{BondMode, SSID}, }; use cidr::IpInet; use macaddr::MacAddr6; use std::{collections::HashMap, net::IpAddr, str::FromStr}; use uuid::Uuid; use zbus::zvariant::{self, OwnedValue, Value}; const ETHERNET_KEY: &str = "802-3-ethernet"; const BOND_KEY: &str = "bond"; const WIRELESS_KEY: &str = "802-11-wireless"; const WIRELESS_SECURITY_KEY: &str = "802-11-wireless-security"; const LOOPBACK_KEY: &str = "loopback"; const DUMMY_KEY: &str = "dummy"; const VLAN_KEY: &str = "vlan"; const BRIDGE_KEY: &str = "bridge"; const BRIDGE_PORT_KEY: &str = "bridge-port"; const INFINIBAND_KEY: &str = "infiniband"; const TUN_KEY: &str = "tun"; const IEEE_8021X_KEY: &str = "802-1x"; /// Converts a connection struct into a HashMap that can be sent over D-Bus. /// /// * `conn`: Connection to convert. pub fn connection_to_dbus<'a>( conn: &'a Connection, controller: Option<&'a Connection>, ) -> NestedHash<'a> { let mut result = NestedHash::new(); let mut connection_dbus = HashMap::from([ ("id", conn.id.as_str().into()), ("type", ETHERNET_KEY.into()), ]); if let Some(interface) = &conn.interface { connection_dbus.insert("interface-name", interface.to_owned().into()); } if let Some(controller) = controller { let slave_type = match controller.config { ConnectionConfig::Bond(_) => BOND_KEY, ConnectionConfig::Bridge(_) => BRIDGE_KEY, _ => { log::error!("Controller {} has unhandled config type", controller.id); "" } }; connection_dbus.insert("slave-type", slave_type.into()); let master = controller .interface .as_deref() .unwrap_or(controller.id.as_str()); connection_dbus.insert("master", master.into()); } else { connection_dbus.insert("slave-type", "".into()); connection_dbus.insert("master", "".into()); } if let Some(zone) = &conn.firewall_zone { connection_dbus.insert("zone", zone.into()); } result.insert("ipv4", ip_config_to_ipv4_dbus(&conn.ip_config)); result.insert("ipv6", ip_config_to_ipv6_dbus(&conn.ip_config)); result.insert("match", match_config_to_dbus(&conn.match_config)); if conn.is_ethernet() { let ethernet_config = HashMap::from([ ( "assigned-mac-address", Value::new(conn.mac_address.to_string()), ), ("mtu", Value::new(conn.mtu)), ]); result.insert(ETHERNET_KEY, ethernet_config); } match &conn.config { ConnectionConfig::Wireless(wireless) => { connection_dbus.insert("type", WIRELESS_KEY.into()); let mut wireless_dbus = wireless_config_to_dbus(wireless); if let Some(wireless_dbus_key) = wireless_dbus.get_mut(WIRELESS_KEY) { wireless_dbus_key.extend(HashMap::from([ ("mtu", Value::new(conn.mtu)), ( "assigned-mac-address", Value::new(conn.mac_address.to_string()), ), ])); } result.extend(wireless_dbus); } ConnectionConfig::Bond(bond) => { connection_dbus.insert("type", BOND_KEY.into()); if !connection_dbus.contains_key("interface-name") { connection_dbus.insert("interface-name", conn.id.as_str().into()); } result.insert(BOND_KEY, bond_config_to_dbus(bond)); } ConnectionConfig::Dummy => { connection_dbus.insert("type", DUMMY_KEY.into()); } ConnectionConfig::Vlan(vlan) => { connection_dbus.insert("type", VLAN_KEY.into()); result.extend(vlan_config_to_dbus(vlan)); } ConnectionConfig::Bridge(bridge) => { connection_dbus.insert("type", BRIDGE_KEY.into()); result.insert(BRIDGE_KEY, bridge_config_to_dbus(bridge)); } ConnectionConfig::Infiniband(infiniband) => { connection_dbus.insert("type", INFINIBAND_KEY.into()); result.insert(INFINIBAND_KEY, infiniband_config_to_dbus(infiniband)); } ConnectionConfig::Loopback => { connection_dbus.insert("type", LOOPBACK_KEY.into()); } ConnectionConfig::Tun(tun) => { connection_dbus.insert("type", TUN_KEY.into()); result.insert(TUN_KEY, tun_config_to_dbus(tun)); } _ => {} } match &conn.port_config { PortConfig::Bridge(bridge_port) => { result.insert(BRIDGE_PORT_KEY, bridge_port_config_to_dbus(bridge_port)); } PortConfig::None => {} } if let Some(ieee_8021x_config) = &conn.ieee_8021x_config { result.insert(IEEE_8021X_KEY, ieee_8021x_config_to_dbus(ieee_8021x_config)); } result.insert("connection", connection_dbus); result } /// Converts an OwnedNestedHash from D-Bus into a Connection. /// /// This functions tries to turn a OwnedHashMap coming from D-Bus into a Connection. pub fn connection_from_dbus(conn: OwnedNestedHash) -> Option<Connection> { let mut connection = base_connection_from_dbus(&conn)?; if let Some(bridge_port_config) = bridge_port_config_from_dbus(&conn) { connection.port_config = PortConfig::Bridge(bridge_port_config); } if let Some(ieee_8021x_config) = ieee_8021x_config_from_dbus(&conn) { connection.ieee_8021x_config = Some(ieee_8021x_config); } if let Some(wireless_config) = wireless_config_from_dbus(&conn) { connection.config = ConnectionConfig::Wireless(wireless_config); return Some(connection); } if let Some(bond_config) = bond_config_from_dbus(&conn) { connection.config = ConnectionConfig::Bond(bond_config); return Some(connection); } if let Some(vlan_config) = vlan_config_from_dbus(&conn) { connection.config = ConnectionConfig::Vlan(vlan_config); return Some(connection); } if let Some(bridge_config) = bridge_config_from_dbus(&conn) { connection.config = ConnectionConfig::Bridge(bridge_config); return Some(connection); } if let Some(infiniband_config) = infiniband_config_from_dbus(&conn) { connection.config = ConnectionConfig::Infiniband(infiniband_config); return Some(connection); } if let Some(tun_config) = tun_config_from_dbus(&conn) { connection.config = ConnectionConfig::Tun(tun_config); return Some(connection); } if conn.contains_key(DUMMY_KEY) { connection.config = ConnectionConfig::Dummy; return Some(connection); }; if conn.contains_key(LOOPBACK_KEY) { connection.config = ConnectionConfig::Loopback; return Some(connection); }; if conn.contains_key(ETHERNET_KEY) { return Some(connection); }; None } /// Merges a NestedHash and an OwnedNestedHash connections. /// /// Only the top-level sections that are present in the `original` hash are considered for update. /// /// * `original`: original hash coming from D-Bus. /// * `updated`: updated hash to write to D-Bus. pub fn merge_dbus_connections<'a>( original: &'a OwnedNestedHash, updated: &'a NestedHash, ) -> NestedHash<'a> { let mut merged = HashMap::with_capacity(original.len()); for (key, orig_section) in original { let mut inner: HashMap<&str, zbus::zvariant::Value> = HashMap::with_capacity(orig_section.len()); for (inner_key, value) in orig_section { inner.insert(inner_key.as_str(), value.into()); } if let Some(upd_section) = updated.get(key.as_str()) { for (inner_key, value) in upd_section { inner.insert(inner_key, value.clone()); } } merged.insert(key.as_str(), inner); } cleanup_dbus_connection(&mut merged); merged } /// Cleans up the NestedHash that represents a connection. /// /// By now it just removes the "addresses" key from the "ipv4" and "ipv6" objects, which is /// replaced with "address-data". However, if "addresses" is present, it takes precedence. /// /// * `conn`: connection represented as a NestedHash. pub fn cleanup_dbus_connection(conn: &mut NestedHash) { if let Some(connection) = conn.get_mut("connection") { if connection.get("interface-name").is_some_and(is_empty_value) { connection.remove("interface-name"); } if connection.get("master").is_some_and(is_empty_value) { connection.remove("master"); } if connection.get("slave-type").is_some_and(is_empty_value) { connection.remove("slave-type"); } } if let Some(ipv4) = conn.get_mut("ipv4") { ipv4.remove("addresses"); ipv4.remove("dns"); if ipv4.get("address-data").is_some_and(is_empty_value) { ipv4.remove("gateway"); } } if let Some(ipv6) = conn.get_mut("ipv6") { ipv6.remove("addresses"); ipv6.remove("dns"); if ipv6.get("address-data").is_some_and(is_empty_value) { ipv6.remove("gateway"); } } } /// Ancillary function to get the controller for a given interface. pub fn controller_from_dbus(conn: &OwnedNestedHash) -> Option<String> { let connection = conn.get("connection")?; let master: &str = connection.get("master")?.downcast_ref()?; Some(master.to_string()) } fn ip_config_to_ipv4_dbus(ip_config: &IpConfig) -> HashMap<&str, zvariant::Value> { let addresses: Vec<HashMap<&str, Value>> = ip_config .addresses .iter() .filter(|ip| ip.is_ipv4()) .map(|ip| { HashMap::from([ ("address", Value::new(ip.address().to_string())), ("prefix", Value::new(ip.network_length() as u32)), ]) }) .collect(); let address_data: Value = addresses.into(); let dns_data: Value = ip_config .nameservers .iter() .filter(|ip| ip.is_ipv4()) .map(|ns| ns.to_string()) .collect::<Vec<_>>() .into(); let mut ipv4_dbus = HashMap::from([ ("address-data", address_data), ("dns-data", dns_data), ("dns-search", ip_config.dns_searchlist.clone().into()), ("ignore-auto-dns", ip_config.ignore_auto_dns.into()), ("method", ip_config.method4.to_string().into()), ]); if let Some(routes4) = &ip_config.routes4 { ipv4_dbus.insert( "route-data", routes4 .iter() .map(|route| route.into()) .collect::<Vec<HashMap<&str, Value>>>() .into(), ); } if let Some(gateway) = &ip_config.gateway4 { ipv4_dbus.insert("gateway", gateway.to_string().into()); } ipv4_dbus } fn ip_config_to_ipv6_dbus(ip_config: &IpConfig) -> HashMap<&str, zvariant::Value> { let addresses: Vec<HashMap<&str, Value>> = ip_config .addresses .iter() .filter(|ip| ip.is_ipv6()) .map(|ip| { HashMap::from([ ("address", Value::new(ip.address().to_string())), ("prefix", Value::new(ip.network_length() as u32)), ]) }) .collect(); let address_data: Value = addresses.into(); let dns_data: Value = ip_config .nameservers .iter() .filter(|ip| ip.is_ipv6()) .map(|ns| ns.to_string()) .collect::<Vec<_>>() .into(); let mut ipv6_dbus = HashMap::from([ ("address-data", address_data), ("dns-data", dns_data), ("dns-search", ip_config.dns_searchlist.clone().into()), ("ignore-auto-dns", ip_config.ignore_auto_dns.into()), ("method", ip_config.method6.to_string().into()), ]); if let Some(routes6) = &ip_config.routes6 { ipv6_dbus.insert( "route-data", routes6 .iter() .map(|route| route.into()) .collect::<Vec<HashMap<&str, Value>>>() .into(), ); } if let Some(gateway) = &ip_config.gateway6 { ipv6_dbus.insert("gateway", gateway.to_string().into()); } ipv6_dbus } fn wireless_config_to_dbus(config: &'_ WirelessConfig) -> NestedHash<'_> { let mut wireless: HashMap<&str, zvariant::Value> = HashMap::from([ ("mode", Value::new(config.mode.to_string())), ("ssid", Value::new(config.ssid.to_vec())), ("hidden", Value::new(config.hidden)), ]); if let Some(band) = &config.band { wireless.insert("band", band.to_string().into()); wireless.insert("channel", config.channel.into()); } if let Some(bssid) = &config.bssid { wireless.insert("bssid", bssid.as_bytes().into()); } let mut security: HashMap<&str, zvariant::Value> = HashMap::from([ ("key-mgmt", config.security.to_string().into()), ( "group", config .group_algorithms .iter() .map(|x| x.to_string()) .collect::<Vec<String>>() .into(), ), ( "pairwise", config .pairwise_algorithms .iter() .map(|x| x.to_string()) .collect::<Vec<String>>() .into(), ), ( "proto", config .wpa_protocol_versions .iter() .map(|x| x.to_string()) .collect::<Vec<String>>() .into(), ), ("pmf", Value::new(config.pmf)), ]); if let Some(password) = &config.password { security.insert("psk", password.to_string().into()); } if let Some(wep_security) = &config.wep_security { security.insert( "wep-key-type", (wep_security.wep_key_type.clone() as u32).into(), ); security.insert("auth-alg", wep_security.auth_alg.to_string().into()); for (i, wep_key) in wep_security.keys.clone().into_iter().enumerate() { security.insert( // FIXME: lifetimes are fun if i == 0 { "wep-key0" } else if i == 1 { "wep-key1" } else if i == 2 { "wep-key2" } else if i == 3 { "wep-key3" } else { break; }, wep_key.into(), ); } security.insert("wep-tx-keyidx", wep_security.wep_key_index.into()); } NestedHash::from([(WIRELESS_KEY, wireless), (WIRELESS_SECURITY_KEY, security)]) } fn bond_config_to_dbus(config: &BondConfig) -> HashMap<&str, zvariant::Value> { let mut options = config.options.0.clone(); options.insert("mode".to_string(), config.mode.to_string()); HashMap::from([("options", Value::new(options))]) } fn bridge_config_to_dbus(bridge: &BridgeConfig) -> HashMap<&str, zvariant::Value> { let mut hash = HashMap::new(); hash.insert("stp", bridge.stp.into()); if let Some(prio) = bridge.priority { hash.insert("priority", prio.into()); } if let Some(fwd_delay) = bridge.forward_delay { hash.insert("forward-delay", fwd_delay.into()); } if let Some(hello_time) = bridge.hello_time { hash.insert("hello-time", hello_time.into()); } if let Some(max_age) = bridge.max_age { hash.insert("max-age", max_age.into()); } if let Some(ageing_time) = bridge.ageing_time { hash.insert("ageing-time", ageing_time.into()); } hash } fn bridge_config_from_dbus(conn: &OwnedNestedHash) -> Option<BridgeConfig> { let bridge = conn.get(BRIDGE_KEY)?; let stp = bridge.get("stp")?; let mut bc = BridgeConfig { stp: *stp.downcast_ref::<bool>()?, ..Default::default() }; if let Some(prio) = bridge.get("priority") { bc.priority = Some(*prio.downcast_ref::<u32>()?); } if let Some(fwd_delay) = bridge.get("forward-delay") { bc.forward_delay = Some(*fwd_delay.downcast_ref::<u32>()?); } if let Some(hello_time) = bridge.get("hello-time") { bc.hello_time = Some(*hello_time.downcast_ref::<u32>()?); } if let Some(max_age) = bridge.get("max-age") { bc.max_age = Some(*max_age.downcast_ref::<u32>()?); } if let Some(ageing_time) = bridge.get("ageing-time") { bc.ageing_time = Some(*ageing_time.downcast_ref::<u32>()?); } Some(bc) } fn bridge_port_config_to_dbus(bridge_port: &BridgePortConfig) -> HashMap<&str, zvariant::Value> { let mut hash = HashMap::new(); if let Some(prio) = bridge_port.priority { hash.insert("priority", prio.into()); } if let Some(pc) = bridge_port.path_cost { hash.insert("path-cost", pc.into()); } hash } fn bridge_port_config_from_dbus(conn: &OwnedNestedHash) -> Option<BridgePortConfig> { let bridge_port = conn.get(BRIDGE_PORT_KEY)?; let mut bpc = BridgePortConfig::default(); if let Some(prio) = bridge_port.get("priority") { bpc.priority = Some(*prio.downcast_ref::<u32>()?); } if let Some(path_cost) = bridge_port.get("path_cost") { bpc.path_cost = Some(*path_cost.downcast_ref::<u32>()?); } Some(bpc) } fn infiniband_config_to_dbus(config: &InfinibandConfig) -> HashMap<&str, zvariant::Value> { let mut infiniband_config: HashMap<&str, zvariant::Value> = HashMap::from([ ( "transport-mode", Value::new(config.transport_mode.to_string()), ), ("p-key", Value::new(config.p_key.unwrap_or(-1))), ]); if let Some(parent) = &config.parent { infiniband_config.insert("parent", parent.into()); } infiniband_config } fn infiniband_config_from_dbus(conn: &OwnedNestedHash) -> Option<InfinibandConfig> { let infiniband = conn.get(INFINIBAND_KEY)?; let mut infiniband_config = InfinibandConfig::default(); if let Some(p_key) = infiniband.get("p-key") { infiniband_config.p_key = Some(*p_key.downcast_ref::<i32>()?); } if let Some(parent) = infiniband.get("parent") { infiniband_config.parent = Some(parent.downcast_ref::<str>()?.to_string()); } if let Some(transport_mode) = infiniband.get("transport-mode") { infiniband_config.transport_mode = InfinibandTransportMode::from_str(transport_mode.downcast_ref::<str>()?).ok()?; } Some(infiniband_config) } fn tun_config_to_dbus(config: &TunConfig) -> HashMap<&str, zvariant::Value> { let mut tun_config: HashMap<&str, zvariant::Value> = HashMap::from([("mode", Value::new(config.mode.clone() as u32))]); if let Some(group) = &config.group { tun_config.insert("group", group.into()); } if let Some(owner) = &config.owner { tun_config.insert("owner", owner.into()); } tun_config } fn tun_config_from_dbus(conn: &OwnedNestedHash) -> Option<TunConfig> { let tun = conn.get(TUN_KEY)?; let mut tun_config = TunConfig::default(); if let Some(mode) = tun.get("mode") { tun_config.mode = match mode.downcast_ref::<u32>()? { 2 => TunMode::Tap, _ => TunMode::Tun, } } if let Some(group) = tun.get("group") { tun_config.group = Some(group.downcast_ref::<str>()?.to_string()); } if let Some(owner) = tun.get("owner") { tun_config.owner = Some(owner.downcast_ref::<str>()?.to_string()); } Some(tun_config) } /// Converts a MatchConfig struct into a HashMap that can be sent over D-Bus. /// /// * `match_config`: MatchConfig to convert. fn match_config_to_dbus(match_config: &MatchConfig) -> HashMap<&str, zvariant::Value> { let drivers: Value = match_config.driver.to_vec().into(); let kernels: Value = match_config.kernel.to_vec().into(); let paths: Value = match_config.path.to_vec().into(); let interfaces: Value = match_config.interface.to_vec().into(); HashMap::from([ ("driver", drivers), ("kernel-command-line", kernels), ("path", paths), ("interface-name", interfaces), ]) } fn base_connection_from_dbus(conn: &OwnedNestedHash) -> Option<Connection> { let connection = conn.get("connection")?; let id: &str = connection.get("id")?.downcast_ref()?; let uuid: &str = connection.get("uuid")?.downcast_ref()?; let uuid: Uuid = uuid.try_into().ok()?; let mut base_connection = Connection { id: id.to_string(), uuid, ..Default::default() }; if let Some(interface) = connection.get("interface-name") { let interface: &str = interface.downcast_ref()?; base_connection.interface = Some(interface.to_string()); } if let Some(match_config) = conn.get("match") { base_connection.match_config = match_config_from_dbus(match_config)?; } if let Some(zone) = connection.get("zone") { let zone: &str = zone.downcast_ref()?; base_connection.firewall_zone = Some(zone.to_string()); } if let Some(ethernet_config) = conn.get(ETHERNET_KEY) { base_connection.mac_address = mac_address_from_dbus(ethernet_config)?; base_connection.mtu = mtu_from_dbus(ethernet_config); } else if let Some(wireless_config) = conn.get(WIRELESS_KEY) { base_connection.mac_address = mac_address_from_dbus(wireless_config)?; base_connection.mtu = mtu_from_dbus(wireless_config); } base_connection.ip_config = ip_config_from_dbus(conn)?; Some(base_connection) } fn mac_address_from_dbus(config: &HashMap<String, OwnedValue>) -> Option<MacAddress> { if let Some(mac_address) = config.get("assigned-mac-address") { match MacAddress::from_str(mac_address.downcast_ref::<str>()?) { Ok(mac) => Some(mac), Err(e) => { log::warn!("Couldn't parse MAC: {}", e); None } } } else { Some(MacAddress::Unset) } } fn mtu_from_dbus(config: &HashMap<String, OwnedValue>) -> u32 { if let Some(mtu) = config.get("mtu") { *mtu.downcast_ref::<u32>().unwrap_or(&0) } else { 0 } } fn match_config_from_dbus( match_config: &HashMap<String, zvariant::OwnedValue>, ) -> Option<MatchConfig> { let mut match_conf = MatchConfig::default(); if let Some(drivers) = match_config.get("driver") { let drivers = drivers.downcast_ref::<zbus::zvariant::Array>()?; for driver in drivers.get() { let driver: &str = driver.downcast_ref()?; match_conf.driver.push(driver.to_string()); } } if let Some(interface_names) = match_config.get("interface-name") { let interface_names = interface_names.downcast_ref::<zbus::zvariant::Array>()?; for name in interface_names.get() { let name: &str = name.downcast_ref()?; match_conf.interface.push(name.to_string()); } } if let Some(paths) = match_config.get("path") { let paths = paths.downcast_ref::<zbus::zvariant::Array>()?; for path in paths.get() { let path: &str = path.downcast_ref()?; match_conf.path.push(path.to_string()); } } if let Some(kernel_options) = match_config.get("kernel-command-line") { let options = kernel_options.downcast_ref::<zbus::zvariant::Array>()?; for option in options.get() { let option: &str = option.downcast_ref()?; match_conf.kernel.push(option.to_string()); } } Some(match_conf) } fn ip_config_from_dbus(conn: &OwnedNestedHash) -> Option<IpConfig> { let mut ip_config = IpConfig::default(); if let Some(ipv4) = conn.get("ipv4") { let method4: &str = ipv4.get("method")?.downcast_ref()?; ip_config.method4 = NmMethod(method4.to_string()).try_into().ok()?; let address_data = ipv4.get("address-data")?; let mut addresses = addresses_with_prefix_from_dbus(address_data)?; ip_config.addresses.append(&mut addresses); if let Some(dns_data) = ipv4.get("dns-data") { let mut servers = nameservers_from_dbus(dns_data)?; ip_config.nameservers.append(&mut servers); } if let Some(dns_search) = ipv4.get("dns-search") { let searchlist: Vec<String> = dns_search .downcast_ref::<zbus::zvariant::Array>()? .iter() .flat_map(|x| x.downcast_ref::<str>()) .map(|x| x.to_string()) .collect(); for searchdomain in searchlist { if !ip_config.dns_searchlist.contains(&searchdomain) { ip_config.dns_searchlist.push(searchdomain); } } } if let Some(ignore_auto_dns) = ipv4.get("ignore-auto-dns") { ip_config.ignore_auto_dns = ignore_auto_dns.try_into().ok()?; } if let Some(route_data) = ipv4.get("route-data") { ip_config.routes4 = routes_from_dbus(route_data); } if let Some(gateway) = ipv4.get("gateway") { let gateway: &str = gateway.downcast_ref()?; ip_config.gateway4 = Some(gateway.parse().unwrap()); } } if let Some(ipv6) = conn.get("ipv6") { let method6: &str = ipv6.get("method")?.downcast_ref()?; ip_config.method6 = NmMethod(method6.to_string()).try_into().ok()?; let address_data = ipv6.get("address-data")?; let mut addresses = addresses_with_prefix_from_dbus(address_data)?; ip_config.addresses.append(&mut addresses); if let Some(dns_data) = ipv6.get("dns-data") { let mut servers = nameservers_from_dbus(dns_data)?; ip_config.nameservers.append(&mut servers); } if let Some(dns_search) = ipv6.get("dns-search") { let searchlist: Vec<String> = dns_search .downcast_ref::<zbus::zvariant::Array>()? .iter() .flat_map(|x| x.downcast_ref::<str>()) .map(|x| x.to_string()) .collect(); for searchdomain in searchlist { if !ip_config.dns_searchlist.contains(&searchdomain) { ip_config.dns_searchlist.push(searchdomain); } } } if let Some(ignore_auto_dns) = ipv6.get("ignore-auto-dns") { ip_config.ignore_auto_dns = ignore_auto_dns.try_into().ok()?; } if let Some(route_data) = ipv6.get("route-data") { ip_config.routes6 = routes_from_dbus(route_data); } if let Some(gateway) = ipv6.get("gateway") { let gateway: &str = gateway.downcast_ref()?; ip_config.gateway6 = Some(gateway.parse().unwrap()); } } Some(ip_config) } fn addresses_with_prefix_from_dbus(address_data: &OwnedValue) -> Option<Vec<IpInet>> { let address_data = address_data.downcast_ref::<zbus::zvariant::Array>()?; let mut addresses: Vec<IpInet> = vec![]; for addr in address_data.get() { let dict = addr.downcast_ref::<zvariant::Dict>()?; let map = <HashMap<String, zvariant::Value<'_>>>::try_from(dict.clone()).unwrap(); let addr_str: &str = map.get("address")?.downcast_ref()?; let prefix: &u32 = map.get("prefix")?.downcast_ref()?; let prefix = *prefix as u8; let address = IpInet::new(addr_str.parse().unwrap(), prefix).ok()?; addresses.push(address) } Some(addresses) } fn routes_from_dbus(route_data: &OwnedValue) -> Option<Vec<IpRoute>> { let route_data = route_data.downcast_ref::<zbus::zvariant::Array>()?; let mut routes: Vec<IpRoute> = vec![]; for route in route_data.get() { let route_dict = route.downcast_ref::<zvariant::Dict>()?; let route_map = <HashMap<String, zvariant::Value<'_>>>::try_from(route_dict.clone()).ok()?; let dest_str: &str = route_map.get("dest")?.downcast_ref()?; let prefix: u8 = *route_map.get("prefix")?.downcast_ref::<u32>()? as u8; let destination = IpInet::new(dest_str.parse().unwrap(), prefix).ok()?; let mut new_route = IpRoute { destination, next_hop: None, metric: None, }; if let Some(next_hop) = route_map.get("next-hop") { let next_hop_str: &str = next_hop.downcast_ref()?; new_route.next_hop = Some(IpAddr::from_str(next_hop_str).unwrap()); } if let Some(metric) = route_map.get("metric") { let metric: u32 = *metric.downcast_ref()?; new_route.metric = Some(metric); } routes.push(new_route) } Some(routes) } fn nameservers_from_dbus(dns_data: &OwnedValue) -> Option<Vec<IpAddr>> { let dns_data = dns_data.downcast_ref::<zbus::zvariant::Array>()?; let mut servers: Vec<IpAddr> = vec![]; for server in dns_data.get() { let server: &str = server.downcast_ref()?; servers.push(server.parse().unwrap()); } Some(servers) } fn wireless_config_from_dbus(conn: &OwnedNestedHash) -> Option<WirelessConfig> { let wireless = conn.get(WIRELESS_KEY)?; let mode: &str = wireless.get("mode")?.downcast_ref()?; let ssid = wireless.get("ssid")?; let ssid: &zvariant::Array = ssid.downcast_ref()?; let ssid: Vec<u8> = ssid .get() .iter() .map(|u| *u.downcast_ref::<u8>().unwrap()) .collect(); let mut wireless_config = WirelessConfig { mode: NmWirelessMode(mode.to_string()).try_into().ok()?, ssid: SSID(ssid), ..Default::default() }; if let Some(band) = wireless.get("band") { wireless_config.band = Some(band.downcast_ref::<str>()?.try_into().ok()?) } if let Some(channel) = wireless.get("channel") { wireless_config.channel = *channel.downcast_ref()?; } if let Some(bssid) = wireless.get("bssid") { let bssid: &zvariant::Array = bssid.downcast_ref()?; let bssid: Vec<u8> = bssid .get() .iter() .map(|u| *u.downcast_ref::<u8>().unwrap()) .collect(); wireless_config.bssid = Some(MacAddr6::new( *bssid.first()?, *bssid.get(1)?, *bssid.get(2)?, *bssid.get(3)?, *bssid.get(4)?, *bssid.get(5)?, )); } if let Some(hidden) = wireless.get("hidden") { wireless_config.hidden = *hidden.downcast_ref::<bool>()?; } if let Some(security) = conn.get(WIRELESS_SECURITY_KEY) { let key_mgmt: &str = security.get("key-mgmt")?.downcast_ref()?; wireless_config.security = NmKeyManagement(key_mgmt.to_string()).try_into().ok()?; if let Some(password) = security.get("psk") { wireless_config.password = Some(password.to_string()); } match wireless_config.security { SecurityProtocol::WEP => { let wep_key_type = security .get("wep-key-type") .and_then(|alg| WEPKeyType::try_from(*alg.downcast_ref::<u32>()?).ok()) .unwrap_or_default(); let auth_alg = security .get("auth-alg") .and_then(|alg| WEPAuthAlg::try_from(alg.downcast_ref()?).ok()) .unwrap_or_default(); let wep_key_index = security .get("wep-tx-keyidx") .and_then(|idx| idx.downcast_ref::<u32>().cloned()) .unwrap_or_default(); wireless_config.wep_security = Some(WEPSecurity { wep_key_type, auth_alg, wep_key_index, ..Default::default() }); } _ => wireless_config.wep_security = None, } if let Some(group_algorithms) = security.get("group") { let group_algorithms: &zvariant::Array = group_algorithms.downcast_ref()?; let group_algorithms: Vec<&str> = group_algorithms .iter() .map(|x| x.downcast_ref::<str>()) .collect::<Option<Vec<&str>>>()?; let group_algorithms: Vec<GroupAlgorithm> = group_algorithms .iter() .map(|x| GroupAlgorithm::from_str(x)) .collect::<Result<Vec<GroupAlgorithm>, InvalidGroupAlgorithm>>() .ok()?; wireless_config.group_algorithms = group_algorithms } if let Some(pairwise_algorithms) = security.get("pairwise") { let pairwise_algorithms: &zvariant::Array = pairwise_algorithms.downcast_ref()?; let pairwise_algorithms: Vec<&str> = pairwise_algorithms .iter() .map(|x| x.downcast_ref::<str>()) .collect::<Option<Vec<&str>>>()?; let pairwise_algorithms: Vec<PairwiseAlgorithm> = pairwise_algorithms .iter() .map(|x| PairwiseAlgorithm::from_str(x)) .collect::<Result<Vec<PairwiseAlgorithm>, InvalidPairwiseAlgorithm>>() .ok()?; wireless_config.pairwise_algorithms = pairwise_algorithms } if let Some(wpa_protocol_versions) = security.get("proto") { let wpa_protocol_versions: &zvariant::Array = wpa_protocol_versions.downcast_ref()?; let wpa_protocol_versions: Vec<&str> = wpa_protocol_versions .iter() .map(|x| x.downcast_ref::<str>()) .collect::<Option<Vec<&str>>>()?; let wpa_protocol_versions: Vec<WPAProtocolVersion> = wpa_protocol_versions .iter() .map(|x| WPAProtocolVersion::from_str(x)) .collect::<Result<Vec<WPAProtocolVersion>, InvalidWPAProtocolVersion>>() .ok()?; wireless_config.wpa_protocol_versions = wpa_protocol_versions } if let Some(pmf) = security.get("pmf") { wireless_config.pmf = *pmf.downcast_ref::<i32>()?; } } Some(wireless_config) } fn bond_config_from_dbus(conn: &OwnedNestedHash) -> Option<BondConfig> { let bond = conn.get(BOND_KEY)?; let dict: &zvariant::Dict = bond.get("options")?.downcast_ref()?; let mut options = <HashMap<String, String>>::try_from(dict.clone()).unwrap(); let mode = options.remove("mode"); let mut bond = BondConfig { options: BondOptions(options), ..Default::default() }; if let Some(mode) = mode { bond.mode = BondMode::try_from(mode.as_str()).unwrap_or_default(); } Some(bond) } fn vlan_config_to_dbus(cfg: &VlanConfig) -> NestedHash { let vlan: HashMap<&str, zvariant::Value> = HashMap::from([ ("id", cfg.id.into()), ("parent", cfg.parent.clone().into()), ("protocol", cfg.protocol.to_string().into()), ]); NestedHash::from([("vlan", vlan)]) } fn vlan_config_from_dbus(conn: &OwnedNestedHash) -> Option<VlanConfig> { let vlan = conn.get(VLAN_KEY)?; let id = vlan.get("id")?; let id = id.downcast_ref::<u32>()?; let parent = vlan.get("parent")?; let parent: &str = parent.downcast_ref()?; let protocol = match vlan.get("protocol") { Some(x) => { let x: &str = x.downcast_ref()?; VlanProtocol::from_str(x).unwrap_or_default() } _ => Default::default(), }; Some(VlanConfig { id: *id, parent: String::from(parent), protocol, }) } fn ieee_8021x_config_to_dbus(config: &IEEE8021XConfig) -> HashMap<&str, zvariant::Value> { let mut ieee_8021x_config: HashMap<&str, zvariant::Value> = HashMap::from([( "eap", config .eap .iter() .map(|x| x.to_string()) .collect::<Vec<String>>() .into(), )]); if let Some(phase2_auth) = &config.phase2_auth { ieee_8021x_config.insert("phase2-auth", phase2_auth.to_string().into()); } if let Some(identity) = &config.identity { ieee_8021x_config.insert("identity", identity.into()); } if let Some(password) = &config.password { ieee_8021x_config.insert("password", password.into()); } if let Some(ca_cert) = &config.ca_cert { ieee_8021x_config.insert("ca-cert", format_nm_path(ca_cert).into_bytes().into()); } if let Some(ca_cert_password) = &config.ca_cert_password { ieee_8021x_config.insert("ca-cert-password", ca_cert_password.into()); } if let Some(client_cert) = &config.client_cert { ieee_8021x_config.insert( "client-cert", format_nm_path(client_cert).into_bytes().into(), ); } if let Some(client_cert_password) = &config.client_cert_password { ieee_8021x_config.insert("client-cert-password", client_cert_password.into()); } if let Some(private_key) = &config.private_key { ieee_8021x_config.insert( "private-key", format_nm_path(private_key).into_bytes().into(), ); } if let Some(private_key_password) = &config.private_key_password { ieee_8021x_config.insert("private-key-password", private_key_password.into()); } if let Some(anonymous_identity) = &config.anonymous_identity { ieee_8021x_config.insert("anonymous-identity", anonymous_identity.into()); } if let Some(peap_version) = &config.peap_version { ieee_8021x_config.insert("phase1-peapver", peap_version.into()); } ieee_8021x_config.insert( "phase1-peaplabel", if config.peap_label { "1" } else { "0" }.into(), ); ieee_8021x_config } fn format_nm_path(path: &String) -> String { format!("file://{path}\0") } fn ieee_8021x_config_from_dbus(conn: &OwnedNestedHash) -> Option<IEEE8021XConfig> { let ieee_8021x = conn.get(IEEE_8021X_KEY)?; let mut ieee_8021x_config = IEEE8021XConfig::default(); if let Some(eap) = ieee_8021x.get("eap") { let eap: &zvariant::Array = eap.downcast_ref()?; let eap: Vec<&str> = eap .iter() .map(|x| x.downcast_ref::<str>()) .collect::<Option<Vec<&str>>>()?; let eap: Vec<EAPMethod> = eap .iter() .map(|x| EAPMethod::from_str(x)) .collect::<Result<Vec<EAPMethod>, InvalidEAPMethod>>() .ok()?; ieee_8021x_config.eap = eap; } if let Some(phase2_auth) = ieee_8021x.get("phase2-auth") { ieee_8021x_config.phase2_auth = Some(Phase2AuthMethod::from_str(phase2_auth.downcast_ref::<str>()?).ok()?); } if let Some(identity) = ieee_8021x.get("identity") { ieee_8021x_config.identity = Some(identity.downcast_ref::<str>()?.to_string()); } if let Some(password) = ieee_8021x.get("password") { ieee_8021x_config.password = Some(password.downcast_ref::<str>()?.to_string()); } if let Some(ca_cert) = ieee_8021x.get("ca-cert") { let ca_cert: &zvariant::Array = ca_cert.downcast_ref()?; let ca_cert: String = ca_cert .get() .iter() .map(|u| u.downcast_ref::<u8>()) .collect::<Option<Vec<&u8>>>()? .iter() .map(|x| **x as char) .collect(); ieee_8021x_config.ca_cert = strip_nm_file_path(ca_cert); } if let Some(ca_cert_password) = ieee_8021x.get("ca-cert-password") { ieee_8021x_config.ca_cert_password = Some(ca_cert_password.downcast_ref::<str>()?.to_string()); } if let Some(client_cert) = ieee_8021x.get("client-cert") { let client_cert: &zvariant::Array = client_cert.downcast_ref()?; let client_cert: String = client_cert .get() .iter() .map(|u| u.downcast_ref::<u8>()) .collect::<Option<Vec<&u8>>>()? .iter() .map(|x| **x as char) .collect(); ieee_8021x_config.client_cert = strip_nm_file_path(client_cert); } if let Some(client_cert_password) = ieee_8021x.get("client-cert-password") { ieee_8021x_config.client_cert_password = Some(client_cert_password.downcast_ref::<str>()?.to_string()); } if let Some(private_key) = ieee_8021x.get("private-key") { let private_key: &zvariant::Array = private_key.downcast_ref()?; let private_key: String = private_key .get() .iter() .map(|u| u.downcast_ref::<u8>()) .collect::<Option<Vec<&u8>>>()? .iter() .map(|x| **x as char) .collect(); ieee_8021x_config.private_key = strip_nm_file_path(private_key); } if let Some(private_key_password) = ieee_8021x.get("private-key-password") { ieee_8021x_config.private_key_password = Some(private_key_password.downcast_ref::<str>()?.to_string()); } if let Some(anonymous_identity) = ieee_8021x.get("anonymous-identity") { ieee_8021x_config.anonymous_identity = Some(anonymous_identity.downcast_ref::<str>()?.to_string()); } if let Some(peap_version) = ieee_8021x.get("phase1-peapver") { ieee_8021x_config.peap_version = Some(peap_version.downcast_ref::<str>()?.to_string()); } if let Some(peap_label) = ieee_8021x.get("phase1-peaplabel") { ieee_8021x_config.peap_label = peap_label.downcast_ref::<str>()? == "1"; } Some(ieee_8021x_config) } // Strips NetworkManager path from "file://{path}\0" so only path remains. fn strip_nm_file_path(path: String) -> Option<String> { let stripped_path = path .strip_prefix("file://") .and_then(|x| x.strip_suffix("\0"))?; Some(stripped_path.to_string()) } /// Determines whether a value is empty. /// /// TODO: Generalize for other kind of values, like dicts or arrays. /// /// * `value`: value to analyze fn is_empty_value(value: &zvariant::Value) -> bool { if let Some(value) = value.downcast_ref::<zvariant::Str>() { return value.is_empty(); } if let Some(value) = value.downcast_ref::<zvariant::Array>() { return value.is_empty(); } false } #[cfg(test)] mod test { use super::{ connection_from_dbus, connection_to_dbus, merge_dbus_connections, NestedHash, OwnedNestedHash, }; use crate::network::{ model::*, nm::dbus::{BOND_KEY, ETHERNET_KEY, INFINIBAND_KEY, WIRELESS_KEY, WIRELESS_SECURITY_KEY}, }; use agama_lib::network::types::{BondMode, SSID}; use cidr::IpInet; use std::{collections::HashMap, net::IpAddr, str::FromStr}; use uuid::Uuid; use zbus::zvariant::{self, Array, Dict, OwnedValue, Value}; #[test] fn test_connection_from_dbus() { let uuid = Uuid::new_v4().to_string(); let connection_section = HashMap::from([ ("id".to_string(), Value::new("eth0").to_owned()), ("uuid".to_string(), Value::new(uuid).to_owned()), ]); let address_v4_data = vec![HashMap::from([ ("address".to_string(), Value::new("192.168.0.10")), ("prefix".to_string(), Value::new(24_u32)), ])]; let route_v4_data = vec![HashMap::from([ ("dest".to_string(), Value::new("192.168.0.0")), ("prefix".to_string(), Value::new(24_u32)), ("next-hop".to_string(), Value::new("192.168.0.1")), ("metric".to_string(), Value::new(100_u32)), ])]; let ipv4_section = HashMap::from([ ("method".to_string(), Value::new("auto").to_owned()), ( "address-data".to_string(), Value::new(address_v4_data).to_owned(), ), ("gateway".to_string(), Value::new("192.168.0.1").to_owned()), ( "dns-data".to_string(), Value::new(vec!["192.168.0.2"]).to_owned(), ), ( "dns-search".to_string(), Value::new(vec!["suse.com", "example.com"]).to_owned(), ), ("ignore-auto-dns".to_string(), Value::new(true).to_owned()), ( "route-data".to_string(), Value::new(route_v4_data).to_owned(), ), ]); let address_v6_data = vec![HashMap::from([ ("address".to_string(), Value::new("::ffff:c0a8:10a")), ("prefix".to_string(), Value::new(24_u32)), ])]; let route_v6_data = vec![HashMap::from([ ("dest".to_string(), Value::new("2001:db8::")), ("prefix".to_string(), Value::new(64_u32)), ("next-hop".to_string(), Value::new("2001:db8::1")), ("metric".to_string(), Value::new(100_u32)), ])]; let ipv6_section = HashMap::from([ ("method".to_string(), Value::new("auto").to_owned()), ( "address-data".to_string(), Value::new(address_v6_data).to_owned(), ), ( "gateway".to_string(), Value::new("::ffff:c0a8:101").to_owned(), ), ( "dns-data".to_string(), Value::new(vec!["::ffff:c0a8:102"]).to_owned(), ), ( "dns-search".to_string(), Value::new(vec!["suse.com", "suse.de"]).to_owned(), ), ( "route-data".to_string(), Value::new(route_v6_data).to_owned(), ), ]); let match_section = HashMap::from([( "kernel-command-line".to_string(), Value::new(vec!["pci-0000:00:19.0"]).to_owned(), )]); let dbus_conn = HashMap::from([ ("connection".to_string(), connection_section), ("ipv4".to_string(), ipv4_section), ("ipv6".to_string(), ipv6_section), ("match".to_string(), match_section), (ETHERNET_KEY.to_string(), build_ethernet_section_from_dbus()), ]); let connection = connection_from_dbus(dbus_conn).unwrap(); assert_eq!(connection.id, "eth0"); let ip_config = connection.ip_config; let match_config = connection.match_config; assert_eq!(match_config.kernel, vec!["pci-0000:00:19.0"]); assert_eq!(connection.mac_address.to_string(), "12:34:56:78:9A:BC"); assert_eq!(connection.mtu, 9000_u32); assert_eq!( ip_config.addresses, vec![ "192.168.0.10/24".parse().unwrap(), "::ffff:c0a8:10a/24".parse().unwrap() ] ); assert_eq!( ip_config.nameservers, vec![ "192.168.0.2".parse::<IpAddr>().unwrap(), "::ffff:c0a8:102".parse::<IpAddr>().unwrap() ] ); assert_eq!(ip_config.dns_searchlist.len(), 3); assert!(ip_config.dns_searchlist.contains(&"suse.com".to_string())); assert!(ip_config.dns_searchlist.contains(&"suse.de".to_string())); assert!(ip_config .dns_searchlist .contains(&"example.com".to_string())); assert!(ip_config.ignore_auto_dns); assert_eq!(ip_config.method4, Ipv4Method::Auto); assert_eq!(ip_config.method6, Ipv6Method::Auto); assert_eq!( ip_config.routes4, Some(vec![IpRoute { destination: IpInet::new("192.168.0.0".parse().unwrap(), 24_u8).unwrap(), next_hop: Some(IpAddr::from_str("192.168.0.1").unwrap()), metric: Some(100) }]) ); assert_eq!( ip_config.routes6, Some(vec![IpRoute { destination: IpInet::new("2001:db8::".parse().unwrap(), 64_u8).unwrap(), next_hop: Some(IpAddr::from_str("2001:db8::1").unwrap()), metric: Some(100) }]) ); } #[test] fn test_connection_from_dbus_missing_connection() { let dbus_conn: HashMap<String, HashMap<String, OwnedValue>> = HashMap::new(); let connection = connection_from_dbus(dbus_conn); assert_eq!(connection, None); } #[test] fn test_connection_from_dbus_wireless() { let uuid = Uuid::new_v4().to_string(); let connection_section = HashMap::from([ ("id".to_string(), Value::new("wlan0").to_owned()), ("uuid".to_string(), Value::new(uuid).to_owned()), ]); let wireless_section = HashMap::from([ ("mode".to_string(), Value::new("infrastructure").to_owned()), ( "ssid".to_string(), Value::new("agama".as_bytes()).to_owned(), ), ( "assigned-mac-address".to_string(), Value::new("13:45:67:89:AB:CD").to_owned(), ), ("band".to_string(), Value::new("a").to_owned()), ("channel".to_string(), Value::new(32_u32).to_owned()), ( "bssid".to_string(), Value::new(vec![18_u8, 52_u8, 86_u8, 120_u8, 154_u8, 188_u8]).to_owned(), ), ("hidden".to_string(), Value::new(false).to_owned()), ]); let security_section = HashMap::from([ ("key-mgmt".to_string(), Value::new("wpa-psk").to_owned()), ( "wep-key-type".to_string(), Value::new(WEPKeyType::Key as u32).to_owned(), ), ("auth-alg".to_string(), Value::new("open").to_owned()), ("wep-tx-keyidx".to_string(), Value::new(1_u32).to_owned()), ( "group".to_string(), Value::new(vec!["wep40", "tkip"]).to_owned(), ), ( "pairwise".to_string(), Value::new(vec!["tkip", "ccmp"]).to_owned(), ), ("proto".to_string(), Value::new(vec!["rsn"]).to_owned()), ("pmf".to_string(), Value::new(2_i32).to_owned()), ]); let dbus_conn = HashMap::from([ ("connection".to_string(), connection_section), (WIRELESS_KEY.to_string(), wireless_section), (WIRELESS_SECURITY_KEY.to_string(), security_section), ]); let connection = connection_from_dbus(dbus_conn).unwrap(); assert_eq!(connection.mac_address.to_string(), "13:45:67:89:AB:CD"); assert!(matches!(connection.config, ConnectionConfig::Wireless(_))); if let ConnectionConfig::Wireless(wireless) = &connection.config { assert_eq!(wireless.ssid, SSID(vec![97, 103, 97, 109, 97])); assert_eq!(wireless.mode, WirelessMode::Infra); assert_eq!(wireless.security, SecurityProtocol::WPA2); assert_eq!(wireless.band, Some(WirelessBand::A)); assert_eq!(wireless.channel, 32_u32); assert_eq!( wireless.bssid, Some(macaddr::MacAddr6::from_str("12:34:56:78:9A:BC").unwrap()) ); assert!(!wireless.hidden); assert_eq!(wireless.wep_security, None); assert_eq!( wireless.group_algorithms, vec![GroupAlgorithm::Wep40, GroupAlgorithm::Tkip] ); assert_eq!( wireless.pairwise_algorithms, vec![PairwiseAlgorithm::Tkip, PairwiseAlgorithm::Ccmp] ); assert_eq!( wireless.wpa_protocol_versions, vec![WPAProtocolVersion::Rsn] ); assert_eq!(wireless.pmf, 2_i32); } } #[test] fn test_connection_from_dbus_bonding() { let uuid = Uuid::new_v4().to_string(); let connection_section = HashMap::from([ ("id".to_string(), Value::new("bond0").to_owned()), ("uuid".to_string(), Value::new(uuid).to_owned()), ]); let bond_options = Value::new(HashMap::from([( "options".to_string(), HashMap::from([("mode".to_string(), Value::new("active-backup").to_owned())]), )])); let dbus_conn = HashMap::from([ ("connection".to_string(), connection_section), (BOND_KEY.to_string(), bond_options.try_into().unwrap()), ]); let connection = connection_from_dbus(dbus_conn).unwrap(); if let ConnectionConfig::Bond(config) = connection.config { assert_eq!(config.mode, BondMode::ActiveBackup); } } #[test] fn test_connection_from_dbus_infiniband() { let uuid = Uuid::new_v4().to_string(); let connection_section = HashMap::from([ ("id".to_string(), Value::new("ib0").to_owned()), ("uuid".to_string(), Value::new(uuid).to_owned()), ]); let infiniband_section = HashMap::from([ ("p-key".to_string(), Value::new(0x8001_i32).to_owned()), ("parent".to_string(), Value::new("ib0").to_owned()), ( "transport-mode".to_string(), Value::new("datagram").to_owned(), ), ]); let dbus_conn = HashMap::from([ ("connection".to_string(), connection_section), (INFINIBAND_KEY.to_string(), infiniband_section), ]); let connection = connection_from_dbus(dbus_conn).unwrap(); let ConnectionConfig::Infiniband(infiniband) = &connection.config else { panic!("Wrong connection type") }; assert_eq!(infiniband.p_key, Some(0x8001)); assert_eq!(infiniband.parent, Some("ib0".to_string())); assert_eq!(infiniband.transport_mode, InfinibandTransportMode::Datagram); } #[test] fn test_connection_from_dbus_ieee_8021x() { let connection_section = HashMap::from([ ("id".to_string(), Value::new("eap0").to_owned()), ( "uuid".to_string(), Value::new(Uuid::new_v4().to_string()).to_owned(), ), ]); let ieee_8021x_section = HashMap::from([ ( "eap".to_string(), Value::new(vec!["md5", "leap"]).to_owned(), ), ("phase2-auth".to_string(), Value::new("gtc").to_owned()), ("identity".to_string(), Value::new("test_user").to_owned()), ("password".to_string(), Value::new("test_pw").to_owned()), ( "ca-cert".to_string(), Value::new("file:///path/to/ca_cert.pem\0".as_bytes()).to_owned(), ), ( "ca-cert-password".to_string(), Value::new("ca_cert_pw").to_owned(), ), ( "client-cert".to_string(), Value::new("not_valid_value".as_bytes()).to_owned(), ), ( "client-cert-password".to_string(), Value::new("client_cert_pw").to_owned(), ), ( "private-key".to_string(), Value::new("file://relative_path/private_key\0".as_bytes()).to_owned(), ), ( "private-key-password".to_string(), Value::new("private_key_pw").to_owned(), ), ( "anonymous-identity".to_string(), Value::new("anon_identity").to_owned(), ), ("phase1-peaplabel".to_string(), Value::new("0").to_owned()), ("phase1-peapver".to_string(), Value::new("1").to_owned()), ]); let dbus_conn = HashMap::from([ ("connection".to_string(), connection_section), (super::IEEE_8021X_KEY.to_string(), ieee_8021x_section), (super::LOOPBACK_KEY.to_string(), HashMap::new().to_owned()), ]); let connection = connection_from_dbus(dbus_conn).unwrap(); let Some(config) = &connection.ieee_8021x_config else { panic!("No eap config set") }; assert_eq!(config.eap, vec![EAPMethod::MD5, EAPMethod::LEAP]); assert_eq!(config.phase2_auth, Some(Phase2AuthMethod::GTC)); assert_eq!(config.identity, Some("test_user".to_string())); assert_eq!(config.password, Some("test_pw".to_string())); assert_eq!(config.ca_cert, Some("/path/to/ca_cert.pem".to_string())); assert_eq!(config.ca_cert_password, Some("ca_cert_pw".to_string())); assert_eq!(config.client_cert, None); assert_eq!( config.client_cert_password, Some("client_cert_pw".to_string()) ); assert_eq!( config.private_key, Some("relative_path/private_key".to_string()) ); assert_eq!( config.private_key_password, Some("private_key_pw".to_string()) ); assert_eq!(config.anonymous_identity, Some("anon_identity".to_string())); assert_eq!(config.peap_version, Some("1".to_string())); assert!(!config.peap_label); } #[test] fn test_dbus_from_infiniband_connection() { let config = InfinibandConfig { p_key: Some(0x8002), parent: Some("ib1".to_string()), transport_mode: InfinibandTransportMode::Connected, }; let mut infiniband = build_base_connection(); infiniband.config = ConnectionConfig::Infiniband(config); let infiniband_dbus = connection_to_dbus(&infiniband, None); let infiniband = infiniband_dbus.get(INFINIBAND_KEY).unwrap(); let p_key: i32 = *infiniband.get("p-key").unwrap().downcast_ref().unwrap(); assert_eq!(p_key, 0x8002); let parent: &str = infiniband.get("parent").unwrap().downcast_ref().unwrap(); assert_eq!(parent, "ib1"); let transport_mode: &str = infiniband .get("transport-mode") .unwrap() .downcast_ref() .unwrap(); assert_eq!( transport_mode, InfinibandTransportMode::Connected.to_string() ); } #[test] fn test_dbus_from_wireless_connection() { let config = WirelessConfig { mode: WirelessMode::Infra, security: SecurityProtocol::WPA2, password: Some("wpa-password".to_string()), ssid: SSID(vec![97, 103, 97, 109, 97]), band: Some(WirelessBand::BG), channel: 10, bssid: Some(macaddr::MacAddr6::from_str("12:34:56:78:9A:BC").unwrap()), wep_security: Some(WEPSecurity { auth_alg: WEPAuthAlg::Open, wep_key_type: WEPKeyType::Key, wep_key_index: 1, keys: vec![ "5b73215e232f4c577c5073455d".to_string(), "hello".to_string(), ], }), hidden: true, group_algorithms: vec![GroupAlgorithm::Wep104, GroupAlgorithm::Tkip], pairwise_algorithms: vec![PairwiseAlgorithm::Tkip, PairwiseAlgorithm::Ccmp], wpa_protocol_versions: vec![WPAProtocolVersion::Wpa], pmf: 1, ..Default::default() }; let mut wireless = build_base_connection(); wireless.config = ConnectionConfig::Wireless(config); let wireless_dbus = connection_to_dbus(&wireless, None); let wireless = wireless_dbus.get(WIRELESS_KEY).unwrap(); let mode: &str = wireless.get("mode").unwrap().downcast_ref().unwrap(); assert_eq!(mode, "infrastructure"); let mac_address: &str = wireless .get("assigned-mac-address") .unwrap() .downcast_ref() .unwrap(); assert_eq!(mac_address, "FD:CB:A9:87:65:43"); let ssid: &zvariant::Array = wireless.get("ssid").unwrap().downcast_ref().unwrap(); let ssid: Vec<u8> = ssid .get() .iter() .map(|u| *u.downcast_ref::<u8>().unwrap()) .collect(); assert_eq!(ssid, "agama".as_bytes()); let band: &str = wireless.get("band").unwrap().downcast_ref().unwrap(); assert_eq!(band, "bg"); let channel: u32 = *wireless.get("channel").unwrap().downcast_ref().unwrap(); assert_eq!(channel, 10); let bssid: &zvariant::Array = wireless.get("bssid").unwrap().downcast_ref().unwrap(); let bssid: Vec<u8> = bssid .get() .iter() .map(|u| *u.downcast_ref::<u8>().unwrap()) .collect(); assert_eq!(bssid, vec![18, 52, 86, 120, 154, 188]); let hidden: bool = *wireless.get("hidden").unwrap().downcast_ref().unwrap(); assert!(hidden); let security = wireless_dbus.get(WIRELESS_SECURITY_KEY).unwrap(); let key_mgmt: &str = security.get("key-mgmt").unwrap().downcast_ref().unwrap(); assert_eq!(key_mgmt, "wpa-psk"); let password: &str = security.get("psk").unwrap().downcast_ref().unwrap(); assert_eq!(password, "wpa-password"); let auth_alg: WEPAuthAlg = security .get("auth-alg") .unwrap() .downcast_ref::<str>() .unwrap() .try_into() .unwrap(); assert_eq!(auth_alg, WEPAuthAlg::Open); let wep_key_type: u32 = *security .get("wep-key-type") .unwrap() .downcast_ref::<u32>() .unwrap(); assert_eq!(wep_key_type, WEPKeyType::Key as u32); let wep_key_index: u32 = *security .get("wep-tx-keyidx") .unwrap() .downcast_ref() .unwrap(); assert_eq!(wep_key_index, 1); let wep_key0: &str = security.get("wep-key0").unwrap().downcast_ref().unwrap(); assert_eq!(wep_key0, "5b73215e232f4c577c5073455d"); let wep_key1: &str = security.get("wep-key1").unwrap().downcast_ref().unwrap(); assert_eq!(wep_key1, "hello"); let group_algorithms: &zvariant::Array = security.get("group").unwrap().downcast_ref().unwrap(); let group_algorithms: Vec<GroupAlgorithm> = group_algorithms .get() .iter() .map(|x| x.downcast_ref::<str>().unwrap()) .collect::<Vec<&str>>() .iter() .map(|x| GroupAlgorithm::from_str(x).unwrap()) .collect(); assert_eq!( group_algorithms, vec![GroupAlgorithm::Wep104, GroupAlgorithm::Tkip] ); let pairwise_algorithms: &zvariant::Array = security.get("pairwise").unwrap().downcast_ref().unwrap(); let pairwise_algorithms: Vec<PairwiseAlgorithm> = pairwise_algorithms .get() .iter() .map(|x| x.downcast_ref::<str>().unwrap()) .collect::<Vec<&str>>() .iter() .map(|x| PairwiseAlgorithm::from_str(x).unwrap()) .collect(); assert_eq!( pairwise_algorithms, vec![PairwiseAlgorithm::Tkip, PairwiseAlgorithm::Ccmp] ); let wpa_protocol_versions: &zvariant::Array = security.get("proto").unwrap().downcast_ref().unwrap(); let wpa_protocol_versions: Vec<WPAProtocolVersion> = wpa_protocol_versions .get() .iter() .map(|x| x.downcast_ref::<str>().unwrap()) .collect::<Vec<&str>>() .iter() .map(|x| WPAProtocolVersion::from_str(x).unwrap()) .collect(); assert_eq!(wpa_protocol_versions, vec![WPAProtocolVersion::Wpa]); let pmf: i32 = *security.get("pmf").unwrap().downcast_ref().unwrap(); assert_eq!(pmf, 1); } #[test] fn test_dbus_from_ieee_8021x() { let ieee_8021x_config = IEEE8021XConfig { eap: vec![ EAPMethod::from_str("tls").unwrap(), EAPMethod::from_str("peap").unwrap(), ], phase2_auth: Some(Phase2AuthMethod::MSCHAPV2), identity: Some("test_user".to_string()), password: Some("test_pw".to_string()), ca_cert: Some("/path/to/ca_cert.pem".to_string()), ca_cert_password: Some("ca_cert_pw".to_string()), client_cert: Some("/client_cert".to_string()), client_cert_password: Some("client_cert_pw".to_string()), private_key: Some("relative_path/private_key".to_string()), private_key_password: Some("private_key_pw".to_string()), anonymous_identity: Some("anon_identity".to_string()), peap_version: Some("0".to_string()), peap_label: true, }; let mut conn = build_base_connection(); conn.ieee_8021x_config = Some(ieee_8021x_config); let conn_dbus = connection_to_dbus(&conn, None); let config = conn_dbus.get(super::IEEE_8021X_KEY).unwrap(); let eap: &Array = config.get("eap").unwrap().downcast_ref().unwrap(); let eap: Vec<&str> = eap .iter() .map(|x| x.downcast_ref::<str>().unwrap()) .collect(); assert_eq!(eap, ["tls".to_string(), "peap".to_string()]); let identity: &str = config.get("identity").unwrap().downcast_ref().unwrap(); assert_eq!(identity, "test_user"); let phase2_auth: &str = config.get("phase2-auth").unwrap().downcast_ref().unwrap(); assert_eq!(phase2_auth, "mschapv2"); let password: &str = config.get("password").unwrap().downcast_ref().unwrap(); assert_eq!(password, "test_pw"); let ca_cert: &Array = config.get("ca-cert").unwrap().downcast_ref().unwrap(); let ca_cert: String = ca_cert .iter() .map(|x| *x.downcast_ref::<u8>().unwrap() as char) .collect(); assert_eq!(ca_cert, "file:///path/to/ca_cert.pem\0"); let ca_cert_password: &str = config .get("ca-cert-password") .unwrap() .downcast_ref() .unwrap(); assert_eq!(ca_cert_password, "ca_cert_pw"); let client_cert: &Array = config.get("client-cert").unwrap().downcast_ref().unwrap(); let client_cert: String = client_cert .iter() .map(|x| *x.downcast_ref::<u8>().unwrap() as char) .collect(); assert_eq!(client_cert, "file:///client_cert\0"); let client_cert_password: &str = config .get("client-cert-password") .unwrap() .downcast_ref() .unwrap(); assert_eq!(client_cert_password, "client_cert_pw"); let private_key: &Array = config.get("private-key").unwrap().downcast_ref().unwrap(); let private_key: String = private_key .iter() .map(|x| *x.downcast_ref::<u8>().unwrap() as char) .collect(); assert_eq!(private_key, "file://relative_path/private_key\0"); let private_key_password: &str = config .get("private-key-password") .unwrap() .downcast_ref() .unwrap(); assert_eq!(private_key_password, "private_key_pw"); let anonymous_identity: &str = config .get("anonymous-identity") .unwrap() .downcast_ref() .unwrap(); assert_eq!(anonymous_identity, "anon_identity"); let peap_version: &str = config .get("phase1-peapver") .unwrap() .downcast_ref() .unwrap(); assert_eq!(peap_version, "0"); let peap_label: &str = config .get("phase1-peaplabel") .unwrap() .downcast_ref() .unwrap(); assert_eq!(peap_label, "1"); } #[test] fn test_dbus_from_ethernet_connection() { let ethernet = build_base_connection(); let ethernet_dbus = connection_to_dbus(ðernet, None); check_dbus_base_connection(ðernet_dbus); } #[test] fn test_merge_dbus_connections() { let mut original = OwnedNestedHash::new(); let connection = HashMap::from([ ("id".to_string(), Value::new("conn0".to_string()).to_owned()), ( "type".to_string(), Value::new(ETHERNET_KEY.to_string()).to_owned(), ), ]); let ipv4 = HashMap::from([ ( "method".to_string(), Value::new("manual".to_string()).to_owned(), ), ( "gateway".to_string(), Value::new("192.168.1.1".to_string()).to_owned(), ), ( "addresses".to_string(), Value::new(vec!["192.168.1.1"]).to_owned(), ), ]); let ipv6 = HashMap::from([ ( "method".to_string(), Value::new("manual".to_string()).to_owned(), ), ( "gateway".to_string(), Value::new("::ffff:c0a8:101".to_string()).to_owned(), ), ( "addresses".to_string(), Value::new(vec!["::ffff:c0a8:102"]).to_owned(), ), ]); original.insert("connection".to_string(), connection); original.insert("ipv4".to_string(), ipv4); original.insert("ipv6".to_string(), ipv6); let ethernet = Connection { id: "agama".to_string(), interface: Some("eth0".to_string()), ..Default::default() }; let updated = connection_to_dbus(ðernet, None); let merged = merge_dbus_connections(&original, &updated); let connection = merged.get("connection").unwrap(); assert_eq!( *connection.get("id").unwrap(), Value::new("agama".to_string()) ); assert_eq!( *connection.get("interface-name").unwrap(), Value::new("eth0".to_string()) ); let ipv4 = merged.get("ipv4").unwrap(); assert_eq!( *ipv4.get("method").unwrap(), Value::new("disabled".to_string()) ); // there are not addresses ("address-data"), so no gateway is allowed assert!(ipv4.get("gateway").is_none()); assert!(ipv4.get("addresses").is_none()); let ipv6 = merged.get("ipv6").unwrap(); assert_eq!( *ipv6.get("method").unwrap(), Value::new("disabled".to_string()) ); // there are not addresses ("address-data"), so no gateway is allowed assert!(ipv6.get("gateway").is_none()); } #[test] fn test_merged_connections_are_clean() { let mut original = OwnedNestedHash::new(); let connection = HashMap::from([ ("id".to_string(), Value::new("conn0".to_string()).to_owned()), ( "type".to_string(), Value::new(ETHERNET_KEY.to_string()).to_owned(), ), ( "interface-name".to_string(), Value::new("eth0".to_string()).to_owned(), ), ]); let ethernet = HashMap::from([ ( "assigned-mac-address".to_string(), Value::new("12:34:56:78:9A:BC".to_string()).to_owned(), ), ("mtu".to_string(), Value::new(9000).to_owned()), ]); original.insert("connection".to_string(), connection); original.insert(ETHERNET_KEY.to_string(), ethernet); let updated = Connection { interface: Some("".to_string()), mac_address: MacAddress::Unset, ..Default::default() }; let updated = connection_to_dbus(&updated, None); let merged = merge_dbus_connections(&original, &updated); let connection = merged.get("connection").unwrap(); assert_eq!(connection.get("interface-name"), None); let ethernet = merged.get(ETHERNET_KEY).unwrap(); assert_eq!(ethernet.get("assigned-mac-address"), Some(&Value::from(""))); assert_eq!(ethernet.get("mtu"), Some(&Value::from(0_u32))); } fn build_ethernet_section_from_dbus() -> HashMap<String, OwnedValue> { HashMap::from([ ("auto-negotiate".to_string(), true.into()), ( "assigned-mac-address".to_string(), Value::new("12:34:56:78:9A:BC").to_owned(), ), ("mtu".to_string(), Value::new(9000_u32).to_owned()), ]) } fn build_base_connection() -> Connection { let addresses = vec![ "192.168.0.2/24".parse().unwrap(), "::ffff:c0a8:2".parse().unwrap(), ]; let ip_config = IpConfig { addresses, gateway4: Some("192.168.0.1".parse().unwrap()), gateway6: Some("::ffff:c0a8:1".parse().unwrap()), routes4: Some(vec![IpRoute { destination: IpInet::new("192.168.0.0".parse().unwrap(), 24_u8).unwrap(), next_hop: Some(IpAddr::from_str("192.168.0.1").unwrap()), metric: Some(100), }]), routes6: Some(vec![IpRoute { destination: IpInet::new("2001:db8::".parse().unwrap(), 64_u8).unwrap(), next_hop: Some(IpAddr::from_str("2001:db8::1").unwrap()), metric: Some(100), }]), dns_searchlist: vec!["suse.com".to_string(), "suse.de".to_string()], ..Default::default() }; let mac_address = MacAddress::from_str("FD:CB:A9:87:65:43").unwrap(); Connection { id: "agama".to_string(), ip_config, mac_address, mtu: 1500_u32, ..Default::default() } } fn check_dbus_base_connection(conn_dbus: &NestedHash) { let connection_dbus = conn_dbus.get("connection").unwrap(); let id: &str = connection_dbus.get("id").unwrap().downcast_ref().unwrap(); assert_eq!(id, "agama"); let ethernet_connection = conn_dbus.get(ETHERNET_KEY).unwrap(); let mac_address: &str = ethernet_connection .get("assigned-mac-address") .unwrap() .downcast_ref() .unwrap(); assert_eq!(mac_address, "FD:CB:A9:87:65:43"); assert_eq!( *ethernet_connection .get("mtu") .unwrap() .downcast_ref::<u32>() .unwrap(), 1500_u32 ); let ipv4_dbus = conn_dbus.get("ipv4").unwrap(); let gateway4: &str = ipv4_dbus.get("gateway").unwrap().downcast_ref().unwrap(); assert_eq!(gateway4, "192.168.0.1"); let routes4_array: Array = ipv4_dbus .get("route-data") .unwrap() .downcast_ref::<Value>() .unwrap() .try_into() .unwrap(); for route4 in routes4_array.iter() { let route4_dict: Dict = route4.downcast_ref::<Value>().unwrap().try_into().unwrap(); let route4_hashmap: HashMap<String, Value> = route4_dict.try_into().unwrap(); assert!(route4_hashmap.contains_key("dest")); assert_eq!(route4_hashmap["dest"], Value::from("192.168.0.0")); assert!(route4_hashmap.contains_key("prefix")); assert_eq!(route4_hashmap["prefix"], Value::from(24_u32)); assert!(route4_hashmap.contains_key("next-hop")); assert_eq!(route4_hashmap["next-hop"], Value::from("192.168.0.1")); assert!(route4_hashmap.contains_key("metric")); assert_eq!(route4_hashmap["metric"], Value::from(100_u32)); } let dns_searchlist_array: Array = ipv4_dbus .get("dns-search") .unwrap() .downcast_ref::<Value>() .unwrap() .try_into() .unwrap(); let dns_searchlist: Vec<String> = dns_searchlist_array .iter() .flat_map(|x| x.downcast_ref::<str>()) .map(|x| x.to_string()) .collect(); assert_eq!(dns_searchlist.len(), 2); assert!(dns_searchlist.contains(&"suse.com".to_string())); assert!(dns_searchlist.contains(&"suse.de".to_string())); assert!(!ipv4_dbus .get("ignore-auto-dns") .unwrap() .downcast_ref::<bool>() .unwrap()); let ipv6_dbus = conn_dbus.get("ipv6").unwrap(); let gateway6: &str = ipv6_dbus.get("gateway").unwrap().downcast_ref().unwrap(); assert_eq!(gateway6, "::ffff:192.168.0.1"); let routes6_array: Array = ipv6_dbus .get("route-data") .unwrap() .downcast_ref::<Value>() .unwrap() .try_into() .unwrap(); for route6 in routes6_array.iter() { let route6_dict: Dict = route6.downcast_ref::<Value>().unwrap().try_into().unwrap(); let route6_hashmap: HashMap<String, Value> = route6_dict.try_into().unwrap(); assert!(route6_hashmap.contains_key("dest")); assert_eq!(route6_hashmap["dest"], Value::from("2001:db8::")); assert!(route6_hashmap.contains_key("prefix")); assert_eq!(route6_hashmap["prefix"], Value::from(64_u32)); assert!(route6_hashmap.contains_key("next-hop")); assert_eq!(route6_hashmap["next-hop"], Value::from("2001:db8::1")); assert!(route6_hashmap.contains_key("metric")); assert_eq!(route6_hashmap["metric"], Value::from(100_u32)); } let dns_searchlist_array: Array = ipv6_dbus .get("dns-search") .unwrap() .downcast_ref::<Value>() .unwrap() .try_into() .unwrap(); let dns_searchlist: Vec<String> = dns_searchlist_array .iter() .flat_map(|x| x.downcast_ref::<str>()) .map(|x| x.to_string()) .collect(); assert_eq!(dns_searchlist.len(), 2); assert!(dns_searchlist.contains(&"suse.com".to_string())); assert!(dns_searchlist.contains(&"suse.de".to_string())); assert!(!ipv6_dbus .get("ignore-auto-dns") .unwrap() .downcast_ref::<bool>() .unwrap()); } } 070701000000AD000081A4000000000000000000000001671F5A64000005BD000000000000000000000000000000000000002B00000000agama/agama-server/src/network/nm/error.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! NetworkManager error types use crate::network::error::NetworkStateError; use thiserror::Error; #[derive(Error, Debug)] pub enum NmError { #[error("Unsupported IP method: '{0}'")] UnsupportedIpMethod(String), #[error("Unsupported device type: '{0}'")] UnsupportedDeviceType(u32), #[error("Unsupported security protocol: '{0}'")] UnsupportedSecurityProtocol(String), #[error("Unsupported wireless mode: '{0}'")] UnsupporedWirelessMode(String), } impl From<NmError> for NetworkStateError { fn from(value: NmError) -> NetworkStateError { NetworkStateError::AdapterError(value.to_string()) } } 070701000000AE000081A4000000000000000000000001671F5A64000017AC000000000000000000000000000000000000002B00000000agama/agama-server/src/network/nm/model.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Set of structs and enums to handle devices and connections from NetworkManager. //! //! This are meant to be used internally, so we omit everything it is not useful for us. /// NetworkManager wireless mode /// /// Using the newtype pattern around an String is enough. For proper support, we might replace this /// struct with an enum. use crate::network::{ model::{Ipv4Method, Ipv6Method, SecurityProtocol, WirelessMode}, nm::error::NmError, }; use agama_lib::network::types::DeviceType; use std::fmt; use std::str::FromStr; #[derive(Debug, PartialEq)] pub struct NmWirelessMode(pub String); impl Default for NmWirelessMode { fn default() -> Self { NmWirelessMode("infrastructure".to_string()) } } impl From<&str> for NmWirelessMode { fn from(value: &str) -> Self { Self(value.to_string()) } } impl NmWirelessMode { pub fn as_str(&self) -> &str { self.0.as_str() } } impl TryFrom<NmWirelessMode> for WirelessMode { type Error = NmError; fn try_from(value: NmWirelessMode) -> Result<Self, Self::Error> { match value.as_str() { "infrastructure" => Ok(WirelessMode::Infra), "adhoc" => Ok(WirelessMode::AdHoc), "mesh" => Ok(WirelessMode::Mesh), "ap" => Ok(WirelessMode::AP), _ => Err(NmError::UnsupporedWirelessMode(value.to_string())), } } } impl fmt::Display for NmWirelessMode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", &self.0) } } /// Device types /// /// As we are using the number just to filter wireless devices, using the newtype /// pattern around an u32 is enough. For proper support, we might replace this /// struct with an enum. #[derive(Debug, Default, Clone, Copy)] pub struct NmDeviceType(pub u32); impl From<NmDeviceType> for u32 { fn from(value: NmDeviceType) -> u32 { value.0 } } impl fmt::Display for NmDeviceType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } } impl TryFrom<NmDeviceType> for DeviceType { type Error = NmError; fn try_from(value: NmDeviceType) -> Result<Self, Self::Error> { match value { NmDeviceType(1) => Ok(DeviceType::Ethernet), NmDeviceType(2) => Ok(DeviceType::Wireless), NmDeviceType(10) => Ok(DeviceType::Bond), NmDeviceType(13) => Ok(DeviceType::Bridge), NmDeviceType(22) => Ok(DeviceType::Dummy), NmDeviceType(32) => Ok(DeviceType::Loopback), NmDeviceType(_) => Err(NmError::UnsupportedDeviceType(value.into())), } } } /// Key management /// /// Using the newtype pattern around an String is enough. For proper support, we might replace this /// struct with an enum. #[derive(Debug, PartialEq)] pub struct NmKeyManagement(pub String); impl Default for NmKeyManagement { fn default() -> Self { NmKeyManagement("none".to_string()) } } impl From<&str> for NmKeyManagement { fn from(value: &str) -> Self { Self(value.to_string()) } } impl TryFrom<NmKeyManagement> for SecurityProtocol { type Error = NmError; fn try_from(value: NmKeyManagement) -> Result<Self, Self::Error> { match value.as_str() { "owe" => Ok(SecurityProtocol::OWE), "ieee8021x" => Ok(SecurityProtocol::DynamicWEP), "wpa-psk" => Ok(SecurityProtocol::WPA2), "wpa-eap" => Ok(SecurityProtocol::WPA3Personal), "sae" => Ok(SecurityProtocol::WPA2Enterprise), "wpa-eap-suite-b-192" => Ok(SecurityProtocol::WPA2Enterprise), "none" => Ok(SecurityProtocol::WEP), _ => Err(NmError::UnsupportedSecurityProtocol(value.to_string())), } } } impl NmKeyManagement { pub fn as_str(&self) -> &str { self.0.as_str() } } impl fmt::Display for NmKeyManagement { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", &self.0) } } #[derive(Debug, PartialEq)] pub struct NmMethod(pub String); impl Default for NmMethod { fn default() -> Self { NmMethod("auto".to_string()) } } impl NmMethod { pub fn as_str(&self) -> &str { self.0.as_str() } } impl TryFrom<NmMethod> for Ipv4Method { type Error = NmError; fn try_from(value: NmMethod) -> Result<Self, Self::Error> { match Ipv4Method::from_str(value.as_str()) { Ok(method) => Ok(method), _ => Err(NmError::UnsupportedIpMethod(value.to_string())), } } } impl TryFrom<NmMethod> for Ipv6Method { type Error = NmError; fn try_from(value: NmMethod) -> Result<Self, Self::Error> { match Ipv6Method::from_str(value.as_str()) { Ok(method) => Ok(method), _ => Err(NmError::UnsupportedIpMethod(value.to_string())), } } } impl fmt::Display for NmMethod { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", &self.0) } } #[derive(Debug, Default, PartialEq)] pub struct NmIp4Config { pub addresses: Vec<(String, u32)>, pub nameservers: Vec<String>, pub gateway: Option<String>, pub method: NmMethod, } 070701000000AF000081A4000000000000000000000001671F5A6400006D0C000000000000000000000000000000000000002D00000000agama/agama-server/src/network/nm/proxies.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! D-Bus interface proxy for: `org.freedesktop.NetworkManager` //! //! This code was generated by `zbus-xmlgen` `3.1.0` from DBus introspection data. //! //! These D-Bus objects implements //! [standard D-Bus interfaces](https://dbus.freedesktop.org/doc/dbus-specification.html), //! (`org.freedesktop.DBus.*`) for which the following zbus proxies can be used: //! //! * [`zbus::fdo::PropertiesProxy`] //! * [`zbus::fdo::IntrospectableProxy`] //! //! …consequently `zbus-xmlgen` did not generate code for the above interfaces. //! Also some proxies can be used against multiple services when they share interface. use agama_lib::dbus::OwnedNestedHash; use zbus::dbus_proxy; #[dbus_proxy( interface = "org.freedesktop.NetworkManager", default_service = "org.freedesktop.NetworkManager", default_path = "/org/freedesktop/NetworkManager", gen_blocking = false )] trait NetworkManager { /// ActivateConnection method fn activate_connection( &self, connection: &zbus::zvariant::ObjectPath<'_>, device: &zbus::zvariant::ObjectPath<'_>, specific_object: &zbus::zvariant::ObjectPath<'_>, ) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// AddAndActivateConnection method fn add_and_activate_connection( &self, connection: std::collections::HashMap< &str, std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, >, device: &zbus::zvariant::ObjectPath<'_>, specific_object: &zbus::zvariant::ObjectPath<'_>, ) -> zbus::Result<( zbus::zvariant::OwnedObjectPath, zbus::zvariant::OwnedObjectPath, )>; /// AddAndActivateConnection2 method fn add_and_activate_connection2( &self, connection: std::collections::HashMap< &str, std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, >, device: &zbus::zvariant::ObjectPath<'_>, specific_object: &zbus::zvariant::ObjectPath<'_>, options: std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, ) -> zbus::Result<( zbus::zvariant::OwnedObjectPath, zbus::zvariant::OwnedObjectPath, std::collections::HashMap<String, zbus::zvariant::OwnedValue>, )>; /// CheckConnectivity method fn check_connectivity(&self) -> zbus::Result<u32>; /// CheckpointAdjustRollbackTimeout method fn checkpoint_adjust_rollback_timeout( &self, checkpoint: &zbus::zvariant::ObjectPath<'_>, add_timeout: u32, ) -> zbus::Result<()>; /// CheckpointCreate method fn checkpoint_create( &self, devices: &[zbus::zvariant::ObjectPath<'_>], rollback_timeout: u32, flags: u32, ) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// CheckpointDestroy method fn checkpoint_destroy(&self, checkpoint: &zbus::zvariant::ObjectPath<'_>) -> zbus::Result<()>; /// CheckpointRollback method fn checkpoint_rollback( &self, checkpoint: &zbus::zvariant::ObjectPath<'_>, ) -> zbus::Result<std::collections::HashMap<String, u32>>; /// DeactivateConnection method fn deactivate_connection( &self, active_connection: &zbus::zvariant::ObjectPath<'_>, ) -> zbus::Result<()>; /// Enable method fn enable(&self, enable: bool) -> zbus::Result<()>; /// GetAllDevices method fn get_all_devices(&self) -> zbus::Result<Vec<zbus::zvariant::OwnedObjectPath>>; /// GetDeviceByIpIface method fn get_device_by_ip_iface(&self, iface: &str) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// GetDevices method fn get_devices(&self) -> zbus::Result<Vec<zbus::zvariant::OwnedObjectPath>>; /// GetLogging method fn get_logging(&self) -> zbus::Result<(String, String)>; /// GetPermissions method fn get_permissions(&self) -> zbus::Result<std::collections::HashMap<String, String>>; /// Reload method fn reload(&self, flags: u32) -> zbus::Result<()>; /// SetLogging method fn set_logging(&self, level: &str, domains: &str) -> zbus::Result<()>; /// Sleep method fn sleep(&self, sleep: bool) -> zbus::Result<()>; /// CheckPermissions signal #[dbus_proxy(signal)] fn check_permissions(&self) -> zbus::Result<()>; /// DeviceAdded signal #[dbus_proxy(signal)] fn device_added(&self, device_path: zbus::zvariant::ObjectPath<'_>) -> zbus::Result<()>; /// DeviceRemoved signal #[dbus_proxy(signal)] fn device_removed(&self, device_path: zbus::zvariant::ObjectPath<'_>) -> zbus::Result<()>; /// ActivatingConnection property #[dbus_proxy(property)] fn activating_connection(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// ActiveConnections property #[dbus_proxy(property)] fn active_connections(&self) -> zbus::Result<Vec<zbus::zvariant::OwnedObjectPath>>; /// AllDevices property #[dbus_proxy(property)] fn all_devices(&self) -> zbus::Result<Vec<zbus::zvariant::OwnedObjectPath>>; /// Capabilities property #[dbus_proxy(property)] fn capabilities(&self) -> zbus::Result<Vec<u32>>; /// Checkpoints property #[dbus_proxy(property)] fn checkpoints(&self) -> zbus::Result<Vec<zbus::zvariant::OwnedObjectPath>>; /// Connectivity property #[dbus_proxy(property)] fn connectivity(&self) -> zbus::Result<u32>; /// ConnectivityCheckAvailable property #[dbus_proxy(property)] fn connectivity_check_available(&self) -> zbus::Result<bool>; /// ConnectivityCheckEnabled property #[dbus_proxy(property)] fn connectivity_check_enabled(&self) -> zbus::Result<bool>; #[dbus_proxy(property)] fn set_connectivity_check_enabled(&self, value: bool) -> zbus::Result<()>; /// ConnectivityCheckUri property #[dbus_proxy(property)] fn connectivity_check_uri(&self) -> zbus::Result<String>; /// Devices property #[dbus_proxy(property)] fn devices(&self) -> zbus::Result<Vec<zbus::zvariant::OwnedObjectPath>>; /// GlobalDnsConfiguration property #[dbus_proxy(property)] fn global_dns_configuration( &self, ) -> zbus::Result<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>; #[dbus_proxy(property)] fn set_global_dns_configuration( &self, value: std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, ) -> zbus::Result<()>; /// Metered property #[dbus_proxy(property)] fn metered(&self) -> zbus::Result<u32>; /// NetworkingEnabled property #[dbus_proxy(property)] fn networking_enabled(&self) -> zbus::Result<bool>; /// PrimaryConnection property #[dbus_proxy(property)] fn primary_connection(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// PrimaryConnectionType property #[dbus_proxy(property)] fn primary_connection_type(&self) -> zbus::Result<String>; /// RadioFlags property #[dbus_proxy(property)] fn radio_flags(&self) -> zbus::Result<u32>; /// Startup property #[dbus_proxy(property)] fn startup(&self) -> zbus::Result<bool>; /// State property #[dbus_proxy(property)] fn state(&self) -> zbus::Result<u32>; /// Version property #[dbus_proxy(property)] fn version(&self) -> zbus::Result<String>; /// VersionInfo property #[dbus_proxy(property)] fn version_info(&self) -> zbus::Result<Vec<u32>>; /// WimaxEnabled property #[dbus_proxy(property)] fn wimax_enabled(&self) -> zbus::Result<bool>; #[dbus_proxy(property)] fn set_wimax_enabled(&self, value: bool) -> zbus::Result<()>; /// WimaxHardwareEnabled property #[dbus_proxy(property)] fn wimax_hardware_enabled(&self) -> zbus::Result<bool>; /// WirelessEnabled property #[dbus_proxy(property)] fn wireless_enabled(&self) -> zbus::Result<bool>; #[dbus_proxy(property)] fn set_wireless_enabled(&self, value: bool) -> zbus::Result<()>; /// WirelessHardwareEnabled property #[dbus_proxy(property)] fn wireless_hardware_enabled(&self) -> zbus::Result<bool>; /// WwanEnabled property #[dbus_proxy(property)] fn wwan_enabled(&self) -> zbus::Result<bool>; #[dbus_proxy(property)] fn set_wwan_enabled(&self, value: bool) -> zbus::Result<()>; /// WwanHardwareEnabled property #[dbus_proxy(property)] fn wwan_hardware_enabled(&self) -> zbus::Result<bool>; } #[dbus_proxy( interface = "org.freedesktop.NetworkManager.AccessPoint", default_service = "org.freedesktop.NetworkManager", default_path = "/org/freedesktop/NetworkManager/AccessPoint/1" )] trait AccessPoint { /// Flags property #[dbus_proxy(property)] fn flags(&self) -> zbus::Result<u32>; /// Frequency property #[dbus_proxy(property)] fn frequency(&self) -> zbus::Result<u32>; /// HwAddress property #[dbus_proxy(property)] fn hw_address(&self) -> zbus::Result<String>; /// LastSeen property #[dbus_proxy(property)] fn last_seen(&self) -> zbus::Result<i32>; /// MaxBitrate property #[dbus_proxy(property)] fn max_bitrate(&self) -> zbus::Result<u32>; /// Mode property #[dbus_proxy(property)] fn mode(&self) -> zbus::Result<u32>; /// RsnFlags property #[dbus_proxy(property)] fn rsn_flags(&self) -> zbus::Result<u32>; /// Ssid property #[dbus_proxy(property)] fn ssid(&self) -> zbus::Result<Vec<u8>>; /// Strength property #[dbus_proxy(property)] fn strength(&self) -> zbus::Result<u8>; /// WpaFlags property #[dbus_proxy(property)] fn wpa_flags(&self) -> zbus::Result<u32>; } /// # DBus interface proxies for: `org.freedesktop.NetworkManager.Device.Wireless` /// /// This code was generated by `zbus-xmlgen` `3.1.0` from DBus introspection data. #[dbus_proxy( interface = "org.freedesktop.NetworkManager.Device.Wireless", default_service = "org.freedesktop.NetworkManager", default_path = "/org/freedesktop/NetworkManager/Devices/5" )] trait Wireless { /// GetAllAccessPoints method fn get_all_access_points(&self) -> zbus::Result<Vec<zbus::zvariant::OwnedObjectPath>>; /// RequestScan method fn request_scan( &self, options: std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, ) -> zbus::Result<()>; /// AccessPointAdded signal #[dbus_proxy(signal)] fn access_point_added(&self, access_point: zbus::zvariant::ObjectPath<'_>) -> zbus::Result<()>; /// AccessPointRemoved signal #[dbus_proxy(signal)] fn access_point_removed( &self, access_point: zbus::zvariant::ObjectPath<'_>, ) -> zbus::Result<()>; /// AccessPoints property #[dbus_proxy(property)] fn access_points(&self) -> zbus::Result<Vec<zbus::zvariant::OwnedObjectPath>>; /// ActiveAccessPoint property #[dbus_proxy(property)] fn active_access_point(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// Bitrate property #[dbus_proxy(property)] fn bitrate(&self) -> zbus::Result<u32>; /// HwAddress property #[dbus_proxy(property)] fn hw_address(&self) -> zbus::Result<String>; /// LastScan property #[dbus_proxy(property)] fn last_scan(&self) -> zbus::Result<i64>; /// Mode property #[dbus_proxy(property)] fn mode(&self) -> zbus::Result<u32>; /// PermHwAddress property #[dbus_proxy(property)] fn perm_hw_address(&self) -> zbus::Result<String>; /// WirelessCapabilities property #[dbus_proxy(property)] fn wireless_capabilities(&self) -> zbus::Result<u32>; } /// # DBus interface proxies for: `org.freedesktop.NetworkManager.Device` /// /// This code was generated by `zbus-xmlgen` `3.1.0` from DBus introspection data. #[dbus_proxy( interface = "org.freedesktop.NetworkManager.Device", default_service = "org.freedesktop.NetworkManager", default_path = "/org/freedesktop/NetworkManager/Devices/1" )] trait Device { /// Delete method fn delete(&self) -> zbus::Result<()>; /// Disconnect method fn disconnect(&self) -> zbus::Result<()>; /// GetAppliedConnection method fn get_applied_connection(&self, flags: u32) -> zbus::Result<(OwnedNestedHash, u64)>; /// Reapply method fn reapply( &self, connection: std::collections::HashMap< &str, std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, >, version_id: u64, flags: u32, ) -> zbus::Result<()>; /// ActiveConnection property #[dbus_proxy(property)] fn active_connection(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// Autoconnect property #[dbus_proxy(property)] fn autoconnect(&self) -> zbus::Result<bool>; #[dbus_proxy(property)] fn set_autoconnect(&self, value: bool) -> zbus::Result<()>; /// AvailableConnections property #[dbus_proxy(property)] fn available_connections(&self) -> zbus::Result<Vec<zbus::zvariant::OwnedObjectPath>>; /// Capabilities property #[dbus_proxy(property)] fn capabilities(&self) -> zbus::Result<u32>; /// DeviceType property #[dbus_proxy(property)] fn device_type(&self) -> zbus::Result<u32>; /// Dhcp4Config property #[dbus_proxy(property)] fn dhcp4_config(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// Dhcp6Config property #[dbus_proxy(property)] fn dhcp6_config(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// Driver property #[dbus_proxy(property)] fn driver(&self) -> zbus::Result<String>; /// DriverVersion property #[dbus_proxy(property)] fn driver_version(&self) -> zbus::Result<String>; /// FirmwareMissing property #[dbus_proxy(property)] fn firmware_missing(&self) -> zbus::Result<bool>; /// FirmwareVersion property #[dbus_proxy(property)] fn firmware_version(&self) -> zbus::Result<String>; /// HwAddress property #[dbus_proxy(property)] fn hw_address(&self) -> zbus::Result<String>; /// Interface property #[dbus_proxy(property)] fn interface(&self) -> zbus::Result<String>; /// InterfaceFlags property #[dbus_proxy(property)] fn interface_flags(&self) -> zbus::Result<u32>; /// Ip4Address property #[dbus_proxy(property)] fn ip4_address(&self) -> zbus::Result<u32>; /// Ip4Config property #[dbus_proxy(property)] fn ip4_config(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// Ip4Connectivity property #[dbus_proxy(property)] fn ip4_connectivity(&self) -> zbus::Result<u32>; /// Ip6Config property #[dbus_proxy(property)] fn ip6_config(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// Ip6Connectivity property #[dbus_proxy(property)] fn ip6_connectivity(&self) -> zbus::Result<u32>; /// IpInterface property #[dbus_proxy(property)] fn ip_interface(&self) -> zbus::Result<String>; /// LldpNeighbors property #[dbus_proxy(property)] fn lldp_neighbors( &self, ) -> zbus::Result<Vec<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>>; /// Managed property #[dbus_proxy(property)] fn managed(&self) -> zbus::Result<bool>; #[dbus_proxy(property)] fn set_managed(&self, value: bool) -> zbus::Result<()>; /// Metered property #[dbus_proxy(property)] fn metered(&self) -> zbus::Result<u32>; /// Mtu property #[dbus_proxy(property)] fn mtu(&self) -> zbus::Result<u32>; /// NmPluginMissing property #[dbus_proxy(property)] fn nm_plugin_missing(&self) -> zbus::Result<bool>; /// Path property #[dbus_proxy(property)] fn path(&self) -> zbus::Result<String>; /// PhysicalPortId property #[dbus_proxy(property)] fn physical_port_id(&self) -> zbus::Result<String>; /// Ports property #[dbus_proxy(property)] fn ports(&self) -> zbus::Result<Vec<zbus::zvariant::OwnedObjectPath>>; /// Real property #[dbus_proxy(property)] fn real(&self) -> zbus::Result<bool>; /// State property #[dbus_proxy(property)] fn state(&self) -> zbus::Result<u32>; /// StateReason property #[dbus_proxy(property)] fn state_reason(&self) -> zbus::Result<(u32, u32)>; /// Udi property #[dbus_proxy(property)] fn udi(&self) -> zbus::Result<String>; } /// # DBus interface proxy for: `org.freedesktop.NetworkManager.Settings` /// /// This code was generated by `zbus-xmlgen` `3.1.0` from DBus introspection data. #[dbus_proxy( interface = "org.freedesktop.NetworkManager.Settings", default_service = "org.freedesktop.NetworkManager", default_path = "/org/freedesktop/NetworkManager/Settings", gen_blocking = false )] trait Settings { /// AddConnection method fn add_connection( &self, connection: std::collections::HashMap< &str, std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, >, ) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// AddConnection2 method fn add_connection2( &self, settings: std::collections::HashMap< &str, std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, >, flags: u32, args: std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, ) -> zbus::Result<( zbus::zvariant::OwnedObjectPath, std::collections::HashMap<String, zbus::zvariant::OwnedValue>, )>; /// AddConnectionUnsaved method fn add_connection_unsaved( &self, connection: std::collections::HashMap< &str, std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, >, ) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// GetConnectionByUuid method fn get_connection_by_uuid(&self, uuid: &str) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// ListConnections method fn list_connections(&self) -> zbus::Result<Vec<zbus::zvariant::OwnedObjectPath>>; /// LoadConnections method fn load_connections(&self, filenames: &[&str]) -> zbus::Result<(bool, Vec<String>)>; /// ReloadConnections method fn reload_connections(&self) -> zbus::Result<bool>; /// SaveHostname method fn save_hostname(&self, hostname: &str) -> zbus::Result<()>; /// ConnectionRemoved signal #[dbus_proxy(signal)] fn connection_removed(&self, connection: zbus::zvariant::ObjectPath<'_>) -> zbus::Result<()>; /// NewConnection signal #[dbus_proxy(signal)] fn new_connection(&self, connection: zbus::zvariant::ObjectPath<'_>) -> zbus::Result<()>; /// CanModify property #[dbus_proxy(property)] fn can_modify(&self) -> zbus::Result<bool>; /// Connections property #[dbus_proxy(property)] fn connections(&self) -> zbus::Result<Vec<zbus::zvariant::OwnedObjectPath>>; /// Hostname property #[dbus_proxy(property)] fn hostname(&self) -> zbus::Result<String>; } /// # DBus interface proxy for: `org.freedesktop.NetworkManager.Settings.Connection` /// /// This code was generated by `zbus-xmlgen` `3.1.0` from DBus introspection data. #[dbus_proxy( interface = "org.freedesktop.NetworkManager.Settings.Connection", default_service = "org.freedesktop.NetworkManager", default_path = "/org/freedesktop/NetworkManager/Settings/1", gen_blocking = false )] trait Connection { /// ClearSecrets method fn clear_secrets(&self) -> zbus::Result<()>; /// Delete method fn delete(&self) -> zbus::Result<()>; /// GetSecrets method fn get_secrets( &self, setting_name: &str, ) -> zbus::Result< std::collections::HashMap< String, std::collections::HashMap<String, zbus::zvariant::OwnedValue>, >, >; /// GetSettings method fn get_settings( &self, ) -> zbus::Result< std::collections::HashMap< String, std::collections::HashMap<String, zbus::zvariant::OwnedValue>, >, >; /// Save method fn save(&self) -> zbus::Result<()>; /// Update method fn update( &self, properties: std::collections::HashMap< &str, std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, >, ) -> zbus::Result<()>; /// Update2 method fn update2( &self, settings: std::collections::HashMap< &str, std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, >, flags: u32, args: std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, ) -> zbus::Result<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>; /// UpdateUnsaved method fn update_unsaved( &self, properties: std::collections::HashMap< &str, std::collections::HashMap<&str, zbus::zvariant::Value<'_>>, >, ) -> zbus::Result<()>; /// Removed signal #[dbus_proxy(signal)] fn removed(&self) -> zbus::Result<()>; /// Updated signal #[dbus_proxy(signal)] fn updated(&self) -> zbus::Result<()>; /// Filename property #[dbus_proxy(property)] fn filename(&self) -> zbus::Result<String>; /// Flags property #[dbus_proxy(property)] fn flags(&self) -> zbus::Result<u32>; /// Unsaved property #[dbus_proxy(property)] fn unsaved(&self) -> zbus::Result<bool>; } #[dbus_proxy( interface = "org.freedesktop.NetworkManager.Connection.Active", default_service = "org.freedesktop.NetworkManager", default_path = "/org/freedesktop/NetworkManager/ActiveConnection/1", gen_blocking = false )] trait ActiveConnection { /// Connection property #[dbus_proxy(property)] fn connection(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// Controller property #[dbus_proxy(property)] fn controller(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// Default property #[dbus_proxy(property)] fn default(&self) -> zbus::Result<bool>; /// Default6 property #[dbus_proxy(property)] fn default6(&self) -> zbus::Result<bool>; /// Devices property #[dbus_proxy(property)] fn devices(&self) -> zbus::Result<Vec<zbus::zvariant::OwnedObjectPath>>; /// Dhcp4Config property #[dbus_proxy(property)] fn dhcp4_config(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// Dhcp6Config property #[dbus_proxy(property)] fn dhcp6_config(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// Id property #[dbus_proxy(property)] fn id(&self) -> zbus::Result<String>; /// Ip4Config property #[dbus_proxy(property)] fn ip4_config(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// Ip6Config property #[dbus_proxy(property)] fn ip6_config(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// Master property #[dbus_proxy(property)] fn master(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// SpecificObject property #[dbus_proxy(property)] fn specific_object(&self) -> zbus::Result<zbus::zvariant::OwnedObjectPath>; /// State property #[dbus_proxy(property)] fn state(&self) -> zbus::Result<u32>; /// StateFlags property #[dbus_proxy(property)] fn state_flags(&self) -> zbus::Result<u32>; /// Type property #[dbus_proxy(property)] fn type_(&self) -> zbus::Result<String>; /// Uuid property #[dbus_proxy(property)] fn uuid(&self) -> zbus::Result<String>; /// Vpn property #[dbus_proxy(property)] fn vpn(&self) -> zbus::Result<bool>; } #[dbus_proxy( interface = "org.freedesktop.NetworkManager.IP4Config", default_service = "org.freedesktop.NetworkManager", default_path = "/org/freedesktop/NetworkManager/IP4Config/1" )] trait IP4Config { /// AddressData property #[dbus_proxy(property)] fn address_data( &self, ) -> zbus::Result<Vec<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>>; /// Addresses property #[dbus_proxy(property)] fn addresses(&self) -> zbus::Result<Vec<Vec<u32>>>; /// DnsOptions property #[dbus_proxy(property)] fn dns_options(&self) -> zbus::Result<Vec<String>>; /// DnsPriority property #[dbus_proxy(property)] fn dns_priority(&self) -> zbus::Result<i32>; /// Domains property #[dbus_proxy(property)] fn domains(&self) -> zbus::Result<Vec<String>>; /// Gateway property #[dbus_proxy(property)] fn gateway(&self) -> zbus::Result<String>; /// NameserverData property #[dbus_proxy(property)] fn nameserver_data( &self, ) -> zbus::Result<Vec<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>>; /// Nameservers property #[dbus_proxy(property)] fn nameservers(&self) -> zbus::Result<Vec<u32>>; /// RouteData property #[dbus_proxy(property)] fn route_data( &self, ) -> zbus::Result<Vec<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>>; /// Routes property #[dbus_proxy(property)] fn routes(&self) -> zbus::Result<Vec<Vec<u32>>>; /// Searches property #[dbus_proxy(property)] fn searches(&self) -> zbus::Result<Vec<String>>; /// WinsServerData property #[dbus_proxy(property)] fn wins_server_data(&self) -> zbus::Result<Vec<String>>; /// WinsServers property #[dbus_proxy(property)] fn wins_servers(&self) -> zbus::Result<Vec<u32>>; } #[dbus_proxy( interface = "org.freedesktop.NetworkManager.IP6Config", default_service = "org.freedesktop.NetworkManager", default_path = "/org/freedesktop/NetworkManager/IP6Config/1" )] trait IP6Config { /// AddressData property #[dbus_proxy(property)] fn address_data( &self, ) -> zbus::Result<Vec<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>>; /// Addresses property #[dbus_proxy(property)] fn addresses(&self) -> zbus::Result<Vec<(Vec<u8>, u32, Vec<u8>)>>; /// DnsOptions property #[dbus_proxy(property)] fn dns_options(&self) -> zbus::Result<Vec<String>>; /// DnsPriority property #[dbus_proxy(property)] fn dns_priority(&self) -> zbus::Result<i32>; /// Domains property #[dbus_proxy(property)] fn domains(&self) -> zbus::Result<Vec<String>>; /// Gateway property #[dbus_proxy(property)] fn gateway(&self) -> zbus::Result<String>; /// Nameservers property #[dbus_proxy(property)] fn nameservers(&self) -> zbus::Result<Vec<Vec<u8>>>; /// RouteData property #[dbus_proxy(property)] fn route_data( &self, ) -> zbus::Result<Vec<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>>; /// Routes property #[dbus_proxy(property)] fn routes(&self) -> zbus::Result<Vec<(Vec<u8>, u32, Vec<u8>, u32)>>; /// Searches property #[dbus_proxy(property)] fn searches(&self) -> zbus::Result<Vec<String>>; } 070701000000B0000081A4000000000000000000000001671F5A6400004558000000000000000000000000000000000000002D00000000agama/agama-server/src/network/nm/watcher.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements the mechanism to listen for NetworkManager changes. //! //! Monitors NetworkManager's D-Bus service and emit [actions](crate::network::Action] to update //! the NetworkSystem state when devices or active connections change. use crate::network::{ adapter::Watcher, model::Device, nm::proxies::DeviceProxy, Action, NetworkAdapterError, }; use agama_lib::error::ServiceError; use async_trait::async_trait; use futures_util::ready; use pin_project::pin_project; use std::{ collections::{hash_map::Entry, HashMap}, pin::Pin, sync::Arc, task::{Context, Poll}, }; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use tokio_stream::{Stream, StreamExt, StreamMap}; use zbus::{ fdo::{InterfacesAdded, InterfacesRemoved, PropertiesChanged}, zvariant::OwnedObjectPath, MatchRule, Message, MessageStream, MessageType, }; use super::{builder::DeviceFromProxyBuilder, proxies::NetworkManagerProxy}; /// Implements a [crate::network::adapter::Watcher] for NetworkManager. /// /// This process is composed of the following pieces: /// /// * A stream of potentially useful D-Bus signals (see [DeviceChangedStream]). /// * A dispatcher that receives the signals from the stream and turns them into /// [network system actions](crate::network::Action). /// /// To avoid deadlocks, the stream runs on a separate Tokio task and it communicates /// with the dispatcher through a multi-producer single-consumer (mpsc) channel. /// /// At this point, it detects the following changes: /// /// * A device is added, changed or removed. /// * The status of a device changes. /// * The IPv4 or IPv6 configuration changes. pub struct NetworkManagerWatcher { connection: zbus::Connection, } impl NetworkManagerWatcher { /// Builds a new watcher over a D-Bus connection. pub fn new(connection: &zbus::Connection) -> Self { Self { connection: connection.clone(), } } } #[async_trait] impl Watcher for NetworkManagerWatcher { async fn run( self: Box<Self>, actions: UnboundedSender<Action>, ) -> Result<(), NetworkAdapterError> { let (tx, rx) = unbounded_channel(); // Process the DeviceChangedStream in a separate task. let connection = self.connection.clone(); tokio::spawn(async move { let mut stream = DeviceChangedStream::new(&connection).await.unwrap(); while let Some(change) = stream.next().await { if let Err(e) = tx.send(change) { tracing::error!("Could not dispatch a network change: {e}"); } } }); // Turn the changes into actions in a separate task. let connection = self.connection.clone(); let mut dispatcher = ActionDispatcher::new(connection, rx, actions); dispatcher.run().await.map_err(NetworkAdapterError::Watcher) } } /// Receives the updates and turns them into [network actions](crate::network::Action). /// /// See [ActionDispatcher::run] for further details. struct ActionDispatcher<'a> { connection: zbus::Connection, proxies: ProxiesRegistry<'a>, updates_rx: UnboundedReceiver<DeviceChange>, actions_tx: UnboundedSender<Action>, } impl<'a> ActionDispatcher<'a> { /// Returns a new dispatcher. /// /// * `connection`: D-Bus connection to NetworkManager. /// * `updates_rx`: Channel to receive the updates. /// * `actions_tx`: Channel to dispatch the network actions. pub fn new( connection: zbus::Connection, updates_rx: UnboundedReceiver<DeviceChange>, actions_tx: UnboundedSender<Action>, ) -> Self { Self { proxies: ProxiesRegistry::new(&connection), connection, updates_rx, actions_tx, } } /// Processes the updates. /// /// It runs until the updates channel is closed. pub async fn run(&mut self) -> Result<(), ServiceError> { self.read_devices().await?; while let Some(update) = self.updates_rx.recv().await { let result = match update { DeviceChange::DeviceAdded(path) => self.handle_device_added(path).await, DeviceChange::DeviceUpdated(path) => self.handle_device_updated(path).await, DeviceChange::DeviceRemoved(path) => self.handle_device_removed(path).await, DeviceChange::IP4ConfigChanged(path) => self.handle_ip4_config_changed(path).await, DeviceChange::IP6ConfigChanged(path) => self.handle_ip6_config_changed(path).await, }; if let Err(error) = result { tracing::warn!("Could not process a network update: {error}]") } } Ok(()) } /// Reads the devices. async fn read_devices(&mut self) -> Result<(), ServiceError> { let nm_proxy = NetworkManagerProxy::new(&self.connection).await?; for path in nm_proxy.get_devices().await? { self.proxies.find_or_add_device(&path).await?; } Ok(()) } /// Handles the case where a new device appears. /// /// * `path`: D-Bus object path of the new device. async fn handle_device_added(&mut self, path: OwnedObjectPath) -> Result<(), ServiceError> { let (_, proxy) = self.proxies.find_or_add_device(&path).await?; if let Ok(device) = Self::device_from_proxy(&self.connection, proxy.clone()).await { _ = self.actions_tx.send(Action::AddDevice(Box::new(device))); } // TODO: report an error if the device cannot get generated Ok(()) } /// Handles the case where a device is updated. /// /// * `path`: D-Bus object path of the updated device. async fn handle_device_updated(&mut self, path: OwnedObjectPath) -> Result<(), ServiceError> { let (old_name, proxy) = self.proxies.find_or_add_device(&path).await?; let device = Self::device_from_proxy(&self.connection, proxy.clone()).await?; let new_name = device.name.clone(); _ = self .actions_tx .send(Action::UpdateDevice(old_name.to_string(), Box::new(device))); self.proxies.update_device_name(&path, &new_name); Ok(()) } /// Handles the case where a device is removed. /// /// * `path`: D-Bus object path of the removed device. async fn handle_device_removed(&mut self, path: OwnedObjectPath) -> Result<(), ServiceError> { if let Some((name, _)) = self.proxies.remove_device(&path) { _ = self.actions_tx.send(Action::RemoveDevice(name)); } Ok(()) } /// Handles the case where the IPv4 configuration changes. /// /// * `path`: D-Bus object path of the changed IP configuration. async fn handle_ip4_config_changed( &mut self, path: OwnedObjectPath, ) -> Result<(), ServiceError> { if let Some((name, proxy)) = self.proxies.find_device_for_ip4(&path).await { let device = Self::device_from_proxy(&self.connection, proxy.clone()).await?; _ = self .actions_tx .send(Action::UpdateDevice(name.to_string(), Box::new(device))); } Ok(()) } /// Handles the case where the IPv6 configuration changes. /// /// * `path`: D-Bus object path of the changed IP configuration. async fn handle_ip6_config_changed( &mut self, path: OwnedObjectPath, ) -> Result<(), ServiceError> { if let Some((name, proxy)) = self.proxies.find_device_for_ip6(&path).await { let device = Self::device_from_proxy(&self.connection, proxy.clone()).await?; _ = self .actions_tx .send(Action::UpdateDevice(name.to_string(), Box::new(device))); } Ok(()) } async fn device_from_proxy( connection: &zbus::Connection, proxy: DeviceProxy<'_>, ) -> Result<Device, ServiceError> { let builder = DeviceFromProxyBuilder::new(connection, &proxy); builder.build().await } } /// Stream of device-related events. /// /// This stream listens for many NetworkManager events that are related to network devices (state, /// IP configuration, etc.) and converts them into variants of the [DeviceChange] enum. /// /// It is implemented as a struct because it needs to keep the ObjectManagerProxy alive. #[pin_project] struct DeviceChangedStream { connection: zbus::Connection, #[pin] inner: StreamMap<&'static str, MessageStream>, } impl DeviceChangedStream { /// Builds a new stream using the given D-Bus connection. /// /// * `connection`: D-Bus connection. pub async fn new(connection: &zbus::Connection) -> Result<Self, ServiceError> { let connection = connection.clone(); let mut inner = StreamMap::new(); inner.insert( "object_manager", build_added_and_removed_stream(&connection).await?, ); inner.insert( "properties", build_properties_changed_stream(&connection).await?, ); Ok(Self { connection, inner }) } fn handle_added(message: InterfacesAdded) -> Option<DeviceChange> { let args = message.args().ok()?; let interfaces: Vec<String> = args .interfaces_and_properties() .keys() .map(|i| i.to_string()) .collect(); if interfaces.contains(&"org.freedesktop.NetworkManager.Device".to_string()) { let path = OwnedObjectPath::from(args.object_path().clone()); return Some(DeviceChange::DeviceAdded(path)); } None } fn handle_removed(message: InterfacesRemoved) -> Option<DeviceChange> { let args = message.args().ok()?; if args .interfaces .contains(&"org.freedesktop.NetworkManager.Device") { let path = OwnedObjectPath::from(args.object_path().clone()); return Some(DeviceChange::DeviceRemoved(path)); } None } fn handle_changed(message: PropertiesChanged) -> Option<DeviceChange> { const IP_CONFIG_PROPS: &[&str] = &["AddressData", "Gateway", "NameserverData", "RouteData"]; const DEVICE_PROPS: &[&str] = &[ "DeviceType", "HwAddress", "Interface", "State", "StateReason", ]; let path = OwnedObjectPath::from(message.path()?); let args = message.args().ok()?; match args.interface_name.as_str() { "org.freedesktop.NetworkManager.IP4Config" => { if Self::include_properties(IP_CONFIG_PROPS, &args.changed_properties) { return Some(DeviceChange::IP4ConfigChanged(path)); } } "org.freedesktop.NetworkManager.IP6Config" => { if Self::include_properties(IP_CONFIG_PROPS, &args.changed_properties) { return Some(DeviceChange::IP6ConfigChanged(path)); } } "org.freedesktop.NetworkManager.Device" => { if Self::include_properties(DEVICE_PROPS, &args.changed_properties) { return Some(DeviceChange::DeviceUpdated(path)); } } _ => {} }; None } fn include_properties( wanted: &[&str], changed: &HashMap<&'_ str, zbus::zvariant::Value<'_>>, ) -> bool { let properties: Vec<_> = changed.keys().collect(); wanted.iter().any(|i| properties.contains(&i)) } fn handle_message(message: Result<Arc<Message>, zbus::Error>) -> Option<DeviceChange> { let Ok(message) = message else { return None; }; if let Some(added) = InterfacesAdded::from_message(message.clone()) { return Self::handle_added(added); } if let Some(removed) = InterfacesRemoved::from_message(message.clone()) { return Self::handle_removed(removed); } if let Some(changed) = PropertiesChanged::from_message(message.clone()) { return Self::handle_changed(changed); } None } } impl Stream for DeviceChangedStream { type Item = DeviceChange; fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { let mut pinned = self.project(); Poll::Ready(loop { let item = ready!(pinned.inner.as_mut().poll_next(cx)); let next_value = match item { Some((_, message)) => Self::handle_message(message), _ => None, }; if next_value.is_some() { break next_value; } }) } } async fn build_added_and_removed_stream( connection: &zbus::Connection, ) -> Result<MessageStream, ServiceError> { let rule = MatchRule::builder() .msg_type(MessageType::Signal) .path("/org/freedesktop")? .interface("org.freedesktop.DBus.ObjectManager")? .build(); let stream = MessageStream::for_match_rule(rule, connection, Some(1)).await?; Ok(stream) } /// Returns a stream of properties changes to be used by DeviceChangedStream. /// /// It listens for changes in several objects that are related to a network device. async fn build_properties_changed_stream( connection: &zbus::Connection, ) -> Result<MessageStream, ServiceError> { let rule = MatchRule::builder() .msg_type(MessageType::Signal) .interface("org.freedesktop.DBus.Properties")? .member("PropertiesChanged")? .build(); let stream = MessageStream::for_match_rule(rule, connection, Some(1)).await?; Ok(stream) } #[derive(Debug, Clone)] enum DeviceChange { DeviceAdded(OwnedObjectPath), DeviceUpdated(OwnedObjectPath), DeviceRemoved(OwnedObjectPath), IP4ConfigChanged(OwnedObjectPath), IP6ConfigChanged(OwnedObjectPath), } /// Ancillary class to track the devices and their related D-Bus objects. struct ProxiesRegistry<'a> { connection: zbus::Connection, // the String is the device name like eth0 devices: HashMap<OwnedObjectPath, (String, DeviceProxy<'a>)>, } impl<'a> ProxiesRegistry<'a> { pub fn new(connection: &zbus::Connection) -> Self { Self { connection: connection.clone(), devices: HashMap::new(), } } /// Finds or adds a device to the registry. /// /// * `path`: D-Bus object path. pub async fn find_or_add_device( &mut self, path: &OwnedObjectPath, ) -> Result<&(String, DeviceProxy<'a>), ServiceError> { // Cannot use entry(...).or_insert_with(...) because of the async call. match self.devices.entry(path.clone()) { Entry::Vacant(entry) => { let proxy = DeviceProxy::builder(&self.connection.clone()) .path(path.clone())? .build() .await?; let name = proxy.interface().await?; Ok(entry.insert((name, proxy))) } Entry::Occupied(entry) => Ok(entry.into_mut()), } } /// Removes a device from the registry. /// /// * `path`: D-Bus object path. pub fn remove_device(&mut self, path: &OwnedObjectPath) -> Option<(String, DeviceProxy)> { self.devices.remove(path) } //// Updates a device name. /// /// * `path`: D-Bus object path. /// * `new_name`: New device name. pub fn update_device_name(&mut self, path: &OwnedObjectPath, new_name: &str) { if let Some(value) = self.devices.get_mut(path) { value.0 = new_name.to_string(); }; } //// For the device corresponding to a given IPv4 configuration. /// /// * `ip4_config_path`: D-Bus object path of the IPv4 configuration. pub async fn find_device_for_ip4( &self, ip4_config_path: &OwnedObjectPath, ) -> Option<&(String, DeviceProxy<'_>)> { for device in self.devices.values() { if let Ok(path) = device.1.ip4_config().await { if path == *ip4_config_path { return Some(device); } } } None } //// For the device corresponding to a given IPv6 configuration. /// /// * `ip6_config_path`: D-Bus object path of the IPv6 configuration. pub async fn find_device_for_ip6( &self, ip4_config_path: &OwnedObjectPath, ) -> Option<&(String, DeviceProxy<'_>)> { for device in self.devices.values() { if let Ok(path) = device.1.ip4_config().await { if path == *ip4_config_path { return Some(device); } } } None } } 070701000000B1000081A4000000000000000000000001671F5A64000037ED000000000000000000000000000000000000002900000000agama/agama-server/src/network/system.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use super::{ error::NetworkStateError, model::{AccessPoint, Device, NetworkChange, StateConfig}, NetworkAdapterError, }; use crate::network::{ model::{Connection, GeneralState}, Action, Adapter, NetworkState, }; use agama_lib::{error::ServiceError, network::types::DeviceType}; use std::error::Error; use tokio::sync::{ broadcast::{self, Receiver}, mpsc::{self, error::SendError, UnboundedReceiver, UnboundedSender}, oneshot::{self, error::RecvError}, }; use uuid::Uuid; #[derive(thiserror::Error, Debug)] pub enum NetworkSystemError { #[error("Network state error: {0}")] State(#[from] NetworkStateError), #[error("Could not talk to the network system: {0}")] InputError(#[from] SendError<Action>), #[error("Could not read an answer from the network system: {0}")] OutputError(#[from] RecvError), #[error("D-Bus service error: {0}")] ServiceError(#[from] ServiceError), #[error("Network backend error: {0}")] AdapterError(#[from] NetworkAdapterError), } /// Represents the network configuration service. /// /// It offers an API to start the service and interact with it by using message /// passing like the example below. /// /// ```no_run /// # use agama_server::network::{Action, NetworkManagerAdapter, NetworkSystem}; /// # use agama_lib::connection; /// # use tokio::sync::oneshot; /// /// # tokio_test::block_on(async { /// let adapter = NetworkManagerAdapter::from_system() /// .await /// .expect("Could not connect to NetworkManager."); /// let network = NetworkSystem::new(adapter); /// /// // Start the networking service and get the client for communication. /// let client = network.start() /// .await /// .expect("Could not start the networking configuration system."); /// /// // Perform some action, like getting the list of devices. /// let devices = client.get_devices().await /// .expect("Could not get the list of devices."); /// # }); /// ``` pub struct NetworkSystem<T: Adapter + Send> { adapter: T, } impl<T: Adapter + Send + Sync + 'static> NetworkSystem<T> { /// Returns a new instance of the network configuration system. /// /// This function does not start the system. To get it running, you must call /// the [start](Self::start) method. /// /// * `adapter`: networking configuration adapter. pub fn new(adapter: T) -> Self { Self { adapter } } /// Starts the network configuration service and returns a client for communication purposes. /// /// This function starts the server (using [NetworkSystemServer]) on a separate /// task. All the communication is performed through the returned [NetworkSystemClient]. pub async fn start(self) -> Result<NetworkSystemClient, NetworkSystemError> { let state = self.adapter.read(StateConfig::default()).await?; let (actions_tx, actions_rx) = mpsc::unbounded_channel(); let (updates_tx, _updates_rx) = broadcast::channel(1024); if let Some(watcher) = self.adapter.watcher() { let actions_tx_clone = actions_tx.clone(); tokio::spawn(async move { watcher.run(actions_tx_clone).await.unwrap(); }); } let updates_tx_clone = updates_tx.clone(); tokio::spawn(async move { let mut server = NetworkSystemServer { state, input: actions_rx, output: updates_tx_clone, adapter: self.adapter, }; server.listen().await; }); Ok(NetworkSystemClient { actions: actions_tx, updates: updates_tx, }) } } /// Client to interact with the NetworkSystem once it is running. /// /// It hides the details of the message-passing behind a convenient API. #[derive(Clone)] pub struct NetworkSystemClient { actions: UnboundedSender<Action>, updates: broadcast::Sender<NetworkChange>, } // TODO: add a NetworkSystemError type impl NetworkSystemClient { pub fn subscribe(&self) -> Receiver<NetworkChange> { self.updates.subscribe() } /// Returns the general state. pub async fn get_state(&self) -> Result<GeneralState, NetworkSystemError> { let (tx, rx) = oneshot::channel(); self.actions.send(Action::GetGeneralState(tx))?; Ok(rx.await?) } /// Updates the network general state. pub fn update_state(&self, state: GeneralState) -> Result<(), NetworkSystemError> { self.actions.send(Action::UpdateGeneralState(state))?; Ok(()) } /// Returns the collection of network devices. pub async fn get_devices(&self) -> Result<Vec<Device>, NetworkSystemError> { let (tx, rx) = oneshot::channel(); self.actions.send(Action::GetDevices(tx))?; Ok(rx.await?) } /// Returns the collection of network connections. pub async fn get_connections(&self) -> Result<Vec<Connection>, NetworkSystemError> { let (tx, rx) = oneshot::channel(); self.actions.send(Action::GetConnections(tx))?; Ok(rx.await?) } /// Adds a new connection. pub async fn add_connection(&self, connection: Connection) -> Result<(), NetworkSystemError> { let (tx, rx) = oneshot::channel(); self.actions .send(Action::NewConnection(Box::new(connection.clone()), tx))?; let result = rx.await?; Ok(result?) } /// Returns the connection with the given ID. /// /// * `id`: Connection ID. pub async fn get_connection(&self, id: &str) -> Result<Option<Connection>, NetworkSystemError> { let (tx, rx) = oneshot::channel(); self.actions .send(Action::GetConnection(id.to_string(), tx))?; let result = rx.await?; Ok(result) } /// Updates the connection. /// /// * `connection`: Updated connection. pub async fn update_connection( &self, connection: Connection, ) -> Result<(), NetworkSystemError> { let (tx, rx) = oneshot::channel(); self.actions .send(Action::UpdateConnection(Box::new(connection), tx))?; let result = rx.await?; Ok(result?) } /// Removes the connection with the given ID. /// /// * `id`: Connection ID. pub async fn remove_connection(&self, id: &str) -> Result<(), NetworkSystemError> { let (tx, rx) = oneshot::channel(); self.actions .send(Action::RemoveConnection(id.to_string(), tx))?; let result = rx.await?; Ok(result?) } /// Applies the network configuration. pub async fn apply(&self) -> Result<(), NetworkSystemError> { let (tx, rx) = oneshot::channel(); self.actions.send(Action::Apply(tx))?; let result = rx.await?; Ok(result?) } /// Returns the collection of access points. pub async fn get_access_points(&self) -> Result<Vec<AccessPoint>, NetworkSystemError> { let (tx, rx) = oneshot::channel(); self.actions.send(Action::GetAccessPoints(tx))?; let access_points = rx.await?; Ok(access_points) } pub async fn wifi_scan(&self) -> Result<(), NetworkSystemError> { let (tx, rx) = oneshot::channel(); self.actions.send(Action::RefreshScan(tx)).unwrap(); let result = rx.await?; Ok(result?) } } struct NetworkSystemServer<T: Adapter> { state: NetworkState, input: UnboundedReceiver<Action>, output: broadcast::Sender<NetworkChange>, adapter: T, } impl<T: Adapter> NetworkSystemServer<T> { /// Process incoming actions. /// /// This function is expected to be executed on a separate thread. pub async fn listen(&mut self) { while let Some(action) = self.input.recv().await { match self.dispatch_action(action).await { Ok(Some(update)) => { _ = self.output.send(update); } Err(error) => { eprintln!("Could not process the action: {}", error); } _ => {} } } } /// Dispatch an action. pub async fn dispatch_action( &mut self, action: Action, ) -> Result<Option<NetworkChange>, Box<dyn Error>> { match action { Action::AddConnection(name, ty, tx) => { let result = self.add_connection_action(name, ty).await; tx.send(result).unwrap(); } Action::RefreshScan(tx) => { let state = self .adapter .read(StateConfig { access_points: true, ..Default::default() }) .await?; self.state.general_state = state.general_state; self.state.access_points = state.access_points; tx.send(Ok(())).unwrap(); } Action::GetAccessPoints(tx) => { tx.send(self.state.access_points.clone()).unwrap(); } Action::NewConnection(conn, tx) => { tx.send(self.state.add_connection(*conn)).unwrap(); } Action::GetGeneralState(tx) => { let config = self.state.general_state.clone(); tx.send(config.clone()).unwrap(); } Action::GetConnection(id, tx) => { let conn = self.state.get_connection(id.as_ref()); tx.send(conn.cloned()).unwrap(); } Action::GetConnectionByUuid(uuid, tx) => { let conn = self.state.get_connection_by_uuid(uuid); tx.send(conn.cloned()).unwrap(); } Action::GetConnections(tx) => { let connections = self .state .connections .clone() .into_iter() .filter(|c| !c.is_removed()) .collect(); tx.send(connections).unwrap(); } Action::GetController(uuid, tx) => { let result = self.get_controller_action(uuid); tx.send(result).unwrap() } Action::GetDevice(name, tx) => { let device = self.state.get_device(name.as_str()); tx.send(device.cloned()).unwrap(); } Action::AddDevice(device) => { self.state.add_device(*device.clone())?; return Ok(Some(NetworkChange::DeviceAdded(*device))); } Action::UpdateDevice(name, device) => { self.state.update_device(&name, *device.clone())?; return Ok(Some(NetworkChange::DeviceUpdated(name, *device))); } Action::RemoveDevice(name) => { self.state.remove_device(&name)?; return Ok(Some(NetworkChange::DeviceRemoved(name))); } Action::GetDevices(tx) => { tx.send(self.state.devices.clone()).unwrap(); } Action::SetPorts(uuid, ports, rx) => { let result = self.set_ports_action(uuid, *ports); rx.send(result).unwrap(); } Action::UpdateConnection(conn, tx) => { let result = self.state.update_connection(*conn); tx.send(result).unwrap(); } Action::UpdateGeneralState(general_state) => { self.state.general_state = general_state; } Action::RemoveConnection(id, tx) => { let result = self.state.remove_connection(id.as_str()); tx.send(result).unwrap(); } Action::Apply(tx) => { let result = self.write().await; tx.send(result).unwrap(); } } Ok(None) } async fn add_connection_action( &mut self, name: String, ty: DeviceType, ) -> Result<(), NetworkStateError> { let conn = Connection::new(name, ty); // TODO: handle tree handling problems self.state.add_connection(conn.clone())?; Ok(()) } fn set_ports_action( &mut self, uuid: Uuid, ports: Vec<String>, ) -> Result<(), NetworkStateError> { let conn = self .state .get_connection_by_uuid(uuid) .ok_or(NetworkStateError::UnknownConnection(uuid.to_string()))?; self.state.set_ports(&conn.clone(), ports) } fn get_controller_action( &mut self, uuid: Uuid, ) -> Result<(Connection, Vec<String>), NetworkStateError> { let conn = self .state .get_connection_by_uuid(uuid) .ok_or(NetworkStateError::UnknownConnection(uuid.to_string()))?; let conn = conn.clone(); let controlled = self .state .get_controlled_by(uuid) .iter() .map(|c| c.interface.as_deref().unwrap_or(&c.id).to_string()) .collect::<Vec<_>>(); Ok((conn, controlled)) } /// Writes the network configuration. pub async fn write(&mut self) -> Result<(), NetworkAdapterError> { self.adapter.write(&self.state).await?; self.state = self.adapter.read(StateConfig::default()).await?; Ok(()) } } 070701000000B2000081A4000000000000000000000001671F5A6400002B72000000000000000000000000000000000000002600000000agama/agama-server/src/network/web.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module implements the web API for the network module. use crate::{ error::Error, web::{Event, EventsSender}, }; use anyhow::Context; use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, routing::{delete, get, patch, post}, Json, Router, }; use super::{ error::NetworkStateError, model::{AccessPoint, GeneralState}, system::{NetworkSystemClient, NetworkSystemError}, Adapter, }; use crate::network::{model::Connection, model::Device, NetworkSystem}; use agama_lib::{error::ServiceError, network::settings::NetworkConnection}; use serde_json::json; use thiserror::Error; #[derive(Error, Debug)] pub enum NetworkError { #[error("Unknown connection id: {0}")] UnknownConnection(String), #[error("Cannot translate: {0}")] CannotTranslate(#[from] Error), #[error("Cannot add new connection: {0}")] CannotAddConnection(String), #[error("Cannot update configuration: {0}")] CannotUpdate(String), #[error("Cannot apply configuration")] CannotApplyConfig, // TODO: to be removed after adapting to the NetworkSystemServer API #[error("Network state error: {0}")] Error(#[from] NetworkStateError), #[error("Network system error: {0}")] SystemError(#[from] NetworkSystemError), } impl IntoResponse for NetworkError { fn into_response(self) -> Response { let body = json!({ "error": self.to_string() }); (StatusCode::BAD_REQUEST, Json(body)).into_response() } } #[derive(Clone)] struct NetworkServiceState { network: NetworkSystemClient, } /// Sets up and returns the axum service for the network module. /// * `adapter`: networking configuration adapter. /// * `events`: sending-half of the broadcast channel. pub async fn network_service<T: Adapter + Send + Sync + 'static>( adapter: T, events: EventsSender, ) -> Result<Router, ServiceError> { let network = NetworkSystem::new(adapter); // FIXME: we are somehow abusing ServiceError. The HTTP/JSON API should have its own // error type. let client = network .start() .await .context("Could not start the network configuration service.")?; let mut changes = client.subscribe(); tokio::spawn(async move { loop { match changes.recv().await { Ok(message) => { if let Err(e) = events.send(Event::NetworkChange { change: message }) { eprintln!("Could not send the event: {}", e); } } Err(e) => { eprintln!("Could not send the event: {}", e); } } } }); let state = NetworkServiceState { network: client }; Ok(Router::new() .route("/state", get(general_state).put(update_general_state)) .route("/connections", get(connections).post(add_connection)) .route( "/connections/:id", delete(delete_connection) .put(update_connection) .get(connection), ) .route("/connections/:id/connect", patch(connect)) .route("/connections/:id/disconnect", patch(disconnect)) .route("/devices", get(devices)) .route("/system/apply", post(apply)) .route("/wifi", get(wifi_networks)) .with_state(state)) } #[utoipa::path( get, path = "/state", context_path = "/api/network", responses( (status = 200, description = "Get general network config", body = GenereralState) ) )] async fn general_state( State(state): State<NetworkServiceState>, ) -> Result<Json<GeneralState>, NetworkError> { let general_state = state.network.get_state().await?; Ok(Json(general_state)) } #[utoipa::path( put, path = "/state", context_path = "/api/network", responses( (status = 200, description = "Update general network config", body = GenereralState) ) )] async fn update_general_state( State(state): State<NetworkServiceState>, Json(value): Json<GeneralState>, ) -> Result<Json<GeneralState>, NetworkError> { state.network.update_state(value)?; let state = state.network.get_state().await?; Ok(Json(state)) } #[utoipa::path( get, path = "/wifi", context_path = "/api/network", responses( (status = 200, description = "List of wireless networks", body = Vec<AccessPoint>) ) )] async fn wifi_networks( State(state): State<NetworkServiceState>, ) -> Result<Json<Vec<AccessPoint>>, NetworkError> { state.network.wifi_scan().await?; let access_points = state.network.get_access_points().await?; let mut networks = vec![]; for ap in access_points { if !ap.ssid.to_string().is_empty() { networks.push(ap); } } Ok(Json(networks)) } #[utoipa::path( get, path = "/devices", context_path = "/api/network", responses( (status = 200, description = "List of devices", body = Vec<Device>) ) )] async fn devices( State(state): State<NetworkServiceState>, ) -> Result<Json<Vec<Device>>, NetworkError> { Ok(Json(state.network.get_devices().await?)) } #[utoipa::path( get, path = "/connections", context_path = "/api/network", responses( (status = 200, description = "List of known connections", body = Vec<NetworkConnection>) ) )] async fn connections( State(state): State<NetworkServiceState>, ) -> Result<Json<Vec<NetworkConnection>>, NetworkError> { let connections = state.network.get_connections().await?; let connections = connections .iter() .map(|c| NetworkConnection::try_from(c.clone()).unwrap()) .collect(); Ok(Json(connections)) } #[utoipa::path( post, path = "/connections", context_path = "/api/network", responses( (status = 200, description = "Add a new connection", body = Connection) ) )] async fn add_connection( State(state): State<NetworkServiceState>, Json(conn): Json<NetworkConnection>, ) -> Result<Json<Connection>, NetworkError> { let conn = Connection::try_from(conn)?; let id = conn.id.clone(); state.network.add_connection(conn).await?; match state.network.get_connection(&id).await? { None => Err(NetworkError::CannotAddConnection(id.clone())), Some(conn) => Ok(Json(conn)), } } #[utoipa::path( get, path = "/network/connections/:id", responses( (status = 200, description = "Get connection given by its ID", body = NetworkConnection) ) )] async fn connection( State(state): State<NetworkServiceState>, Path(id): Path<String>, ) -> Result<Json<NetworkConnection>, NetworkError> { let conn = state .network .get_connection(&id) .await? .ok_or_else(|| NetworkError::UnknownConnection(id.clone()))?; let conn = NetworkConnection::try_from(conn)?; Ok(Json(conn)) } #[utoipa::path( delete, path = "/connections/:id", context_path = "/api/network", responses( (status = 200, description = "Delete connection", body = Connection) ) )] async fn delete_connection( State(state): State<NetworkServiceState>, Path(id): Path<String>, ) -> impl IntoResponse { if state.network.remove_connection(&id).await.is_ok() { StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND } } #[utoipa::path( put, path = "/connections/:id", context_path = "/api/network", responses( (status = 204, description = "Update connection", body = Connection) ) )] async fn update_connection( State(state): State<NetworkServiceState>, Path(id): Path<String>, Json(conn): Json<NetworkConnection>, ) -> Result<impl IntoResponse, NetworkError> { let orig_conn = state .network .get_connection(&id) .await? .ok_or_else(|| NetworkError::UnknownConnection(id.clone()))?; let mut conn = Connection::try_from(conn)?; if orig_conn.id != id { // FIXME: why? return Err(NetworkError::UnknownConnection(id)); } else { conn.uuid = orig_conn.uuid; } state.network.update_connection(conn).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path( patch, path = "/connections/:id/connect", context_path = "/api/network", responses( (status = 204, description = "Connect to the given connection", body = String) ) )] async fn connect( State(state): State<NetworkServiceState>, Path(id): Path<String>, ) -> Result<impl IntoResponse, NetworkError> { let Some(mut conn) = state.network.get_connection(&id).await? else { return Err(NetworkError::UnknownConnection(id)); }; conn.set_up(); state .network .update_connection(conn) .await .map_err(|_| NetworkError::CannotApplyConfig)?; state .network .apply() .await .map_err(|_| NetworkError::CannotApplyConfig)?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path( patch, path = "/connections/:id/disconnect", context_path = "/api/network", responses( (status = 204, description = "Connect to the given connection", body = String) ) )] async fn disconnect( State(state): State<NetworkServiceState>, Path(id): Path<String>, ) -> Result<impl IntoResponse, NetworkError> { let Some(mut conn) = state.network.get_connection(&id).await? else { return Err(NetworkError::UnknownConnection(id)); }; conn.set_down(); state .network .update_connection(conn) .await .map_err(|_| NetworkError::CannotApplyConfig)?; state .network .apply() .await .map_err(|_| NetworkError::CannotApplyConfig)?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path( post, path = "/system/apply", context_path = "/api/network", responses( (status = 204, description = "Apply configuration") ) )] async fn apply( State(state): State<NetworkServiceState>, ) -> Result<impl IntoResponse, NetworkError> { state .network .apply() .await .map_err(|_| NetworkError::CannotApplyConfig)?; Ok(StatusCode::NO_CONTENT) } 070701000000B3000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000002100000000agama/agama-server/src/questions070701000000B4000081A4000000000000000000000001671F5A640000304F000000000000000000000000000000000000002400000000agama/agama-server/src/questions.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use std::collections::HashMap; use agama_lib::questions::{self, GenericQuestion, WithPassword}; use log; use zbus::{dbus_interface, fdo::ObjectManager, zvariant::ObjectPath, Connection}; mod answers; pub mod web; #[derive(thiserror::Error, Debug)] pub enum QuestionsError { #[error("Could not read the answers file: {0}")] IO(std::io::Error), #[error("Could not deserialize the answers file: {0}")] Deserialize(serde_json::Error), } #[derive(Clone, Debug)] struct GenericQuestionObject(questions::GenericQuestion); #[dbus_interface(name = "org.opensuse.Agama1.Questions.Generic")] impl GenericQuestionObject { #[dbus_interface(property)] pub fn id(&self) -> u32 { self.0.id } #[dbus_interface(property)] pub fn class(&self) -> &str { &self.0.class } #[dbus_interface(property)] pub fn data(&self) -> HashMap<String, String> { self.0.data.to_owned() } #[dbus_interface(property)] pub fn text(&self) -> &str { self.0.text.as_str() } #[dbus_interface(property)] pub fn options(&self) -> Vec<String> { self.0.options.to_owned() } #[dbus_interface(property)] pub fn default_option(&self) -> &str { self.0.default_option.as_str() } #[dbus_interface(property)] pub fn answer(&self) -> &str { &self.0.answer } #[dbus_interface(property)] pub fn set_answer(&mut self, value: &str) -> zbus::fdo::Result<()> { // TODO verify if answer exists in options or if it is valid in other way self.0.answer = value.to_string(); Ok(()) } } /// Mixin interface for questions that are base + contain question for password struct WithPasswordObject(questions::WithPassword); #[dbus_interface(name = "org.opensuse.Agama1.Questions.WithPassword")] impl WithPasswordObject { #[dbus_interface(property)] pub fn password(&self) -> &str { self.0.password.as_str() } #[dbus_interface(property)] pub fn set_password(&mut self, value: &str) { self.0.password = value.to_string(); } } /// Question types used to be able to properly remove object from dbus enum QuestionType { Base, BaseWithPassword, } /// Trait for objects that can provide answers to all kind of Question. /// /// If no strategy is selected or the answer is unknown, then ask to the user. trait AnswerStrategy { /// Id for quick runtime inspection of strategy type fn id(&self) -> u8; /// Provides answer for generic question /// /// I gets as argument the question to answer. Returned value is `answer` /// property or None. If `None` is used, it means that this object does not /// answer to given question. fn answer(&self, question: &GenericQuestion) -> Option<String>; /// Provides answer and password for base question with password /// /// I gets as argument the question to answer. Returned value is pair /// of `answer` and `password` properties. If `None` is used in any /// position it means that this object does not respond to given property. /// /// It is object responsibility to provide correct pair. For example if /// possible answer can be "Ok" and "Cancel". Then for `Ok` password value /// should be provided and for `Cancel` it can be `None`. fn answer_with_password(&self, question: &WithPassword) -> (Option<String>, Option<String>); } /// AnswerStrategy that provides as answer the default option. struct DefaultAnswers; impl DefaultAnswers { pub fn id() -> u8 { 1 } } impl AnswerStrategy for DefaultAnswers { fn id(&self) -> u8 { DefaultAnswers::id() } fn answer(&self, question: &GenericQuestion) -> Option<String> { Some(question.default_option.clone()) } fn answer_with_password(&self, question: &WithPassword) -> (Option<String>, Option<String>) { (Some(question.base.default_option.clone()), None) } } pub struct Questions { questions: HashMap<u32, QuestionType>, connection: Connection, last_id: u32, answer_strategies: Vec<Box<dyn AnswerStrategy + Sync + Send>>, } #[dbus_interface(name = "org.opensuse.Agama1.Questions")] impl Questions { /// creates new generic question without answer #[dbus_interface(name = "New")] async fn new_question( &mut self, class: &str, text: &str, options: Vec<&str>, default_option: &str, data: HashMap<String, String>, ) -> zbus::fdo::Result<ObjectPath> { log::info!("Creating new question with text: {}.", text); let id = self.last_id; self.last_id += 1; // TODO use some thread safety let options = options.iter().map(|o| o.to_string()).collect(); let mut question = questions::GenericQuestion::new( id, class.to_string(), text.to_string(), options, default_option.to_string(), data, ); self.fill_answer(&mut question); let object_path = ObjectPath::try_from(question.object_path()).unwrap(); let question_object = GenericQuestionObject(question); self.connection .object_server() .at(object_path.clone(), question_object) .await?; self.questions.insert(id, QuestionType::Base); Ok(object_path) } /// creates new specialized luks activation question without answer and password async fn new_with_password( &mut self, class: &str, text: &str, options: Vec<&str>, default_option: &str, data: HashMap<String, String>, ) -> zbus::fdo::Result<ObjectPath> { log::info!("Creating new question with password with text: {}.", text); let id = self.last_id; self.last_id += 1; // TODO use some thread safety // TODO: share code better let options = options.iter().map(|o| o.to_string()).collect(); let base = questions::GenericQuestion::new( id, class.to_string(), text.to_string(), options, default_option.to_string(), data, ); let mut question = questions::WithPassword::new(base); let object_path = ObjectPath::try_from(question.base.object_path()).unwrap(); let base_question = question.base.clone(); self.fill_answer_with_password(&mut question); let base_object = GenericQuestionObject(base_question); self.connection .object_server() .at(object_path.clone(), WithPasswordObject(question)) .await?; // NOTE: order here is important as each interface cause signal, so frontend should wait only for GenericQuestions // which should be the last interface added self.connection .object_server() .at(object_path.clone(), base_object) .await?; self.questions.insert(id, QuestionType::BaseWithPassword); Ok(object_path) } /// Removes question at given object path /// TODO: use id as parameter ( need at first check other users of method ) async fn delete(&mut self, question: ObjectPath<'_>) -> zbus::fdo::Result<()> { // TODO: error checking let id: u32 = question.rsplit('/').next().unwrap().parse().unwrap(); let qtype = self.questions.get(&id).unwrap(); match qtype { QuestionType::Base => { self.connection .object_server() .remove::<GenericQuestionObject, _>(question.clone()) .await?; } QuestionType::BaseWithPassword => { self.connection .object_server() .remove::<GenericQuestionObject, _>(question.clone()) .await?; self.connection .object_server() .remove::<WithPasswordObject, _>(question.clone()) .await?; } }; self.questions.remove(&id); Ok(()) } /// property that defines if questions is interactive or automatically answered with /// default answer #[dbus_interface(property)] fn interactive(&self) -> bool { let last = self.answer_strategies.last(); if let Some(real_strategy) = last { real_strategy.id() != DefaultAnswers::id() } else { true } } #[dbus_interface(property)] fn set_interactive(&mut self, value: bool) { if value != self.interactive() { log::info!("interactive value unchanged - {}", value); return; } log::info!("set interactive to {}", value); if value { self.answer_strategies.pop(); } else { self.answer_strategies.push(Box::new(DefaultAnswers {})); } } fn add_answer_file(&mut self, path: String) -> zbus::fdo::Result<()> { log::info!("Adding answer file {}", path); let answers = answers::Answers::new_from_file(path.as_str()) .map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; self.answer_strategies.push(Box::new(answers)); Ok(()) } } impl Questions { /// Creates new questions interface with clone of connection to be able to /// attach or detach question objects fn new(connection: &Connection) -> Self { Self { questions: HashMap::new(), connection: connection.to_owned(), last_id: 0, answer_strategies: vec![], } } /// tries to provide answer to question using answer strategies /// /// What happens under the hood is that it uses answer_strategies vector /// and try to find the first strategy that provides answer. When /// answer is provided, it returns immediately. fn fill_answer(&self, question: &mut GenericQuestion) { for strategy in self.answer_strategies.iter() { match strategy.answer(question) { None => (), Some(answer) => { question.answer = answer; return; } } } } /// tries to provide answer to question using answer strategies /// /// What happens under the hood is that it uses answer_strategies vector /// and try to find the first strategy that provides answer. When /// answer is provided, it returns immediately. fn fill_answer_with_password(&self, question: &mut WithPassword) { for strategy in self.answer_strategies.iter() { let (answer, password) = strategy.answer_with_password(question); if let Some(password) = password { question.password = password; } if let Some(answer) = answer { question.base.answer = answer; return; } } } } /// Starts questions dbus service together with Object manager pub async fn export_dbus_objects( connection: &Connection, ) -> Result<(), Box<dyn std::error::Error>> { const PATH: &str = "/org/opensuse/Agama1/Questions"; // When serving, request the service name _after_ exposing the main object let questions = Questions::new(connection); connection.object_server().at(PATH, questions).await?; connection.object_server().at(PATH, ObjectManager).await?; Ok(()) } 070701000000B5000081A4000000000000000000000001671F5A6400002A90000000000000000000000000000000000000002C00000000agama/agama-server/src/questions/answers.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use std::collections::HashMap; use agama_lib::questions::GenericQuestion; use serde::{Deserialize, Serialize}; use super::QuestionsError; /// Data structure for single JSON answer. For variables specification see /// corresponding [agama_lib::questions::GenericQuestion] fields. /// The *matcher* part is: `class`, `text`, `data`. /// The *answer* part is: `answer`, `password`. #[derive(Serialize, Deserialize, PartialEq, Debug)] struct Answer { pub class: Option<String>, pub text: Option<String>, /// A matching GenericQuestion can have other data fields too pub data: Option<HashMap<String, String>>, /// The answer text is the only mandatory part of an Answer pub answer: String, /// All possible mixins have to be here, so they can be specified in an Answer pub password: Option<String>, } impl Answer { /// Determines whether the answer responds to the given question. /// /// * `question`: question to compare with. pub fn responds(&self, question: &GenericQuestion) -> bool { if let Some(class) = &self.class { if question.class != *class { return false; } } if let Some(text) = &self.text { if question.text != *text { return false; } } if let Some(data) = &self.data { return data.iter().all(|(key, value)| { let Some(e_val) = question.data.get(key) else { return false; }; e_val == value }); } true } } /// Data structure holding list of Answer. /// The first matching Answer is used, even if there is /// a better (more specific) match later in the list. #[derive(Serialize, Deserialize, PartialEq, Debug)] pub struct Answers { answers: Vec<Answer>, } impl Answers { pub fn new_from_file(path: &str) -> Result<Self, QuestionsError> { let f = std::fs::File::open(path).map_err(QuestionsError::IO)?; let result: Self = serde_json::from_reader(f).map_err(QuestionsError::Deserialize)?; Ok(result) } pub fn id() -> u8 { 2 } fn find_answer(&self, question: &GenericQuestion) -> Option<&Answer> { self.answers.iter().find(|a| a.responds(question)) } } impl crate::questions::AnswerStrategy for Answers { fn id(&self) -> u8 { Answers::id() } fn answer(&self, question: &GenericQuestion) -> Option<String> { let answer = self.find_answer(question); answer.map(|answer| answer.answer.clone()) } fn answer_with_password( &self, question: &agama_lib::questions::WithPassword, ) -> (Option<String>, Option<String>) { // use here fact that with password share same matchers as generic one let answer = self.find_answer(&question.base); if let Some(answer) = answer { (Some(answer.answer.clone()), answer.password.clone()) } else { (None, None) } } } #[cfg(test)] mod tests { use agama_lib::questions::{GenericQuestion, WithPassword}; use crate::questions::AnswerStrategy; use super::*; // set of fixtures for test fn get_answers() -> Answers { Answers { answers: vec![ Answer { class: Some("without_data".to_string()), data: None, text: None, answer: "Ok".to_string(), password: Some("testing pwd".to_string()), // ignored for generic question }, Answer { class: Some("with_data".to_string()), data: Some(HashMap::from([ ("data1".to_string(), "value1".to_string()), ("data2".to_string(), "value2".to_string()), ])), text: None, answer: "Maybe".to_string(), password: None, }, Answer { class: Some("with_data".to_string()), data: Some(HashMap::from([( "data1".to_string(), "another_value1".to_string(), )])), text: None, answer: "Ok2".to_string(), password: None, }, ], } } #[test] fn test_class_match() { let answers = get_answers(); let question = GenericQuestion { id: 1, class: "without_data".to_string(), text: "JFYI we will kill all bugs during installation.".to_string(), options: vec!["Ok".to_string(), "Cancel".to_string()], default_option: "Cancel".to_string(), data: HashMap::new(), answer: "".to_string(), }; assert_eq!(Some("Ok".to_string()), answers.answer(&question)); } #[test] fn test_no_match() { let answers = get_answers(); let question = GenericQuestion { id: 1, class: "non-existing".to_string(), text: "Hard question?".to_string(), options: vec!["Ok".to_string(), "Cancel".to_string()], default_option: "Cancel".to_string(), data: HashMap::new(), answer: "".to_string(), }; assert_eq!(None, answers.answer(&question)); } #[test] fn test_with_password() { let answers = get_answers(); let question = GenericQuestion { id: 1, class: "without_data".to_string(), text: "Please provide password for dooms day.".to_string(), options: vec!["Ok".to_string(), "Cancel".to_string()], default_option: "Cancel".to_string(), data: HashMap::new(), answer: "".to_string(), }; let with_password = WithPassword { password: "".to_string(), base: question, }; let expected = (Some("Ok".to_string()), Some("testing pwd".to_string())); assert_eq!(expected, answers.answer_with_password(&with_password)); } /// An Answer matches on *data* if all its keys and values are in the GenericQuestion *data*. /// The GenericQuestion can have other *data* keys. #[test] fn test_partial_data_match() { let answers = get_answers(); let question = GenericQuestion { id: 1, class: "with_data".to_string(), text: "Hard question?".to_string(), options: vec!["Ok2".to_string(), "Maybe".to_string(), "Cancel".to_string()], default_option: "Cancel".to_string(), data: HashMap::from([ ("data1".to_string(), "value1".to_string()), ("data2".to_string(), "value2".to_string()), ("data3".to_string(), "value3".to_string()), ]), answer: "".to_string(), }; assert_eq!(Some("Maybe".to_string()), answers.answer(&question)); } #[test] fn test_full_data_match() { let answers = get_answers(); let question = GenericQuestion { id: 1, class: "with_data".to_string(), text: "Hard question?".to_string(), options: vec!["Ok2".to_string(), "Maybe".to_string(), "Cancel".to_string()], default_option: "Cancel".to_string(), data: HashMap::from([ ("data1".to_string(), "another_value1".to_string()), ("data2".to_string(), "value2".to_string()), ("data3".to_string(), "value3".to_string()), ]), answer: "".to_string(), }; assert_eq!(Some("Ok2".to_string()), answers.answer(&question)); } #[test] fn test_no_data_match() { let answers = get_answers(); let question = GenericQuestion { id: 1, class: "with_data".to_string(), text: "Hard question?".to_string(), options: vec!["Ok2".to_string(), "Maybe".to_string(), "Cancel".to_string()], default_option: "Cancel".to_string(), data: HashMap::from([ ("data1".to_string(), "different value".to_string()), ("data2".to_string(), "value2".to_string()), ("data3".to_string(), "value3".to_string()), ]), answer: "".to_string(), }; assert_eq!(None, answers.answer(&question)); } // A "universal answer" with unspecified class+text+data is possible #[test] fn test_universal_match() { let answers = Answers { answers: vec![Answer { class: None, text: None, data: None, answer: "Yes".into(), password: None, }], }; let question = GenericQuestion { id: 1, class: "without_data".to_string(), text: "JFYI we will kill all bugs during installation.".to_string(), options: vec!["Ok".to_string(), "Cancel".to_string()], default_option: "Cancel".to_string(), data: HashMap::new(), answer: "".to_string(), }; assert_eq!(Some("Yes".to_string()), answers.answer(&question)); } #[test] fn test_loading_json() { let file = r#" { "answers": [ { "class": "without_data", "answer": "OK" }, { "class": "with_data", "data": { "testk": "testv", "testk2": "testv2" }, "answer": "Cancel" }] } "#; let result: Answers = serde_json::from_str(file).expect("failed to load JSON string"); assert_eq!(result.answers.len(), 2); } } 070701000000B6000081A4000000000000000000000001671F5A6400003573000000000000000000000000000000000000002800000000agama/agama-server/src/questions/web.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module implements the web API for the questions module. //! //! The module offers two public functions: //! //! * `questions_service` which returns the Axum service. //! * `questions_stream` which offers an stream that emits questions related signals. use crate::{error::Error, web::Event}; use agama_lib::{ dbus::{extract_id_from_path, get_property}, error::ServiceError, proxies::{GenericQuestionProxy, QuestionWithPasswordProxy, Questions1Proxy}, questions::model::{Answer, GenericQuestion, PasswordAnswer, Question, QuestionWithPassword}, }; use anyhow::Context; use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, routing::{delete, get}, Json, Router, }; use std::{collections::HashMap, pin::Pin}; use tokio_stream::{Stream, StreamExt}; use zbus::{ fdo::ObjectManagerProxy, names::{InterfaceName, OwnedInterfaceName}, zvariant::{ObjectPath, OwnedObjectPath, OwnedValue}, }; // TODO: move to lib or maybe not and just have in lib client for http API? #[derive(Clone)] struct QuestionsClient<'a> { connection: zbus::Connection, objects_proxy: ObjectManagerProxy<'a>, questions_proxy: Questions1Proxy<'a>, generic_interface: OwnedInterfaceName, with_password_interface: OwnedInterfaceName, } impl<'a> QuestionsClient<'a> { pub async fn new(dbus: zbus::Connection) -> Result<Self, zbus::Error> { let question_path = OwnedObjectPath::from(ObjectPath::try_from("/org/opensuse/Agama1/Questions")?); Ok(Self { connection: dbus.clone(), questions_proxy: Questions1Proxy::new(&dbus).await?, objects_proxy: ObjectManagerProxy::builder(&dbus) .path(question_path)? .destination("org.opensuse.Agama1")? .build() .await?, generic_interface: InterfaceName::from_str_unchecked( "org.opensuse.Agama1.Questions.Generic", ) .into(), with_password_interface: InterfaceName::from_str_unchecked( "org.opensuse.Agama1.Questions.WithPassword", ) .into(), }) } pub async fn create_question(&self, question: Question) -> Result<Question, ServiceError> { // TODO: ugly API is caused by dbus method to create question. It can be changed in future as DBus is internal only API let generic = &question.generic; let options: Vec<&str> = generic.options.iter().map(String::as_ref).collect(); let data: HashMap<&str, &str> = generic .data .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); let path = if question.with_password.is_some() { tracing::info!("creating a question with password"); self.questions_proxy .new_with_password( &generic.class, &generic.text, &options, &generic.default_option, data, ) .await? } else { tracing::info!("creating a generic question"); self.questions_proxy .new_question( &generic.class, &generic.text, &options, &generic.default_option, data, ) .await? }; let mut res = question.clone(); res.generic.id = Some(extract_id_from_path(&path)?); tracing::info!("new question gets id {:?}", res.generic.id); Ok(res) } pub async fn questions(&self) -> Result<Vec<Question>, ServiceError> { let objects = self .objects_proxy .get_managed_objects() .await .context("failed to get managed object with Object Manager")?; let mut result: Vec<Question> = Vec::with_capacity(objects.len()); for (_path, interfaces_hash) in objects.iter() { let generic_properties = interfaces_hash .get(&self.generic_interface) .context("Failed to create interface name for generic question")?; // skip if question is already answered let answer: String = get_property(generic_properties, "Answer")?; if !answer.is_empty() { continue; } let mut question = self.build_generic_question(generic_properties)?; if interfaces_hash.contains_key(&self.with_password_interface) { question.with_password = Some(QuestionWithPassword {}); } result.push(question); } Ok(result) } fn build_generic_question( &self, properties: &HashMap<String, OwnedValue>, ) -> Result<Question, ServiceError> { let result = Question { generic: GenericQuestion { id: Some(get_property(properties, "Id")?), class: get_property(properties, "Class")?, text: get_property(properties, "Text")?, options: get_property(properties, "Options")?, default_option: get_property(properties, "DefaultOption")?, data: get_property(properties, "Data")?, }, with_password: None, }; Ok(result) } pub async fn delete(&self, id: u32) -> Result<(), ServiceError> { let question_path = ObjectPath::try_from(format!("/org/opensuse/Agama1/Questions/{}", id)) .context("Failed to create a D-Bus path")?; self.questions_proxy .delete(&question_path) .await .map_err(|e| e.into()) } pub async fn get_answer(&self, id: u32) -> Result<Option<Answer>, ServiceError> { let question_path = OwnedObjectPath::from( ObjectPath::try_from(format!("/org/opensuse/Agama1/Questions/{}", id)) .context("Failed to create dbus path")?, ); let objects = self.objects_proxy.get_managed_objects().await?; let password_interface = OwnedInterfaceName::from( InterfaceName::from_static_str("org.opensuse.Agama1.Questions.WithPassword") .context("Failed to create interface name for question with password")?, ); let mut result = Answer::default(); let question = objects .get(&question_path) .ok_or(ServiceError::QuestionNotExist(id))?; if let Some(password_iface) = question.get(&password_interface) { result.with_password = Some(PasswordAnswer { password: get_property(password_iface, "Password")?, }); } let generic_interface = OwnedInterfaceName::from( InterfaceName::from_static_str("org.opensuse.Agama1.Questions.Generic") .context("Failed to create interface name for generic question")?, ); let generic_iface = question .get(&generic_interface) .context("Question does not have generic interface")?; let answer: String = get_property(generic_iface, "Answer")?; if answer.is_empty() { Ok(None) } else { result.generic.answer = answer; Ok(Some(result)) } } pub async fn answer(&self, id: u32, answer: Answer) -> Result<(), ServiceError> { let question_path = OwnedObjectPath::from( ObjectPath::try_from(format!("/org/opensuse/Agama1/Questions/{}", id)) .context("Failed to create dbus path")?, ); if let Some(password) = answer.with_password { let dbus_password = QuestionWithPasswordProxy::builder(&self.connection) .path(&question_path)? .cache_properties(zbus::CacheProperties::No) .build() .await?; dbus_password .set_password(password.password.as_str()) .await? } let dbus_generic = GenericQuestionProxy::builder(&self.connection) .path(&question_path)? .cache_properties(zbus::CacheProperties::No) .build() .await?; dbus_generic .set_answer(answer.generic.answer.as_str()) .await?; Ok(()) } } #[derive(Clone)] struct QuestionsState<'a> { questions: QuestionsClient<'a>, } /// Sets up and returns the axum service for the questions module. pub async fn questions_service(dbus: zbus::Connection) -> Result<Router, ServiceError> { let questions = QuestionsClient::new(dbus.clone()).await?; let state = QuestionsState { questions }; let router = Router::new() .route("/", get(list_questions).post(create_question)) .route("/:id", delete(delete_question)) .route("/:id/answer", get(get_answer).put(answer_question)) .with_state(state); Ok(router) } pub async fn questions_stream( dbus: zbus::Connection, ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Error> { let question_path = OwnedObjectPath::from( ObjectPath::try_from("/org/opensuse/Agama1/Questions") .context("failed to create object path")?, ); let proxy = ObjectManagerProxy::builder(&dbus) .path(question_path) .context("Failed to create object manager path")? .destination("org.opensuse.Agama1")? .build() .await .context("Failed to create Object MAnager proxy")?; let add_stream = proxy .receive_interfaces_added() .await? .then(|_| async move { Event::QuestionsChanged }); let remove_stream = proxy .receive_interfaces_removed() .await? .then(|_| async move { Event::QuestionsChanged }); let stream = StreamExt::merge(add_stream, remove_stream); Ok(Box::pin(stream)) } /// Returns the list of questions that waits for answer. /// /// * `state`: service state. #[utoipa::path(get, path = "/questions", responses( (status = 200, description = "List of open questions", body = Vec<Question>), (status = 400, description = "The D-Bus service could not perform the action") ))] async fn list_questions( State(state): State<QuestionsState<'_>>, ) -> Result<Json<Vec<Question>>, Error> { Ok(Json(state.questions.questions().await?)) } /// Get answer to question. /// /// * `state`: service state. /// * `questions_id`: id of question #[utoipa::path(put, path = "/questions/:id/answer", responses( (status = 200, description = "Answer"), (status = 400, description = "The D-Bus service could not perform the action"), (status = 404, description = "Answer was not yet provided"), ))] async fn get_answer( State(state): State<QuestionsState<'_>>, Path(question_id): Path<u32>, ) -> Result<Response, Error> { let res = state.questions.get_answer(question_id).await?; if let Some(answer) = res { Ok(Json(answer).into_response()) } else { Ok(StatusCode::NOT_FOUND.into_response()) } } /// Provide answer to question. /// /// * `state`: service state. /// * `questions_id`: id of question /// * `answer`: struct with answer and possible other data needed for answer like password #[utoipa::path(put, path = "/questions/:id/answer", responses( (status = 200, description = "answer question"), (status = 400, description = "The D-Bus service could not perform the action") ))] async fn answer_question( State(state): State<QuestionsState<'_>>, Path(question_id): Path<u32>, Json(answer): Json<Answer>, ) -> Result<(), Error> { let res = state.questions.answer(question_id, answer).await; Ok(res?) } /// Deletes question. /// /// * `state`: service state. /// * `questions_id`: id of question #[utoipa::path(delete, path = "/questions/:id", responses( (status = 200, description = "question deleted"), (status = 400, description = "The D-Bus service could not perform the action") ))] async fn delete_question( State(state): State<QuestionsState<'_>>, Path(question_id): Path<u32>, ) -> Result<(), Error> { let res = state.questions.delete(question_id).await; Ok(res?) } /// Create new question. /// /// * `state`: service state. /// * `question`: struct with question where id of question is ignored and will be assigned #[utoipa::path(post, path = "/questions", responses( (status = 200, description = "answer question"), (status = 400, description = "The D-Bus service could not perform the action") ))] async fn create_question( State(state): State<QuestionsState<'_>>, Json(question): Json<Question>, ) -> Result<Json<Question>, Error> { let res = state.questions.create_question(question).await?; Ok(Json(res)) } 070701000000B7000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001F00000000agama/agama-server/src/scripts070701000000B8000081A4000000000000000000000001671F5A6400000347000000000000000000000000000000000000002200000000agama/agama-server/src/scripts.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. pub mod web; 070701000000B9000081A4000000000000000000000001671F5A6400000ED5000000000000000000000000000000000000002600000000agama/agama-server/src/scripts/web.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use std::sync::Arc; use agama_lib::{ error::ServiceError, scripts::{Script, ScriptError, ScriptsGroup, ScriptsRepository}, }; use axum::{ extract::State, http::StatusCode, response::{IntoResponse, Response}, routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; use serde_json::json; use thiserror::Error; use tokio::sync::RwLock; #[derive(Clone, Default)] struct ScriptsState { scripts: Arc<RwLock<ScriptsRepository>>, } #[derive(Error, Debug)] #[error("Script error: {0}")] struct ScriptServiceError(#[from] ScriptError); impl IntoResponse for ScriptServiceError { fn into_response(self) -> Response { let body = json!({ "error": self.to_string() }); (StatusCode::BAD_REQUEST, Json(body)).into_response() } } /// Sets up and returns the axum service for the auto-installation scripts. pub async fn scripts_service() -> Result<Router, ServiceError> { let state = ScriptsState::default(); let router = Router::new() .route( "/", get(list_scripts).post(add_script).delete(remove_scripts), ) .route("/run", post(run_scripts)) .with_state(state); Ok(router) } #[utoipa::path( post, path = "/", context_path = "/api/scripts", request_body(content = ScriptConfig, description = "Script definition"), responses( (status = 200, description = "The script was added.") ) )] async fn add_script( state: State<ScriptsState>, Json(script): Json<Script>, ) -> Result<impl IntoResponse, ScriptServiceError> { let mut scripts = state.scripts.write().await; scripts.add(script); Ok(()) } #[utoipa::path( get, path = "/", context_path = "/api/scripts", responses( (status = 200, description = "Defined scripts.") ) )] async fn list_scripts(state: State<ScriptsState>) -> Json<Vec<Script>> { let repo = state.scripts.read().await; Json(repo.scripts.to_vec()) } #[utoipa::path( delete, path = "/", context_path = "/api/scripts", responses( (status = 200, description = "The scripts have been removed.") ) )] async fn remove_scripts( state: State<ScriptsState>, ) -> Result<impl IntoResponse, ScriptServiceError> { let mut scripts = state.scripts.write().await; scripts.clear(); Ok(()) } #[derive(Clone, Serialize, Deserialize)] struct RunScriptParams { group: ScriptsGroup, } #[utoipa::path( post, path = "/run", context_path = "/api/scripts", responses( (status = 200, description = "The scripts were successfully executed.") ) )] async fn run_scripts( state: State<ScriptsState>, Json(group): Json<ScriptsGroup>, ) -> Result<(), ScriptServiceError> { let scripts = state.scripts.write().await; if let Err(error) = scripts.run(group).await { tracing::error!("Could not run user-defined scripts: {error}"); } Ok(()) } 070701000000BA000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000002000000000agama/agama-server/src/software070701000000BB000081A4000000000000000000000001671F5A640000037A000000000000000000000000000000000000002300000000agama/agama-server/src/software.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. pub mod web; pub use web::{software_service, software_streams}; 070701000000BC000081A4000000000000000000000001671F5A6400003AF1000000000000000000000000000000000000002700000000agama/agama-server/src/software/web.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module implements the web API for the software service. //! //! The module offers two public functions: //! //! * `software_service` which returns the Axum service. //! * `software_stream` which offers an stream that emits the software events coming from D-Bus. use crate::{ error::Error, web::{ common::{issues_router, progress_router, service_status_router, EventStreams}, Event, }, }; use agama_lib::{ error::ServiceError, product::{proxies::RegistrationProxy, Product, ProductClient}, software::{ model::{RegistrationInfo, RegistrationParams, SoftwareConfig}, proxies::{Software1Proxy, SoftwareProductProxy}, Pattern, SelectedBy, SoftwareClient, UnknownSelectedBy, }, }; use axum::{ extract::State, http::StatusCode, response::IntoResponse, routing::{get, post, put}, Json, Router, }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use tokio_stream::{Stream, StreamExt}; #[derive(Clone)] struct SoftwareState<'a> { product: ProductClient<'a>, software: SoftwareClient<'a>, } /// Returns an stream that emits software related events coming from D-Bus. /// /// It emits the Event::ProductChanged and Event::PatternsChanged events. /// /// * `connection`: D-Bus connection to listen for events. pub async fn software_streams(dbus: zbus::Connection) -> Result<EventStreams, Error> { let result: EventStreams = vec![ ( "patterns_changed", Box::pin(patterns_changed_stream(dbus.clone()).await?), ), ( "product_changed", Box::pin(product_changed_stream(dbus.clone()).await?), ), ( "registration_requirement_changed", Box::pin(registration_requirement_changed_stream(dbus.clone()).await?), ), ( "registration_code_changed", Box::pin(registration_code_changed_stream(dbus.clone()).await?), ), ( "registration_email_changed", Box::pin(registration_email_changed_stream(dbus.clone()).await?), ), ]; Ok(result) } async fn product_changed_stream( dbus: zbus::Connection, ) -> Result<impl Stream<Item = Event>, Error> { let proxy = SoftwareProductProxy::new(&dbus).await?; let stream = proxy .receive_selected_product_changed() .await .then(|change| async move { if let Ok(id) = change.get().await { return Some(Event::ProductChanged { id }); } None }) .filter_map(|e| e); Ok(stream) } async fn patterns_changed_stream( dbus: zbus::Connection, ) -> Result<impl Stream<Item = Event>, Error> { let proxy = Software1Proxy::new(&dbus).await?; let stream = proxy .receive_selected_patterns_changed() .await .then(|change| async move { if let Ok(patterns) = change.get().await { return match reason_to_selected_by(patterns) { Ok(patterns) => Some(patterns), Err(error) => { log::warn!("Ignoring the list of changed patterns. Error: {}", error); None } }; } None }) .filter_map(|e| e.map(|patterns| Event::SoftwareProposalChanged { patterns })); Ok(stream) } async fn registration_requirement_changed_stream( dbus: zbus::Connection, ) -> Result<impl Stream<Item = Event>, Error> { // TODO: move registration requirement to product in dbus and so just one event will be needed. let proxy = RegistrationProxy::new(&dbus).await?; let stream = proxy .receive_requirement_changed() .await .then(|change| async move { if let Ok(id) = change.get().await { // unwrap is safe as possible numbers is send by our controlled dbus return Some(Event::RegistrationRequirementChanged { requirement: id.try_into().unwrap(), }); } None }) .filter_map(|e| e); Ok(stream) } async fn registration_email_changed_stream( dbus: zbus::Connection, ) -> Result<impl Stream<Item = Event>, Error> { let proxy = RegistrationProxy::new(&dbus).await?; let stream = proxy .receive_email_changed() .await .then(|change| async move { if let Ok(_id) = change.get().await { // TODO: add to stream also proxy and return whole cached registration info return Some(Event::RegistrationChanged); } None }) .filter_map(|e| e); Ok(stream) } async fn registration_code_changed_stream( dbus: zbus::Connection, ) -> Result<impl Stream<Item = Event>, Error> { let proxy = RegistrationProxy::new(&dbus).await?; let stream = proxy .receive_reg_code_changed() .await .then(|change| async move { if let Ok(_id) = change.get().await { return Some(Event::RegistrationChanged); } None }) .filter_map(|e| e); Ok(stream) } // Returns a hash replacing the selection "reason" from D-Bus with a SelectedBy variant. fn reason_to_selected_by( patterns: HashMap<String, u8>, ) -> Result<HashMap<String, SelectedBy>, UnknownSelectedBy> { let mut selected: HashMap<String, SelectedBy> = HashMap::new(); for (id, reason) in patterns { match SelectedBy::try_from(reason) { Ok(selected_by) => selected.insert(id, selected_by), Err(e) => return Err(e), }; } Ok(selected) } /// Sets up and returns the axum service for the software module. pub async fn software_service(dbus: zbus::Connection) -> Result<Router, ServiceError> { const DBUS_SERVICE: &str = "org.opensuse.Agama.Software1"; const DBUS_PATH: &str = "/org/opensuse/Agama/Software1"; const DBUS_PRODUCT_PATH: &str = "/org/opensuse/Agama/Software1/Product"; let status_router = service_status_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; let progress_router = progress_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; let software_issues = issues_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; let product_issues = issues_router(&dbus, DBUS_SERVICE, DBUS_PRODUCT_PATH).await?; let product = ProductClient::new(dbus.clone()).await?; let software = SoftwareClient::new(dbus).await?; let state = SoftwareState { product, software }; let router = Router::new() .route("/patterns", get(patterns)) .route("/products", get(products)) .route( "/registration", get(get_registration).post(register).delete(deregister), ) .route("/proposal", get(proposal)) .route("/config", put(set_config).get(get_config)) .route("/probe", post(probe)) .merge(status_router) .merge(progress_router) .nest("/issues/product", product_issues) .nest("/issues/software", software_issues) .with_state(state); Ok(router) } /// Returns the list of available products. /// /// * `state`: service state. #[utoipa::path( get, path = "/products", context_path = "/api/software", responses( (status = 200, description = "List of known products", body = Vec<Product>), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn products(State(state): State<SoftwareState<'_>>) -> Result<Json<Vec<Product>>, Error> { let products = state.product.products().await?; Ok(Json(products)) } /// returns registration info /// /// * `state`: service state. #[utoipa::path( get, path = "/registration", context_path = "/api/software", responses( (status = 200, description = "registration configuration", body = RegistrationInfo), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn get_registration( State(state): State<SoftwareState<'_>>, ) -> Result<Json<RegistrationInfo>, Error> { let result = RegistrationInfo { key: state.product.registration_code().await?, email: state.product.email().await?, requirement: state.product.registration_requirement().await?, }; Ok(Json(result)) } #[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct FailureDetails { /// ID of error. See dbus API for possible values id: u32, /// human readable error string intended to be displayed to user message: String, } /// Register product /// /// * `state`: service state. #[utoipa::path( post, path = "/registration", context_path = "/api/software", responses( (status = 204, description = "registration successfull"), (status = 422, description = "Registration failed. Details are in body", body = FailureDetails), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn register( State(state): State<SoftwareState<'_>>, Json(config): Json<RegistrationParams>, ) -> Result<impl IntoResponse, Error> { let (id, message) = state.product.register(&config.key, &config.email).await?; let details = FailureDetails { id, message }; if id == 0 { Ok((StatusCode::NO_CONTENT, ().into_response())) } else { Ok(( StatusCode::UNPROCESSABLE_ENTITY, Json(details).into_response(), )) } } /// Deregister product /// /// * `state`: service state. #[utoipa::path( delete, path = "/registration", context_path = "/api/software", responses( (status = 200, description = "deregistration successfull"), (status = 422, description = "De-registration failed. Details are in body", body = FailureDetails), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn deregister(State(state): State<SoftwareState<'_>>) -> Result<impl IntoResponse, Error> { let (id, message) = state.product.deregister().await?; let details = FailureDetails { id, message }; if id == 0 { Ok((StatusCode::NO_CONTENT, ().into_response())) } else { Ok(( StatusCode::UNPROCESSABLE_ENTITY, Json(details).into_response(), )) } } /// Returns the list of software patterns. /// /// * `state`: service state. #[utoipa::path( get, path = "/patterns", context_path = "/api/software", responses( (status = 200, description = "List of known software patterns", body = Vec<Pattern>), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn patterns(State(state): State<SoftwareState<'_>>) -> Result<Json<Vec<Pattern>>, Error> { let patterns = state.software.patterns(true).await?; Ok(Json(patterns)) } /// Sets the software configuration. /// /// * `state`: service state. /// * `config`: software configuration. #[utoipa::path( put, path = "/config", context_path = "/api/software", operation_id = "set_software_config", responses( (status = 200, description = "Set the software configuration"), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn set_config( State(state): State<SoftwareState<'_>>, Json(config): Json<SoftwareConfig>, ) -> Result<(), Error> { if let Some(product) = config.product { state.product.select_product(&product).await?; } if let Some(patterns) = config.patterns { state.software.select_patterns(patterns).await?; } Ok(()) } /// Returns the software configuration. /// /// * `state` : service state. #[utoipa::path( get, path = "/config", context_path = "/api/software", operation_id = "get_software_config", responses( (status = 200, description = "Software configuration", body = SoftwareConfig), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn get_config(State(state): State<SoftwareState<'_>>) -> Result<Json<SoftwareConfig>, Error> { let product = state.product.product().await?; let product = if product.is_empty() { None } else { Some(product) }; let patterns = state .software .user_selected_patterns() .await? .into_iter() .map(|p| (p, true)) .collect(); let config = SoftwareConfig { patterns: Some(patterns), product, }; Ok(Json(config)) } #[derive(Serialize, utoipa::ToSchema)] /// Software proposal information. pub struct SoftwareProposal { /// Space required for installation. It is returned as a formatted string which includes /// a number and a unit (e.g., "GiB"). size: String, /// Patterns selection. It is respresented as a hash map where the key is the pattern's name /// and the value why the pattern is selected. patterns: HashMap<String, SelectedBy>, } /// Returns the proposal information. /// /// At this point, only the required space is reported. #[utoipa::path( get, path = "/proposal", context_path = "/api/software", responses( (status = 200, description = "Software proposal", body = SoftwareProposal) ) )] async fn proposal(State(state): State<SoftwareState<'_>>) -> Result<Json<SoftwareProposal>, Error> { let size = state.software.used_disk_space().await?; let patterns = state.software.selected_patterns().await?; let proposal = SoftwareProposal { size, patterns }; Ok(Json(proposal)) } /// Returns the proposal information. /// /// At this point, only the required space is reported. #[utoipa::path( post, path = "/probe", context_path = "/api/software", responses( (status = 200, description = "Read repositories data"), (status = 400, description = "The D-Bus service could not perform the action ") ), operation_id = "software_probe" )] async fn probe(State(state): State<SoftwareState<'_>>) -> Result<Json<()>, Error> { state.software.probe().await?; Ok(Json(())) } 070701000000BD000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001F00000000agama/agama-server/src/storage070701000000BE000081A4000000000000000000000001671F5A6400000378000000000000000000000000000000000000002200000000agama/agama-server/src/storage.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. pub mod web; pub use web::{storage_service, storage_streams}; 070701000000BF000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000002300000000agama/agama-server/src/storage/web070701000000C0000081A4000000000000000000000001671F5A64000030E2000000000000000000000000000000000000002600000000agama/agama-server/src/storage/web.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module implements the web API for the storage service. //! //! The module offers two public functions: //! //! * `storage_service` which returns the Axum service. //! * `storage_stream` which offers an stream that emits the storage events coming from D-Bus. use agama_lib::{ error::ServiceError, storage::{ model::{Action, Device, DeviceSid, ProposalSettings, ProposalSettingsPatch, Volume}, proxies::Storage1Proxy, StorageClient, StorageSettings, }, }; use axum::{ extract::{Query, State}, routing::{get, post, put}, Json, Router, }; use serde::{Deserialize, Serialize}; use tokio_stream::{Stream, StreamExt}; use zfcp::{zfcp_service, zfcp_stream}; pub mod dasd; pub mod iscsi; pub mod zfcp; use crate::{ error::Error, storage::web::{ dasd::{dasd_service, dasd_stream}, iscsi::{iscsi_service, iscsi_stream}, }, web::{ common::{ issues_router, jobs_service, progress_router, service_status_router, EventStreams, }, Event, }, }; pub async fn storage_streams(dbus: zbus::Connection) -> Result<EventStreams, Error> { let mut result: EventStreams = vec![( "devices_dirty", Box::pin(devices_dirty_stream(dbus.clone()).await?), )]; let mut iscsi = iscsi_stream(&dbus).await?; let mut dasd = dasd_stream(&dbus).await?; let mut zfcp = zfcp_stream(&dbus).await?; result.append(&mut iscsi); result.append(&mut dasd); result.append(&mut zfcp); Ok(result) } async fn devices_dirty_stream(dbus: zbus::Connection) -> Result<impl Stream<Item = Event>, Error> { let proxy = Storage1Proxy::new(&dbus).await?; let stream = proxy .receive_deprecated_system_changed() .await .then(|change| async move { if let Ok(value) = change.get().await { return Some(Event::DevicesDirty { dirty: value }); } None }) .filter_map(|e| e); Ok(stream) } #[derive(Clone)] struct StorageState<'a> { client: StorageClient<'a>, } /// Sets up and returns the axum service for the storage module. pub async fn storage_service(dbus: zbus::Connection) -> Result<Router, ServiceError> { const DBUS_SERVICE: &str = "org.opensuse.Agama.Storage1"; const DBUS_PATH: &str = "/org/opensuse/Agama/Storage1"; const DBUS_DESTINATION: &str = "org.opensuse.Agama.Storage1"; let status_router = service_status_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; let progress_router = progress_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; let issues_router = issues_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; let iscsi_router = iscsi_service(&dbus).await?; let dasd_router = dasd_service(&dbus).await?; let zfcp_router = zfcp_service(&dbus).await?; let jobs_router = jobs_service(&dbus, DBUS_DESTINATION, DBUS_PATH).await?; let client = StorageClient::new(dbus.clone()).await?; let state = StorageState { client }; let router = Router::new() .route("/config", put(set_config).get(get_config)) .route("/probe", post(probe)) .route("/devices/dirty", get(devices_dirty)) .route("/devices/system", get(system_devices)) .route("/devices/result", get(staging_devices)) .route("/product/volume_for", get(volume_for)) .route("/product/params", get(product_params)) .route("/proposal/actions", get(actions)) .route("/proposal/usable_devices", get(usable_devices)) .route( "/proposal/settings", get(get_proposal_settings).put(set_proposal_settings), ) .merge(progress_router) .merge(status_router) .merge(jobs_router) .nest("/issues", issues_router) .nest("/iscsi", iscsi_router) .nest("/dasd", dasd_router) .nest("/zfcp", zfcp_router) .with_state(state); Ok(router) } /// Returns the storage configuration. /// /// * `state` : service state. #[utoipa::path( get, path = "/config", context_path = "/api/storage", operation_id = "get_storage_config", responses( (status = 200, description = "storage configuration", body = StorageSettings), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn get_config(State(state): State<StorageState<'_>>) -> Result<Json<StorageSettings>, Error> { // StorageSettings is just a wrapper over serde_json::value::RawValue let settings = state.client.get_config().await.map_err(Error::Service)?; Ok(Json(settings)) } /// Sets the storage configuration. /// /// * `state`: service state. /// * `config`: storage configuration. #[utoipa::path( put, path = "/config", context_path = "/api/storage", operation_id = "set_storage_config", responses( (status = 200, description = "Set the storage configuration"), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn set_config( State(state): State<StorageState<'_>>, Json(settings): Json<StorageSettings>, ) -> Result<Json<()>, Error> { let _status: u32 = state .client .set_config(settings) .await .map_err(Error::Service)?; Ok(Json(())) } /// Probes the storage devices. #[utoipa::path( post, path = "/probe", context_path = "/api/storage", responses( (status = 200, description = "Devices were probed and an initial proposal were performed"), (status = 400, description = "The D-Bus service could not perform the action") ), operation_id = "storage_probe" )] async fn probe(State(state): State<StorageState<'_>>) -> Result<Json<()>, Error> { Ok(Json(state.client.probe().await?)) } /// Gets whether the system is in a deprecated status. /// /// The system is usually set as deprecated as effect of managing some kind of devices, for example, /// when iSCSI sessions are created or when a zFCP disk is activated. /// /// A deprecated system means that the probed system could not match with the current system. /// /// It is expected that clients probe devices again if the system is deprecated. #[utoipa::path( get, path = "/devices/dirty", context_path = "/api/storage", responses( (status = 200, description = "Whether the devices have changed", body = bool), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn devices_dirty(State(state): State<StorageState<'_>>) -> Result<Json<bool>, Error> { Ok(Json(state.client.devices_dirty_bit().await?)) } /// Gets the probed devices. #[utoipa::path( get, path = "/devices/system", context_path = "/api/storage", responses( (status = 200, description = "List of devices", body = Vec<Device>), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn system_devices(State(state): State<StorageState<'_>>) -> Result<Json<Vec<Device>>, Error> { Ok(Json(state.client.system_devices().await?)) } /// Gets the resulting devices of applying the requested actions. #[utoipa::path( get, path = "/devices/result", context_path = "/api/storage", responses( (status = 200, description = "List of devices", body = Vec<Device>), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn staging_devices( State(state): State<StorageState<'_>>, ) -> Result<Json<Vec<Device>>, Error> { Ok(Json(state.client.staging_devices().await?)) } /// Gets the default values for a volume with the given mount path. #[utoipa::path( get, path = "/product/volume_for", context_path = "/api/storage", params(VolumeForQuery), responses( (status = 200, description = "Volume specification", body = Volume), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn volume_for( State(state): State<StorageState<'_>>, query: Query<VolumeForQuery>, ) -> Result<Json<Volume>, Error> { Ok(Json( state.client.volume_for(query.mount_path.as_str()).await?, )) } #[derive(Deserialize, utoipa::IntoParams)] struct VolumeForQuery { /// Mount path of the volume (empty for an arbitrary volume). mount_path: String, } /// Gets information about the selected product. #[utoipa::path( get, path = "/product/params", context_path = "/api/storage", responses( (status = 200, description = "Product information", body = ProductParams), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn product_params( State(state): State<StorageState<'_>>, ) -> Result<Json<ProductParams>, Error> { let params = ProductParams { mount_points: state.client.product_mount_points().await?, encryption_methods: state.client.encryption_methods().await?, }; Ok(Json(params)) } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct ProductParams { /// Mount points defined by the product. mount_points: Vec<String>, /// Encryption methods allowed by the product. encryption_methods: Vec<String>, } /// Gets the actions to perform in the storage devices. #[utoipa::path( get, path = "/proposal/actions", context_path = "/api/storage", responses( (status = 200, description = "List of actions", body = Vec<Action>), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn actions(State(state): State<StorageState<'_>>) -> Result<Json<Vec<Action>>, Error> { Ok(Json(state.client.actions().await?)) } /// Gets the SID (Storage ID) of the devices usable for the installation. /// /// Note that not all the existing devices can be selected as target device for the installation. #[utoipa::path( get, path = "/proposal/usable_devices", context_path = "/api/storage", responses( (status = 200, description = "Lis of SIDs", body = Vec<DeviceSid>), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn usable_devices( State(state): State<StorageState<'_>>, ) -> Result<Json<Vec<DeviceSid>>, Error> { let sids = state.client.available_devices().await?; Ok(Json(sids)) } /// Gets the settings that were used for calculating the current proposal. #[utoipa::path( get, path = "/proposal/settings", context_path = "/api/storage", responses( (status = 200, description = "Settings", body = ProposalSettings), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn get_proposal_settings( State(state): State<StorageState<'_>>, ) -> Result<Json<ProposalSettings>, Error> { Ok(Json(state.client.proposal_settings().await?)) } /// Tries to calculates a new proposal with the given settings. #[utoipa::path( put, path = "/proposal/settings", context_path = "/api/storage", request_body(content = ProposalSettingsPatch, description = "Proposal settings", content_type = "application/json"), responses( (status = 200, description = "Whether the proposal was successfully calculated", body = bool), (status = 400, description = "The D-Bus service could not perform the action") ) )] async fn set_proposal_settings( State(state): State<StorageState<'_>>, Json(config): Json<ProposalSettingsPatch>, ) -> Result<Json<bool>, Error> { let result = state.client.calculate(config).await?; Ok(Json(result == 0)) } 070701000000C1000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000002800000000agama/agama-server/src/storage/web/dasd070701000000C2000081A4000000000000000000000001671F5A6400001730000000000000000000000000000000000000002B00000000agama/agama-server/src/storage/web/dasd.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module implements the web API for the handling of DASD storage service. //! //! The module offers two public functions: //! //! * `dasd_service` which returns the Axum service. //! * `dasd_stream` which offers an stream that emits the DASD-related events coming from D-Bus. use agama_lib::{ error::ServiceError, storage::{client::dasd::DASDClient, model::dasd::DASDDevice}, }; use axum::{ extract::State, routing::{get, post, put}, Json, Router, }; use serde::Deserialize; use crate::{error::Error, web::common::EventStreams}; use self::stream::{DASDDeviceStream, DASDFormatJobStream}; mod stream; /// Returns the stream of DASD-related events. /// /// The stream combines the following events: /// /// * Changes on the DASD devices collection. /// /// * `dbus`: D-Bus connection to use. pub async fn dasd_stream(dbus: &zbus::Connection) -> Result<EventStreams, Error> { let stream: EventStreams = vec![ ("dasd_devices", Box::pin(DASDDeviceStream::new(dbus).await?)), ( "format_jobs", Box::pin(DASDFormatJobStream::new(dbus).await?), ), ]; Ok(stream) } #[derive(Clone)] struct DASDState<'a> { client: DASDClient<'a>, } pub async fn dasd_service<T>(dbus: &zbus::Connection) -> Result<Router<T>, ServiceError> { let client = DASDClient::new(dbus.clone()).await?; let state = DASDState { client }; let router = Router::new() .route("/supported", get(supported)) .route("/devices", get(devices)) .route("/probe", post(probe)) .route("/format", post(format)) .route("/enable", post(enable)) .route("/disable", post(disable)) .route("/diag", put(set_diag)) .with_state(state); Ok(router) } /// Returns whether DASD technology is supported or not #[utoipa::path( get, path="/supported", context_path="/api/storage/dasd", responses( (status = OK, description = "Returns whether DASD technology is supported") ) )] async fn supported(State(state): State<DASDState<'_>>) -> Result<Json<bool>, Error> { Ok(Json(state.client.supported().await?)) } /// Returns the list of known DASD devices. #[utoipa::path( get, path="/devices", context_path="/api/storage/dasd", responses( (status = OK, description = "List of DASD devices", body = Vec<DASDDevice>) ) )] async fn devices(State(state): State<DASDState<'_>>) -> Result<Json<Vec<DASDDevice>>, Error> { let devices = state .client .devices() .await? .into_iter() .map(|(_path, device)| device) .collect(); Ok(Json(devices)) } /// Find DASD devices in the system. #[utoipa::path( post, path="/probe", context_path="/api/storage/dasd", responses( (status = OK, description = "The probing process ran successfully") ) )] async fn probe(State(state): State<DASDState<'_>>) -> Result<Json<()>, Error> { Ok(Json(state.client.probe().await?)) } /// Formats a set of devices. #[utoipa::path( post, path="/format", context_path="/api/storage/dasd", responses( (status = OK, description = "The formatting process started. The id of format job is in response.") ) )] async fn format( State(state): State<DASDState<'_>>, Json(devices): Json<DevicesList>, ) -> Result<Json<String>, Error> { let path = state.client.format(&devices.as_references()).await?; Ok(Json(path)) } /// Enables a set of devices. #[utoipa::path( post, path="/enable", context_path="/api/storage/dasd", responses( (status = OK, description = "The DASD devices are enabled.") ) )] async fn enable( State(state): State<DASDState<'_>>, Json(devices): Json<DevicesList>, ) -> Result<Json<()>, Error> { state.client.enable(&devices.as_references()).await?; Ok(Json(())) } /// Disables a set of devices. #[utoipa::path( post, path="/disable", context_path="/api/storage/dasd", responses( (status = OK, description = "The DASD devices are disabled.") ) )] async fn disable( State(state): State<DASDState<'_>>, Json(devices): Json<DevicesList>, ) -> Result<Json<()>, Error> { state.client.disable(&devices.as_references()).await?; Ok(Json(())) } /// Sets the diag property for a set of devices. #[utoipa::path( put, path="/diag", context_path="/api/storage/dasd", responses( (status = OK, description = "The DIAG properties are set.") ) )] async fn set_diag( State(state): State<DASDState<'_>>, Json(params): Json<SetDiagParams>, ) -> Result<Json<()>, Error> { state .client .set_diag(¶ms.devices.as_references(), params.diag) .await?; Ok(Json(())) } #[derive(Deserialize)] struct SetDiagParams { #[serde(flatten)] pub devices: DevicesList, pub diag: bool, } #[derive(Deserialize)] struct DevicesList { devices: Vec<String>, } impl DevicesList { pub fn as_references(&self) -> Vec<&str> { self.devices.iter().map(AsRef::as_ref).collect() } } 070701000000C3000081A4000000000000000000000001671F5A6400002590000000000000000000000000000000000000003200000000agama/agama-server/src/storage/web/dasd/stream.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. // FIXME: the code is pretty similar to iscsi::stream. Refactor the stream to reduce the repetition. use std::{collections::HashMap, sync::Arc, task::Poll}; use agama_lib::{ dbus::get_optional_property, error::ServiceError, property_from_dbus, storage::{ client::dasd::DASDClient, model::dasd::{DASDDevice, DASDFormatSummary}, }, }; use futures_util::{ready, Stream}; use pin_project::pin_project; use thiserror::Error; use tokio::sync::mpsc::unbounded_channel; use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt}; use zbus::{ fdo::{PropertiesChanged, PropertiesChangedArgs}, zvariant::{self, ObjectPath, OwnedObjectPath, OwnedValue}, MatchRule, Message, MessageStream, MessageType, }; use crate::{ dbus::{DBusObjectChange, DBusObjectChangesStream, ObjectsCache}, web::Event, }; #[derive(Debug, Error)] enum DASDDeviceStreamError { #[error("Service error: {0}")] Service(#[from] ServiceError), #[error("Unknown DASD device: {0}")] UnknownDevice(OwnedObjectPath), } /// This stream listens for changes in the collection of DASD devices and emits /// the updated objects. /// /// It relies on the [DBusObjectChangesStream] stream and uses a cache to avoid holding a bunch of /// proxy objects. #[pin_project] pub struct DASDDeviceStream { dbus: zbus::Connection, cache: ObjectsCache<DASDDevice>, #[pin] inner: UnboundedReceiverStream<DBusObjectChange>, } impl DASDDeviceStream { /// Creates a new stream /// /// * `dbus`: D-Bus connection to listen on. pub async fn new(dbus: &zbus::Connection) -> Result<Self, ServiceError> { const MANAGER_PATH: &str = "/org/opensuse/Agama/Storage1"; const NAMESPACE: &str = "/org/opensuse/Agama/Storage1/dasds"; let (tx, rx) = unbounded_channel(); let mut stream = DBusObjectChangesStream::new( dbus, &ObjectPath::from_str_unchecked(MANAGER_PATH), &ObjectPath::from_str_unchecked(NAMESPACE), "org.opensuse.Agama.Storage1.DASD.Device", ) .await?; tokio::spawn(async move { while let Some(change) = stream.next().await { let _ = tx.send(change); } }); let rx = UnboundedReceiverStream::new(rx); let mut cache: ObjectsCache<DASDDevice> = Default::default(); let client = DASDClient::new(dbus.clone()).await?; for (path, device) in client.devices().await? { cache.add(path, device); } Ok(Self { dbus: dbus.clone(), cache, inner: rx, }) } fn update_device<'a>( cache: &'a mut ObjectsCache<DASDDevice>, path: &OwnedObjectPath, values: &HashMap<String, OwnedValue>, ) -> Result<&'a DASDDevice, ServiceError> { let device = cache.find_or_create(path); property_from_dbus!(device, id, "Id", values, str); property_from_dbus!(device, enabled, "Enabled", values, bool); property_from_dbus!(device, device_name, "DeviceName", values, str); property_from_dbus!(device, formatted, "Formatted", values, bool); property_from_dbus!(device, diag, "Diag", values, bool); property_from_dbus!(device, status, "Status", values, str); property_from_dbus!(device, device_type, "Type", values, str); property_from_dbus!(device, access_type, "AccessType", values, str); property_from_dbus!(device, partition_info, "PartitionInfo", values, str); Ok(device) } fn remove_device( cache: &mut ObjectsCache<DASDDevice>, path: &OwnedObjectPath, ) -> Result<DASDDevice, DASDDeviceStreamError> { cache .remove(path) .ok_or_else(|| DASDDeviceStreamError::UnknownDevice(path.clone())) } fn handle_change( cache: &mut ObjectsCache<DASDDevice>, change: &DBusObjectChange, ) -> Result<Event, DASDDeviceStreamError> { match change { DBusObjectChange::Added(path, values) => { let device = Self::update_device(cache, path, values)?; Ok(Event::DASDDeviceAdded { device: device.clone(), }) } DBusObjectChange::Changed(path, updated) => { let device = Self::update_device(cache, path, updated)?; Ok(Event::DASDDeviceChanged { device: device.clone(), }) } DBusObjectChange::Removed(path) => { let device = Self::remove_device(cache, path)?; Ok(Event::DASDDeviceRemoved { device }) } } } } impl Stream for DASDDeviceStream { type Item = Event; fn poll_next( self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll<Option<Self::Item>> { let mut pinned = self.project(); Poll::Ready(loop { let change = ready!(pinned.inner.as_mut().poll_next(cx)); let next_value = match change { Some(change) => { if let Ok(event) = Self::handle_change(pinned.cache, &change) { Some(event) } else { log::warn!("Could not process change {:?}", &change); None } } None => break None, }; if next_value.is_some() { break next_value; } }) } } /// This stream listens for DASD progress changes and emits an [Event::DASDFormatJobChanged] event. #[pin_project] pub struct DASDFormatJobStream { #[pin] inner: MessageStream, } impl DASDFormatJobStream { pub async fn new(connection: &zbus::Connection) -> Result<Self, ServiceError> { let rule = MatchRule::builder() .msg_type(MessageType::Signal) .path_namespace("/org/opensuse/Agama/Storage1/jobs")? .interface("org.freedesktop.DBus.Properties")? .member("PropertiesChanged")? .build(); let inner = MessageStream::for_match_rule(rule, connection, None).await?; Ok(Self { inner }) } fn handle_change(message: Result<Arc<Message>, zbus::Error>) -> Option<Event> { let Ok(message) = message else { return None; }; let properties = PropertiesChanged::from_message(message)?; let args = properties.args().ok()?; if args.interface_name.as_str() != "org.opensuse.Agama.Storage1.DASD.Format" { return None; } let id = properties.path()?.to_string(); let event = Self::to_event(id, &args); if event.is_none() { log::warn!("Could not decode the DASDFormatJobChanged event"); } event } fn to_event(path: String, properties_changed: &PropertiesChangedArgs) -> Option<Event> { let dict = properties_changed .changed_properties() .get("Summary")? .downcast_ref::<zvariant::Dict>()?; // the key is the D-Bus path of the DASD device and the value is the progress // of the related formatting process let map = <HashMap<String, zvariant::Value<'_>>>::try_from(dict.clone()).ok()?; let mut format_summary = HashMap::new(); for (dasd_id, summary) in map { let summary_values = summary.downcast_ref::<zvariant::Structure>()?; let fields = summary_values.fields(); let total: &u32 = fields.get(0)?.downcast_ref()?; let step: &u32 = fields.get(1)?.downcast_ref()?; let done: &bool = fields.get(2)?.downcast_ref()?; format_summary.insert( dasd_id.to_string(), DASDFormatSummary { total: *total, step: *step, done: *done, }, ); } Some(Event::DASDFormatJobChanged { job_id: path.to_string(), summary: format_summary, }) } } impl Stream for DASDFormatJobStream { type Item = Event; fn poll_next( self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll<Option<Self::Item>> { let mut pinned = self.project(); Poll::Ready(loop { let item = ready!(pinned.inner.as_mut().poll_next(cx)); let next_value = match item { Some(change) => Self::handle_change(change), None => break None, }; if next_value.is_some() { break next_value; } }) } } 070701000000C4000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000002900000000agama/agama-server/src/storage/web/iscsi070701000000C5000081A4000000000000000000000001671F5A640000283C000000000000000000000000000000000000002C00000000agama/agama-server/src/storage/web/iscsi.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module implements the web API for the iSCSI handling of the storage service. //! //! The module offers two public functions: //! //! * `iscsi_service` which returns the Axum service. //! * `iscsi_stream` which offers an stream that emits the iSCSI-related events coming from D-Bus. use crate::{ error::Error, web::{common::EventStreams, Event}, }; use agama_lib::{ dbus::{get_optional_property, to_owned_hash}, error::ServiceError, storage::{ client::iscsi::{ISCSIAuth, ISCSIInitiator, ISCSINode, LoginResult}, ISCSIClient, }, }; use axum::{ extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::{delete, get, post}, Json, Router, }; use serde::Deserialize; mod stream; use stream::ISCSINodeStream; use tokio_stream::{Stream, StreamExt}; use zbus::{ fdo::{PropertiesChanged, PropertiesProxy}, names::InterfaceName, }; /// Returns the stream of iSCSI-related events. /// /// The stream combines the following events: /// /// * Changes on the iSCSI nodes collection. /// * Changes to the initiator (name or ibft). /// /// * `dbus`: D-Bus connection to use. pub async fn iscsi_stream(dbus: &zbus::Connection) -> Result<EventStreams, Error> { let stream: EventStreams = vec![ ("iscsi_nodes", Box::pin(ISCSINodeStream::new(dbus).await?)), ("initiator", Box::pin(initiator_stream(dbus).await?)), ]; Ok(stream) } async fn initiator_stream( dbus: &zbus::Connection, ) -> Result<impl Stream<Item = Event> + Send, Error> { let proxy = PropertiesProxy::builder(dbus) .destination("org.opensuse.Agama.Storage1")? .path("/org/opensuse/Agama/Storage1")? .build() .await?; let stream = proxy .receive_properties_changed() .await? .filter_map(|change| match handle_initiator_change(change) { Ok(event) => event, Err(error) => { log::warn!("Could not read the initiator change: {}", error); None } }); Ok(stream) } fn handle_initiator_change(change: PropertiesChanged) -> Result<Option<Event>, ServiceError> { let args = change.args()?; let iscsi_iface = InterfaceName::from_str_unchecked("org.opensuse.Agama.Storage1.ISCSI.Initiator"); if iscsi_iface != args.interface_name { return Ok(None); } let changes = to_owned_hash(args.changed_properties()); let name = get_optional_property(&changes, "InitiatorName")?; let ibft = get_optional_property(&changes, "IBFT")?; Ok(Some(Event::ISCSIInitiatorChanged { ibft, name })) } #[derive(Clone)] struct ISCSIState<'a> { client: ISCSIClient<'a>, } /// Sets up and returns the Axum service for the iSCSI part of the storage module. /// /// It acts as a proxy to Agama D-Bus service. /// /// * `dbus`: D-Bus connection to use. pub async fn iscsi_service<T>(dbus: &zbus::Connection) -> Result<Router<T>, ServiceError> { let client = ISCSIClient::new(dbus.clone()).await?; let state = ISCSIState { client }; let router = Router::new() .route("/initiator", get(initiator).patch(update_initiator)) .route("/nodes", get(nodes)) .route("/nodes/:id", delete(delete_node).patch(update_node)) .route("/nodes/:id/login", post(login_node)) .route("/nodes/:id/logout", post(logout_node)) .route("/discover", post(discover)) .with_state(state); Ok(router) } /// Returns the iSCSI initiator properties. /// /// The iSCSI properties include the name and whether iBFT is enabled. #[utoipa::path( get, path="/initiator", context_path="/api/storage/iscsi", responses( (status = OK, description = "iSCSI initiator properties.", body = ISCSIInitiator), (status = BAD_REQUEST, description = "It could not read the iSCSI initiator properties."), ) )] async fn initiator(State(state): State<ISCSIState<'_>>) -> Result<Json<ISCSIInitiator>, Error> { let initiator = state.client.get_initiator().await?; Ok(Json(initiator)) } #[derive(Deserialize, utoipa::ToSchema)] pub struct InitiatorParams { /// iSCSI initiator name. name: String, } /// Updates the iSCSI initiator properties. #[utoipa::path( patch, path="/initiator", context_path="/api/storage/iscsi", responses( (status = NO_CONTENT, description = "The iSCSI initiator properties were succesfully updated."), (status = BAD_REQUEST, description = "It could not update the iSCSI initiator properties."), ) )] async fn update_initiator( State(state): State<ISCSIState<'_>>, Json(params): Json<InitiatorParams>, ) -> Result<impl IntoResponse, Error> { state.client.set_initiator_name(¶ms.name).await?; Ok(StatusCode::NO_CONTENT) } /// Returns the list of known iSCSI nodes. #[utoipa::path( get, path="/nodes", context_path="/api/storage/iscsi", responses( (status = OK, description = "List of iSCSI nodes.", body = Vec<ISCSINode>), (status = BAD_REQUEST, description = "It was not possible to get the list of iSCSI nodes."), ) )] async fn nodes(State(state): State<ISCSIState<'_>>) -> Result<Json<Vec<ISCSINode>>, Error> { let nodes = state.client.get_nodes().await?; Ok(Json(nodes)) } #[derive(Deserialize, utoipa::ToSchema)] pub struct NodeParams { /// Startup value. startup: String, } /// Updates iSCSI node properties. /// /// At this point, only the startup option can be changed. #[utoipa::path( put, path="/nodes/{id}", context_path="/api/storage/iscsi", params( ("id" = u32, Path, description = "iSCSI artificial ID.") ), responses( (status = NO_CONTENT, description = "The iSCSI node was updated.", body = NodeParams), (status = BAD_REQUEST, description = "Could not update the iSCSI node."), ) )] async fn update_node( State(state): State<ISCSIState<'_>>, Path(id): Path<u32>, Json(params): Json<NodeParams>, ) -> Result<impl IntoResponse, Error> { state.client.set_startup(id, ¶ms.startup).await?; Ok(StatusCode::NO_CONTENT) } /// Deletes the iSCSI node. #[utoipa::path( delete, path="/nodes/{id}", context_path="/api/storage/iscsi", params( ("id" = u32, Path, description = "iSCSI artificial ID.") ), responses( (status = NO_CONTENT, description = "The iSCSI node was deleted."), (status = BAD_REQUEST, description = "Could not delete the iSCSI node."), ) )] async fn delete_node( State(state): State<ISCSIState<'_>>, Path(id): Path<u32>, ) -> Result<impl IntoResponse, Error> { state.client.delete_node(id).await?; Ok(StatusCode::NO_CONTENT) } #[derive(Deserialize, utoipa::ToSchema)] pub struct LoginParams { /// Authentication options. #[serde(flatten)] auth: ISCSIAuth, /// Startup value. startup: String, } #[utoipa::path( post, path="/nodes/{id}/login", context_path="/api/storage/iscsi", params( ("id" = u32, Path, description = "iSCSI artificial ID.") ), responses( (status = NO_CONTENT, description = "The login request was successful."), (status = BAD_REQUEST, description = "Could not reach the iSCSI server."), (status = UNPROCESSABLE_ENTITY, description = "The login request failed.", body = LoginResult), ) )] async fn login_node( State(state): State<ISCSIState<'_>>, Path(id): Path<u32>, Json(params): Json<LoginParams>, ) -> Result<impl IntoResponse, Error> { let result = state.client.login(id, params.auth, params.startup).await?; match result { LoginResult::Success => Ok((StatusCode::NO_CONTENT, ().into_response())), error => Ok(( StatusCode::UNPROCESSABLE_ENTITY, Json(error).into_response(), )), } } #[utoipa::path( post, path="/nodes/{id}/logout", context_path="/api/storage/iscsi", params( ("id" = u32, Path, description = "iSCSI artificial ID.") ), responses( (status = 204, description = "The logout request was successful."), (status = 400, description = "Could not reach the iSCSI server."), (status = 422, description = "The logout request failed."), ) )] async fn logout_node( State(state): State<ISCSIState<'_>>, Path(id): Path<u32>, ) -> Result<impl IntoResponse, Error> { if state.client.logout(id).await? { Ok(StatusCode::NO_CONTENT) } else { Ok(StatusCode::UNPROCESSABLE_ENTITY) } } #[derive(Deserialize, utoipa::ToSchema)] pub struct DiscoverParams { /// iSCSI server address. address: String, /// iSCSI service port. port: u32, /// Authentication options. #[serde(default)] options: ISCSIAuth, } /// Performs an iSCSI discovery. #[utoipa::path( post, path="/discover", context_path="/api/storage/iscsi", responses( (status = 204, description = "The iSCSI discovery request was successful."), (status = 400, description = "The iSCSI discovery request failed."), ) )] async fn discover( State(state): State<ISCSIState<'_>>, Json(params): Json<DiscoverParams>, ) -> Result<impl IntoResponse, Error> { let result = state .client .discover(¶ms.address, params.port, params.options) .await?; if result { Ok(StatusCode::NO_CONTENT) } else { Ok(StatusCode::BAD_REQUEST) } } 070701000000C6000081A4000000000000000000000001671F5A6400001770000000000000000000000000000000000000003300000000agama/agama-server/src/storage/web/iscsi/stream.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use std::{collections::HashMap, task::Poll}; use agama_lib::{ dbus::{extract_id_from_path, get_optional_property}, error::ServiceError, property_from_dbus, storage::{ISCSIClient, ISCSINode}, }; use futures_util::{ready, Stream}; use pin_project::pin_project; use thiserror::Error; use tokio::sync::mpsc::unbounded_channel; use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt}; use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue}; use crate::{ dbus::{DBusObjectChange, DBusObjectChangesStream, ObjectsCache}, web::Event, }; /// This stream listens for changes in the collection ISCSI nodes and emits /// the updated objects. /// /// It relies on the [DBusObjectChangesStream] stream and uses a cache to avoid holding a bunch of /// proxy objects. #[pin_project] pub struct ISCSINodeStream { dbus: zbus::Connection, cache: ObjectsCache<ISCSINode>, #[pin] inner: UnboundedReceiverStream<DBusObjectChange>, } /// Internal stream error #[derive(Debug, Error)] enum ISCSINodeStreamError { #[error("Service error: {0}")] Service(#[from] ServiceError), #[error("Unknown ISCSI node: {0}")] UnknownNode(OwnedObjectPath), } impl ISCSINodeStream { /// Creates a new stream. /// /// * `dbus`: D-Bus connection to listen on. pub async fn new(dbus: &zbus::Connection) -> Result<Self, ServiceError> { const MANAGER_PATH: &str = "/org/opensuse/Agama/Storage1"; const NAMESPACE: &str = "/org/opensuse/Agama/Storage1/iscsi_nodes"; let (tx, rx) = unbounded_channel(); let mut stream = DBusObjectChangesStream::new( dbus, &ObjectPath::from_str_unchecked(MANAGER_PATH), &ObjectPath::from_str_unchecked(NAMESPACE), "org.opensuse.Agama.Storage1.ISCSI.Node", ) .await?; tokio::spawn(async move { while let Some(change) = stream.next().await { let _ = tx.send(change); } }); let rx = UnboundedReceiverStream::new(rx); // Populate the objects cache let mut cache: ObjectsCache<ISCSINode> = Default::default(); let client = ISCSIClient::new(dbus.clone()).await?; for node in client.get_nodes().await? { let path = ObjectPath::from_string_unchecked(format!("{}/{}", NAMESPACE, node.id)); cache.add(path.into(), node); } Ok(Self { dbus: dbus.clone(), cache, inner: rx, }) } fn update_node<'a>( cache: &'a mut ObjectsCache<ISCSINode>, path: &OwnedObjectPath, values: &HashMap<String, OwnedValue>, ) -> Result<&'a ISCSINode, ServiceError> { let node = cache.find_or_create(path); node.id = extract_id_from_path(path)?; property_from_dbus!(node, target, "Target", values, str); property_from_dbus!(node, address, "Address", values, str); property_from_dbus!(node, interface, "Interface", values, str); property_from_dbus!(node, startup, "Startup", values, str); property_from_dbus!(node, port, "Port", values, u32); property_from_dbus!(node, connected, "Connected", values, bool); Ok(node) } fn remove_node( cache: &mut ObjectsCache<ISCSINode>, path: &OwnedObjectPath, ) -> Result<ISCSINode, ISCSINodeStreamError> { cache .remove(path) .ok_or_else(|| ISCSINodeStreamError::UnknownNode(path.clone())) } fn handle_change( cache: &mut ObjectsCache<ISCSINode>, change: &DBusObjectChange, ) -> Result<Event, ISCSINodeStreamError> { match change { DBusObjectChange::Added(path, values) => { let node = Self::update_node(cache, path, values)?; Ok(Event::ISCSINodeAdded { node: node.clone() }) } DBusObjectChange::Changed(path, updated) => { let node = Self::update_node(cache, path, updated)?; Ok(Event::ISCSINodeChanged { node: node.clone() }) } DBusObjectChange::Removed(path) => { let node = Self::remove_node(cache, path)?; Ok(Event::ISCSINodeRemoved { node }) } } } } impl Stream for ISCSINodeStream { type Item = Event; fn poll_next( self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll<Option<Self::Item>> { let mut pinned = self.project(); Poll::Ready(loop { let change = ready!(pinned.inner.as_mut().poll_next(cx)); let next_value = match change { Some(change) => { if let Ok(event) = Self::handle_change(pinned.cache, &change) { Some(event) } else { log::warn!("Could not process change {:?}", &change); None } } None => break None, }; if next_value.is_some() { break next_value; } }) } } 070701000000C7000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000002800000000agama/agama-server/src/storage/web/zfcp070701000000C8000081A4000000000000000000000001671F5A6400002097000000000000000000000000000000000000002B00000000agama/agama-server/src/storage/web/zfcp.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module implements the web API for the handling of zFCP storage service. //! //! The module offers two public functions: //! //! * `zfcp_service` which returns the Axum service. //! * `zfcp_stream` which offers an stream that emits the zFCP-related events coming from D-Bus. use agama_lib::{ error::ServiceError, storage::{ model::zfcp::{ZFCPController, ZFCPDisk}, ZFCPClient, }, }; use axum::{ extract::{Path, State}, routing::{get, post}, Json, Router, }; use serde::Serialize; use stream::{ZFCPControllerStream, ZFCPDiskStream}; mod stream; use crate::{error::Error, web::common::EventStreams}; /// Returns the stream of zFCP-related events. /// /// The stream combines the following events: /// /// * Changes on the zFCP devices collection. /// /// * `dbus`: D-Bus connection to use. pub async fn zfcp_stream(dbus: &zbus::Connection) -> Result<EventStreams, Error> { let stream: EventStreams = vec![ ("zfcp_disks", Box::pin(ZFCPDiskStream::new(dbus).await?)), ( "zfcp_controllers", Box::pin(ZFCPControllerStream::new(dbus).await?), ), ]; Ok(stream) } #[derive(Clone)] struct ZFCPState<'a> { client: ZFCPClient<'a>, } pub async fn zfcp_service<T>(dbus: &zbus::Connection) -> Result<Router<T>, ServiceError> { let client = ZFCPClient::new(dbus.clone()).await?; let state = ZFCPState { client }; let router = Router::new() .route("/supported", get(supported)) .route("/controllers", get(controllers)) .route( "/controllers/:controller_id/activate", post(activate_controller), ) .route("/controllers/:controller_id/wwpns", get(get_wwpns)) .route( "/controllers/:controller_id/wwpns/:wwpn_id/luns", get(get_luns), ) .route( "/controllers/:controller_id/wwpns/:wwpn_id/luns/:lun_id/activate_disk", post(activate_disk), ) .route( "/controllers/:controller_id/wwpns/:wwpn_id/luns/:lun_id/deactivate_disk", post(deactivate_disk), ) .route("/disks", get(get_disks)) .route("/probe", post(probe)) .route("/config", get(get_config)) .with_state(state); Ok(router) } /// Returns whether zFCP technology is supported or not #[utoipa::path( get, path="/supported", context_path="/api/storage/zfcp", responses( (status = OK, description = "Returns whether zFCP technology is supported") ) )] async fn supported(State(state): State<ZFCPState<'_>>) -> Result<Json<bool>, Error> { Ok(Json(state.client.supported().await?)) } /// Represents a zFCP global config (specific to s390x systems). #[derive(Clone, Debug, Default, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct ZFCPConfig { /// flag whenever allow_lun_scan is active pub allow_lun_scan: bool, } /// Returns global zFCP configuration #[utoipa::path( get, path="/config", context_path="/api/storage/zfcp", responses( (status = OK, description = "Returns global zFCP configuration", body=ZFCPConfig) ) )] async fn get_config(State(state): State<ZFCPState<'_>>) -> Result<Json<ZFCPConfig>, Error> { let lun_scan = state.client.is_lun_scan_allowed().await?; Ok(Json(ZFCPConfig { allow_lun_scan: lun_scan, })) } /// Returns the list of known zFCP disks. #[utoipa::path( get, path="/disks", context_path="/api/storage/zfcp", responses( (status = OK, description = "List of ZFCP disks", body = Vec<ZFCPDisk>) ) )] async fn get_disks(State(state): State<ZFCPState<'_>>) -> Result<Json<Vec<ZFCPDisk>>, Error> { let devices = state .client .get_disks() .await? .into_iter() .map(|(_path, device)| device) .collect(); Ok(Json(devices)) } /// Returns the list of known zFCP controllers. #[utoipa::path( get, path="/controllers", context_path="/api/storage/zfcp", responses( (status = OK, description = "List of zFCP controllers", body = Vec<ZFCPController>) ) )] async fn controllers( State(state): State<ZFCPState<'_>>, ) -> Result<Json<Vec<ZFCPController>>, Error> { let devices = state .client .get_controllers() .await? .into_iter() .map(|(_path, device)| device) .collect(); Ok(Json(devices)) } /// Activate given zFCP controller. #[utoipa::path( post, path="/controllers/:controller_id/activate", context_path="/api/storage/zfcp", responses( (status = OK, description = "controller activated") ) )] async fn activate_controller( State(state): State<ZFCPState<'_>>, Path(controller_id): Path<String>, ) -> Result<Json<()>, Error> { state .client .activate_controller(controller_id.as_str()) .await?; Ok(Json(())) } /// List WWPNs for given controller. #[utoipa::path( post, path="/controllers/:controller_id/wwpns", context_path="/api/storage/zfcp", responses( (status = OK, description = "List of WWPNs", body=Vec<String>) ) )] async fn get_wwpns( State(state): State<ZFCPState<'_>>, Path(controller_id): Path<String>, ) -> Result<Json<Vec<String>>, Error> { let result = state.client.get_wwpns(controller_id.as_str()).await?; Ok(Json(result)) } /// List LUNS for given controller and wwpn. #[utoipa::path( post, path="/controllers/:controller_id/wwpns/:wwpn_id/luns", context_path="/api/storage/zfcp", responses( (status = OK, description = "list of luns", body=Vec<String>) ) )] async fn get_luns( State(state): State<ZFCPState<'_>>, Path((controller_id, wwpn_id)): Path<(String, String)>, ) -> Result<Json<Vec<String>>, Error> { let result = state.client.get_luns(&controller_id, &wwpn_id).await?; Ok(Json(result)) } /// Activates a disk on given controller with given WWPN id and LUN id. #[utoipa::path( post, path="/controllers/:controller_id/wwpns/:wwpn_id/luns/:lun_id/activate_disk", context_path="/api/storage/zfcp", responses( (status = OK, description = "The activation was succesful.") ) )] async fn activate_disk( State(state): State<ZFCPState<'_>>, Path((controller_id, wwpn_id, lun_id)): Path<(String, String, String)>, ) -> Result<Json<()>, Error> { state .client .activate_disk(&controller_id, &wwpn_id, &lun_id) .await?; Ok(Json(())) } /// Deactivates disk on given controller with given WWPN id and LUN id. #[utoipa::path( post, path="/controllers/:controller_id/wwpns/:wwpn_id/luns/:lun_id/deactivate_disk", context_path="/api/storage/zfcp", responses( (status = OK, description = "The activation was succesful.") ) )] async fn deactivate_disk( State(state): State<ZFCPState<'_>>, Path((controller_id, wwpn_id, lun_id)): Path<(String, String, String)>, ) -> Result<Json<()>, Error> { state .client .deactivate_disk(&controller_id, &wwpn_id, &lun_id) .await?; Ok(Json(())) } /// Find zFCP devices in the system. #[utoipa::path( post, path="/probe", context_path="/api/storage/zfcp", responses( (status = OK, description = "The probing process ran successfully") ) )] async fn probe(State(state): State<ZFCPState<'_>>) -> Result<Json<()>, Error> { Ok(Json(state.client.probe().await?)) } 070701000000C9000081A4000000000000000000000001671F5A640000285D000000000000000000000000000000000000003200000000agama/agama-server/src/storage/web/zfcp/stream.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. // FIXME: the code is pretty similar to iscsi::stream and dasd::stream. Refactor the stream to reduce the repetition. use std::{collections::HashMap, task::Poll}; use agama_lib::{ dbus::get_optional_property, error::ServiceError, property_from_dbus, storage::{ client::zfcp::ZFCPClient, model::zfcp::{ZFCPController, ZFCPDisk}, }, }; use futures_util::{ready, Stream}; use pin_project::pin_project; use thiserror::Error; use tokio::sync::mpsc::unbounded_channel; use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt}; use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue}; use crate::{ dbus::{DBusObjectChange, DBusObjectChangesStream, ObjectsCache}, web::Event, }; #[derive(Debug, Error)] enum ZFCPDiskStreamError { #[error("Service error: {0}")] Service(#[from] ServiceError), #[error("Unknown ZFCP disk: {0}")] UnknownDevice(OwnedObjectPath), } /// This stream listens for changes in the collection of zFCP disks and emits /// the updated objects. /// /// It relies on the [DBusObjectChangesStream] stream and uses a cache to avoid holding a bunch of /// proxy objects. #[pin_project] pub struct ZFCPDiskStream { dbus: zbus::Connection, cache: ObjectsCache<ZFCPDisk>, #[pin] inner: UnboundedReceiverStream<DBusObjectChange>, } impl ZFCPDiskStream { /// Creates a new stream /// /// * `dbus`: D-Bus connection to listen on. pub async fn new(dbus: &zbus::Connection) -> Result<Self, ServiceError> { const MANAGER_PATH: &str = "/org/opensuse/Agama/Storage1"; const NAMESPACE: &str = "/org/opensuse/Agama/Storage1/zfcp_disks"; let (tx, rx) = unbounded_channel(); let mut stream = DBusObjectChangesStream::new( dbus, &ObjectPath::from_str_unchecked(MANAGER_PATH), &ObjectPath::from_str_unchecked(NAMESPACE), "org.opensuse.Agama.Storage1.ZFCP.Disk", ) .await?; tokio::spawn(async move { while let Some(change) = stream.next().await { let _ = tx.send(change); } }); let rx = UnboundedReceiverStream::new(rx); let mut cache: ObjectsCache<ZFCPDisk> = Default::default(); let client = ZFCPClient::new(dbus.clone()).await?; for (path, device) in client.get_disks().await? { cache.add(path.into(), device); } Ok(Self { dbus: dbus.clone(), cache, inner: rx, }) } fn update_device<'a>( cache: &'a mut ObjectsCache<ZFCPDisk>, path: &OwnedObjectPath, values: &HashMap<String, OwnedValue>, ) -> Result<&'a ZFCPDisk, ServiceError> { let device = cache.find_or_create(path); property_from_dbus!(device, name, "Name", values, str); property_from_dbus!(device, channel, "Channel", values, str); property_from_dbus!(device, wwpn, "WWPN", values, str); property_from_dbus!(device, lun, "LUN", values, str); Ok(device) } fn remove_device( cache: &mut ObjectsCache<ZFCPDisk>, path: &OwnedObjectPath, ) -> Result<ZFCPDisk, ZFCPDiskStreamError> { cache .remove(path) .ok_or_else(|| ZFCPDiskStreamError::UnknownDevice(path.clone())) } fn handle_change( cache: &mut ObjectsCache<ZFCPDisk>, change: &DBusObjectChange, ) -> Result<Event, ZFCPDiskStreamError> { match change { DBusObjectChange::Added(path, values) => { let device = Self::update_device(cache, path, values)?; Ok(Event::ZFCPDiskAdded { device: device.clone(), }) } DBusObjectChange::Changed(path, updated) => { let device = Self::update_device(cache, path, updated)?; Ok(Event::ZFCPDiskChanged { device: device.clone(), }) } DBusObjectChange::Removed(path) => { let device = Self::remove_device(cache, path)?; Ok(Event::ZFCPDiskRemoved { device }) } } } } impl Stream for ZFCPDiskStream { type Item = Event; fn poll_next( self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll<Option<Self::Item>> { let mut pinned = self.project(); Poll::Ready(loop { let change = ready!(pinned.inner.as_mut().poll_next(cx)); let next_value = match change { Some(change) => { if let Ok(event) = Self::handle_change(pinned.cache, &change) { Some(event) } else { log::warn!("Could not process change {:?}", &change); None } } None => break None, }; if next_value.is_some() { break next_value; } }) } } #[derive(Debug, Error)] enum ZFCPControllerStreamError { #[error("Service error: {0}")] Service(#[from] ServiceError), #[error("Unknown ZFCP controller: {0}")] UnknownDevice(OwnedObjectPath), } /// This stream listens for changes in the collection of zFCP controllers and emits /// the updated objects. /// /// It relies on the [DBusObjectChangesStream] stream and uses a cache to avoid holding a bunch of /// proxy objects. #[pin_project] pub struct ZFCPControllerStream { dbus: zbus::Connection, cache: ObjectsCache<ZFCPController>, #[pin] inner: UnboundedReceiverStream<DBusObjectChange>, } impl ZFCPControllerStream { /// Creates a new stream /// /// * `dbus`: D-Bus connection to listen on. pub async fn new(dbus: &zbus::Connection) -> Result<Self, ServiceError> { const MANAGER_PATH: &str = "/org/opensuse/Agama/Storage1"; const NAMESPACE: &str = "/org/opensuse/Agama/Storage1/zfcp_controllers"; let (tx, rx) = unbounded_channel(); let mut stream = DBusObjectChangesStream::new( dbus, &ObjectPath::from_str_unchecked(MANAGER_PATH), &ObjectPath::from_str_unchecked(NAMESPACE), "org.opensuse.Agama.Storage1.ZFCP.Controller", ) .await?; tokio::spawn(async move { while let Some(change) = stream.next().await { let _ = tx.send(change); } }); let rx = UnboundedReceiverStream::new(rx); let mut cache: ObjectsCache<ZFCPController> = Default::default(); let client = ZFCPClient::new(dbus.clone()).await?; for (path, device) in client.get_controllers().await? { cache.add(path.into(), device); } Ok(Self { dbus: dbus.clone(), cache, inner: rx, }) } fn update_device<'a>( cache: &'a mut ObjectsCache<ZFCPController>, path: &OwnedObjectPath, values: &HashMap<String, OwnedValue>, ) -> Result<&'a ZFCPController, ServiceError> { let device = cache.find_or_create(path); property_from_dbus!(device, channel, "Channel", values, str); property_from_dbus!(device, lun_scan, "LUNScan", values, bool); property_from_dbus!(device, active, "Active", values, bool); Ok(device) } fn remove_device( cache: &mut ObjectsCache<ZFCPController>, path: &OwnedObjectPath, ) -> Result<ZFCPController, ZFCPControllerStreamError> { cache .remove(path) .ok_or_else(|| ZFCPControllerStreamError::UnknownDevice(path.clone())) } fn handle_change( cache: &mut ObjectsCache<ZFCPController>, change: &DBusObjectChange, ) -> Result<Event, ZFCPControllerStreamError> { match change { DBusObjectChange::Added(path, values) => { let device = Self::update_device(cache, path, values)?; Ok(Event::ZFCPControllerAdded { device: device.clone(), }) } DBusObjectChange::Changed(path, updated) => { let device = Self::update_device(cache, path, updated)?; Ok(Event::ZFCPControllerChanged { device: device.clone(), }) } DBusObjectChange::Removed(path) => { let device = Self::remove_device(cache, path)?; Ok(Event::ZFCPControllerRemoved { device }) } } } } impl Stream for ZFCPControllerStream { type Item = Event; fn poll_next( self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll<Option<Self::Item>> { let mut pinned = self.project(); Poll::Ready(loop { let change = ready!(pinned.inner.as_mut().poll_next(cx)); let next_value = match change { Some(change) => { if let Ok(event) = Self::handle_change(pinned.cache, &change) { Some(event) } else { log::warn!("Could not process change {:?}", &change); None } } None => break None, }; if next_value.is_some() { break next_value; } }) } } 070701000000CA000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001D00000000agama/agama-server/src/users070701000000CB000081A4000000000000000000000001671F5A6400000374000000000000000000000000000000000000002000000000agama/agama-server/src/users.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. pub mod web; pub use web::{users_service, users_streams}; 070701000000CC000081A4000000000000000000000001671F5A6400002118000000000000000000000000000000000000002400000000agama/agama-server/src/users/web.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! //! The module offers two public functions: //! //! * `users_service` which returns the Axum service. //! * `users_stream` which offers an stream that emits the users events coming from D-Bus. use crate::{ error::Error, web::{ common::{issues_router, service_status_router, EventStreams}, Event, }, }; use agama_lib::{ error::ServiceError, users::{ model::{RootConfig, RootPatchSettings}, proxies::Users1Proxy, FirstUser, UsersClient, }, }; use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::get, Json, Router}; use tokio_stream::{Stream, StreamExt}; #[derive(Clone)] struct UsersState<'a> { users: UsersClient<'a>, } /// Returns streams that emits users related events coming from D-Bus. /// /// It emits the Event::RootPasswordChange, Event::RootSSHKeyChanged and Event::FirstUserChanged events. /// /// * `connection`: D-Bus connection to listen for events. pub async fn users_streams(dbus: zbus::Connection) -> Result<EventStreams, Error> { const FIRST_USER_ID: &str = "first_user"; const ROOT_PASSWORD_ID: &str = "root_password"; const ROOT_SSHKEY_ID: &str = "root_sshkey"; // here we have three streams, but only two events. Reason is // that we have three streams from dbus about property change // and unify two root user properties into single event to http API let result: EventStreams = vec![ ( FIRST_USER_ID, Box::pin(first_user_changed_stream(dbus.clone()).await?), ), ( ROOT_PASSWORD_ID, Box::pin(root_password_changed_stream(dbus.clone()).await?), ), ( ROOT_SSHKEY_ID, Box::pin(root_ssh_key_changed_stream(dbus.clone()).await?), ), ]; Ok(result) } async fn first_user_changed_stream( dbus: zbus::Connection, ) -> Result<impl Stream<Item = Event> + Send, Error> { let proxy = Users1Proxy::new(&dbus).await?; let stream = proxy .receive_first_user_changed() .await .then(|change| async move { if let Ok(user) = change.get().await { let user_struct = FirstUser { full_name: user.0, user_name: user.1, password: user.2, autologin: user.3, data: user.4, }; return Some(Event::FirstUserChanged(user_struct)); } None }) .filter_map(|e| e); Ok(stream) } async fn root_password_changed_stream( dbus: zbus::Connection, ) -> Result<impl Stream<Item = Event> + Send, Error> { let proxy = Users1Proxy::new(&dbus).await?; let stream = proxy .receive_root_password_set_changed() .await .then(|change| async move { if let Ok(is_set) = change.get().await { return Some(Event::RootChanged { password: Some(is_set), sshkey: None, }); } None }) .filter_map(|e| e); Ok(stream) } async fn root_ssh_key_changed_stream( dbus: zbus::Connection, ) -> Result<impl Stream<Item = Event> + Send, Error> { let proxy = Users1Proxy::new(&dbus).await?; let stream = proxy .receive_root_sshkey_changed() .await .then(|change| async move { if let Ok(key) = change.get().await { return Some(Event::RootChanged { password: None, sshkey: Some(key), }); } None }) .filter_map(|e| e); Ok(stream) } /// Sets up and returns the axum service for the users module. pub async fn users_service(dbus: zbus::Connection) -> Result<Router, ServiceError> { const DBUS_SERVICE: &str = "org.opensuse.Agama.Manager1"; const DBUS_PATH: &str = "/org/opensuse/Agama/Users1"; let users = UsersClient::new(dbus.clone()).await?; let state = UsersState { users }; let issues_router = issues_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; let status_router = service_status_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; let router = Router::new() .route( "/first", get(get_user_config) .put(set_first_user) .delete(remove_first_user), ) .route("/root", get(get_root_config).patch(patch_root)) .merge(status_router) .nest("/issues", issues_router) .with_state(state); Ok(router) } /// Removes the first user settings #[utoipa::path(delete, path = "/users/first", responses( (status = 200, description = "Removes the first user"), (status = 400, description = "The D-Bus service could not perform the action"), ))] async fn remove_first_user(State(state): State<UsersState<'_>>) -> Result<(), Error> { state.users.remove_first_user().await?; Ok(()) } #[utoipa::path(put, path = "/users/first", responses( (status = 200, description = "Sets the first user"), (status = 400, description = "The D-Bus service could not perform the action"), (status = 422, description = "Invalid first user. Details are in body", body = Vec<String>), ))] async fn set_first_user( State(state): State<UsersState<'_>>, Json(config): Json<FirstUser>, ) -> Result<impl IntoResponse, Error> { // issues: for example, trying to use a system user id; empty password // success: simply issues.is_empty() let (_success, issues) = state.users.set_first_user(&config).await?; let status = if issues.is_empty() { StatusCode::OK } else { StatusCode::UNPROCESSABLE_ENTITY }; Ok((status, Json(issues).into_response())) } #[utoipa::path(get, path = "/users/first", responses( (status = 200, description = "Configuration for the first user", body = FirstUser), (status = 400, description = "The D-Bus service could not perform the action"), ))] async fn get_user_config(State(state): State<UsersState<'_>>) -> Result<Json<FirstUser>, Error> { Ok(Json(state.users.first_user().await?)) } #[utoipa::path(patch, path = "/users/root", responses( (status = 200, description = "Root configuration is modified", body = RootPatchSettings), (status = 400, description = "The D-Bus service could not perform the action"), ))] async fn patch_root( State(state): State<UsersState<'_>>, Json(config): Json<RootPatchSettings>, ) -> Result<impl IntoResponse, Error> { let mut retcode1 = 0; if let Some(key) = config.sshkey { retcode1 = state.users.set_root_sshkey(&key).await?; } let mut retcode2 = 0; if let Some(password) = config.password { retcode2 = if password.is_empty() { state.users.remove_root_password().await? } else { state .users .set_root_password(&password, config.password_encrypted == Some(true)) .await? } } let retcode: u32 = if retcode1 != 0 { retcode1 } else { retcode2 }; Ok(Json(retcode)) } #[utoipa::path(get, path = "/users/root", responses( (status = 200, description = "Configuration for the root user", body = RootConfig), (status = 400, description = "The D-Bus service could not perform the action"), ))] async fn get_root_config(State(state): State<UsersState<'_>>) -> Result<Json<RootConfig>, Error> { let password = state.users.is_root_password().await?; let sshkey = state.users.root_ssh_key().await?; let config = RootConfig { password, sshkey }; Ok(Json(config)) } 070701000000CD000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001B00000000agama/agama-server/src/web070701000000CE000081A4000000000000000000000001671F5A6400001B85000000000000000000000000000000000000001E00000000agama/agama-server/src/web.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module implements a web-based API for Agama. It is responsible for: //! //! * Exposing an HTTP API to interact with Agama. //! * Emit relevant events via websocket. //! * Serve the code for the web user interface (not implemented yet). use crate::{ error::Error, l10n::web::l10n_service, manager::web::{manager_service, manager_stream}, network::{web::network_service, NetworkManagerAdapter}, questions::web::{questions_service, questions_stream}, scripts::web::scripts_service, software::web::{software_service, software_streams}, storage::web::{storage_service, storage_streams}, users::web::{users_service, users_streams}, web::common::{issues_stream, jobs_stream, progress_stream, service_status_stream}, }; use axum::Router; mod auth; pub mod common; mod config; pub mod docs; mod event; mod http; mod service; mod state; mod ws; use agama_lib::{connection, error::ServiceError}; pub use config::ServiceConfig; pub use event::{Event, EventsReceiver, EventsSender}; pub use service::MainServiceBuilder; use std::path::Path; use tokio_stream::{StreamExt, StreamMap}; /// Returns a service that implements the web-based Agama API. /// /// * `config`: service configuration. /// * `events`: channel to send the events through the WebSocket. /// * `dbus`: D-Bus connection. /// * `web_ui_dir`: public directory containing the web UI. pub async fn service<P>( config: ServiceConfig, events: EventsSender, dbus: zbus::Connection, web_ui_dir: P, ) -> Result<Router, ServiceError> where P: AsRef<Path>, { let network_adapter = NetworkManagerAdapter::from_system() .await .expect("Could not connect to NetworkManager to read the configuration"); let router = MainServiceBuilder::new(events.clone(), web_ui_dir) .add_service("/l10n", l10n_service(dbus.clone(), events.clone()).await?) .add_service("/manager", manager_service(dbus.clone()).await?) .add_service("/software", software_service(dbus.clone()).await?) .add_service("/storage", storage_service(dbus.clone()).await?) .add_service("/network", network_service(network_adapter, events).await?) .add_service("/questions", questions_service(dbus.clone()).await?) .add_service("/users", users_service(dbus.clone()).await?) .add_service("/scripts", scripts_service().await?) .with_config(config) .build(); Ok(router) } /// Starts monitoring the D-Bus service progress. /// /// The events are sent to the `events` channel. /// /// * `events`: channel to send the events to. pub async fn run_monitor(events: EventsSender) -> Result<(), ServiceError> { let connection = connection().await?; tokio::spawn(run_events_monitor(connection, events.clone())); Ok(()) } /// Emits the events from the system streams through the events channel. /// /// * `connection`: D-Bus connection. /// * `events`: channel to send the events to. async fn run_events_monitor(dbus: zbus::Connection, events: EventsSender) -> Result<(), Error> { let mut stream = StreamMap::new(); stream.insert("manager", manager_stream(dbus.clone()).await?); stream.insert( "manager-status", service_status_stream( dbus.clone(), "org.opensuse.Agama.Manager1", "/org/opensuse/Agama/Manager1", ) .await?, ); stream.insert( "manager-progress", progress_stream( dbus.clone(), "org.opensuse.Agama.Manager1", "/org/opensuse/Agama/Manager1", ) .await?, ); for (id, user_stream) in users_streams(dbus.clone()).await? { stream.insert(id, user_stream); } for (id, storage_stream) in storage_streams(dbus.clone()).await? { stream.insert(id, storage_stream); } for (id, software_stream) in software_streams(dbus.clone()).await? { stream.insert(id, software_stream); } stream.insert( "storage-status", service_status_stream( dbus.clone(), "org.opensuse.Agama.Storage1", "/org/opensuse/Agama/Storage1", ) .await?, ); stream.insert( "storage-progress", progress_stream( dbus.clone(), "org.opensuse.Agama.Storage1", "/org/opensuse/Agama/Storage1", ) .await?, ); stream.insert( "storage-issues", issues_stream( dbus.clone(), "org.opensuse.Agama.Storage1", "/org/opensuse/Agama/Storage1", ) .await?, ); stream.insert( "storage-jobs", jobs_stream( dbus.clone(), "org.opensuse.Agama.Storage1", "/org/opensuse/Agama/Storage1", "/org/opensuse/Agama/Storage1/jobs", ) .await?, ); stream.insert( "software-status", service_status_stream( dbus.clone(), "org.opensuse.Agama.Software1", "/org/opensuse/Agama/Software1", ) .await?, ); stream.insert( "software-progress", progress_stream( dbus.clone(), "org.opensuse.Agama.Software1", "/org/opensuse/Agama/Software1", ) .await?, ); stream.insert("questions", questions_stream(dbus.clone()).await?); stream.insert( "software-issues", issues_stream( dbus.clone(), "org.opensuse.Agama.Software1", "/org/opensuse/Agama/Software1", ) .await?, ); stream.insert( "software-product-issues", issues_stream( dbus.clone(), "org.opensuse.Agama.Software1", "/org/opensuse/Agama/Software1/Product", ) .await?, ); stream.insert( "users-issues", issues_stream( dbus.clone(), "org.opensuse.Agama.Manager1", "/org/opensuse/Agama/Users1", ) .await?, ); tokio::pin!(stream); let e = events.clone(); while let Some((_, event)) = stream.next().await { _ = e.send(event); } Ok(()) } 070701000000CF000081A4000000000000000000000001671F5A6400000BA0000000000000000000000000000000000000002300000000agama/agama-server/src/web/auth.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Contains the code to handle access authorization. use super::state::ServiceState; use agama_lib::auth::{AuthToken, AuthTokenError, TokenClaims}; use async_trait::async_trait; use axum::{ extract::FromRequestParts, http::{request, StatusCode}, response::{IntoResponse, Response}, Json, RequestPartsExt, }; use axum_extra::{ headers::{self, authorization::Bearer}, TypedHeader, }; use pam::PamError; use serde_json::json; use thiserror::Error; /// Represents an authentication error. #[derive(Error, Debug)] pub enum AuthError { /// The authentication error is not included in the headers. #[error("Missing authentication token")] MissingToken, /// The authentication error is invalid. #[error("Invalid authentication token: {0}")] InvalidToken(#[from] AuthTokenError), /// The authentication failed (most probably the password is wrong) #[error("Authentication via PAM failed: {0}")] Failed(#[from] PamError), } impl IntoResponse for AuthError { fn into_response(self) -> Response { let body = json!({ "error": self.to_string() }); (StatusCode::BAD_REQUEST, Json(body)).into_response() } } #[async_trait] impl FromRequestParts<ServiceState> for TokenClaims { type Rejection = AuthError; async fn from_request_parts( parts: &mut request::Parts, state: &ServiceState, ) -> Result<Self, Self::Rejection> { let token = match parts .extract::<TypedHeader<headers::Authorization<Bearer>>>() .await { Ok(TypedHeader(headers::Authorization(bearer))) => bearer.token().to_owned(), Err(_) => { let cookie = parts .extract::<TypedHeader<headers::Cookie>>() .await .map_err(|_| AuthError::MissingToken)?; cookie .get("agamaToken") .ok_or(AuthError::MissingToken)? .to_owned() } }; let token = AuthToken::new(&token); Ok(token.claims(&state.config.jwt_secret)?) } } 070701000000D0000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000002200000000agama/agama-server/src/web/common070701000000D1000081A4000000000000000000000001671F5A6400002CAB000000000000000000000000000000000000002500000000agama/agama-server/src/web/common.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! This module defines functions to be used accross all services. use std::{pin::Pin, task::Poll}; use agama_lib::{ error::ServiceError, progress::Progress, proxies::{IssuesProxy, ProgressProxy, ServiceStatusProxy}, }; use axum::{extract::State, routing::get, Json, Router}; use pin_project::pin_project; use serde::Serialize; use tokio_stream::{Stream, StreamExt}; use zbus::PropertyStream; use crate::error::Error; mod jobs; pub use jobs::{jobs_service, jobs_stream}; use super::Event; pub type EventStreams = Vec<(&'static str, Pin<Box<dyn Stream<Item = Event> + Send>>)>; /// Builds a router to the `org.opensuse.Agama1.ServiceStatus` interface of the /// given D-Bus object. /// /// ```no_run /// # use axum::{extract::State, routing::get, Json, Router}; /// # use agama_lib::connection; /// # use agama_server::web::common::service_status_router; /// # use tokio_test; /// /// # tokio_test::block_on(async { /// async fn hello(state: State<HelloWorldState>) {}; /// /// #[derive(Clone)] /// struct HelloWorldState {}; /// /// let dbus = connection().await.unwrap(); /// let status_router = service_status_router( /// &dbus, "org.opensuse.HelloWorld", "/org/opensuse/hello" /// ).await.unwrap(); /// let router: Router<HelloWorldState> = Router::new() /// .route("/hello", get(hello)) /// .merge(status_router) /// .with_state(HelloWorldState {}); /// }); /// ``` /// /// * `dbus`: D-Bus connection. /// * `destination`: D-Bus service name. /// * `path`: D-Bus object path. pub async fn service_status_router<T>( dbus: &zbus::Connection, destination: &str, path: &str, ) -> Result<Router<T>, ServiceError> { let proxy = build_service_status_proxy(dbus, destination, path).await?; let state = ServiceStatusState { proxy }; Ok(Router::new() .route("/status", get(service_status)) .with_state(state)) } async fn service_status(State(state): State<ServiceStatusState<'_>>) -> Json<ServiceStatus> { Json(ServiceStatus { current: state.proxy.current().await.unwrap(), }) } #[derive(Clone)] struct ServiceStatusState<'a> { proxy: ServiceStatusProxy<'a>, } #[derive(Clone, Serialize)] struct ServiceStatus { /// Current service status. current: u32, } /// Builds a stream of the changes in the the `org.opensuse.Agama1.ServiceStatus` /// interface of the given D-Bus object. /// /// * `dbus`: D-Bus connection. /// * `destination`: D-Bus service name. /// * `path`: D-Bus object path. pub async fn service_status_stream( dbus: zbus::Connection, destination: &'static str, path: &'static str, ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Error> { let proxy = build_service_status_proxy(&dbus, destination, path).await?; let stream = proxy .receive_current_changed() .await .then(move |change| async move { if let Ok(status) = change.get().await { Some(Event::ServiceStatusChanged { service: destination.to_string(), status, }) } else { None } }) .filter_map(|e| e); Ok(Box::pin(stream)) } async fn build_service_status_proxy<'a>( dbus: &zbus::Connection, destination: &str, path: &str, ) -> Result<ServiceStatusProxy<'a>, zbus::Error> { let proxy = ServiceStatusProxy::builder(dbus) .destination(destination.to_string())? .path(path.to_string())? .build() .await?; Ok(proxy) } /// Builds a router to the `org.opensuse.Agama1.Progress` /// interface of the given D-Bus object. /// /// ```no_run /// # use axum::{extract::State, routing::get, Json, Router}; /// # use agama_lib::connection; /// # use agama_server::web::common::progress_router; /// # use tokio_test; /// /// # tokio_test::block_on(async { /// async fn hello(state: State<HelloWorldState>) {}; /// /// #[derive(Clone)] /// struct HelloWorldState {}; /// /// let dbus = connection().await.unwrap(); /// let progress_router = progress_router( /// &dbus, "org.opensuse.HelloWorld", "/org/opensuse/hello" /// ).await.unwrap(); /// let router: Router<HelloWorldState> = Router::new() /// .route("/hello", get(hello)) /// .merge(progress_router) /// .with_state(HelloWorldState {}); /// }); /// ``` /// /// * `dbus`: D-Bus connection. /// * `destination`: D-Bus service name. /// * `path`: D-Bus object path. pub async fn progress_router<T>( dbus: &zbus::Connection, destination: &str, path: &str, ) -> Result<Router<T>, ServiceError> { let proxy = build_progress_proxy(dbus, destination, path).await?; let state = ProgressState { proxy }; Ok(Router::new() .route("/progress", get(progress)) .with_state(state)) } #[derive(Clone)] struct ProgressState<'a> { proxy: ProgressProxy<'a>, } /// Information about the current progress sequence. #[derive(Clone, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct ProgressSequence { /// Sequence steps if known in advance steps: Vec<String>, #[serde(flatten)] progress: Progress, } async fn progress(State(state): State<ProgressState<'_>>) -> Result<Json<ProgressSequence>, Error> { let proxy = state.proxy; let progress = Progress::from_proxy(&proxy).await?; let steps = proxy.steps().await?; let sequence = ProgressSequence { steps, progress }; Ok(Json(sequence)) } #[pin_project] pub struct ProgressStream<'a> { #[pin] inner: PropertyStream<'a, (u32, String)>, proxy: ProgressProxy<'a>, } pub async fn progress_stream<'a>( dbus: zbus::Connection, destination: &'static str, path: &'static str, ) -> Result<Pin<Box<impl Stream<Item = Event> + Send>>, zbus::Error> { let proxy = build_progress_proxy(&dbus, destination, path).await?; Ok(Box::pin(ProgressStream::new(proxy).await)) } impl<'a> ProgressStream<'a> { pub async fn new(proxy: ProgressProxy<'a>) -> Self { let stream = proxy.receive_current_step_changed().await; ProgressStream { inner: stream, proxy, } } } impl<'a> Stream for ProgressStream<'a> { type Item = Event; fn poll_next( self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll<Option<Self::Item>> { let pinned = self.project(); match pinned.inner.poll_next(cx) { Poll::Pending => Poll::Pending, Poll::Ready(_change) => match Progress::from_cached_proxy(pinned.proxy) { Some(progress) => { let event = Event::Progress { progress, service: pinned.proxy.destination().to_string(), }; Poll::Ready(Some(event)) } _ => Poll::Pending, }, } } } async fn build_progress_proxy<'a>( dbus: &zbus::Connection, destination: &str, path: &str, ) -> Result<ProgressProxy<'a>, zbus::Error> { let proxy = ProgressProxy::builder(dbus) .destination(destination.to_string())? .path(path.to_string())? .build() .await?; Ok(proxy) } /// Builds a router to the `org.opensuse.Agama1.Issues` interface of a given /// D-Bus object. /// /// ```no_run /// # use axum::{extract::State, routing::get, Json, Router}; /// # use agama_lib::connection; /// # use agama_server::web::common::{issues_router, service_status_router}; /// # use tokio_test; /// /// # tokio_test::block_on(async { /// async fn hello(state: State<HelloWorldState>) {}; /// /// #[derive(Clone)] /// struct HelloWorldState {}; /// /// let dbus = connection().await.unwrap(); /// let issues_router = issues_router( /// &dbus, "org.opensuse.HelloWorld", "/org/opensuse/hello" /// ).await.unwrap(); /// let router: Router<HelloWorldState> = Router::new() /// .route("/hello", get(hello)) /// .merge(issues_router) /// .with_state(HelloWorldState {}); /// }); /// ``` /// /// * `dbus`: D-Bus connection. /// * `destination`: D-Bus service name. /// * `path`: D-Bus object path. pub async fn issues_router<T>( dbus: &zbus::Connection, destination: &str, path: &str, ) -> Result<Router<T>, ServiceError> { let proxy = build_issues_proxy(dbus, destination, path).await?; let state = IssuesState { proxy }; Ok(Router::new().route("/", get(issues)).with_state(state)) } async fn issues(State(state): State<IssuesState<'_>>) -> Result<Json<Vec<Issue>>, Error> { let issues = state.proxy.all().await?; let issues: Vec<Issue> = issues.into_iter().map(Issue::from_tuple).collect(); Ok(Json(issues)) } #[derive(Clone)] struct IssuesState<'a> { proxy: IssuesProxy<'a>, } #[derive(Clone, Debug, Serialize)] pub struct Issue { description: String, details: Option<String>, source: u32, severity: u32, } impl Issue { pub fn from_tuple( (description, details, source, severity): (String, String, u32, u32), ) -> Self { let details = if details.is_empty() { None } else { Some(details) }; Self { description, details, source, severity, } } } /// Builds a stream of the changes in the the `org.opensuse.Agama1.Issues` /// interface of the given D-Bus object. /// /// * `dbus`: D-Bus connection. /// * `destination`: D-Bus service name. /// * `path`: D-Bus object path. pub async fn issues_stream( dbus: zbus::Connection, destination: &'static str, path: &'static str, ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Error> { let proxy = build_issues_proxy(&dbus, destination, path).await?; let stream = proxy .receive_all_changed() .await .then(move |change| async move { if let Ok(issues) = change.get().await { let issues = issues.into_iter().map(Issue::from_tuple).collect(); Some(Event::IssuesChanged { service: destination.to_string(), path: path.to_string(), issues, }) } else { None } }) .filter_map(|e| e); Ok(Box::pin(stream)) } async fn build_issues_proxy<'a>( dbus: &zbus::Connection, destination: &str, path: &str, ) -> Result<IssuesProxy<'a>, zbus::Error> { let proxy = IssuesProxy::builder(dbus) .destination(destination.to_string())? .path(path.to_string())? .build() .await?; Ok(proxy) } 070701000000D2000081A4000000000000000000000001671F5A64000018C8000000000000000000000000000000000000002A00000000agama/agama-server/src/web/common/jobs.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use std::{collections::HashMap, pin::Pin, task::Poll}; use agama_lib::{ dbus::get_optional_property, error::ServiceError, jobs::{client::JobsClient, Job}, property_from_dbus, }; use axum::{extract::State, routing::get, Json, Router}; use futures_util::{ready, Stream}; use pin_project::pin_project; use tokio::sync::mpsc::unbounded_channel; use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt}; use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue}; use crate::{ dbus::{DBusObjectChange, DBusObjectChangesStream, ObjectsCache}, error::Error, web::Event, }; /// Builds a router for the jobs objects. pub async fn jobs_service<T>( dbus: &zbus::Connection, destination: &'static str, path: &'static str, ) -> Result<Router<T>, ServiceError> { let client = JobsClient::new(dbus.clone(), destination, path).await?; let state = JobsState { client }; Ok(Router::new().route("/jobs", get(jobs)).with_state(state)) } #[derive(Clone)] struct JobsState<'a> { client: JobsClient<'a>, } async fn jobs(State(state): State<JobsState<'_>>) -> Result<Json<Vec<Job>>, Error> { let jobs = state .client .jobs() .await? .into_iter() .map(|(_path, job)| job) .collect(); Ok(Json(jobs)) } /// Returns the stream of jobs-related events. /// /// The stream combines the following events: /// /// * Changes on the DASD devices collection. /// /// * `dbus`: D-Bus connection to use. pub async fn jobs_stream( dbus: zbus::Connection, destination: &'static str, manager: &'static str, namespace: &'static str, ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Error> { let stream = JobsStream::new(&dbus, destination, manager, namespace).await?; Ok(Box::pin(stream)) } #[pin_project] pub struct JobsStream { dbus: zbus::Connection, cache: ObjectsCache<Job>, #[pin] inner: UnboundedReceiverStream<DBusObjectChange>, } #[derive(Debug, thiserror::Error)] enum JobsStreamError { #[error("Service error: {0}")] Service(#[from] ServiceError), #[error("Unknown job: {0}")] UnknownJob(OwnedObjectPath), } impl JobsStream { pub async fn new( dbus: &zbus::Connection, destination: &'static str, manager: &'static str, namespace: &'static str, ) -> Result<Self, ServiceError> { let (tx, rx) = unbounded_channel(); let mut stream = DBusObjectChangesStream::new( dbus, &ObjectPath::from_static_str(manager)?, &ObjectPath::from_static_str(namespace)?, "org.opensuse.Agama.Storage1.Job", ) .await?; tokio::spawn(async move { while let Some(change) = stream.next().await { let _ = tx.send(change); } }); let rx = UnboundedReceiverStream::new(rx); let mut cache: ObjectsCache<Job> = Default::default(); let client = JobsClient::new(dbus.clone(), destination, manager).await?; for (path, job) in client.jobs().await? { cache.add(path, job); } Ok(Self { dbus: dbus.clone(), cache, inner: rx, }) } fn update_job<'a>( cache: &'a mut ObjectsCache<Job>, path: &OwnedObjectPath, values: &HashMap<String, OwnedValue>, ) -> Result<&'a Job, ServiceError> { let job = cache.find_or_create(path); job.id = path.to_string(); property_from_dbus!(job, running, "Running", values, bool); property_from_dbus!(job, exit_code, "ExitCode", values, u32); Ok(job) } fn remove_job( cache: &mut ObjectsCache<Job>, path: &OwnedObjectPath, ) -> Result<Job, JobsStreamError> { cache .remove(path) .ok_or_else(|| JobsStreamError::UnknownJob(path.clone())) } fn handle_change( cache: &mut ObjectsCache<Job>, change: &DBusObjectChange, ) -> Result<Event, JobsStreamError> { match change { DBusObjectChange::Added(path, values) => { let job = Self::update_job(cache, path, values)?; Ok(Event::JobAdded { job: job.clone() }) } DBusObjectChange::Changed(path, updated) => { let job = Self::update_job(cache, path, updated)?; Ok(Event::JobChanged { job: job.clone() }) } DBusObjectChange::Removed(path) => { let job = Self::remove_job(cache, path)?; Ok(Event::JobRemoved { job }) } } } } impl Stream for JobsStream { type Item = Event; fn poll_next( self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll<Option<Self::Item>> { let mut pinned = self.project(); Poll::Ready(loop { let change = ready!(pinned.inner.as_mut().poll_next(cx)); let next_value = match change { Some(change) => { if let Ok(event) = Self::handle_change(pinned.cache, &change) { Some(event) } else { log::warn!("Could not process change {:?}", &change); None } } None => break None, }; if next_value.is_some() { break next_value; } }) } } 070701000000D3000081A4000000000000000000000001671F5A640000092E000000000000000000000000000000000000002500000000agama/agama-server/src/web/config.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Handles Agama web server configuration. //! //! The configuration can be written in YAML or JSON formats, although we plan to choose just one //! of them in the future. It is read from the following locations: //! //! * `/usr/etc/agama.d/server.{json/yaml}` //! * `/etc/agama.d/server.{json/yaml}` //! * `./agama-dbus-server/share/server.{json/yaml}` //! //! All the settings are merged into a single configuration. The values in the latter locations //! take precedence. use config::{Config, ConfigError, File}; use rand::distributions::{Alphanumeric, DistString}; use serde::Deserialize; /// Web service configuration. #[derive(Clone, Debug, Deserialize)] pub struct ServiceConfig { /// Key to sign the JSON Web Tokens. pub jwt_secret: String, } impl ServiceConfig { pub fn load() -> Result<Self, ConfigError> { const JWT_SECRET_SIZE: usize = 30; let jwt_secret: String = Alphanumeric.sample_string(&mut rand::thread_rng(), JWT_SECRET_SIZE); let config = Config::builder() .set_default("jwt_secret", jwt_secret)? .add_source(File::with_name("/usr/etc/agama.d/server").required(false)) .add_source(File::with_name("/etc/agama.d/server").required(false)) .add_source(File::with_name("etc/agama.d/server").required(false)) .build()?; config.try_deserialize() } } impl Default for ServiceConfig { fn default() -> Self { Self { jwt_secret: "".to_string(), } } } 070701000000D4000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000002000000000agama/agama-server/src/web/docs070701000000D5000081A4000000000000000000000001671F5A6400000715000000000000000000000000000000000000002300000000agama/agama-server/src/web/docs.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use utoipa::openapi::{Components, InfoBuilder, OpenApiBuilder, Paths}; mod network; pub use network::NetworkApiDocBuilder; mod storage; pub use storage::StorageApiDocBuilder; mod software; pub use software::SoftwareApiDocBuilder; mod l10n; pub use l10n::L10nApiDocBuilder; mod questions; pub use questions::QuestionsApiDocBuilder; mod manager; pub use manager::ManagerApiDocBuilder; mod users; pub use users::UsersApiDocBuilder; mod misc; pub use misc::MiscApiDocBuilder; pub trait ApiDocBuilder { fn title(&self) -> String { "Agama HTTP API".to_string() } fn paths(&self) -> Paths; fn components(&self) -> Components; fn build(&self) -> utoipa::openapi::OpenApi { let info = InfoBuilder::new() .title(self.title()) .version("0.1.0") .build(); OpenApiBuilder::new() .info(info) .paths(self.paths()) .components(Some(self.components())) .build() } } 070701000000D6000081A4000000000000000000000001671F5A640000045C000000000000000000000000000000000000002800000000agama/agama-server/src/web/docs/l10n.rsuse utoipa::openapi::{Components, ComponentsBuilder, Paths, PathsBuilder}; use super::ApiDocBuilder; pub struct L10nApiDocBuilder; impl ApiDocBuilder for L10nApiDocBuilder { fn title(&self) -> String { "Localization HTTP API".to_string() } fn paths(&self) -> Paths { PathsBuilder::new() .path_from::<crate::l10n::web::__path_get_config>() .path_from::<crate::l10n::web::__path_keymaps>() .path_from::<crate::l10n::web::__path_locales>() .path_from::<crate::l10n::web::__path_set_config>() .path_from::<crate::l10n::web::__path_timezones>() .build() } fn components(&self) -> Components { ComponentsBuilder::new() .schema_from::<agama_lib::localization::model::LocaleConfig>() .schema_from::<agama_locale_data::KeymapId>() .schema_from::<agama_locale_data::LocaleId>() .schema_from::<crate::l10n::Keymap>() .schema_from::<crate::l10n::LocaleEntry>() .schema_from::<crate::l10n::TimezoneEntry>() .build() } } 070701000000D7000081A4000000000000000000000001671F5A6400000374000000000000000000000000000000000000002B00000000agama/agama-server/src/web/docs/manager.rsuse utoipa::openapi::{ComponentsBuilder, PathsBuilder}; use super::ApiDocBuilder; pub struct ManagerApiDocBuilder; impl ApiDocBuilder for ManagerApiDocBuilder { fn title(&self) -> String { "Manager HTTP API".to_string() } fn paths(&self) -> utoipa::openapi::Paths { PathsBuilder::new() .path_from::<crate::manager::web::__path_finish_action>() .path_from::<crate::manager::web::__path_install_action>() .path_from::<crate::manager::web::__path_installer_status>() .path_from::<crate::manager::web::__path_probe_action>() .build() } fn components(&self) -> utoipa::openapi::Components { ComponentsBuilder::new() .schema_from::<agama_lib::manager::InstallationPhase>() .schema_from::<crate::manager::web::InstallerStatus>() .build() } } 070701000000D8000081A4000000000000000000000001671F5A6400000239000000000000000000000000000000000000002800000000agama/agama-server/src/web/docs/misc.rsuse utoipa::openapi::{Components, ComponentsBuilder, Paths, PathsBuilder}; use super::ApiDocBuilder; pub struct MiscApiDocBuilder; impl ApiDocBuilder for MiscApiDocBuilder { fn title(&self) -> String { "Miscelaneous HTTP API".to_string() } fn paths(&self) -> Paths { PathsBuilder::new() .path_from::<crate::web::http::__path_ping>() .build() } fn components(&self) -> Components { ComponentsBuilder::new() .schema_from::<crate::web::http::PingResponse>() .build() } } 070701000000D9000081A4000000000000000000000001671F5A6400001439000000000000000000000000000000000000002B00000000agama/agama-server/src/web/docs/network.rsuse serde_json::json; use utoipa::openapi::{Components, ComponentsBuilder, ObjectBuilder, Paths, PathsBuilder}; use super::ApiDocBuilder; pub struct NetworkApiDocBuilder; impl ApiDocBuilder for NetworkApiDocBuilder { fn title(&self) -> String { "Network HTTP API".to_string() } fn paths(&self) -> Paths { PathsBuilder::new() .path_from::<crate::network::web::__path_add_connection>() .path_from::<crate::network::web::__path_apply>() .path_from::<crate::network::web::__path_connect>() .path_from::<crate::network::web::__path_connections>() .path_from::<crate::network::web::__path_delete_connection>() .path_from::<crate::network::web::__path_devices>() .path_from::<crate::network::web::__path_disconnect>() .path_from::<crate::network::web::__path_update_connection>() .path_from::<crate::network::web::__path_apply>() .build() } fn components(&self) -> Components { ComponentsBuilder::new() .schema_from::<agama_lib::network::settings::BondSettings>() .schema_from::<agama_lib::network::settings::IEEE8021XSettings>() .schema_from::<agama_lib::network::settings::MatchSettings>() .schema_from::<agama_lib::network::settings::NetworkConnection>() .schema_from::<agama_lib::network::settings::NetworkSettings>() .schema_from::<agama_lib::network::settings::NetworkSettings>() .schema_from::<agama_lib::network::settings::WirelessSettings>() .schema_from::<agama_lib::network::types::BondMode>() .schema_from::<agama_lib::network::types::DeviceState>() .schema_from::<agama_lib::network::types::DeviceType>() .schema_from::<agama_lib::network::types::SSID>() .schema_from::<agama_lib::network::types::Status>() .schema_from::<crate::network::model::BondConfig>() .schema_from::<crate::network::model::BondOptions>() .schema_from::<crate::network::model::BridgeConfig>() .schema_from::<crate::network::model::BridgePortConfig>() .schema_from::<crate::network::model::Connection>() .schema_from::<crate::network::model::ConnectionConfig>() .schema_from::<crate::network::model::Device>() .schema_from::<crate::network::model::EAPMethod>() .schema_from::<crate::network::model::GroupAlgorithm>() .schema_from::<crate::network::model::IEEE8021XConfig>() .schema_from::<crate::network::model::InfinibandConfig>() .schema_from::<crate::network::model::InfinibandTransportMode>() .schema_from::<crate::network::model::IpConfig>() .schema_from::<crate::network::model::IpRoute>() .schema_from::<crate::network::model::Ipv4Method>() .schema_from::<crate::network::model::Ipv6Method>() .schema_from::<crate::network::model::MacAddress>() .schema_from::<crate::network::model::MatchConfig>() .schema_from::<crate::network::model::PairwiseAlgorithm>() .schema_from::<crate::network::model::Phase2AuthMethod>() .schema_from::<crate::network::model::PortConfig>() .schema_from::<crate::network::model::SecurityProtocol>() .schema_from::<crate::network::model::TunConfig>() .schema_from::<crate::network::model::TunMode>() .schema_from::<crate::network::model::VlanConfig>() .schema_from::<crate::network::model::VlanProtocol>() .schema_from::<crate::network::model::WEPAuthAlg>() .schema_from::<crate::network::model::WEPKeyType>() .schema_from::<crate::network::model::WEPSecurity>() .schema_from::<crate::network::model::WPAProtocolVersion>() .schema_from::<crate::network::model::WirelessBand>() .schema_from::<crate::network::model::WirelessConfig>() .schema_from::<crate::network::model::WirelessMode>() .schema( "IpAddr", ObjectBuilder::new() .schema_type(utoipa::openapi::SchemaType::String) .description(Some("An IP address (IPv4 or IPv6)".to_string())) .example(Some(json!("192.168.1.100"))) .build(), ) .schema( "IpInet", ObjectBuilder::new() .schema_type(utoipa::openapi::SchemaType::String) .description(Some( "An IP address (IPv4 or IPv6) including the prefix".to_string(), )) .example(Some(json!("192.168.1.254/24"))) .build(), ) .schema( "macaddr.MacAddr6", ObjectBuilder::new() .schema_type(utoipa::openapi::SchemaType::String) .description(Some("MAC address in EUI-48 format".to_string())) .build(), ) .build() } } 070701000000DA000081A4000000000000000000000001671F5A64000004E8000000000000000000000000000000000000002D00000000agama/agama-server/src/web/docs/questions.rsuse utoipa::openapi::{Components, ComponentsBuilder, Paths, PathsBuilder}; use super::ApiDocBuilder; pub struct QuestionsApiDocBuilder; impl ApiDocBuilder for QuestionsApiDocBuilder { fn title(&self) -> String { "Questions HTTP API".to_string() } fn paths(&self) -> Paths { PathsBuilder::new() .path_from::<crate::questions::web::__path_answer_question>() .path_from::<crate::questions::web::__path_create_question>() .path_from::<crate::questions::web::__path_delete_question>() .path_from::<crate::questions::web::__path_get_answer>() .path_from::<crate::questions::web::__path_list_questions>() .build() } fn components(&self) -> Components { ComponentsBuilder::new() .schema_from::<agama_lib::questions::model::Answer>() .schema_from::<agama_lib::questions::model::GenericAnswer>() .schema_from::<agama_lib::questions::model::GenericQuestion>() .schema_from::<agama_lib::questions::model::PasswordAnswer>() .schema_from::<agama_lib::questions::model::Question>() .schema_from::<agama_lib::questions::model::QuestionWithPassword>() .build() } } 070701000000DB000081A4000000000000000000000001671F5A6400000576000000000000000000000000000000000000002C00000000agama/agama-server/src/web/docs/software.rsuse utoipa::openapi::{Components, ComponentsBuilder, Paths, PathsBuilder}; use super::ApiDocBuilder; pub struct SoftwareApiDocBuilder; impl ApiDocBuilder for SoftwareApiDocBuilder { fn title(&self) -> String { "Software HTTP API".to_string() } fn paths(&self) -> Paths { PathsBuilder::new() .path_from::<crate::software::web::__path_get_config>() .path_from::<crate::software::web::__path_patterns>() .path_from::<crate::software::web::__path_probe>() .path_from::<crate::software::web::__path_products>() .path_from::<crate::software::web::__path_proposal>() .path_from::<crate::software::web::__path_set_config>() .build() } fn components(&self) -> Components { ComponentsBuilder::new() .schema_from::<agama_lib::product::Product>() .schema_from::<agama_lib::product::RegistrationRequirement>() .schema_from::<agama_lib::software::Pattern>() .schema_from::<agama_lib::software::model::RegistrationInfo>() .schema_from::<agama_lib::software::model::RegistrationParams>() .schema_from::<agama_lib::software::SelectedBy>() .schema_from::<agama_lib::software::model::SoftwareConfig>() .schema_from::<crate::software::web::SoftwareProposal>() .build() } } 070701000000DC000081A4000000000000000000000001671F5A6400001090000000000000000000000000000000000000002B00000000agama/agama-server/src/web/docs/storage.rsuse utoipa::openapi::{Components, ComponentsBuilder, Paths, PathsBuilder}; use super::ApiDocBuilder; pub struct StorageApiDocBuilder; impl ApiDocBuilder for StorageApiDocBuilder { fn title(&self) -> String { "Storage HTTP API".to_string() } fn paths(&self) -> Paths { PathsBuilder::new() .path_from::<crate::storage::web::__path_actions>() .path_from::<crate::storage::web::__path_devices_dirty>() .path_from::<crate::storage::web::__path_get_proposal_settings>() .path_from::<crate::storage::web::__path_probe>() .path_from::<crate::storage::web::__path_product_params>() .path_from::<crate::storage::web::__path_set_proposal_settings>() .path_from::<crate::storage::web::__path_staging_devices>() .path_from::<crate::storage::web::__path_system_devices>() .path_from::<crate::storage::web::__path_usable_devices>() .path_from::<crate::storage::web::__path_volume_for>() .path_from::<crate::storage::web::iscsi::__path_delete_node>() .path_from::<crate::storage::web::iscsi::__path_discover>() .path_from::<crate::storage::web::iscsi::__path_initiator>() .path_from::<crate::storage::web::iscsi::__path_login_node>() .path_from::<crate::storage::web::iscsi::__path_logout_node>() .path_from::<crate::storage::web::iscsi::__path_nodes>() .path_from::<crate::storage::web::iscsi::__path_update_initiator>() .path_from::<crate::storage::web::iscsi::__path_update_node>() .build() } fn components(&self) -> Components { ComponentsBuilder::new() .schema_from::<crate::storage::web::ProductParams>() .schema_from::<crate::storage::web::iscsi::DiscoverParams>() .schema_from::<crate::storage::web::iscsi::InitiatorParams>() .schema_from::<crate::storage::web::iscsi::LoginParams>() .schema_from::<crate::storage::web::iscsi::NodeParams>() .schema_from::<agama_lib::storage::model::Action>() .schema_from::<agama_lib::storage::model::BlockDevice>() .schema_from::<agama_lib::storage::model::Component>() .schema_from::<agama_lib::storage::model::Device>() .schema_from::<agama_lib::storage::model::DeviceInfo>() .schema_from::<agama_lib::storage::model::DeviceSid>() .schema_from::<agama_lib::storage::model::Drive>() .schema_from::<agama_lib::storage::model::DriveInfo>() .schema_from::<agama_lib::storage::model::DeviceSize>() .schema_from::<agama_lib::storage::model::Filesystem>() .schema_from::<agama_lib::storage::model::LvmLv>() .schema_from::<agama_lib::storage::model::LvmVg>() .schema_from::<agama_lib::storage::model::Md>() .schema_from::<agama_lib::storage::model::Multipath>() .schema_from::<agama_lib::storage::model::Partition>() .schema_from::<agama_lib::storage::model::PartitionTable>() .schema_from::<agama_lib::storage::model::ProposalSettings>() .schema_from::<agama_lib::storage::model::ProposalSettingsPatch>() .schema_from::<agama_lib::storage::model::ProposalTarget>() .schema_from::<agama_lib::storage::model::Raid>() .schema_from::<agama_lib::storage::model::ShrinkingInfo>() .schema_from::<agama_lib::storage::model::SpaceAction>() .schema_from::<agama_lib::storage::model::SpaceActionSettings>() .schema_from::<agama_lib::storage::model::UnusedSlot>() .schema_from::<agama_lib::storage::model::Volume>() .schema_from::<agama_lib::storage::model::VolumeOutline>() .schema_from::<agama_lib::storage::model::VolumeTarget>() .schema_from::<agama_lib::storage::client::iscsi::ISCSIAuth>() .schema_from::<agama_lib::storage::client::iscsi::ISCSIInitiator>() .schema_from::<agama_lib::storage::client::iscsi::ISCSINode>() .schema_from::<agama_lib::storage::client::iscsi::LoginResult>() .build() } } 070701000000DD000081A4000000000000000000000001671F5A64000004DF000000000000000000000000000000000000002900000000agama/agama-server/src/web/docs/users.rsuse utoipa::openapi::{ComponentsBuilder, Paths, PathsBuilder}; use super::ApiDocBuilder; pub struct UsersApiDocBuilder; impl ApiDocBuilder for UsersApiDocBuilder { fn title(&self) -> String { "Users HTTP API".to_string() } fn paths(&self) -> Paths { PathsBuilder::new() .path_from::<crate::users::web::__path_get_root_config>() .path_from::<crate::users::web::__path_get_user_config>() .path_from::<crate::users::web::__path_patch_root>() .path_from::<crate::users::web::__path_remove_first_user>() .path_from::<crate::users::web::__path_set_first_user>() .build() } fn components(&self) -> utoipa::openapi::Components { ComponentsBuilder::new() .schema_from::<agama_lib::users::FirstUser>() .schema_from::<agama_lib::users::model::RootConfig>() .schema_from::<agama_lib::users::model::RootPatchSettings>() .schema( "zbus.zvariant.OwnedValue", utoipa::openapi::ObjectBuilder::new() .description(Some("Additional user information (unused)".to_string())) .build(), ) .build() } } 070701000000DE000081A4000000000000000000000001671F5A6400000EC3000000000000000000000000000000000000002400000000agama/agama-server/src/web/event.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use crate::network::model::NetworkChange; use agama_lib::{ jobs::Job, localization::model::LocaleConfig, manager::InstallationPhase, product::RegistrationRequirement, progress::Progress, software::SelectedBy, storage::{ model::{ dasd::{DASDDevice, DASDFormatSummary}, zfcp::{ZFCPController, ZFCPDisk}, }, ISCSINode, }, users::FirstUser, }; use serde::Serialize; use std::collections::HashMap; use tokio::sync::broadcast::{Receiver, Sender}; use super::common::Issue; #[derive(Clone, Debug, Serialize)] #[serde(tag = "type")] pub enum Event { L10nConfigChanged(LocaleConfig), LocaleChanged { locale: String, }, DevicesDirty { dirty: bool, }, Progress { service: String, #[serde(flatten)] progress: Progress, }, ProductChanged { id: String, }, RegistrationRequirementChanged { requirement: RegistrationRequirement, }, RegistrationChanged, FirstUserChanged(FirstUser), RootChanged { password: Option<bool>, sshkey: Option<String>, }, NetworkChange { #[serde(flatten)] change: NetworkChange, }, // TODO: it should include the full software proposal or, at least, // all the relevant changes. SoftwareProposalChanged { patterns: HashMap<String, SelectedBy>, }, QuestionsChanged, InstallationPhaseChanged { phase: InstallationPhase, }, ServiceStatusChanged { service: String, status: u32, }, IssuesChanged { service: String, path: String, issues: Vec<Issue>, }, ValidationChanged { service: String, path: String, errors: Vec<String>, }, ISCSINodeAdded { node: ISCSINode, }, ISCSINodeChanged { node: ISCSINode, }, ISCSINodeRemoved { node: ISCSINode, }, ISCSIInitiatorChanged { name: Option<String>, ibft: Option<bool>, }, DASDDeviceAdded { device: DASDDevice, }, DASDDeviceChanged { device: DASDDevice, }, DASDDeviceRemoved { device: DASDDevice, }, JobAdded { job: Job, }, JobChanged { job: Job, }, JobRemoved { job: Job, }, DASDFormatJobChanged { #[serde(rename = "jobId")] job_id: String, summary: HashMap<String, DASDFormatSummary>, }, ZFCPDiskAdded { device: ZFCPDisk, }, ZFCPDiskChanged { device: ZFCPDisk, }, ZFCPDiskRemoved { device: ZFCPDisk, }, ZFCPControllerAdded { device: ZFCPController, }, ZFCPControllerChanged { device: ZFCPController, }, ZFCPControllerRemoved { device: ZFCPController, }, } pub type EventsSender = Sender<Event>; pub type EventsReceiver = Receiver<Event>; 070701000000DF000081A4000000000000000000000001671F5A6400001B94000000000000000000000000000000000000002300000000agama/agama-server/src/web/http.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements the basic handlers for the HTTP-based API (login, logout, ping, etc.). use super::{auth::AuthError, state::ServiceState}; use agama_lib::auth::{AuthToken, TokenClaims}; use axum::{ body::Body, extract::{Query, State}, http::{header, HeaderMap, HeaderValue, StatusCode}, response::IntoResponse, Json, }; use axum_extra::extract::cookie::CookieJar; use pam::Client; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; #[derive(Serialize, ToSchema)] pub struct PingResponse { /// API status status: String, } #[utoipa::path(get, path = "/ping", responses( (status = 200, description = "The API is working", body = PingResponse) ))] pub async fn ping() -> Json<PingResponse> { Json(PingResponse { status: "success".to_string(), }) } #[derive(Serialize)] pub struct AuthResponse { /// Bearer token to use on subsequent calls token: String, } #[derive(Deserialize)] pub struct LoginRequest { /// User password pub password: String, } #[utoipa::path(post, path = "/api/auth", responses( (status = 200, description = "The user has been successfully authenticated.", body = AuthResponse) ))] pub async fn login( State(state): State<ServiceState>, Json(login): Json<LoginRequest>, ) -> Result<impl IntoResponse, AuthError> { let mut pam_client = Client::with_password("agama")?; pam_client .conversation_mut() .set_credentials("root", login.password); pam_client.authenticate()?; let token = AuthToken::generate(&state.config.jwt_secret)?; let content = Json(AuthResponse { token: token.to_string(), }); let mut headers = HeaderMap::new(); let cookie = auth_cookie_from_token(&token); headers.insert( header::SET_COOKIE, cookie.parse().expect("could not build a valid cookie"), ); Ok((headers, content)) } #[derive(Clone, Deserialize, utoipa::ToSchema)] pub struct LoginFromQueryParams { /// Token to use for authentication. token: String, } #[utoipa::path(get, path = "/login", responses( (status = 301, description = "Injects the authentication cookie if correct and redirects to the web UI") ))] pub async fn login_from_query( State(state): State<ServiceState>, Query(params): Query<LoginFromQueryParams>, ) -> impl IntoResponse { let mut headers = HeaderMap::new(); let token = AuthToken::new(¶ms.token); if token.claims(&state.config.jwt_secret).is_ok() { let cookie = auth_cookie_from_token(&token); headers.insert( header::SET_COOKIE, cookie.parse().expect("could not build a valid cookie"), ); } headers.insert(header::LOCATION, HeaderValue::from_static("/")); (StatusCode::TEMPORARY_REDIRECT, headers) } #[utoipa::path(delete, path = "/api/auth", responses( (status = 204, description = "The user has been logged out.") ))] pub async fn logout(_claims: TokenClaims) -> Result<impl IntoResponse, AuthError> { let mut headers = HeaderMap::new(); let cookie = "agamaToken=deleted; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:00 GMT".to_string(); headers.insert( header::SET_COOKIE, cookie.parse().expect("could not build a valid cookie"), ); Ok(headers) } /// Check whether the user is authenticated. #[utoipa::path(get, path = "/api/auth", responses( (status = 200, description = "The user is authenticated."), (status = 400, description = "The user is not authenticated.") ))] pub async fn session(_claims: TokenClaims) -> Result<(), AuthError> { Ok(()) } /// Creates the cookie containing the authentication token. /// /// It is a session token (no expiration date) so it should be gone /// when the browser is closed. /// /// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie /// for further information. /// /// * `token`: authentication token. fn auth_cookie_from_token(token: &AuthToken) -> String { format!("agamaToken={}; HttpOnly", &token.to_string()) } // builds a response tuple for translation redirection fn redirect_to_file(file: &str) -> (StatusCode, HeaderMap, Body) { tracing::info!("Redirecting to translation file {}", file); let mut response_headers = HeaderMap::new(); // translation found, redirect to the real file response_headers.insert( header::LOCATION, // if the file exists then the name is a valid value and unwrapping is safe HeaderValue::from_str(file).unwrap(), ); ( StatusCode::TEMPORARY_REDIRECT, response_headers, Body::empty(), ) } // handle the /po.js request // the requested language (locale) is sent in the "agamaLang" HTTP cookie // this reimplements the Cockpit translation support pub async fn po(State(state): State<ServiceState>, jar: CookieJar) -> impl IntoResponse { if let Some(cookie) = jar.get("agamaLang") { tracing::info!("Language cookie: {}", cookie.value()); // try parsing the cookie if let Some((lang, region)) = cookie.value().split_once('-') { // first try language + country let target_file = format!("po.{}_{}.js", lang, region.to_uppercase()); if state.public_dir.join(&target_file).exists() { return redirect_to_file(&target_file); } else { // then try the language only let target_file = format!("po.{}.js", lang); if state.public_dir.join(&target_file).exists() { return redirect_to_file(&target_file); }; } } else { // use the cookie as is let target_file = format!("po.{}.js", cookie.value()); if state.public_dir.join(&target_file).exists() { return redirect_to_file(&target_file); } } } tracing::info!("Translation not found"); // fallback, return empty javascript translations if the language is not supported let mut response_headers = HeaderMap::new(); response_headers.insert( header::CONTENT_TYPE, HeaderValue::from_static("text/javascript"), ); (StatusCode::OK, response_headers, Body::empty()) } 070701000000E0000081A4000000000000000000000001671F5A64000011C7000000000000000000000000000000000000002600000000agama/agama-server/src/web/service.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use super::http::{login, login_from_query, logout, session}; use super::{config::ServiceConfig, state::ServiceState, EventsSender}; use agama_lib::auth::TokenClaims; use axum::{ body::Body, extract::Request, middleware, response::{IntoResponse, Response}, routing::{get, post}, Router, }; use std::time::Duration; use std::{ convert::Infallible, path::{Path, PathBuf}, }; use tower::Service; use tower_http::{compression::CompressionLayer, services::ServeDir, trace::TraceLayer}; use tracing::Span; /// Builder for Agama main service. /// /// It is responsible for building an axum service which includes: /// /// * A static assets directory (`public_dir`). /// * A websocket at the `/ws` path. /// * An authentication endpoint at `/auth`. /// * A 'ping' endpoint at '/ping'. /// * A number of authenticated services that are added using the `add_service` function. pub struct MainServiceBuilder { config: ServiceConfig, events: EventsSender, api_router: Router<ServiceState>, public_dir: PathBuf, } impl MainServiceBuilder { /// Returns a new service builder. /// /// * `events`: channel to send events through the WebSocket. /// * `public_dir`: path to the public directory. pub fn new<P>(events: EventsSender, public_dir: P) -> Self where P: AsRef<Path>, { let api_router = Router::new().route("/ws", get(super::ws::ws_handler)); let config = ServiceConfig::default(); Self { events, api_router, config, public_dir: PathBuf::from(public_dir.as_ref()), } } pub fn with_config(self, config: ServiceConfig) -> Self { Self { config, ..self } } /// Add an authenticated service. /// /// * `path`: Path to mount the service under `/api`. /// * `service`: Service to mount on the given `path`. pub fn add_service<T>(self, path: &str, service: T) -> Self where T: Service<Request, Error = Infallible> + Clone + Send + 'static, T::Response: IntoResponse, T::Future: Send + 'static, { Self { api_router: self.api_router.nest_service(path, service), ..self } } pub fn build(self) -> Router { let state = ServiceState { config: self.config, events: self.events, public_dir: self.public_dir.clone(), }; let api_router = self .api_router .route_layer(middleware::from_extractor_with_state::<TokenClaims, _>( state.clone(), )) .route("/ping", get(super::http::ping)) .route("/auth", post(login).get(session).delete(logout)); tracing::info!("Serving static files from {}", self.public_dir.display()); let serve = ServeDir::new(self.public_dir).precompressed_gzip(); Router::new() .nest_service("/", serve) .route("/login", get(login_from_query)) .route("/po.js", get(super::http::po)) .nest("/api", api_router) .layer( TraceLayer::new_for_http() .on_request(|request: &Request<Body>, _span: &Span| { tracing::info!("request: {} {}", request.method(), request.uri().path()) }) .on_response( |response: &Response<Body>, latency: Duration, _span: &Span| { tracing::info!("response: {} {:?}", response.status(), latency) }, ), ) .layer(CompressionLayer::new().br(true)) .with_state(state) } } 070701000000E1000081A4000000000000000000000001671F5A64000004B1000000000000000000000000000000000000002400000000agama/agama-server/src/web/state.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements the web service state. use super::{config::ServiceConfig, EventsSender}; use std::path::PathBuf; /// Web service state. /// /// It holds the service configuration, the current D-Bus connection and a channel to send events. #[derive(Clone)] pub struct ServiceState { pub config: ServiceConfig, pub events: EventsSender, pub public_dir: PathBuf, } 070701000000E2000081A4000000000000000000000001671F5A64000005E9000000000000000000000000000000000000002100000000agama/agama-server/src/web/ws.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. //! Implements the websocket handling. use super::{state::ServiceState, EventsSender}; use axum::{ extract::{ ws::{Message, WebSocket}, State, WebSocketUpgrade, }, response::IntoResponse, }; pub async fn ws_handler( State(state): State<ServiceState>, ws: WebSocketUpgrade, ) -> impl IntoResponse { ws.on_upgrade(move |socket| handle_socket(socket, state.events)) } async fn handle_socket(mut socket: WebSocket, events: EventsSender) { let mut rx = events.subscribe(); while let Ok(msg) = rx.recv().await { if let Ok(json) = serde_json::to_string(&msg) { _ = socket.send(Message::Text(json)).await; } } } 070701000000E3000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001900000000agama/agama-server/tests070701000000E4000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000002000000000agama/agama-server/tests/common070701000000E5000081A4000000000000000000000001671F5A6400001419000000000000000000000000000000000000002700000000agama/agama-server/tests/common/mod.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. use agama_lib::error::ServiceError; use axum::body::{to_bytes, Body}; use std::{ error::Error, future::Future, process::{Child, Command}, time::Duration, }; use tokio_stream::StreamExt; use uuid::Uuid; use zbus::{MatchRule, MessageStream, MessageType}; const DBUS_SERVICE: &str = "org.opensuse.Agama1"; /// D-Bus server to be used on tests. /// /// Takes care of starting and stopping a dbus-daemon to be used on integration tests. Each server /// uses a different socket, so they do not collide. /// /// NOTE: this struct implements the [typestate pattern](http://cliffle.com/blog/rust-typestate/). pub struct DBusServer<S: ServerState> { address: String, extra: S, } pub struct Started { connection: zbus::Connection, child: Child, } impl Drop for Started { fn drop(&mut self) { self.child.kill().unwrap(); } } pub struct Stopped; pub trait ServerState {} impl ServerState for Started {} impl ServerState for Stopped {} impl Default for DBusServer<Stopped> { fn default() -> Self { Self::new() } } impl DBusServer<Stopped> { pub fn new() -> Self { let uuid = Uuid::new_v4(); DBusServer { address: format!("unix:path=/tmp/agama-tests-{uuid}"), extra: Stopped, } } pub async fn start(self) -> Result<DBusServer<Started>, ServiceError> { let child = Command::new("/usr/bin/dbus-daemon") .args([ "--config-file", "../share/dbus-test.conf", "--address", &self.address, ]) .spawn() .expect("to start the testing D-Bus daemon"); let connection = async_retry(|| agama_lib::connection_to(&self.address)).await?; Ok(DBusServer { address: self.address, extra: Started { child, connection }, }) } } impl DBusServer<Started> { pub fn connection(&self) -> zbus::Connection { self.extra.connection.clone() } pub async fn request_name(&mut self) -> Result<(), Box<dyn Error>> { let connection = self.connection(); let mut stream = NameOwnerChangedStream::for_connection(&connection).await?; let cloned = connection.clone(); tokio::spawn(async move { cloned .request_name(DBUS_SERVICE) .await .expect("Request the D-Bus service name"); }); stream.wait_for("org.opensuse.Agama1").await; Ok(()) } } // FIXME: check whether zbus has an API for this use case. struct NameOwnerChangedStream(MessageStream); impl NameOwnerChangedStream { pub async fn for_connection(connection: &zbus::Connection) -> Result<Self, Box<dyn Error>> { let rule = MatchRule::builder() .msg_type(MessageType::Signal) .sender("org.freedesktop.DBus")? .member("NameOwnerChanged")? .build(); let stream = MessageStream::for_match_rule(rule, connection, None).await?; Ok(Self(stream)) } pub async fn wait_for(&mut self, name: &str) { loop { let signal = self.0.next().await.unwrap().unwrap(); let (sname, _, _): (String, String, String) = signal.body().unwrap(); if sname == name { return; } } } } /// Run and retry an async function. /// /// Beware that, if the function is failing for a legit reason, you will /// introduce a delay in your code. /// /// * `func`: async function to run. pub async fn async_retry<O, F, T, E>(func: F) -> Result<T, E> where F: Fn() -> O, O: Future<Output = Result<T, E>>, { const RETRIES: u8 = 10; const INTERVAL: u64 = 500; let mut retry = 0; loop { match func().await { Ok(result) => return Ok(result), Err(error) => { if retry > RETRIES { return Err(error); } retry += 1; let wait_time = Duration::from_millis(INTERVAL); tokio::time::sleep(wait_time).await; } } } } pub async fn body_to_string(body: Body) -> String { let bytes = to_bytes(body, usize::MAX).await.unwrap(); String::from_utf8(bytes.to_vec()).unwrap() } 070701000000E6000081A4000000000000000000000001671F5A640000108E000000000000000000000000000000000000002100000000agama/agama-server/tests/l10n.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. pub mod common; use std::error::Error; use agama_server::l10n::web::l10n_service; use axum::{ body::Body, http::{Request, StatusCode}, Router, }; use common::{body_to_string, DBusServer}; use tokio::{sync::broadcast::channel, test}; use tower::ServiceExt; async fn build_service(dbus: zbus::Connection) -> Router { let (tx, _) = channel(16); l10n_service(dbus, tx).await.unwrap() } #[test] async fn test_get_config() -> Result<(), Box<dyn Error>> { let dbus_server = DBusServer::new().start().await?; let service = build_service(dbus_server.connection()).await; let request = Request::builder() .uri("/config") .body(Body::empty()) .unwrap(); let response = service.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); Ok(()) } #[test] async fn test_locales() -> Result<(), Box<dyn Error>> { let dbus_server = DBusServer::new().start().await?; let service = build_service(dbus_server.connection()).await; let request = Request::builder() .uri("/locales") .body(Body::empty()) .unwrap(); let response = service.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; assert!(body.contains(r#""language":"English""#)); Ok(()) } #[test] async fn test_keymaps() -> Result<(), Box<dyn Error>> { let dbus_server = DBusServer::new().start().await?; let service = build_service(dbus_server.connection()).await; let request = Request::builder() .uri("/keymaps") .body(Body::empty()) .unwrap(); let response = service.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; assert!(body.contains(r#""id":"us""#)); Ok(()) } #[test] async fn test_timezones() -> Result<(), Box<dyn Error>> { let dbus_server = DBusServer::new().start().await?; let service = build_service(dbus_server.connection()).await; let request = Request::builder() .uri("/timezones") .body(Body::empty()) .unwrap(); let response = service.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; assert!(body.contains(r#""code":"Atlantic/Canary""#)); Ok(()) } #[test] async fn test_set_config_locales() -> Result<(), Box<dyn Error>> { let dbus_server = DBusServer::new().start().await?; let service = build_service(dbus_server.connection()).await; let content = "{\"locales\":[\"es_ES.UTF-8\"]}"; let body = Body::from(content); let request = Request::patch("/config") .header("Content-Type", "application/json") .body(body)?; let response = service.clone().oneshot(request).await?; assert_eq!(response.status(), StatusCode::NO_CONTENT); // check whether the value changed let request = Request::get("/config") .header("Content-Type", "application/json") .body(Body::empty())?; let response = service.oneshot(request).await?; assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; assert!(body.contains(r#""locales":["es_ES.UTF-8"]"#)); // TODO: check whether the D-Bus value was synchronized Ok(()) } 070701000000E7000081A4000000000000000000000001671F5A6400001E42000000000000000000000000000000000000002C00000000agama/agama-server/tests/network_service.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. pub mod common; use agama_lib::error::ServiceError; use agama_lib::network::settings::{BondSettings, NetworkConnection}; use agama_lib::network::types::{DeviceType, SSID}; use agama_server::network::web::network_service; use agama_server::network::{ self, model::{self, AccessPoint, GeneralState, StateConfig}, Adapter, NetworkAdapterError, NetworkState, }; use async_trait::async_trait; use axum::http::header; use axum::{ body::Body, http::{Method, Request, StatusCode}, Router, }; use common::body_to_string; use serde_json::to_string; use std::error::Error; use tokio::{sync::broadcast, test}; use tower::ServiceExt; async fn build_state() -> NetworkState { let general_state = GeneralState::default(); let device = model::Device { name: String::from("eth0"), type_: DeviceType::Ethernet, ..Default::default() }; let eth0 = model::Connection::new("eth0".to_string(), DeviceType::Ethernet); NetworkState::new(general_state, vec![], vec![device], vec![eth0]) } async fn build_service(state: NetworkState) -> Result<Router, ServiceError> { let adapter = NetworkTestAdapter(state); let (tx, _rx) = broadcast::channel(16); network_service(adapter, tx).await } #[derive(Default)] pub struct NetworkTestAdapter(network::NetworkState); #[async_trait] impl Adapter for NetworkTestAdapter { async fn read(&self, _: StateConfig) -> Result<network::NetworkState, NetworkAdapterError> { Ok(self.0.clone()) } async fn write(&self, _network: &network::NetworkState) -> Result<(), NetworkAdapterError> { unimplemented!("Not used in tests"); } } #[test] async fn test_network_state() -> Result<(), Box<dyn Error>> { let state = build_state().await; let network_service = build_service(state).await?; let request = Request::builder() .uri("/state") .method(Method::GET) .body(Body::empty()) .unwrap(); let response = network_service.oneshot(request).await?; assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; assert!(body.contains(r#""wireless_enabled":false"#)); Ok(()) } #[test] async fn test_change_network_state() -> Result<(), Box<dyn Error>> { let mut state = build_state().await; let network_service = build_service(state.clone()).await?; state.general_state.wireless_enabled = true; let request = Request::builder() .uri("/state") .method(Method::PUT) .header(header::CONTENT_TYPE, "application/json") .body(to_string(&state.general_state)?) .unwrap(); let response = network_service.oneshot(request).await?; assert_eq!(response.status(), StatusCode::OK); let body = response.into_body(); let body = body_to_string(body).await; assert_eq!(body, to_string(&state.general_state)?); Ok(()) } #[test] async fn test_network_connections() -> Result<(), Box<dyn Error>> { let state = build_state().await; let network_service = build_service(state.clone()).await?; let request = Request::builder() .uri("/connections") .method(Method::GET) .body(Body::empty()) .unwrap(); let response = network_service.oneshot(request).await?; assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; assert!(body.contains(r#""id":"eth0""#)); Ok(()) } #[test] async fn test_network_devices() -> Result<(), Box<dyn Error>> { let state = build_state().await; let network_service = build_service(state.clone()).await?; let request = Request::builder() .uri("/devices") .method(Method::GET) .body(Body::empty()) .unwrap(); let response = network_service.oneshot(request).await?; assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; assert!(body.contains(r#""name":"eth0""#)); Ok(()) } #[test] async fn test_network_wifis() -> Result<(), Box<dyn Error>> { let mut state = build_state().await; state.access_points = vec![ AccessPoint { ssid: SSID("AgamaNetwork".as_bytes().into()), hw_address: "00:11:22:33:44:00".into(), ..Default::default() }, AccessPoint { ssid: SSID("AgamaNetwork2".as_bytes().into()), hw_address: "00:11:22:33:44:01".into(), ..Default::default() }, ]; let network_service = build_service(state.clone()).await?; let request = Request::builder() .uri("/wifi") .method(Method::GET) .body(Body::empty()) .unwrap(); let response = network_service.oneshot(request).await?; assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; assert!(body.contains(r#""ssid":"AgamaNetwork""#)); assert!(body.contains(r#""ssid":"AgamaNetwork2""#)); Ok(()) } #[test] async fn test_add_bond_connection() -> Result<(), Box<dyn Error>> { let state = build_state().await; let network_service = build_service(state.clone()).await?; let eth0 = NetworkConnection { id: "eth2".to_string(), ..Default::default() }; let bond0 = NetworkConnection { id: "bond0".to_string(), method4: Some("auto".to_string()), method6: Some("disabled".to_string()), interface: Some("bond0".to_string()), bond: Some(BondSettings { mode: "active-backup".to_string(), ports: vec!["eth0".to_string()], options: Some("primary=eth0".to_string()), }), ..Default::default() }; let request = Request::builder() .uri("/connections") .header("Content-Type", "application/json") .method(Method::POST) .body(serde_json::to_string(ð0)?) .unwrap(); let response = network_service.clone().oneshot(request).await?; assert_eq!(response.status(), StatusCode::OK); let request = Request::builder() .uri("/connections") .header("Content-Type", "application/json") .method(Method::POST) .body(serde_json::to_string(&bond0)?) .unwrap(); let response = network_service.clone().oneshot(request).await?; assert_eq!(response.status(), StatusCode::OK); let request = Request::builder() .uri("/connections") .method(Method::GET) .body(Body::empty()) .unwrap(); let response = network_service.clone().oneshot(request).await?; assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; assert!(body.contains(r#""id":"eth0""#)); assert!(body.contains(r#""id":"bond0""#)); assert!(body.contains(r#""mode":"active-backup""#)); assert!(body.contains(r#""primary=eth0""#)); Ok(()) } 070701000000E8000081A4000000000000000000000001671F5A6400000D15000000000000000000000000000000000000002400000000agama/agama-server/tests/service.rs// Copyright (c) [2024] SUSE LLC // // All Rights Reserved. // // This program is free software; you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation; either version 2 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along // with this program; if not, contact SUSE LLC. // // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. pub mod common; use agama_lib::auth::AuthToken; use agama_server::web::{MainServiceBuilder, ServiceConfig}; use axum::{ body::Body, http::{Method, Request, StatusCode}, response::Response, routing::get, }; use common::body_to_string; use std::{error::Error, path::PathBuf}; use tokio::{sync::broadcast::channel, test}; use tower::ServiceExt; fn public_dir() -> PathBuf { std::env::current_dir().unwrap().join("public") } #[test] async fn test_ping() -> Result<(), Box<dyn Error>> { let config = ServiceConfig::default(); let (tx, _) = channel(16); let web_service = MainServiceBuilder::new(tx, public_dir()) .add_service("/protected", get(protected)) .with_config(config) .build(); let request = Request::builder() .uri("/api/ping") .body(Body::empty()) .unwrap(); let response = web_service.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; assert_eq!(&body, "{\"status\":\"success\"}"); Ok(()) } async fn protected() -> String { "OK".to_string() } async fn access_protected_route(token: &str, jwt_secret: &str) -> Response { let config = ServiceConfig { jwt_secret: jwt_secret.to_string(), }; let (tx, _) = channel(16); let web_service = MainServiceBuilder::new(tx, public_dir()) .add_service("/protected", get(protected)) .with_config(config) .build(); let request = Request::builder() .uri("/api/protected") .method(Method::GET) .header("Authorization", format!("Bearer {}", token)) .body(Body::empty()) .unwrap(); web_service.oneshot(request).await.unwrap() } // TODO: The following test should belong to `auth.rs` #[test] async fn test_access_protected_route() -> Result<(), Box<dyn Error>> { let token = AuthToken::generate("nots3cr3t")?; let response = access_protected_route(token.as_str(), "nots3cr3t").await; assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; assert_eq!(body, "OK"); Ok(()) } // // TODO: The following test should belong to `auth.rs`. #[test] async fn test_access_protected_route_failed() -> Result<(), Box<dyn Error>> { let token = AuthToken::generate("nots3cr3t")?; let response = access_protected_route(token.as_str(), "wrong").await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); Ok(()) } 070701000000E9000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000000A00000000agama/etc070701000000EA000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001200000000agama/etc/agama.d070701000000EB000081A4000000000000000000000001671F5A640000001D000000000000000000000000000000000000001E00000000agama/etc/agama.d/server.yaml--- jwt_secret: "replace-me" 070701000000EC000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000000E00000000agama/package070701000000ED000081A4000000000000000000000001671F5A64000000FB000000000000000000000000000000000000001B00000000agama/package/_constraints<constraints> <hardware> <jobs>4</jobs> <disk> <size unit="G">20</size> </disk> <physicalmemory> <size unit="G">8</size> </physicalmemory> </hardware> <hostlabel exclude="true">SLOW_CPU</hostlabel> </constraints> 070701000000EE000081A4000000000000000000000001671F5A640000057F000000000000000000000000000000000000001700000000agama/package/_service<services> <service name="obs_scm" mode="manual"> <!-- the URL is modified by the .github/workflows/obs-staging-shared.yml action when submitting to OBS --> <param name="url">https://github.com/agama-project/agama.git</param> <param name="versionformat">@PARENT_TAG@+@TAG_OFFSET@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="scm">git</param> <!-- the revision might be changed to "release" branch or a git tag by the .github/workflows/obs-staging-shared.yml action when submitting to OBS --> <param name="revision">master</param> <param name="subdir">rust</param> <param name="without-version">enable</param> <param name="extract">package/agama.changes</param> <param name="extract">package/agama.spec</param> <param name="extract">package/_constraints</param> </service> <service name="cargo_vendor" mode="manual"> <param name="srcdir">agama/rust</param> <param name="compression">zst</param> <param name="update">false</param> </service> <service name="cargo_audit" mode="manual"> <param name="srcdir">agama/rust</param> </service> <service mode="buildtime" name="tar"> <param name="obsinfo">agama.obsinfo</param> <param name="filename">agama</param> </service> <service mode="buildtime" name="set_version"> <param name="basename">agama</param> </service> </services> 070701000000EF000081A4000000000000000000000001671F5A640000880E000000000000000000000000000000000000001C00000000agama/package/agama.changes------------------------------------------------------------------- Mon Oct 28 09:24:48 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Use the correct method to apply the network configuration from the CLI (gh#agama-project/agama#1701). ------------------------------------------------------------------- Wed Oct 23 15:25:36 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Fix the action to download the logs (gh#agama-project/agama#1693). ------------------------------------------------------------------- Tue Oct 22 09:46:41 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Improve OpenAPI specification generation (gh#agama-project/agama#1564): - Add a lot of missing elements to make the specification valid. - Use a xtask to generate the OpenAPI specification at build time. - Ship the specification in a separate package (agama-openapi). ------------------------------------------------------------------- Wed Oct 16 15:07:33 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Add support for running user-defined scripts before and after the installation (gh#agama-project/agama#1673). ------------------------------------------------------------------- Wed Oct 16 07:55:27 UTC 2024 - Michal Filka <mfilka@suse.com> - Implemented option for providing remote API address for the CLI gh#agama-project/agama#1495 ------------------------------------------------------------------- Mon Oct 14 13:53:10 UTC 2024 - Josef Reidinger <jreidinger@suse.com> - CLI: change format for questions answers file from YAML to JSON to be consistent with other commands (gh#agama-project/agama#1667). ------------------------------------------------------------------- Fri Sep 27 13:09:10 UTC 2024 - Jorik Cronenberg <jorik.cronenberg@suse.com> - Fix optional network settings (gh#agama-project/agama#1641). ------------------------------------------------------------------- Fri Sep 27 10:44:22 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Expose keymaps localized descriptions (gh#agama-project/agama#1643). ------------------------------------------------------------------- Wed Sep 25 14:33:50 UTC 2024 - Clemens Famulla-Conrad <cfamullaconrad@suse.com> - Rename wireless key-mgmt value wpa-eap-suite-b192 to wpa-eap-suite-b-192 (gh#agama-project/agama#1640) ------------------------------------------------------------------- Fri Sep 20 11:42:06 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Version 10 ------------------------------------------------------------------- Fri Sep 20 11:24:56 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Change the license to GPL-2.0-or-later (gh#openSUSE/agama#1621). ------------------------------------------------------------------- Tue Sep 18 13:20:47 UTC 2024 - Josef Reidinger <jreidinger@suse.com> - Expose the zFCP D-Bus API through HTTP (gh#openSUSE/agama#1570). ------------------------------------------------------------------- Wed Sep 18 08:27:13 UTC 2024 - Martin Vidner <mvidner@suse.com> - For CLI, use HTTP clients instead of D-Bus clients, final piece: Storage (gh#openSUSE/agama#1600) - added StorageHTTPClient ------------------------------------------------------------------- Wed Sep 13 12:25:28 UTC 2024 - Jorik Cronenberg <jorik.cronenberg@suse.com> - Add additional wireless settings (gh#openSUSE/agama#1602). ------------------------------------------------------------------- Wed Sep 10 15:00:33 UTC 2024 - Jorik Cronenberg <jorik.cronenberg@suse.com> - Implement 802.1x (EAP) in network settings (gh#openSUSE/agama#1597). ------------------------------------------------------------------- Mon Sep 9 09:09:54 UTC 2024 - Martin Vidner <mvidner@suse.com> - For CLI, use HTTP clients instead of D-Bus clients, for Product (name and registration) (gh#openSUSE/agama#1548) - added ProductHTTPClient ------------------------------------------------------------------- Thu Sep 5 16:25:00 UTC 2024 - Lubos Kocman <lubos.kocman@suse.com> - Show product logo in product selector (gh#openSUSE/agama#1415). ------------------------------------------------------------------- Wed Aug 28 12:37:34 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Expose the DASD D-Bus API through HTTP (gh#openSUSE/agama#1532). ------------------------------------------------------------------- Tue Aug 27 13:57:35 UTC 2024 - José Iván López González <jlopez@suse.com> - Schema definition for basic storage settings (gh#openSUSE/agama#1455). ------------------------------------------------------------------- Mon Aug 26 11:19:27 UTC 2024 - Martin Vidner <mvidner@suse.com> - For CLI, use HTTP clients instead of D-Bus clients, for Software (gh#openSUSE/agama#1548) - added SoftwareHTTPClient ------------------------------------------------------------------- Thu Aug 15 08:33:02 UTC 2024 - Josef Reidinger <jreidinger@suse.com> - Use sd_notify for starting agama-web-service to notify systemd when service is ready. It helps with race condition in agama-auto (gh#openSUSE/agama#1539) - improve systemd dependencies of agama-web-service to ensure that agama service runs ------------------------------------------------------------------- Fri Aug 9 08:50:31 UTC 2024 - Martin Vidner <mvidner@suse.com> - For CLI, use HTTP clients instead of D-Bus clients, for Users and Localization (gh#openSUSE/agama#1438) - service clients used by CLI: - added UsersHTTPClient, LocalizationHTTPClient - removed LocalizationClient - BaseHTTPClient API reworked: - return () or deserialized objects - added PUT and PATCH - web service: - PUT /api/users/first: do report backend errors - PATCH /api/users/root: report the (potential) backend errors - tests: - added tests using httpmock - env_logger added to dev-dependencies ------------------------------------------------------------------- Mon Jul 22 15:27:44 UTC 2024 - Josef Reidinger <jreidinger@suse.com> - Fix `agama questions list` to list only unaswered questions and improve its performance (gh#openSUSE/agama#1476) ------------------------------------------------------------------- Wed Jul 17 11:15:33 UTC 2024 - Jorik Cronenberg <jorik.cronenberg@suse.com> - Add dns search domains and ignore-auto-dns to network settings (gh#openSUSE/agama#1330). ------------------------------------------------------------------- Tue Jul 16 11:56:29 UTC 2024 - Josef Reidinger <jreidinger@suse.com> - CLI: -- Add `agama questions list` to get list of unanswered questions -- Add `agama questions ask` to ask for question and wait for answer - agama-lib: -- Add BaseHTTPClient that is base for clients that communicate with agama-web-server (gh#openSUSE/agama#1457) ------------------------------------------------------------------- Wed Jul 10 20:11:39 UTC 2024 - Josef Reidinger <jreidinger@suse.com> - Add to HTTP API a method to remove questions - Add to HTTP API method to get the answer to a question (gh#openSUSE/agama#1453) ------------------------------------------------------------------- Wed Jul 10 10:01:18 UTC 2024 - Josef Reidinger <jreidinger@suse.com> - Add to HTTP API method POST for question to ask new question (gh#openSUSE/agama#1451) ------------------------------------------------------------------- Fri Jul 5 13:17:17 UTC 2024 - José Iván López González <jlopez@suse.com> - Adapt storage model to changes in D-Bus API (gh#openSUSE/agama#1428). ------------------------------------------------------------------- Mon Jul 1 15:50:40 UTC 2024 - José Iván López González <jlopez@suse.com> - Schema definition for guided and AutoYaST storage proposals (gh#openSUSE/agama#1263). ------------------------------------------------------------------- Fri Jun 28 06:56:02 UTC 2024 - Martin Vidner <mvidner@suse.com> - Use gzip (.gz) instead of bzip2 (.bz2) to compress logs so that they can be attached to GitHub issues (gh#openSUSE/agama#1378) ------------------------------------------------------------------- Thu Jun 27 13:22:51 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Version 9 ------------------------------------------------------------------- Thu Jun 27 07:02:29 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Improve the prompt to introduce the password in the "auth login" command (gh#openSUSE/agama#1271). ------------------------------------------------------------------- Wed Jun 26 12:56:31 UTC 2024 - Knut Anderssen <kanderssen@suse.com> - Filter only external configured connections (gh#openSUSE/agama#1383). - Expose more details about devices status in the API (gh#openSUSE/agama#1365). ------------------------------------------------------------------- Wed Jun 26 10:29:05 UTC 2024 - José Iván López González <jlopez@suse.com> - Set and get storage config (gh#openSUSE/agama#1293). ------------------------------------------------------------------- Tue Jun 25 15:16:33 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Use the new SetLocale D-Bus method to change the language and the keyboard layout (gh#openSUSE/agama#1375). ------------------------------------------------------------------- Tue Jun 25 15:04:20 UTC 2024 - David Diaz <dgonzalez@suse.com> - Add resize actions to storage model (gh#openSUSE/agama#1354). ------------------------------------------------------------------- Thu Jun 21 15:00:00 UTC 2024 - Clemens Famulla-Conrad <cfamullaconrad@suse.de> - Add tun/tap model (gh#openSUSE/agama#1353) ------------------------------------------------------------------- Thu Jun 20 12:58:32 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Add a new "config edit" command allows editing installation settings using an external editor (gh#openSUSE/agama#1360). - Remove the "--format" option (gh#openSUSE/agama#1360). ------------------------------------------------------------------- Thu Jun 20 05:32:42 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Add support for progress sequences with pre-defined descriptions (gh#openSUSE/agama#1356). - Fix the "Progress" signal to use camelCase (gh#openSUSE/agama#1356). ------------------------------------------------------------------- Fri Jun 14 06:17:52 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Remove references to the old "config add/set" subcommands (gh#openSUSE/agama/#1338). ------------------------------------------------------------------- Thu Jun 13 10:50:44 UTC 2024 - Knut Anderssen <kanderssen@suse.com> - Apply network changes when connecting or disconnecting (gh#openSUSE/agama#1320). ------------------------------------------------------------------- Thu Jun 13 10:39:57 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Expose Issues API in users-related interface (gh#openSUSE/agama#1202). - Drop the old validations API. ------------------------------------------------------------------- Wed Jun 12 10:15:33 UTC 2024 - Jorik Cronenberg <jorik.cronenberg@suse.com> - Allow writing to loopback connection in agama-server (gh#openSUSE/agama#1318). ------------------------------------------------------------------- Tue Jun 11 21:35:00 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - CLI: use the master token /run/agama/token if available and readable (gh#openSUSE/agama#1287). - CLI: remove the "config add/set" subcommands (gh#openSUSE/agama#1314). ------------------------------------------------------------------- Mon Jun 10 14:24:33 UTC 2024 - Jorik Cronenberg <jorik.cronenberg@suse.com> - Add mtu property for network connections (gh#openSUSE/agama#1101). ------------------------------------------------------------------- Fri Jun 7 05:58:48 UTC 2024 - Michal Filka <mfilka@suse.com> - Improvements in HTTPS setup - self-signed certificate contains hostname - self-signed certificate is stored into default location - before creating new self-signed certificate a default location (/etc/agama.d/ssl) is checked for a certificate - gh#openSUSE/agama#1228 ------------------------------------------------------------------- Wed Jun 5 13:53:59 UTC 2024 - José Iván López González <jlopez@suse.com> - Process the legacyAutoyastStorage section of the profile (gh#openSUSE/agama#1284). ------------------------------------------------------------------- Mon Jun 3 07:49:16 UTC 2024 - Josef Reidinger <jreidinger@suse.com> - CLI: Add new commands "agama download" and "agama profile autoyast" and remove "agama profile download" to separate common curl-like download and autoyast specific one which do conversion to json (gh#openSUSE/agama#1279) ------------------------------------------------------------------- Wed May 29 12:15:37 UTC 2024 - Josef Reidinger <jreidinger@suse.com> - CLI: Add new command "agama profile import" that does the whole autoinstallation processing and loads the configuration (gh#openSUSE/agama#1270). ------------------------------------------------------------------- Wed May 29 11:16:11 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Improve command-line interface help (gh#openSUSE/agama#1269 and (gh#openSUSE/agama#1273). - agama-web-server connects to D-Bus only when needed (gh#openSUSE/agama#1273). ------------------------------------------------------------------- Wed May 29 10:40:21 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - The HTTP request to perform a probing is not blocking anymore (gh#openSUSE/agama#1272). ------------------------------------------------------------------- Mon May 27 14:11:55 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - The "agama auth" command reads the password from the standard input (gh#openSUSE/agama#1265). ------------------------------------------------------------------- Mon May 27 05:49:46 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Add agama.libssonnet to the spec file (gh#openSUSE/agama#1261). ------------------------------------------------------------------- Thu May 23 15:47:28 UTC 2024 - Ladislav Slezák <lslezak@suse.com> - Avoid deadlock when "setxkbmap" call gets stucked, use a timeout (gh#openSUSE/agama#1249) ------------------------------------------------------------------- Wed May 22 12:31:25 UTC 2024 - Josef Reidinger <jreidinger@suse.com> - autoinstallation jsonnet: Inject complete lshw json output and provide helper functions for filtering it (gh#openSUSE/agama#1242) ------------------------------------------------------------------- Fri May 17 09:52:25 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Version 8 ------------------------------------------------------------------- Tue May 16 12:48:42 UTC 2024 - Knut Anderssen <kanderssen@suse.com> - Allow to download Agama logs through the manager HTTP API (gh#openSUSE/agama#1216). ------------------------------------------------------------------- Thu May 16 12:34:43 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Restarting agama.service causes agama-web-server.service to be restarted too (gh#openSUSE/agama#1222). ------------------------------------------------------------------- Thu May 16 12:24:26 UTC 2024 - José Iván López González <jlopez@suse.com> - Small changes in the storage HTTP API (gh#openSUSE/agama#1208): - /storage/proposal/usable_devices (get): returns a list of SIDs instead of device names. - /storage/proposal/settings (put): returns whether the proposal was successfully calculated. ------------------------------------------------------------------- Thu May 16 10:31:38 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - The CLI does not fail when the storage proposal is missing (gh#openSUSE/agama#1220). - Properly detect whether LVM is activated. ------------------------------------------------------------------- Thu May 16 06:19:36 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Change the web server to listen on port 80 by default (gh#openSUSE/agama#1217). ------------------------------------------------------------------- Wed May 15 15:21:30 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Improve logging in the D-Bus and web servers (gh#openSUSE/agama#1215): - Write to the stdout if they are not connected to systemd-journald. - The stdout logger includes the file/line (it was already included when logging to systemd-journald). ------------------------------------------------------------------- Wed May 15 14:08:26 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Do not crash if the /etc/agama.d/locales file does not contain any valid locale (gh#openSUSE/agama#1213). ------------------------------------------------------------------- Tue May 14 12:39:49 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - If present, read the locales list from the /etc/agama.d/locales file (gh#openSUSE/agama#1205). ------------------------------------------------------------------- Tue May 14 10:48:42 UTC 2024 - Knut Anderssen <kanderssen@suse.com> - Dropped the network D-Bus service as it is not needed anymore (gh#openSUSE/agama#1199). ------------------------------------------------------------------- Mon May 13 09:01:21 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Extend the storage HTTP API to support handling the iSCSI configuration (gh#openSUSE/agama#1187). ------------------------------------------------------------------- Mon May 13 08:47:27 UTC 2024 - José Iván López González <jlopez@suse.com> - Provide HTTP API for storage (gh#openSUSE/agama#1175). ------------------------------------------------------------------- Mon May 6 05:13:54 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Extend the HTTP/JSON API: - Localization (gh#openSUSE/agama#1047, gh#openSUSE/agama#1120). - Networking (gh#openSUSE/agama#1064). - Software (gh#openSUSE/agama#1069). - Manager service (gh#openSUSE/agama#1089). - Questions (gh#openSUSE/agama#1091). - Progress interface (gh#openSUSE/agama#1092). - Issues interface (gh#openSUSE/agama#1100). - Users (gh#openSUSE/agama#1117). - Product registration (gh#openSUSE/agama#1146). - Add an "agama-web-server" service (gh#openSUSE/agama/1119). - Fix the generation of the self-signed certificate (gh#openSUSE/agama#1131). - Improve agama-server logging (gh#openSUSE/agama#1143). - Provide frontend translations via the /po.js path (gh#openSUSE/agama#1126). ------------------------------------------------------------------- Wed Mar 13 12:42:58 UTC 2024 - Jorik Cronenberg <jorik.cronenberg@suse.com> - Add infiniband to network model (gh#openSUSE/agama#1032). ------------------------------------------------------------------- Thu Mar 7 10:52:58 UTC 2024 - Michal Filka <mfilka@suse.com> - CLI: added auth command with login / logout / show subcommands for handling authentication token management with new agama web server ------------------------------------------------------------------- Thu Feb 29 09:49:18 UTC 2024 - Ladislav Slezák <lslezak@suse.com> - Web server: - Accept also IPv6 connections (gh#openSUSE/agama#1057) - Added SSL (HTTPS) support (gh#openSUSE/agama#1062) - Use either the cerfificate specified via command line arguments or generate a self-signed certificate - Redirect external HTTP requests to HTTPS - Allow HTTP for internal connections (http://localhost) - Optionally listen on a secondary address (to allow listening on both HTTP/80 and HTTPS/433 ports) ------------------------------------------------------------------- Tue Feb 27 15:55:28 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Reorganize RPM packages (gh#openSUSE/agama#1056): * agama is now the main package and it contains agama-dbus-server and agama-web-server. * agama-cli is a subpackage. ------------------------------------------------------------------- Wed Feb 7 11:49:02 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Add preliminary support to import AutoYaST profiles (gh#openSUSE/agama#1029). ------------------------------------------------------------------- Mon Jan 29 15:53:56 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Better network configuration handling (gh#openSUSE/agama#1006): * Write only changed connections. * Roll back when updating the NetworkManager configuration failed. * Improved error handling when reading or writing the changes. * Properly remove deleted connections from the D-Bus tree. * Use the UUID to identify connections. * Do not support multiple connections with the same ID. ------------------------------------------------------------------- Mon Jan 29 15:37:56 UTC 2024 - Jorik Cronenberg <jorik.cronenberg@suse.com> - Add hidden property for wireless in network model (gh#openSUSE/agama#1024). ------------------------------------------------------------------- Mon Jan 29 10:22:49 UTC 2024 - Jorik Cronenberg <jorik.cronenberg@suse.com> - Add more wireless options to network model (gh#openSUSE/agama#1014). ------------------------------------------------------------------- Thu Jan 23 18:00:00 UTC 2024 - Clemens Famulla-Conrad <cfamullaconrad@suse.de> - Add Bridge model (gh#openSUSE/agama#1008) ------------------------------------------------------------------- Thu Jan 23 17:38:23 UTC 2024 - Clemens Famulla-Conrad <cfamullaconrad@suse.de> - Add VLAN model (gh#openSUSE/agama#918) ------------------------------------------------------------------- Thu Jan 11 15:34:15 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Include the encoding as part of the locales (gh#openSUSE/agama#987). ------------------------------------------------------------------- Mon Jan 8 17:02:40 UTC 2024 - José Iván López González <jlopez@suse.com> - Fix the list of keymaps to avoid duplicated values (gh#openSUSE/agama#981). ------------------------------------------------------------------- Thu Dec 21 14:23:33 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Version 7 ------------------------------------------------------------------- Thu Dec 21 11:12:45 UTC 2023 - Ancor Gonzalez Sosa <ancor@suse.com> - The result of ListTimezones includes the localized country name for each timezone (gh#openSUSE/agama#946) ------------------------------------------------------------------- Fri Dec 15 16:29:20 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Update agama-cli dependencies including the zerocopy crate to address a security alert (see gh#google/zerocopy#716). ------------------------------------------------------------------- Wed Dec 13 22:41:34 UTC 2023 - Knut Anderssen <kanderssen@suse.com> - Add support for bonding connections (gh#openSUSE/agama#885). ------------------------------------------------------------------- Fri Dec 8 09:23:09 UTC 2023 - Josef Reidinger <jreidinger@suse.com> - Change the config in a way that: (gh#openSUSE/agama#919) 1. product is moved to own section and is now under product.id 2. in product section is now also registrationCode and registrationEmail 3. in software section is now patterns to select patterns to install - adapt profile.schema according to above changes - org.opensuse.Agama.Software1 API changed to report missing patterns ------------------------------------------------------------------- Tue Dec 5 11:18:41 UTC 2023 - Jorik Cronenberg <jorik.cronenberg@suse.com> - Add ability to assign a custom MAC address for network connections (gh#openSUSE/agama#893) ------------------------------------------------------------------- Tue Dec 5 09:46:48 UTC 2023 - José Iván López González <jlopez@suse.com> - Explicitly add dependencies instead of relying on the live ISO to provide the required packages (gh#openSUSE/agama/911). ------------------------------------------------------------------- Tue Dec 5 08:56:13 UTC 2023 - Jorik Cronenberg <jorik.cronenberg@suse.com> - Add support for dummy network devices although they are not exposed on D-Bus yet (gh#openSUSE/agama#913). ------------------------------------------------------------------- Sun Dec 3 15:53:34 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Use a single call to systemd-firstboot to write the localization settings (gh#openSUSE/agama#903). ------------------------------------------------------------------- Sat Dec 2 18:05:54 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Version 6 ------------------------------------------------------------------- Wed Nov 29 11:19:51 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Rework the org.opensuse.Agama1.Locale interface (gh#openSUSE/agama#881): * Replace LabelsForLocales function with ListLocales. * Add a ListKeymaps function. * Extend the ListTimezone function to include the translation of each part. * Drop ListUILocales and ListVConsoleKeyboards functions. * Remove the SupportedLocales and VConsoleKeyboard properties. * Do not read the lists of locales, keymaps and timezones on each request. * Peform some validation when trying to change the Locales, Keymap and Timezone properties. ------------------------------------------------------------------- Thu Nov 16 11:06:30 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Update dependencies to compatible versions (gh#openSUSE/agama#874). - Replace tempdir with tempfile to prevent RUSTSEC-2023-0018. ------------------------------------------------------------------- Wed Nov 15 12:35:32 UTC 2023 - José Iván López González <jlopez@suse.com> - Adapt to changes in software D-Bus API (gh#openSUSE/agama#869). ------------------------------------------------------------------- Wed Nov 15 11:27:10 UTC 2023 - Michal Filka <mfilka@suse.com> - Improved "agama logs store" (gh#openSUSE/agama#823) - added an option which allows to define the archive destination ------------------------------------------------------------------- Tue Nov 14 15:44:15 UTC 2023 - Jorik Cronenberg <jorik.cronenberg@suse.com> - Add support for routing to the network model (gh#openSUSE/agama#824) ------------------------------------------------------------------- Mon Oct 23 14:43:59 UTC 2023 - Michal Filka <mfilka@suse.com> - Improved "agama logs store" (gh#openSUSE/agama#812) - the archive file owner is root:root - the permissions is set to r/w for the owner ------------------------------------------------------------------- Mon Oct 23 11:33:40 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Version 5 ------------------------------------------------------------------- Mon Oct 10 07:37:00 UTC 2023 - Michal Filka <mfilka@suse.com> - Improve file and directory names in "agama logs store". - Add an "agama logs list" subcommand. ------------------------------------------------------------------- Tue Sep 26 15:57:14 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Version 4 ------------------------------------------------------------------- Tue Sep 26 12:05:52 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Wait until the manager is ready before probing (gh#openSUSE/agama#771). ------------------------------------------------------------------- Mon Sep 25 11:32:53 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Add support for IPv6 network settings (gh#openSUSE/agama#761). ------------------------------------------------------------------- Mon Sep 25 10:46:53 UTC 2023 - Michal Filka <mfilka@suse.com> - CLI: added (sub)commands for handling logs. "store" subcommand is similar to what old save_y2logs did. (gh#openSUSE/agama#757) ------------------------------------------------------------------- Tue Sep 19 11:16:16 UTC 2023 - José Iván López González <jlopez@suse.com> - Adapt to new storage D-Bus API and explicitly call to probe after selecting a new product (gh#openSUSE/agama#748). ------------------------------------------------------------------- Thu Sep 14 19:44:57 UTC 2023 - Josef Reidinger <jreidinger@suse.com> - Improve questions CLI help text (gh#openSUSE/agama#754) ------------------------------------------------------------------- Thu Sep 14 10:10:37 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Use a single D-Bus service to connect to the manager and the users API (gh#openSUSE/agama#753, follow-up of gh#openSUSE/agama#729). ------------------------------------------------------------------- Wed Sep 13 09:27:22 UTC 2023 - Knut Anderssen <kanderssen@suse.com> - Allow to bind a connection to an specific interface through its name or through a set of match settings (gh#opensSUSE/agama#723). ------------------------------------------------------------------- Thu Aug 31 10:30:28 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Use a single D-Bus service to expose locale, network and questions settings (gh#openSUSE/agama#729). ------------------------------------------------------------------- Wed Aug 30 12:57:59 UTC 2023 - Josef Reidinger <jreidinger@suse.com> - Locale service: add value for UI locale (gh#openSUSE/agama#725) ------------------------------------------------------------------- Thu Aug 3 08:34:14 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Move the settings functionality to a separate package, agama-settings (gh#openSUSE/agama#666). - Make the "Settings" derive macro reusable from other crates. - Extend the "Settings" derive macro to generate code for InstallSettings and NetworkSettings. - Improve error reporting when working with the "config" subcommand. ------------------------------------------------------------------- Wed Aug 2 10:03:18 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Version 3 ------------------------------------------------------------------- Wed Jul 26 11:08:09 UTC 2023 - Josef Reidinger <jreidinger@suse.com> - CLI: add to "questions" command "answers" subcommand to set file with predefined answers - dbus-server: add "AddAnswersFile" method to Questions service (gh#openSUSE/agama#669) ------------------------------------------------------------------- Tue Jul 18 13:32:04 UTC 2023 - Josef Reidinger <jreidinger@suse.com> - Add to CLI "questions" subcommand with mode option to set interactive and non-interactive mode (gh#openSUSE/agama#668) ------------------------------------------------------------------- Mon Jul 17 13:36:56 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Fix the logic to decide which network connections to write due to a bug introduced in gh#openSUSE/agama#662 (gh#openSUSE/agama#667). ------------------------------------------------------------------- Mon Jul 17 09:16:38 UTC 2023 - Josef Reidinger <jreidinger@suse.com> - Adapt to new questions D-Bus API to allow automatic answering of questions when requested (gh#openSUSE/agama#637, reverts gh#openSUSE/agama#649 as now default option is mandatory) ------------------------------------------------------------------- Thu Jul 13 10:22:36 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Improve error reporting in the command-line interface (gh#openSUSE/agama#659 and gh#openSUSE/agama#660). ------------------------------------------------------------------- Thu Jul 13 08:56:40 UTC 2023 - José Iván López González <jlopez@suse.com> - Read the storage candidate devices and show them with "agama config show" (gh#openSUSE/agama#658). ------------------------------------------------------------------- Fri Jul 7 14:12:03 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Improve the progress reporting (gh#openSUSE/agama#653). ------------------------------------------------------------------- Thu Jul 6 09:13:47 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Improve the waiting logic and implement a retry mechanism for the "agama install" command (bsc#1213047). ------------------------------------------------------------------- Wed Jul 5 11:11:20 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Fix the questions service to handle questions with no default option (gh#openSUSE/agama#649). ------------------------------------------------------------------- Thu Jun 1 08:14:14 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Add a localization D-Bus service (gh#openSUSE/agama#533). - Add a network D-Bus service (gh#openSUSE/agama#571). ------------------------------------------------------------------- Tue May 23 11:51:26 UTC 2023 - Martin Vidner <mvidner@suse.com> - Version 2.1 ------------------------------------------------------------------- Mon May 22 12:29:20 UTC 2023 - Martin Vidner <mvidner@suse.com> - Version 2 ------------------------------------------------------------------- Thu May 11 11:00:11 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Import root authentication settings when reading a Jsonnet file (bsc#1211300, gh#openSUSE/agama#573). - Do not export the SSH public key as an empty string when it is not defined. ------------------------------------------------------------------- Fri Mar 24 14:36:36 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Version 0.2: * Add validation for software and users settings (gh#yast/agama-cli#48, gh#yast/agama-cli#51). * Better error reporting when the bus is not found (gh#yast/agama-cli#48). * Improve the progress reporting mechanism, although it is still a work in progress (gh#yast/agama-cli#50). ------------------------------------------------------------------- Wed Mar 22 09:39:29 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Add support for setting root authentication mechanisms (gh#yast/agama-cli#47). ------------------------------------------------------------------- Tue Mar 21 16:06:02 UTC 2023 - Martin Vidner <mvidner@suse.com> - Do not fall back to the system D-Bus (gh#yast/agama-cli#45). ------------------------------------------------------------------- Wed Mar 21 13:28:01 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - Use JSON as the default format (gh#yast/agama-cli#46). ------------------------------------------------------------------- Tue Mar 21 08:55:39 UTC 2023 - Josef Reidinger <jreidinger@suse.com> - Fix the path of the JSON schema (gh#yast/agama-cli#44). ------------------------------------------------------------------- Thu Mar 16 11:56:42 UTC 2023 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com> - First version of the package: * Querying and setting simple values. * Adding elements to collections * Handling of auto-installation profiles. * Basic error handling - 0.1 070701000000F0000081A4000000000000000000000001671F5A6400001BB0000000000000000000000000000000000000001900000000agama/package/agama.spec# # spec file for package agama # # Copyright (c) 2023-2024 SUSE LLC # # All modifications and additions to the file contributed by third parties # remain the property of their copyright owners, unless otherwise agreed # upon. The license for this file, and modifications and additions to the # file, is the same license as for the pristine package itself (unless the # license for the pristine package is not an Open Source License, in which # case the license is the MIT License). An "Open Source License" is a # license that conforms to the Open Source Definition (Version 1.9) # published by the Open Source Initiative. # Please submit bugfixes or comments via http://bugs.opensuse.org/ # Name: agama # This will be set by osc services, that will run after this. Version: 0 Release: 0 Summary: Agama Installer # If you know the license, put it's SPDX string here. # Alternately, you can use cargo lock2rpmprovides to help generate this. License: GPL-2.0-or-later Url: https://github.com/opensuse/agama Source0: agama.tar Source1: vendor.tar.zst BuildRequires: cargo-packaging BuildRequires: pkgconfig(openssl) # used in tests for dbus service BuildRequires: dbus-1-common Requires: dbus-1-common # required by agama-dbus-server integration tests BuildRequires: dbus-1-daemon BuildRequires: clang-devel BuildRequires: pkgconfig(pam) # required by autoinstallation Requires: jsonnet Requires: lshw # required by "agama logs store" Requires: gzip # required to compress the manual pages Requires: tar # required for translating the keyboards descriptions BuildRequires: xkeyboard-config-lang Requires: xkeyboard-config-lang # required for getting the list of timezones Requires: timezone BuildRequires: timezone # required for getting the languages information BuildRequires: python-langtable-data Requires: python-langtable-data # dependency on the YaST part of Agama Requires: agama-yast # conflicts with the old packages Conflicts: agama-dbus-server %description Agama is a service-based Linux installer. It is composed of an HTTP-based API, a web user interface, a command-line interface and a D-Bus service which exposes part of the YaST libraries. %package -n agama-cli # This will be set by osc services, that will run after this. Version: 0 Release: 0 Summary: Agama command-line interface License: GPL-2.0-only Url: https://github.com/opensuse/agama %description -n agama-cli Command line program to interact with the Agama installer. %package -n agama-cli-bash-completion Summary: Bash Completion for %{name}-cli Group: System/Shells Supplements: (%{name}-cli and bash-completion) Requires: %{name}-cli = %{version} Requires: bash-completion BuildArch: noarch %description -n agama-cli-bash-completion Bash command-line completion support for %{name}. %package -n agama-cli-fish-completion Summary: Fish Completion for %{name}-cli Group: System/Shells Supplements: (%{name}-cli and fish) Requires: %{name}-cli = %{version} Requires: fish BuildArch: noarch %description -n agama-cli-fish-completion Fish command-line completion support for %{name}-cli. %package -n agama-cli-zsh-completion Summary: Zsh Completion for %{name}-cli Group: System/Shells Supplements: (%{name}-cli and zsh) Requires: %{name}-cli = %{version} Requires: zsh BuildArch: noarch %description -n agama-cli-zsh-completion Zsh command-line completion support for %{name}-cli. %package -n agama-openapi Summary: Agama's OpenAPI Specification %description -n agama-openapi The OpenAPI Specification (OAS) allows describing an HTTP API in an standard and language-agnostic way. This package contains the specification for Agama's HTTP API. %prep %autosetup -a1 -n agama # Remove exec bits to prevent an issue in fedora shebang checking. Uncomment only if required. # find vendor -type f -name \*.rs -exec chmod -x '{}' \; %build %{cargo_build} cargo run --package xtask -- manpages gzip out/man/* cargo run --package xtask -- completions cargo run --package xtask -- openapi %install install -D -d -m 0755 %{buildroot}%{_bindir} install -m 0755 %{_builddir}/agama/target/release/agama %{buildroot}%{_bindir}/agama install -m 0755 %{_builddir}/agama/target/release/agama-dbus-server %{buildroot}%{_bindir}/agama-dbus-server install -m 0755 %{_builddir}/agama/target/release/agama-web-server %{buildroot}%{_bindir}/agama-web-server install -D -p -m 644 %{_builddir}/agama/share/agama.pam $RPM_BUILD_ROOT%{_pam_vendordir}/agama install -D -d -m 0755 %{buildroot}%{_datadir}/agama-cli install -m 0644 %{_builddir}/agama/agama-lib/share/profile.schema.json %{buildroot}%{_datadir}/agama-cli install -m 0644 %{_builddir}/agama/share/agama.libsonnet %{buildroot}%{_datadir}/agama-cli install --directory %{buildroot}%{_datadir}/dbus-1/agama-services install -m 0644 --target-directory=%{buildroot}%{_datadir}/dbus-1/agama-services %{_builddir}/agama/share/org.opensuse.Agama1.service install -D -m 0644 %{_builddir}/agama/share/agama-web-server.service %{buildroot}%{_unitdir}/agama-web-server.service # install manpages mkdir -p %{buildroot}%{_mandir}/man1 install -m 0644 %{_builddir}/agama/out/man/* %{buildroot}%{_mandir}/man1/ # install shell completion scripts install -Dm644 %{_builddir}/agama/out/shell/%{name}.bash %{buildroot}%{_datadir}/bash-completion/completions/%{name} install -Dm644 %{_builddir}/agama/out/shell/_%{name} %{buildroot}%{_datadir}/zsh/site-functions/_%{name} install -Dm644 %{_builddir}/agama/out/shell/%{name}.fish %{buildroot}%{_datadir}/fish/vendor_completions.d/%{name}.fish # install OpenAPI specification mkdir -p %{buildroot}%{_datadir}/agama/openapi install -m 0644 %{_builddir}/agama/out/openapi/* %{buildroot}%{_datadir}/agama/openapi %check PATH=$PWD/share/bin:$PATH %ifarch aarch64 /usr/bin/cargo auditable test -j1 --offline --no-fail-fast %else echo $PATH %{cargo_test} %endif %pre %service_add_pre agama-web-server.service %post %service_add_post agama-web-server.service %preun %service_del_preun agama-web-server.service %postun %service_del_postun_with_restart agama-web-server.service %files %doc README.md %license LICENSE %{_bindir}/agama-dbus-server %{_bindir}/agama-web-server %{_datadir}/dbus-1/agama-services %{_pam_vendordir}/agama %{_unitdir}/agama-web-server.service %files -n agama-cli %{_bindir}/agama %dir %{_datadir}/agama-cli %{_datadir}/agama-cli/agama.libsonnet %{_datadir}/agama-cli/profile.schema.json %{_mandir}/man1/agama*1%{?ext_man} %files -n agama-cli-bash-completion %{_datadir}/bash-completion/* %files -n agama-cli-fish-completion %dir %{_datadir}/fish %{_datadir}/fish/* %files -n agama-cli-zsh-completion %dir %{_datadir}/zsh %{_datadir}/zsh/* %files -n agama-openapi %dir %{_datadir}/agama %dir %{_datadir}/agama/openapi %{_datadir}/agama/openapi/*.json %changelog 070701000000F1000081A4000000000000000000000001671F5A6400000011000000000000000000000000000000000000001300000000agama/rustfmt.tomledition = "2021" 070701000000F2000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000000C00000000agama/share070701000000F3000081A4000000000000000000000001671F5A64000001B9000000000000000000000000000000000000002500000000agama/share/agama-web-server.service[Unit] Description=Agama Web Server # agama-hostname might change the host name which is used when creating # a self signed certificate, run it before the web server After=network-online.target agama.service agama-hostname.service BindsTo=agama.service [Service] Type=notify ExecStart=/usr/bin/agama-web-server serve --address :::80 --address2 :::443 PIDFile=/run/agama/web.pid User=root TimeoutStopSec=5 [Install] WantedBy=default.target 070701000000F4000081A4000000000000000000000001671F5A640000052D000000000000000000000000000000000000001C00000000agama/share/agama.libsonnet// function go throught lshw output and enlist only given class. // Basically it is same as calling `lshw -class <class>`. // @param lshw: Object with content of `lshw -json` // @param class: String with class identifier as can be found in "class" element of lshw // @return Array of objects with given class selectByClass(lshw, class):: local selectClass_(parent, class) = if std.objectHas(parent, 'class') && parent.class == class then [ parent ] else if std.objectHas(parent, 'children') then std.flattenArrays(std.prune(std.map(function(x) selectClass_(x, class), parent.children ))) else []; local result = selectClass_(lshw, class); result, // function go throught lshw output and returns object with given "id" or null if not found. // @param lshw: Object with content of `lshw -json` // @param id: String with identifier as can be found in "id" element of lshw // @return Object with given id or null findByID(lshw, id):: local findID_(parent, id) = if std.objectHas(parent, 'id') && parent.id == id then [parent] else if std.objectHas(parent, 'children') then std.flattenArrays(std.prune(std.map(function(x) findID_(x, id), parent.children ))) else null; local result = findID_(lshw, id); if std.length(result) > 0 then result[0] else null, 070701000000F5000081A4000000000000000000000001671F5A6400000042000000000000000000000000000000000000001600000000agama/share/agama.pam#%PAM-1.0 auth include common-auth account include common-account 070701000000F6000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001000000000agama/share/bin070701000000F7000081A4000000000000000000000001671F5A6400000123000000000000000000000000000000000000001A00000000agama/share/bin/README.mdThis directory contains commands that replaces real ones during CI testing. The reason is that these commands might not work in the CI environment (e.g., systemd related commands). To use these "binaries" in the tests, just set the right PATH: ``` PATH=$PWD/share/bin:$PATH cargo test ``` 070701000000F8000081ED000000000000000000000001671F5A640000006C000000000000000000000000000000000000001A00000000agama/share/bin/localectl#!/usr/bin/env sh SCRIPT=$(readlink -f "$0") DATADIR=$(dirname "$SCRIPT")/.. cat $DATADIR/localectl-$1.txt 070701000000F9000081A4000000000000000000000001671F5A6400000E19000000000000000000000000000000000000001B00000000agama/share/dbus-test.conf<!-- This configuration file controls the Agama Installer message bus. It is based on /usr/share/dbus-1/session.conf but including some changes: - Type is set to "org.opensuse.Agama". - Removed the policy section for the default context. - Added a new policy section for the root user. - The local-session.conf file is not read. --> <!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd"> <busconfig> <type>org.opensuse.Agama</type> <!-- If we fork, keep the user's original umask to avoid affecting the behavior of child processes. --> <keep_umask/> <listen>unix:tmpdir=/tmp</listen> <!-- On Unix systems, the most secure authentication mechanism is EXTERNAL, which uses credential-passing over Unix sockets. This authentication mechanism is not available on Windows, is not suitable for use with the tcp: or nonce-tcp: transports, and will not work on obscure flavours of Unix that do not have a supported credentials-passing mechanism. On those platforms/transports, comment out the <auth> element to allow fallback to DBUS_COOKIE_SHA1. --> <auth>EXTERNAL</auth> <!-- only root can own the services --> <policy context="default"> <!-- Allow everything to be sent --> <allow send_destination="*" eavesdrop="true"/> <!-- Allow everything to be received --> <allow eavesdrop="true"/> <allow own="org.opensuse.Agama1" /> <allow own="org.opensuse.Agama.Manager1" /> <allow own="org.opensuse.Agama.Software1" /> <allow own="org.opensuse.Agama.Storage1" /> <!-- only root can send anything to the services --> <allow send_destination="org.opensuse.Agama1" /> <allow send_destination="org.opensuse.Agama.Manager1" /> <allow send_destination="org.opensuse.Agama.Software1" /> <allow send_destination="org.opensuse.Agama.Storage1" /> </policy> <include if_selinux_enabled="yes" selinux_root_relative="yes">contexts/dbus_contexts</include> <!-- For the session bus, override the default relatively-low limits with essentially infinite limits, since the bus is just running as the user anyway, using up bus resources is not something we need to worry about. In some cases, we do set the limits lower than "all available memory" if exceeding the limit is almost certainly a bug, having the bus enforce a limit is nicer than a huge memory leak. But the intent is that these limits should never be hit. --> <!-- the memory limits are 1G instead of say 4G because they can't exceed 32-bit signed int max --> <limit name="max_incoming_bytes">1000000000</limit> <limit name="max_incoming_unix_fds">250000000</limit> <limit name="max_outgoing_bytes">1000000000</limit> <limit name="max_outgoing_unix_fds">250000000</limit> <limit name="max_message_size">1000000000</limit> <!-- We do not override max_message_unix_fds here since the in-kernel limit is also relatively low --> <limit name="service_start_timeout">600000</limit> <limit name="auth_timeout">240000</limit> <limit name="pending_fd_timeout">150000</limit> <limit name="max_completed_connections">100000</limit> <limit name="max_incomplete_connections">10000</limit> <limit name="max_connections_per_user">100000</limit> <limit name="max_pending_service_starts">10000</limit> <limit name="max_names_per_connection">50000</limit> <limit name="max_match_rules_per_connection">50000</limit> <limit name="max_replies_per_connection">50000</limit> </busconfig> 070701000000FA000081A4000000000000000000000001671F5A64000018D1000000000000000000000000000000000000002700000000agama/share/localectl-list-keymaps.txt3l ANSI-dvorak Pl02 adnw al al-plisi amiga-de amiga-us apple-a1048-sv apple-a1243-sv apple-a1243-sv-fn-reverse apple-internal-0x0253-sv apple-internal-0x0253-sv-fn-reverse applkey ara at at-mac at-nodeadkeys atari-de atari-se atari-uk-falcon atari-us az azerty ba ba-alternatequotes ba-unicode ba-unicodeus ba-us backspace bashkir be be-iso-alternate be-latin1 be-nodeadkeys be-oss be-oss_latin9 be-wang bg-cp1251 bg-cp855 bg_bds-cp1251 bg_bds-utf8 bg_pho-cp1251 bg_pho-utf8 bone br br-abnt br-abnt-alt br-abnt2 br-abnt2-old br-dvorak br-latin1-abnt2 br-latin1-us br-nativo br-nativo-epo br-nativo-us br-nodeadkeys br-thinkpad by by-cp1251 by-latin bywin-cp1251 ca ca-eng ca-fr-dvorak ca-fr-legacy ca-multix carpalx carpalx-full cf ch ch-de_mac ch-de_nodeadkeys ch-fr ch-fr_mac ch-fr_nodeadkeys ch-legacy chinese cm cm-azerty cm-dvorak cm-french cm-mmuock cm-qwerty cn cn-altgr-pinyin cn-latin1 croat ctrl cz cz-bksl cz-cp1250 cz-dvorak-ucw cz-lat2 cz-lat2-prog cz-lat2-us cz-qwerty cz-qwerty-mac cz-qwerty_bksl cz-rus cz-us-qwertz cz-winkeys cz-winkeys-qwerty de de-T3 de-deadacute de-deadgraveacute de-deadtilde de-dsb de-dsb_qwertz de-dvorak de-e1 de-e2 de-latin1 de-latin1-nodeadkeys de-mac de-mac_nodeadkeys de-mobii de-neo de-nodeadkeys de-qwerty de-ro de-ro_nodeadkeys de-tr de-us de_CH-latin1 de_alt_UTF-8 defkeymap defkeymap_V1.0 dk dk-dvorak dk-latin1 dk-mac dk-mac_nodeadkeys dk-nodeadkeys dk-winkeys dvorak dvorak-ca-fr dvorak-de dvorak-es dvorak-fr dvorak-l dvorak-la dvorak-no dvorak-programmer dvorak-r dvorak-ru dvorak-sv-a1 dvorak-sv-a5 dvorak-uk dvorak-ukp dz dz-azerty-deadkeys dz-qwerty-gb-deadkeys dz-qwerty-us-deadkeys ee ee-dvorak ee-nodeadkeys ee-us emacs emacs2 en en-latin9 epo epo-legacy es es-ast es-cat es-cp850 es-deadtilde es-dvorak es-nodeadkeys es-olpc es-winkeys et et-nodeadkeys euro euro1 euro2 fa fi fi-classic fi-kotoistus fi-mac fi-nodeadkeys fi-smi fi-winkeys fo fo-nodeadkeys fr fr-afnor fr-azerty fr-bepo fr-bepo-latin9 fr-bepo_afnor fr-bepo_latin9 fr-bre fr-dvorak fr-latin1 fr-latin9 fr-latin9_nodeadkeys fr-mac fr-nodeadkeys fr-oci fr-oss fr-oss_latin9 fr-oss_nodeadkeys fr-pc fr-us fr_CH fr_CH-latin1 gb gb-colemak gb-colemak_dh gb-dvorak gb-dvorakukp gb-extd gb-gla gb-intl gb-mac gb-mac_intl gb-pl ge ge-ergonomic ge-mess ge-ru gh gh-akan gh-avn gh-ewe gh-fula gh-ga gh-generic gh-gillbt gh-hausa gr gr-pc hr hr-alternatequotes hr-unicode hr-unicodeus hr-us hu hu-101_qwerty_comma_dead hu-101_qwerty_comma_nodead hu-101_qwerty_dot_dead hu-101_qwerty_dot_nodead hu-101_qwertz_comma_dead hu-101_qwertz_comma_nodead hu-101_qwertz_dot_dead hu-101_qwertz_dot_nodead hu-102_qwerty_comma_dead hu-102_qwerty_comma_nodead hu-102_qwerty_dot_dead hu-102_qwerty_dot_nodead hu-102_qwertz_comma_dead hu-102_qwertz_comma_nodead hu-102_qwertz_dot_dead hu-102_qwertz_dot_nodead hu-nodeadkeys hu-qwerty hu-standard hu101 id ie ie-CloGaelach ie-UnicodeExpert ie-ogam_is434 il il-heb il-phonetic il-si2 in-eng in-iipa iq-ku iq-ku_alt iq-ku_ara iq-ku_f ir ir-ku ir-ku_alt ir-ku_ara ir-ku_f is is-dvorak is-latin1 is-latin1-us is-mac is-mac_legacy it it-fur it-geo it-ibm it-intl it-mac it-nodeadkeys it-scn it-us it-winkeys it2 jp jp-OADG109A jp-dvorak jp-kana86 jp106 kazakh ke ke-kik keypad khmer koy kr kr-kr104 ky_alt_sh-UTF-8 kyrgyz kz-latin la-latin1 latam latam-colemak latam-deadtilde latam-dvorak latam-nodeadkeys lk-us lt lt-ibm lt-lekp lt-lekpa lt-ratise lt-sgs lt-std lt-us lt.baltic lt.l4 lt.std lv lv-adapted lv-apostrophe lv-ergonomic lv-fkey lv-modern lv-tilde ma-french ma-rif mac-Pl02 mac-be mac-br-abnt2 mac-cz-us-qwertz mac-de-latin1 mac-de-latin1-nodeadkeys mac-de_CH mac-dk-latin1 mac-dvorak mac-es mac-euro mac-euro2 mac-fi-latin1 mac-fr mac-fr-legacy mac-fr_CH-latin1 mac-gr mac-hu mac-it mac-jp106 mac-no-latin1 mac-pl mac-pt-latin1 mac-ru1 mac-se mac-template mac-uk mac-us md md-gag me me-latinalternatequotes me-latinunicode me-latinunicodeyz me-latinyz mk mk-cp1251 mk-utf mk0 ml ml-fr-oss ml-us-intl ml-us-mac mm mm-mnw mm-shn mod-dh-ansi-us mod-dh-ansi-us-awing mod-dh-ansi-us-fatz mod-dh-ansi-us-fatz-wide mod-dh-ansi-us-wide mod-dh-iso-uk mod-dh-iso-uk-wide mod-dh-iso-us mod-dh-iso-us-wide mod-dh-matrix-us mt mt-alt-gb mt-alt-us mt-us neo neoqwertz ng ng-hausa ng-igbo ng-yoruba nl nl-mac nl-std nl-us nl2 no no-colemak no-colemak_dh no-colemak_dh_wide no-dvorak no-latin1 no-mac no-mac_nodeadkeys no-nodeadkeys no-smi no-smi_nodeadkeys no-winkeys nz nz-mao pc110 ph ph-capewell-dvorak ph-capewell-qwerf2k6 ph-colemak ph-dvorak pl pl-csb pl-dvorak pl-dvorak_altquotes pl-dvorak_quotes pl-dvp pl-legacy pl-qwertz pl-szl pl1 pl2 pl3 pl4 pt pt-latin1 pt-latin9 pt-mac pt-mac_nodeadkeys pt-nativo pt-nativo-epo pt-nativo-us pt-nodeadkeys ro ro-latin2 ro-std ro-winkeys ro_std ro_win rs-latin rs-latinalternatequotes rs-latinunicode rs-latinunicodeyz rs-latinyz ru ru-cp1251 ru-cv_latin ru-ms ru-ruchey_en ru-yawerty ru1 ru1_win-utf ru2 ru3 ru4 ru_win ruwin_alt-CP1251 ruwin_alt-KOI8-R ruwin_alt-UTF-8 ruwin_alt_sh-UTF-8 ruwin_cplk-CP1251 ruwin_cplk-KOI8-R ruwin_cplk-UTF-8 ruwin_ct_sh-CP1251 ruwin_ct_sh-KOI8-R ruwin_ct_sh-UTF-8 ruwin_ctrl-CP1251 ruwin_ctrl-KOI8-R ruwin_ctrl-UTF-8 se se-dvorak se-fi-ir209 se-fi-lat6 se-ir209 se-lat6 se-latin1 se-mac se-nodeadkeys se-smi se-svdvorak se-us se-us_dvorak sg sg-latin1 sg-latin1-lk450 si si-alternatequotes si-us sk sk-bksl sk-prog-qwerty sk-prog-qwertz sk-qwerty sk-qwerty_bksl sk-qwertz slovene sr-cy sr-latin sun-pl sun-pl-altgraph sundvorak sunkeymap sunt4-es sunt4-fi-latin1 sunt4-no-latin1 sunt5-cz-us sunt5-de-latin1 sunt5-es sunt5-fi-latin1 sunt5-fr-latin1 sunt5-ru sunt5-uk sunt5-us-cz sunt6-uk sv-latin1 sy-ku sy-ku_alt sy-ku_f taiwanese tj_alt-UTF8 tm tm-alt tr tr-alt tr-e tr-f tr-intl tr-ku tr-ku_alt tr-ku_f tr_f-latin5 tr_q-latin5 tralt trf trq ttwin_alt-UTF-8 ttwin_cplk-UTF-8 ttwin_ct_sh-UTF-8 ttwin_ctrl-UTF-8 tw tw-indigenous tw-saisiyat ua ua-cp1251 ua-crh ua-crh_alt ua-crh_f ua-utf ua-utf-ws ua-ws uk unicode us us-acentos us-acentos-old us-alt-intl us-altgr-intl us-colemak us-colemak_dh us-colemak_dh_iso us-colemak_dh_ortho us-colemak_dh_wide us-colemak_dh_wide_iso us-dvorak us-dvorak-alt-intl us-dvorak-classic us-dvorak-intl us-dvorak-l us-dvorak-mac us-dvorak-r us-dvp us-euro us-haw us-hbs us-intl us-mac us-norman us-olpc2 us-symbolic us-workman us-workman-intl us1 uz-latin vn vn-fr vn-us wangbe wangbe2 windowkeys 070701000000FB000081A4000000000000000000000001671F5A6400000000000000000000000000000000000000000000002600000000agama/share/localectl-list-locale.txt070701000000FC000081A4000000000000000000000001671F5A6400000714000000000000000000000000000000000000002700000000agama/share/localectl-list-locales.txtC.UTF-8 aa_DJ.UTF-8 af_ZA.UTF-8 an_ES.UTF-8 ar_AE.UTF-8 ar_BH.UTF-8 ar_DZ.UTF-8 ar_EG.UTF-8 ar_IQ.UTF-8 ar_JO.UTF-8 ar_KW.UTF-8 ar_LB.UTF-8 ar_LY.UTF-8 ar_MA.UTF-8 ar_OM.UTF-8 ar_QA.UTF-8 ar_SA.UTF-8 ar_SD.UTF-8 ar_SY.UTF-8 ar_TN.UTF-8 ar_YE.UTF-8 ast_ES.UTF-8 be_BY.UTF-8 bg_BG.UTF-8 bhb_IN.UTF-8 br_FR.UTF-8 bs_BA.UTF-8 ca_AD.UTF-8 ca_ES.UTF-8 ca_FR.UTF-8 ca_IT.UTF-8 cs_CZ.UTF-8 cy_GB.UTF-8 da_DK.UTF-8 de_AT.UTF-8 de_BE.UTF-8 de_CH.UTF-8 de_DE.UTF-8 de_IT.UTF-8 de_LI.UTF-8 de_LU.UTF-8 el_CY.UTF-8 el_GR.UTF-8 en_AU.UTF-8 en_BW.UTF-8 en_CA.UTF-8 en_DK.UTF-8 en_GB.UTF-8 en_HK.UTF-8 en_IE.UTF-8 en_NZ.UTF-8 en_PH.UTF-8 en_SC.UTF-8 en_SG.UTF-8 en_US.UTF-8 en_ZA.UTF-8 en_ZW.UTF-8 es_AR.UTF-8 es_BO.UTF-8 es_CL.UTF-8 es_CO.UTF-8 es_CR.UTF-8 es_DO.UTF-8 es_EC.UTF-8 es_ES.UTF-8 es_GT.UTF-8 es_HN.UTF-8 es_MX.UTF-8 es_NI.UTF-8 es_PA.UTF-8 es_PE.UTF-8 es_PR.UTF-8 es_PY.UTF-8 es_SV.UTF-8 es_US.UTF-8 es_UY.UTF-8 es_VE.UTF-8 et_EE.UTF-8 eu_ES.UTF-8 fi_FI.UTF-8 fo_FO.UTF-8 fr_BE.UTF-8 fr_CA.UTF-8 fr_CH.UTF-8 fr_FR.UTF-8 fr_LU.UTF-8 ga_IE.UTF-8 gd_GB.UTF-8 gl_ES.UTF-8 gv_GB.UTF-8 he_IL.UTF-8 hr_HR.UTF-8 hsb_DE.UTF-8 hu_HU.UTF-8 id_ID.UTF-8 is_IS.UTF-8 it_CH.UTF-8 it_IT.UTF-8 ja_JP.UTF-8 ka_GE.UTF-8 kk_KZ.UTF-8 kl_GL.UTF-8 ko_KR.UTF-8 ku_TR.UTF-8 kw_GB.UTF-8 lg_UG.UTF-8 lt_LT.UTF-8 lv_LV.UTF-8 mg_MG.UTF-8 mi_NZ.UTF-8 mk_MK.UTF-8 ms_MY.UTF-8 mt_MT.UTF-8 nb_NO.UTF-8 nl_BE.UTF-8 nl_NL.UTF-8 nn_NO.UTF-8 no_NO.UTF-8 oc_FR.UTF-8 om_KE.UTF-8 pl_PL.UTF-8 pt_BR.UTF-8 pt_PT.UTF-8 ro_RO.UTF-8 ru_RU.UTF-8 ru_UA.UTF-8 sk_SK.UTF-8 sl_SI.UTF-8 so_DJ.UTF-8 so_KE.UTF-8 so_SO.UTF-8 sq_AL.UTF-8 st_ZA.UTF-8 sv_FI.UTF-8 sv_SE.UTF-8 tcy_IN.UTF-8 tg_TJ.UTF-8 th_TH.UTF-8 tl_PH.UTF-8 tr_CY.UTF-8 tr_TR.UTF-8 uk_UA.UTF-8 uz_UZ.UTF-8 wa_BE.UTF-8 xh_ZA.UTF-8 yi_US.UTF-8 zh_CN.UTF-8 zh_HK.UTF-8 zh_SG.UTF-8 zh_TW.UTF-8 zu_ZA.UTF-8 070701000000FD000081A4000000000000000000000001671F5A6400000053000000000000000000000000000000000000002800000000agama/share/org.opensuse.Agama1.service[D-BUS Service] Name=org.opensuse.Agama1 Exec=/usr/bin/agama-dbus-server User=root 070701000000FE000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000000C00000000agama/xtask070701000000FF000081A4000000000000000000000001671F5A6400000144000000000000000000000000000000000000001700000000agama/xtask/Cargo.toml[package] name = "xtask" version = "0.1.0" rust-version.workspace = true edition.workspace = true [dependencies] agama-cli = { path = "../agama-cli" } agama-server = { path = "../agama-server" } clap = { version = "4.5.19", default-features = false } clap-markdown = "0.1.4" clap_complete = "4.5.32" clap_mangen = "0.2.23" 07070100000100000081A4000000000000000000000001671F5A64000003BE000000000000000000000000000000000000001600000000agama/xtask/README.md# Agama project tasks This package implements a set of project tasks following the [xtask pattern](https://github.com/matklad/cargo-xtask). This pattern allows writing the typical maintenance tasks using Rust code. ## Defined tasks - `manpages`: generates manpages for the command-line interface. - `completions`: generates shell completion snippets for Bash, Fish and Zsh. - `markdown`: generates a manual page for the command-line interface in Markdown format. Useful to be included in our website. ## Running a task To run a task, just type `cargo xtask TASK` where `TASK` is the name of the task (check the [Defined tasks](#defined-tasks) section). ```shell cargo xtask manpages ``` Most of the artifacts are generated in an `out` directory. You can modify the target by setting the `OUT_DIR` environment variable. ## Writing a new task Tasks are defined using regular Rust code. Check the [main.rs](src/main.rs) file for further information. 07070100000101000041ED000000000000000000000002671F5A6400000000000000000000000000000000000000000000001000000000agama/xtask/src07070100000102000081A4000000000000000000000001671F5A6400000F9E000000000000000000000000000000000000001800000000agama/xtask/src/main.rsuse std::{env, path::PathBuf}; mod tasks { use std::{fs::File, io::Write, path::Path}; use agama_cli::Cli; use agama_server::web::docs::{ ApiDocBuilder, L10nApiDocBuilder, ManagerApiDocBuilder, MiscApiDocBuilder, NetworkApiDocBuilder, QuestionsApiDocBuilder, SoftwareApiDocBuilder, StorageApiDocBuilder, UsersApiDocBuilder, }; use clap::CommandFactory; use clap_complete::aot; use clap_markdown::MarkdownOptions; use crate::create_output_dir; /// Generate auto-completion snippets for common shells. pub fn generate_completions() -> std::io::Result<()> { let out_dir = create_output_dir("shell")?; let mut cmd = Cli::command(); clap_complete::generate_to(aot::Bash, &mut cmd, "agama", &out_dir)?; clap_complete::generate_to(aot::Fish, &mut cmd, "agama", &out_dir)?; clap_complete::generate_to(aot::Zsh, &mut cmd, "agama", &out_dir)?; println!("Generate shell completions at {}.", out_dir.display()); Ok(()) } /// Generate Agama's CLI documentation in markdown format. pub fn generate_markdown() -> std::io::Result<()> { let out_dir = create_output_dir("markdown")?; let options = MarkdownOptions::new() .title("Command-line reference".to_string()) .show_footer(false); let markdown = clap_markdown::help_markdown_custom::<Cli>(&options); let filename = out_dir.join("agama.md"); let mut file = File::create(&filename)?; file.write_all(markdown.as_bytes())?; println!("Generate Markdown documentation at {}.", filename.display()); Ok(()) } /// Generate Agama's CLI man pages. pub fn generate_manpages() -> std::io::Result<()> { let out_dir = create_output_dir("man")?; let cmd = Cli::command(); clap_mangen::generate_to(cmd, &out_dir)?; println!("Generate manpages documentation at {}.", out_dir.display()); Ok(()) } /// Generate Agama's OpenAPI specification. pub fn generate_openapi() -> std::io::Result<()> { let out_dir = create_output_dir("openapi")?; write_openapi(L10nApiDocBuilder {}, out_dir.join("l10n.json"))?; write_openapi(ManagerApiDocBuilder {}, out_dir.join("manager.json"))?; write_openapi(NetworkApiDocBuilder {}, out_dir.join("network.json"))?; write_openapi(SoftwareApiDocBuilder {}, out_dir.join("software.json"))?; write_openapi(StorageApiDocBuilder {}, out_dir.join("storage.json"))?; write_openapi(UsersApiDocBuilder {}, out_dir.join("users.json"))?; write_openapi(QuestionsApiDocBuilder {}, out_dir.join("questions.json"))?; write_openapi(MiscApiDocBuilder {}, out_dir.join("misc.json"))?; println!( "Generate the OpenAPI specification at {}.", out_dir.display() ); Ok(()) } fn write_openapi<T, P: AsRef<Path>>(builder: T, path: P) -> std::io::Result<()> where T: ApiDocBuilder, { let openapi = builder.build().to_pretty_json()?; let mut file = File::create(path)?; file.write_all(openapi.as_bytes())?; Ok(()) } } fn create_output_dir(name: &str) -> std::io::Result<PathBuf> { let out_dir = std::env::var_os("OUT_DIR") .map(PathBuf::from) .unwrap_or(PathBuf::from("out")) .join(name); std::fs::create_dir_all(&out_dir)?; Ok(out_dir) } fn main() -> std::io::Result<()> { let Some(task) = env::args().nth(1) else { eprintln!("You must specify a xtask"); std::process::exit(1); }; match task.as_str() { "completions" => tasks::generate_completions(), "markdown" => tasks::generate_markdown(), "manpages" => tasks::generate_manpages(), "openapi" => tasks::generate_openapi(), other => { eprintln!("Unknown task '{}'", other); std::process::exit(1); } } } 07070100000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000B00000000TRAILER!!!2445 blocks
Locations
Projects
Search
Status Monitor
Help
OpenBuildService.org
Documentation
API Documentation
Code of Conduct
Contact
Support
@OBShq
Terms
openSUSE Build Service is sponsored by
The Open Build Service is an
openSUSE project
.
Sign Up
Log In
Places
Places
All Projects
Status Monitor