diff --git a/Cargo.lock b/Cargo.lock index 0df96ed8..67d734ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -670,9 +670,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -681,9 +681,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" dependencies = [ "cc", "cmake", @@ -874,19 +874,20 @@ dependencies = [ [[package]] name = "borsh" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ "borsh-derive", + "bytes", "cfg_aliases", ] [[package]] name = "borsh-derive" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" dependencies = [ "once_cell", "proc-macro-crate", @@ -943,7 +944,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" dependencies = [ "rust_decimal", - "schemars 1.2.1", + "schemars", "serde", "utf8-width", ] @@ -1834,7 +1835,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde_core", ] [[package]] @@ -2772,6 +2772,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "http" version = "0.1.0" @@ -3180,7 +3191,6 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", - "serde", ] [[package]] @@ -3375,9 +3385,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "ittapi" @@ -3998,6 +4008,7 @@ dependencies = [ "colored", "futures-util", "hex", + "hostname", "indexmap 2.13.0", "libcommon", "libdatastore", @@ -6165,7 +6176,6 @@ dependencies = [ "js-sys", "log", "mime", - "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -6651,9 +6661,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", "ring", @@ -6664,7 +6674,7 @@ dependencies = [ [[package]] name = "rustpython" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" +source = "git+https://github.com/RustPython/RustPython.git#a1203ae207d5db054418f6b29a23d21b24eb6327" dependencies = [ "cfg-if", "dirs-next", @@ -6672,7 +6682,6 @@ dependencies = [ "lexopt", "libc", "log", - "ruff_python_parser", "rustpython-compiler", "rustpython-pylib", "rustpython-stdlib", @@ -6684,7 +6693,7 @@ dependencies = [ [[package]] name = "rustpython-codegen" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" +source = "git+https://github.com/RustPython/RustPython.git#a1203ae207d5db054418f6b29a23d21b24eb6327" dependencies = [ "ahash 0.8.12", "bitflags 2.11.0", @@ -6707,7 +6716,7 @@ dependencies = [ [[package]] name = "rustpython-common" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" +source = "git+https://github.com/RustPython/RustPython.git#a1203ae207d5db054418f6b29a23d21b24eb6327" dependencies = [ "ascii", "bitflags 2.11.0", @@ -6735,7 +6744,7 @@ dependencies = [ [[package]] name = "rustpython-compiler" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" +source = "git+https://github.com/RustPython/RustPython.git#a1203ae207d5db054418f6b29a23d21b24eb6327" dependencies = [ "ruff_python_ast", "ruff_python_parser", @@ -6749,7 +6758,7 @@ dependencies = [ [[package]] name = "rustpython-compiler-core" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" +source = "git+https://github.com/RustPython/RustPython.git#a1203ae207d5db054418f6b29a23d21b24eb6327" dependencies = [ "bitflags 2.11.0", "bitflagset", @@ -6764,7 +6773,7 @@ dependencies = [ [[package]] name = "rustpython-derive" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" +source = "git+https://github.com/RustPython/RustPython.git#a1203ae207d5db054418f6b29a23d21b24eb6327" dependencies = [ "rustpython-compiler", "rustpython-derive-impl", @@ -6774,7 +6783,7 @@ dependencies = [ [[package]] name = "rustpython-derive-impl" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" +source = "git+https://github.com/RustPython/RustPython.git#a1203ae207d5db054418f6b29a23d21b24eb6327" dependencies = [ "itertools 0.14.0", "maplit", @@ -6790,7 +6799,7 @@ dependencies = [ [[package]] name = "rustpython-doc" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" +source = "git+https://github.com/RustPython/RustPython.git#a1203ae207d5db054418f6b29a23d21b24eb6327" dependencies = [ "phf 0.13.1", ] @@ -6798,7 +6807,7 @@ dependencies = [ [[package]] name = "rustpython-literal" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" +source = "git+https://github.com/RustPython/RustPython.git#a1203ae207d5db054418f6b29a23d21b24eb6327" dependencies = [ "hexf-parse", "is-macro", @@ -6811,7 +6820,7 @@ dependencies = [ [[package]] name = "rustpython-pylib" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" +source = "git+https://github.com/RustPython/RustPython.git#a1203ae207d5db054418f6b29a23d21b24eb6327" dependencies = [ "glob", "rustpython-compiler-core", @@ -6821,7 +6830,7 @@ dependencies = [ [[package]] name = "rustpython-sre_engine" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" +source = "git+https://github.com/RustPython/RustPython.git#a1203ae207d5db054418f6b29a23d21b24eb6327" dependencies = [ "bitflags 2.11.0", "num_enum", @@ -6832,7 +6841,7 @@ dependencies = [ [[package]] name = "rustpython-stdlib" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" +source = "git+https://github.com/RustPython/RustPython.git#a1203ae207d5db054418f6b29a23d21b24eb6327" dependencies = [ "adler32", "ahash 0.8.12", @@ -6869,7 +6878,6 @@ dependencies = [ "mt19937", "nix 0.30.1", "num-complex", - "num-integer", "num-traits", "num_enum", "oid-registry", @@ -6907,9 +6915,7 @@ dependencies = [ "unic-ucd-age", "unic-ucd-bidi", "unic-ucd-category", - "unic-ucd-ident", "unicode-bidi-mirroring", - "unicode-casing", "unicode_names2 2.0.0", "uuid", "webpki-roots", @@ -6923,7 +6929,7 @@ dependencies = [ [[package]] name = "rustpython-vm" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" +source = "git+https://github.com/RustPython/RustPython.git#a1203ae207d5db054418f6b29a23d21b24eb6327" dependencies = [ "ahash 0.8.12", "ascii", @@ -6979,7 +6985,6 @@ dependencies = [ "strum 0.27.2", "strum_macros 0.28.0", "thiserror 2.0.18", - "thread_local", "timsort", "uname", "unic-ucd-bidi", @@ -6995,7 +7000,7 @@ dependencies = [ [[package]] name = "rustpython-wtf8" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" +source = "git+https://github.com/RustPython/RustPython.git#a1203ae207d5db054418f6b29a23d21b24eb6327" dependencies = [ "ascii", "bstr", @@ -7073,18 +7078,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "schemars" version = "1.2.1" @@ -7274,37 +7267,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_with" -version = "3.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" -dependencies = [ - "base64", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.13.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -7793,8 +7755,6 @@ dependencies = [ name = "sysinspect-client" version = "0.1.0" dependencies = [ - "base64", - "hex", "libcommon", "libsysinspect", "log", @@ -7802,8 +7762,6 @@ dependencies = [ "rpassword", "serde", "serde_json", - "sodiumoxide", - "syswebclient", "tokio", ] @@ -7841,6 +7799,7 @@ dependencies = [ "serde_json", "serde_yaml", "sled", + "tempfile", "tokio", "tokio-rustls", "uuid", @@ -7851,6 +7810,7 @@ name = "sysminion" version = "0.4.0" dependencies = [ "async-trait", + "chrono", "clap", "colored", "daemonize", @@ -7921,18 +7881,6 @@ dependencies = [ "winx", ] -[[package]] -name = "syswebclient" -version = "0.1.1" -dependencies = [ - "reqwest 0.12.28", - "serde", - "serde_json", - "serde_repr", - "serde_with", - "url", -] - [[package]] name = "tap" version = "1.0.1" @@ -10449,18 +10397,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", diff --git a/docs/apidoc/overview.rst b/docs/apidoc/overview.rst index 613b371c..fb6f63a3 100644 --- a/docs/apidoc/overview.rst +++ b/docs/apidoc/overview.rst @@ -10,7 +10,7 @@ The Web API is automatically documented using Swagger, which provides a user-fri and their parameters. You can access the Swagger UI at the following URL: ``` -http://:4202/api-doc/ +http://:4202/doc/ ``` This interface, running on default port **4202**, allows you to interact with the API, view the available endpoints, diff --git a/docs/genusage/cli.rst b/docs/genusage/cli.rst index 9004740c..cd1da748 100644 --- a/docs/genusage/cli.rst +++ b/docs/genusage/cli.rst @@ -71,14 +71,51 @@ cluster: .. code-block:: bash sysinspect --sync - sysinspect --online sysinspect --shutdown sysinspect --unregister 30006546535e428aba0a0caa6712e225 ``--sync`` instructs minions to refresh cluster artefacts and then report their current traits back to the master. -``--online`` prints the current online-minion summary directly to stdout. +Network Operations +------------------ + +The ``network`` subcommand groups transport and minion-presence +operations. + +.. code-block:: bash + + sysinspect network --status + sysinspect network --status --pending + sysinspect network --status --idle 'db*' + sysinspect network --rotate 'web*' + sysinspect network --rotate --id 30006546535e428aba0a0caa6712e225 + sysinspect network --online + sysinspect network --online --traits 'system.os.name:Ubuntu' + sysinspect network --info --id 30006546535e428aba0a0caa6712e225 + sysinspect network --info db01.example.net + +Supported operations: + +* ``--status`` prints managed transport state for the selected minions +* ``--rotate`` stages or dispatches transport key rotation for the selected minions +* ``--online`` prints online-state summaries for the selected minions +* ``--info`` prints detailed registry-backed minion information for exactly one minion + +Supported selectors: + +* ``--id`` targets one minion by System Id +* ``--query`` or trailing positional query targets minions by hostname glob +* ``--traits`` further narrows the target set by traits query +* if no query is provided, the default selector is ``*`` + +For ``--info``, broad selectors are rejected. Use either one hostname/FQDN or ``--id``. + +Transport status filters: + +* ``--all`` shows all selected minions; this is the default +* ``--pending`` shows only minions with a non-idle rotation state +* ``--idle`` shows only minions with an idle rotation state Traits Management ----------------- diff --git a/docs/genusage/overview.rst b/docs/genusage/overview.rst index 513ff548..bcbef776 100644 --- a/docs/genusage/overview.rst +++ b/docs/genusage/overview.rst @@ -47,6 +47,7 @@ sections: cli distributed_model + secure_transport systraits targeting virtual_minions diff --git a/docs/genusage/secure_transport.rst b/docs/genusage/secure_transport.rst new file mode 100644 index 00000000..a9964dca --- /dev/null +++ b/docs/genusage/secure_transport.rst @@ -0,0 +1,437 @@ +Secure Master/Minion Transport +============================== + +This page explains, in user-facing terms, how Sysinspect protects traffic +between a Master and its Minions. + +For most users, the important point is simple: Master/Minion communication is +secured automatically. You normally do not need to configure the transport by +hand or understand the protocol internals. + +This page is only about the Master/Minion link. It does not describe the Web +API. + +What You Need To Know +--------------------- + +- Sysinspect secures traffic between the Master and Minions automatically. +- The Master/Minion link does not use browser-style TLS certificates. +- Trust is based on the identities of the Master and the Minion. +- Session protection is created automatically when they connect. + +In normal operation, there is nothing special you need to do beyond registering +the Minion and keeping the trust relationship intact. + +Why This Matters +---------------- + +Sysinspect is designed to work in environments where ordinary TLS deployment +can be inconvenient, especially on embedded or unstable networks. For example: + +- DNS may not exist +- IP addresses may change +- reconnects may happen often + +Because of that, Sysinspect uses its own secure Master/Minion transport instead +of requiring a certificate-based TLS setup for every node. + +What Sysinspect Manages For You +------------------------------- + +Sysinspect keeps transport state on disk so it can maintain the trusted +relationship automatically. + +You may see these files: + +- on a Minion: ``transport/master/state.json`` +- on a Master: ``transport/minions//state.json`` + +These files are managed by Sysinspect. They are not meant to be part of normal +day-to-day configuration. + +In normal use, Sysinspect handles: + +- preparing transport state when a Minion is registered +- refreshing transport state when services start +- tracking the information needed to re-establish trust + +You do not need to create or copy transport secrets yourself. + +How It Works On The Master +-------------------------- + +In simple terms, the Master side works like this: + +1. The Master has its own RSA identity keypair. +2. When you register a Minion, the Master stores that Minion's public RSA key. +3. The Master also creates managed transport state for that Minion under + ``transport/minions//state.json``. +4. When the Minion connects, it starts a secure bootstrap using the already + trusted identities. +5. The Master checks that the Minion is known, that the fingerprints match, + and that the bootstrap request belongs to this protocol version. +6. If everything matches, the Master accepts the bootstrap and creates a secure + session for that TCP connection. +7. After that point, normal Master-to-Minion traffic is sent through the secure + session instead of plain JSON frames. +8. If the bootstrap is broken, unsupported, or duplicated, the Master rejects + it and drops the connection. + +What this means for an operator: + +- registration prepares the trust relationship +- reconnects can create a fresh secure session +- one broken connection does not require you to rebuild keys by hand + +How It Works On The Minion +-------------------------- + +The Minion side follows the same trust relationship from the other direction: + +1. The Minion has its own RSA identity keypair. +2. During registration, the Minion learns the Master's public RSA key. +3. The Minion stores managed transport state under + ``transport/master/state.json``. +4. On normal startup, the Minion loads that managed state and starts a secure + bootstrap before it begins processing normal Master commands. +5. If the Master accepts the bootstrap, the Minion switches the connection to a + secure session. +6. Commands, pings, trait updates, events, and other normal traffic then use + that secure session automatically. +7. If the secure bootstrap fails, the Minion reconnects instead of silently + continuing on an insecure path. +8. If the Minion no longer has trusted transport data, it does not continue in + plain mode. It stops and waits for secure recovery, re-bootstrap, or + re-registration. + +What this means for an operator: + +- a healthy Minion should secure the connection automatically on startup +- replacing or re-registering a Minion may require a fresh trust relationship +- if trust data is stale, recovery should use re-bootstrap or re-registration, + not hand-edited files +- if the Minion cannot prove trust anymore, it should fail closed rather than + quietly continue insecurely + +What Actually Protects The Traffic +---------------------------------- + +The protection happens in two steps: + +1. RSA identity keys prove who the Master and Minion are. +2. A short-lived secure session protects the normal traffic after bootstrap. + +So the long-term trust comes from the registered identities, while everyday +traffic is protected by a fresh session created when the connection starts. + +What Operators Should Do +------------------------ + +For regular administration, the best approach is: + +- let Sysinspect manage the transport state +- use the normal registration or recovery workflow if trust breaks +- check that the Master and Minion still recognize each other as trusted peers + +Avoid manual fixes unless you are doing low-level recovery work: + +- do not edit ``state.json`` files casually +- do not copy secrets or key material between systems by hand +- do not treat transport state as ordinary configuration + +When Something Breaks +--------------------- + +If a Minion can no longer establish a secure connection, the usual causes are: + +- the node was reinstalled or re-registered +- trust metadata is stale +- the Master and Minion no longer agree on identity + +In those cases, prefer the supported recovery path such as re-registration or +re-bootstrap instead of editing transport files manually. + +Transport Rotation Workflow +--------------------------- + +Sysinspect supports managed transport-key rotation from the Master control +plane. + +For operators, this means: + +- you can rotate one target in one command path +- online Minions receive a signed rotation intent immediately +- offline Minions are marked as pending and receive rotation on next reconnect +- old key material is kept briefly as ``retiring`` and then removed after the + grace overlap window + +Typical usage: + +- rotate a specific minion id: ``sysinspect network --rotate --id `` +- rotate by selector query: ``sysinspect network --rotate ''`` +- inspect transport status: ``sysinspect network --status `` + +Practical Rotation Procedure +---------------------------- + +For day-to-day operation, the usual workflow is: + +1. Check which Minions are online. +2. Inspect current transport state. +3. Run rotation for one Minion or a selected group. +4. Wait for reconnect/bootstrap. +5. Confirm the new active key and ``last-rotated`` timestamp. + +In practice this looks like: + +1. Show online Minions: + + .. code-block:: bash + + sysinspect network --online + +2. Inspect one Minion before rotation: + + .. code-block:: bash + + sysinspect network --status + +3. Rotate that Minion with the default overlap window: + + .. code-block:: bash + + sysinspect network --rotate + +4. Inspect it again after it reconnects: + + .. code-block:: bash + + sysinspect network --status + +If the Minion is online, Sysinspect sends the signed rotation intent +immediately. + +If the Minion is offline, Sysinspect stores the exact requested rotation as a +pending action and sends it when that Minion reconnects later. + +What Each Rotation Option Does +------------------------------ + +The current operator-facing rotation options are: + +``network --rotate `` +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Starts a managed transport rotation. + +- If ```` looks like a plain Minion id, Sysinspect targets that exact + Minion. +- If ```` contains glob characters such as ``*`` or a comma-separated + selector list, Sysinspect treats it as a query target. + +Examples: + +.. code-block:: bash + + sysinspect network --rotate minion-42 + sysinspect network --rotate 'edge-*' + sysinspect network --rotate 'edge-1,edge-2' + +``network --rotate --rotate-overlap `` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Controls how long the old key remains in ``retiring`` state before it is +removed. + +- Default: ``900`` seconds +- Purpose: give unstable or slow-to-reconnect systems a grace window during + cutover + +Example: + +.. code-block:: bash + + sysinspect network --rotate minion-42 --rotate-overlap 1800 + +That keeps the previous key material around for 30 minutes before retirement +cleanup removes it. + +``network --rotate --rotate-reason `` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Adds operator-visible context to the signed rotation intent. + +- This is recorded in the staged rotation request. +- If the Minion is offline, the same reason is preserved and replayed later. + +Example: + +.. code-block:: bash + + sysinspect network --rotate minion-42 --rotate-reason quarterly-maintenance + +``network --status `` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Shows current managed transport state for one Minion or a selected set. + +The status output includes: + +- active transport key id +- key age +- last successful handshake time +- current rotation state +- ``security.transport.last-rotated-at`` + +Examples: + +.. code-block:: bash + + sysinspect network --status minion-42 + sysinspect network --status 'edge-*' + +Related Operational Options +--------------------------- + +These options are not rotation-specific, but they are commonly used together +with transport-key rotation: + +``network --online`` +~~~~~~~~~~~~~~~~~~ + +Shows which registered Minions are currently online. + +Use this before rotation to decide whether the request is likely to be applied +immediately or deferred until reconnect. + +.. code-block:: bash + + sysinspect network --online + +``--sync`` +~~~~~~~~~~ + +Synchronises modules, libraries, profiles, and related managed artefacts +across the cluster. + +This is separate from transport-key rotation, but operators often run it after +maintenance work when they want Minions to be both current and reconnected. + +.. code-block:: bash + + sysinspect --sync + +``--unregister `` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Removes a Minion's registration and its managed transport metadata from the +Master. + +Use this only when you intend to force a fresh trust relationship. + +.. code-block:: bash + + sysinspect --unregister minion-42 + +After unregistering, the Minion must be registered again before normal secure +bootstrap can continue. + +What Actually Happens During Rotation +------------------------------------- + +From an operator point of view, a rotation request does the following: + +1. The Master creates a new managed transport key record with fresh secret + material. +2. The Master signs a rotation intent with its RSA identity. +3. The Minion verifies that signed intent against the trusted Master RSA key. +4. The Minion updates its managed transport state and reconnects. +5. The next secure bootstrap uses the new managed transport key material. +6. After the overlap window expires, the old key is retired and removed. + +This means rotation changes real managed transport secret material, not only a +label or identifier. + +Recommended Operator Patterns +----------------------------- + +For one Minion: + +.. code-block:: bash + + sysinspect network --status minion-42 + sysinspect network --rotate minion-42 --rotate-reason planned-maintenance + sysinspect network --status minion-42 + +For a group with a longer grace window: + +.. code-block:: bash + + sysinspect network --online + sysinspect network --rotate 'edge-*' --rotate-overlap 3600 --rotate-reason staged-rollout + sysinspect network --status 'edge-*' + +For offline or unstable Minions: + +- issue the same rotation command normally +- let Sysinspect keep the request pending +- verify the result after the Minion reconnects with ``network --status`` + +The status view includes: + +- active transport key id +- key age +- last successful handshake timestamp +- current rotation state +- ``security.transport.last-rotated-at`` value + +Rotation Safety Model +--------------------- + +Rotation intents are signed by the Master RSA trust anchor and verified by the +Minion before any transport-state changes are applied. + +If message construction fails after state staging on the Master, the Master +restores the previous state automatically. This provides a safe automatic +rollback path without requiring operators to perform manual state surgery. + +Unregister Cleanup +------------------ + +Removing a Minion from the Master removes both: + +- the Minion registration RSA artifact +- the Minion managed transport state directory + +This keeps registration lifecycle and transport lifecycle consistent. + +Disaster Recovery +----------------- + +Use managed recovery flows for key loss or metadata corruption. + +Lost or Corrupted Minion Transport State +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Recreate trust from the Master side by rotating (or re-registering if + needed). +2. Restart the Minion to force a fresh secure bootstrap. +3. Confirm status via ``sysinspect network --status ``. + +Lost Master-Side Transport Metadata For One Minion +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Keep the Minion registration key if still valid. +2. Trigger ``sysinspect network --rotate `` to stage a new signed intent. +3. Let the Minion reconnect and apply the new state. + +Master Rebuild Scenario +~~~~~~~~~~~~~~~~~~~~~~~ + +If the Master identity changes, existing trust bindings are no longer valid. +In that case: + +1. Re-establish Master RSA trust according to the registration workflow. +2. Re-register Minions to bind them to the rebuilt Master identity. +3. Run rotation/status checks to verify all nodes have fresh managed transport + state. diff --git a/docs/global_config.rst b/docs/global_config.rst index 4bd38684..ee1e8d6e 100644 --- a/docs/global_config.rst +++ b/docs/global_config.rst @@ -129,6 +129,29 @@ Master Sysinspect Master configuration is located under earlier mentioned ``master`` section, and contains the following directives: +``socket`` +########## + + Type: **string** + + Path to the local Unix socket that the ``sysinspect`` CLI uses to talk to + the running Sysinspect Master process. + + In practice, this is the control socket on the master host itself. When you + run a command such as ``sysinspect apply ...``, the CLI writes the request to + this socket and the master daemon forwards the work to minions over the + network. This setting does **not** change the network listener for minions; + that is controlled by ``bind.ip`` and ``bind.port``. + + Default value is ``/var/run/sysinspect-master.socket``. + + Change this value only if you need the socket in a different location, for + example because ``/var/run`` is not writable in your environment or you want + to keep runtime files under another service directory. If you change it, + make sure both the master service and the ``sysinspect`` command use the + same configuration file, otherwise the CLI will not be able to reach the + master. + ``console.bind.ip`` ################### @@ -261,21 +284,15 @@ Below are directives for the configuration of the File Server service: .. important:: - The WebAPI uses ``libsodium`` for encryption instead of standard SSL/TLS. This is because embedded - or IoT networks may lack DNS or have changing IP addresses, making SSL/TLS certificates unreliable, - as they are tied to specific DNS names or IPs. To connect to the WebAPI, use the Sysinspect client, - which authenticates using an RSA keypair and symmetric keys over an internal protocol. + When enabled, the WebAPI serves its OpenAPI documentation through Swagger UI. + The documentation endpoint is available at ``http://:/doc/``. - The URL the Swagger UI is typically is running over unencrypted plain text HTTP protocol at ``http://:/api-doc/``. + The Swagger UI is typically available at ``http://:/doc/``. Default port is ``4202``. .. note:: - Swagger UI is a web-based interface for the WebAPI service, allowing users to interact with the API. - However it runs only if development mode is enabled, because it relies on unencrypted HTTP traffic - and API requires a proper protocol interaction that cannot be achieved with Swagger UI. - - **In development mode authentication is fully disabled and no traffic is encrypted.** + Swagger UI is served whenever the WebAPI is enabled. Default is ``true``. @@ -312,12 +329,13 @@ Below are directives for the configuration of the File Server service: Type: **boolean** - Enable or disable development mode for the WebAPI service. + Enable or disable development-only helpers for the WebAPI service. .. danger:: - This option is exclusively only for development purposes! If it is enabled, Swagger UI will be running - on + This option is exclusively for development purposes. If enabled, the + authentication endpoint returns a static token and the development query + endpoint is exposed. Default is ``false``. @@ -460,6 +478,7 @@ Example configuration for the Sysinspect Master: config: master: + socket: /var/run/sysinspect-master.socket console.bind.ip: 127.0.0.1 console.bind.port: 4203 bind.ip: 0.0.0.0 diff --git a/etc/sysinspect.conf.sample b/etc/sysinspect.conf.sample index 0265731c..93b3d5d3 100644 --- a/etc/sysinspect.conf.sample +++ b/etc/sysinspect.conf.sample @@ -113,6 +113,7 @@ config: pidfile: /tmp/sysinspect.pid # API configuration + # Set api.devmode only when you need auth bypass and the development query endpoint. api.devmode: false api.enabled: true diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index c9bcd234..9b9c96a8 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -94,10 +94,7 @@ impl SysInspectModPakMinion { return Err(SysinspectError::MasterGeneralError(format!("Failed to get modpak index: {}", resp.status()))); } - let buff = resp - .bytes() - .await - .map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to read modpak index response: {e}")))?; + let buff = resp.bytes().await.map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to read modpak index response: {e}")))?; let idx = ModPakRepoIndex::from_yaml(&String::from_utf8_lossy(&buff))?; Ok(idx) } @@ -115,10 +112,7 @@ impl SysInspectModPakMinion { return Err(SysinspectError::MasterGeneralError(format!("Failed to get profiles index: {}", resp.status()))); } - let buff = resp - .bytes() - .await - .map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to read profiles index response: {e}")))?; + let buff = resp.bytes().await.map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to read profiles index response: {e}")))?; Self::validate_profiles_index(ModPakProfilesIndex::from_yaml(&String::from_utf8_lossy(&buff))?) } @@ -149,12 +143,9 @@ impl SysInspectModPakMinion { { fs::create_dir_all(parent)?; } - fs::write(&dst, resp.bytes().await.map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to read response: {e}")))? )?; + fs::write(&dst, resp.bytes().await.map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to read response: {e}")))?)?; if !get_file_sha256(dst.clone())?.eq(profile.checksum()) { - return Err(SysinspectError::MasterGeneralError(format!( - "Checksum mismatch for profile {}", - name.bright_yellow() - ))); + return Err(SysinspectError::MasterGeneralError(format!("Checksum mismatch for profile {}", name.bright_yellow()))); } } @@ -166,10 +157,7 @@ impl SysInspectModPakMinion { return Ok(()); } - Err(SysinspectError::MasterGeneralError(format!( - "Invalid profile path in profiles.index: {}", - path.display() - ))) + Err(SysinspectError::MasterGeneralError(format!("Invalid profile path in profiles.index: {}", path.display()))) } fn validate_profiles_index(index: ModPakProfilesIndex) -> Result { @@ -180,7 +168,9 @@ impl SysInspectModPakMinion { Ok(index) } - fn filtered_repo_index(&self, ridx: ModPakRepoIndex, profiles: &ModPakProfilesIndex, names: &[String]) -> Result { + fn filtered_repo_index( + &self, ridx: ModPakRepoIndex, profiles: &ModPakProfilesIndex, names: &[String], + ) -> Result { if profiles.profiles().is_empty() { return Ok(ridx); } @@ -198,7 +188,8 @@ impl SysInspectModPakMinion { let mut libraries = IndexSet::new(); for name in found { if let Some(profile) = profiles.get(&name) { - ModPakProfile::from_yaml(&fs::read_to_string(self.cfg.profiles_dir().join(profile.file()))?)?.merge_into(&mut modules, &mut libraries); + ModPakProfile::from_yaml(&fs::read_to_string(self.cfg.profiles_dir().join(profile.file()))?)? + .merge_into(&mut modules, &mut libraries); } } @@ -496,9 +487,9 @@ impl SysInspectModPak { /// Load the on-disk profiles index published next to the module repository. fn get_profiles_index(&self) -> Result { - SysInspectModPakMinion::validate_profiles_index(ModPakProfilesIndex::from_yaml( - &fs::read_to_string(self.root.parent().unwrap_or(&self.root).join(REPO_PROFILES_INDEX))?, - )?) + SysInspectModPakMinion::validate_profiles_index(ModPakProfilesIndex::from_yaml(&fs::read_to_string( + self.root.parent().unwrap_or(&self.root).join(REPO_PROFILES_INDEX), + )?)?) } /// Persist the profiles index next to the module repository. @@ -509,29 +500,20 @@ impl SysInspectModPak { fn validate_profile_name_and_file(name: &str, file: &Path) -> Result<(), SysinspectError> { if name.contains('/') || name.contains('\\') || name.contains("..") { - return Err(SysinspectError::MasterGeneralError(format!( - "Invalid profile name {}", - name.bright_yellow() - ))); + return Err(SysinspectError::MasterGeneralError(format!("Invalid profile name {}", name.bright_yellow()))); } if file.components().all(|component| matches!(component, Component::Normal(_))) { return Ok(()); } - Err(SysinspectError::MasterGeneralError(format!( - "Invalid profile file path for {}: {}", - name.bright_yellow(), - file.display() - ))) + Err(SysinspectError::MasterGeneralError(format!("Invalid profile file path for {}: {}", name.bright_yellow(), file.display()))) } /// Load one profile by canonical name and verify the file content name matches the index entry. fn get_profile(&self, name: &str) -> Result { let index = self.get_profiles_index()?; - let entry = index - .get(name) - .ok_or_else(|| SysinspectError::MasterGeneralError(format!("Profile {} was not found", name.bright_yellow())))?; + let entry = index.get(name).ok_or_else(|| SysinspectError::MasterGeneralError(format!("Profile {} was not found", name.bright_yellow())))?; Self::validate_profile_name_and_file(name, entry.file())?; { let profile = @@ -550,7 +532,8 @@ impl SysInspectModPak { /// Persist one profile file and refresh its `profiles.index` checksum entry. fn set_profile(&self, name: &str, profile: &ModPakProfile) -> Result<(), SysinspectError> { let mut index = self.get_profiles_index()?; - let file = index.get(name).map(|entry| entry.file().to_path_buf()).unwrap_or_else(|| PathBuf::from(format!("{}.profile", name.to_lowercase()))); + let file = + index.get(name).map(|entry| entry.file().to_path_buf()).unwrap_or_else(|| PathBuf::from(format!("{}.profile", name.to_lowercase()))); Self::validate_profile_name_and_file(name, &file)?; let path = self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).join(&file); if !self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).exists() { @@ -859,23 +842,23 @@ impl SysInspectModPak { os: module.os().iter().map(|os| os_display_name(os).to_string()).collect::>().join(", "), arch: module.arch().join(", "), sha256: module - .checksums() - .iter() - .map(|checksum| format!("{}...{}", &checksum[..4], &checksum[checksum.len() - 4..])) - .collect::>() - .join(", "), + .checksums() + .iter() + .map(|checksum| format!("{}...{}", &checksum[..4], &checksum[checksum.len() - 4..])) + .collect::>() + .join(", "), }) .collect::>(); rows.extend(libraries.into_iter().map(|(name, file)| ArtefactRow { - kind: match file.kind() { - "wasm" | "binary" => "binary".to_string(), - _ => "script".to_string(), - }, - name: name.clone(), - display_name: Self::format_library_name(&name), - os: "Any".to_string(), - arch: "noarch".to_string(), - sha256: format!("{}...{}", &file.checksum()[..4], &file.checksum()[file.checksum().len() - 4..]), + kind: match file.kind() { + "wasm" | "binary" => "binary".to_string(), + _ => "script".to_string(), + }, + name: name.clone(), + display_name: Self::format_library_name(&name), + os: "Any".to_string(), + arch: "noarch".to_string(), + sha256: format!("{}...{}", &file.checksum()[..4], &file.checksum()[file.checksum().len() - 4..]), })); Ok(Self::render_artefact_table(rows)) } @@ -901,7 +884,8 @@ impl SysInspectModPak { /// List profile names filtered by an optional glob expression. pub fn list_profiles(&self, expr: Option<&str>) -> Result, SysinspectError> { let expr = glob::Pattern::new(expr.unwrap_or("*")).map_err(|e| SysinspectError::MasterGeneralError(format!("Invalid pattern: {e}")))?; - let mut profiles = self.get_profiles_index()?.profiles().keys().filter(|name| expr.matches(name)).map(|name| name.to_string()).collect::>(); + let mut profiles = + self.get_profiles_index()?.profiles().keys().filter(|name| expr.matches(name)).map(|name| name.to_string()).collect::>(); profiles.sort(); Ok(profiles) } diff --git a/libmodpak/src/lib_ut.rs b/libmodpak/src/lib_ut.rs index 27806d7d..6c27ade6 100644 --- a/libmodpak/src/lib_ut.rs +++ b/libmodpak/src/lib_ut.rs @@ -1,11 +1,11 @@ #[cfg(test)] mod tests { use crate::{SysInspectModPak, mpk::ModPakMetadata}; + use colored::control; + use libsysinspect::cfg::mmconf::CFG_PROFILES_ROOT; use libsysinspect::{cfg::mmconf::MinionConfig, traits::effective_profiles}; use std::collections::HashSet; - use colored::control; use std::{fs, path::Path}; - use libsysinspect::cfg::mmconf::CFG_PROFILES_ROOT; /// Creates a minimal library tree under `src/lib`. /// @@ -233,29 +233,18 @@ mod tests { let repo = SysInspectModPak::new(root.path().join("repo")).expect("repo should be created"); repo.new_profile("toto").expect("profile should be created"); - repo.add_profile_matches("toto", vec!["runtime.lua".to_string(), "net.*".to_string()], false) - .expect("module selectors should be added"); + repo.add_profile_matches("toto", vec!["runtime.lua".to_string(), "net.*".to_string()], false).expect("module selectors should be added"); repo.add_profile_matches("toto", vec!["runtime/lua/*.lua".to_string()], true).expect("library selectors should be added"); assert_eq!(repo.list_profiles(None).expect("profiles should list"), vec!["toto".to_string()]); - assert!(repo - .list_profile_matches(Some("toto"), false) - .expect("profile modules should list") - .contains(&"toto: runtime.lua".to_string())); - assert!(repo - .list_profile_matches(Some("toto"), false) - .expect("profile modules should list") - .contains(&"toto: net.*".to_string())); - assert!(repo - .list_profile_matches(Some("toto"), true) - .expect("profile libraries should list") - .contains(&"toto: runtime/lua/*.lua".to_string())); + assert!(repo.list_profile_matches(Some("toto"), false).expect("profile modules should list").contains(&"toto: runtime.lua".to_string())); + assert!(repo.list_profile_matches(Some("toto"), false).expect("profile modules should list").contains(&"toto: net.*".to_string())); + assert!( + repo.list_profile_matches(Some("toto"), true).expect("profile libraries should list").contains(&"toto: runtime/lua/*.lua".to_string()) + ); repo.remove_profile_matches("toto", vec!["net.*".to_string()], false).expect("module selector should be removed"); - assert!(!repo - .list_profile_matches(Some("toto"), false) - .expect("profile modules should list") - .contains(&"toto: net.*".to_string())); + assert!(!repo.list_profile_matches(Some("toto"), false).expect("profile modules should list").contains(&"toto: net.*".to_string())); repo.delete_profile("toto").expect("profile should be deleted"); assert!(repo.list_profiles(None).expect("profiles should list").is_empty()); @@ -289,16 +278,9 @@ mod tests { let root = tempfile::tempdir().expect("repo tempdir should be created"); let repo = SysInspectModPak::new(root.path().join("repo")).expect("repo should be created"); let profiles_root = root.path().join(CFG_PROFILES_ROOT); - fs::write( - profiles_root.join("totobullshit.profile"), - "name: Toto\nmodules:\n - runtime.lua\n", - ) - .expect("profile file should be written"); - fs::write( - root.path().join("profiles.index"), - "profiles:\n Toto:\n file: totobullshit.profile\n checksum: deadbeef\n", - ) - .expect("profiles index should be written"); + fs::write(profiles_root.join("totobullshit.profile"), "name: Toto\nmodules:\n - runtime.lua\n").expect("profile file should be written"); + fs::write(root.path().join("profiles.index"), "profiles:\n Toto:\n file: totobullshit.profile\n checksum: deadbeef\n") + .expect("profiles index should be written"); repo.add_profile_matches("Toto", vec!["net.*".to_string()], false).expect("profile should be updated"); @@ -314,11 +296,8 @@ mod tests { fn profiles_index_rejects_parent_dir_traversal() { let root = tempfile::tempdir().expect("repo tempdir should be created"); let repo = SysInspectModPak::new(root.path().join("repo")).expect("repo should be created"); - fs::write( - root.path().join("profiles.index"), - "profiles:\n Toto:\n file: ../escape.profile\n checksum: deadbeef\n", - ) - .expect("profiles index should be written"); + fs::write(root.path().join("profiles.index"), "profiles:\n Toto:\n file: ../escape.profile\n checksum: deadbeef\n") + .expect("profiles index should be written"); assert!(repo.get_profiles_index().is_err()); } diff --git a/libmodpak/src/mpk.rs b/libmodpak/src/mpk.rs index d375a973..22904435 100644 --- a/libmodpak/src/mpk.rs +++ b/libmodpak/src/mpk.rs @@ -468,7 +468,9 @@ impl ModPakRepoIndex { let mut index = Self::new(); for (platform, archset) in &self.platform { for (arch, entries) in archset { - for (name, attrs) in entries.iter().filter(|(name, _)| modules.iter().any(|expr| glob::Pattern::new(expr).is_ok_and(|pattern| pattern.matches(name)))) { + for (name, attrs) in + entries.iter().filter(|(name, _)| modules.iter().any(|expr| glob::Pattern::new(expr).is_ok_and(|pattern| pattern.matches(name)))) + { index .platform .entry(platform.to_string()) @@ -494,7 +496,9 @@ impl ModPakRepoIndex { let mut views = IndexMap::::new(); for (platform, archset) in &self.platform { for (arch, entries) in archset { - for (name, attrs) in entries.iter().filter(|(name, _)| patterns.iter().any(|expr| glob::Pattern::new(expr).is_ok_and(|pattern| pattern.matches(name)))) { + for (name, attrs) in + entries.iter().filter(|(name, _)| patterns.iter().any(|expr| glob::Pattern::new(expr).is_ok_and(|pattern| pattern.matches(name)))) + { views.entry(name.to_string()).or_insert_with(|| ModPakRepoModuleView::new(name)).merge_variant(platform, arch, attrs.checksum()); } } diff --git a/libmodpak/src/mpk_ut.rs b/libmodpak/src/mpk_ut.rs index 68e556cb..8160f2a7 100644 --- a/libmodpak/src/mpk_ut.rs +++ b/libmodpak/src/mpk_ut.rs @@ -18,9 +18,10 @@ fn runtime_dispatcher_names_are_reserved() { fn profiles_index_and_profile_roundtrip() { let mut index = ModPakProfilesIndex::new(); index.insert("default", PathBuf::from("default.profile"), "deadbeef"); - let index = ModPakProfilesIndex::from_yaml(&index.to_yaml().expect("profiles index should serialize")).expect("profiles index should deserialize"); - let profile = - ModPakProfile::from_yaml("name: default\nmodules:\n - runtime.lua\nlibraries:\n - runtime/lua/reader.lua\n").expect("profile should deserialize"); + let index = + ModPakProfilesIndex::from_yaml(&index.to_yaml().expect("profiles index should serialize")).expect("profiles index should deserialize"); + let profile = ModPakProfile::from_yaml("name: default\nmodules:\n - runtime.lua\nlibraries:\n - runtime/lua/reader.lua\n") + .expect("profile should deserialize"); assert_eq!(index.get("default").expect("default profile should exist").file(), &PathBuf::from("default.profile")); assert_eq!(profile.name(), "default"); @@ -53,8 +54,7 @@ library: .expect("repo index should deserialize"); repo.index_module("runtime.lua", "runtime/lua", "any", "noarch", "lua runtime", false, "deadbeef", None, None) .expect("runtime module should index"); - repo.index_module("net.ping", "net/ping", "any", "noarch", "ping module", false, "cafebabe", None, None) - .expect("ping module should index"); + repo.index_module("net.ping", "net/ping", "any", "noarch", "ping module", false, "cafebabe", None, None).expect("ping module should index"); let filtered = repo.retain_profiles(&modules, &libraries); let modules = filtered.modules(); diff --git a/libmodpak/tests/profile_sync.rs b/libmodpak/tests/profile_sync.rs index 69f16915..081916c1 100644 --- a/libmodpak/tests/profile_sync.rs +++ b/libmodpak/tests/profile_sync.rs @@ -3,7 +3,10 @@ use libsysinspect::{ cfg::mmconf::MinionConfig, traits::{TraitUpdateRequest, ensure_master_traits_file}, }; -use std::{fs, path::{Path, PathBuf}}; +use std::{ + fs, + path::{Path, PathBuf}, +}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::TcpListener, @@ -50,11 +53,7 @@ async fn start_fileserver(root: PathBuf) -> (u16, tokio::task::JoinHandle<()>) { let mut buf = [0_u8; 4096]; let Ok(n) = stream.read(&mut buf).await else { return }; let req = String::from_utf8_lossy(&buf[..n]); - let path = req - .lines() - .next() - .and_then(|line| line.split_whitespace().nth(1)) - .unwrap_or("/"); + let path = req.lines().next().and_then(|line| line.split_whitespace().nth(1)).unwrap_or("/"); let file = root.join(path.trim_start_matches('/')); let response = match fs::read(&file) { Ok(body) => format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).into_bytes(), @@ -189,11 +188,8 @@ async fn sync_fails_if_effective_profiles_are_missing_from_profiles_index() { async fn sync_rejects_profile_paths_with_traversal_components() { let master = tempfile::tempdir().expect("master tempdir should be created"); fs::create_dir_all(master.path().join("data")).expect("data dir should be created"); - fs::write( - master.path().join("data/profiles.index"), - "profiles:\n Escape:\n file: ../escape.profile\n checksum: deadbeef\n", - ) - .expect("profiles index should be written"); + fs::write(master.path().join("data/profiles.index"), "profiles:\n Escape:\n file: ../escape.profile\n checksum: deadbeef\n") + .expect("profiles index should be written"); let (port, server) = start_fileserver(master.path().join("data")).await; let minion = tempfile::tempdir().expect("minion tempdir should be created"); @@ -219,13 +215,9 @@ async fn sync_fails_if_downloaded_profile_checksum_does_not_match_index() { add_script_module(&master.path().join("data/repo"), "alpha.demo", "# alpha"); set_script_modules(&master.path().join("data/repo"), &["alpha.demo"]); repo.new_profile("Broken").expect("Broken should be created"); - repo.add_profile_matches("Broken", vec!["alpha.demo".to_string()], false) - .expect("Broken selector should be added"); - fs::write( - master.path().join("data/profiles.index"), - "profiles:\n Broken:\n file: broken.profile\n checksum: deadbeef\n", - ) - .expect("profiles index should be overwritten"); + repo.add_profile_matches("Broken", vec!["alpha.demo".to_string()], false).expect("Broken selector should be added"); + fs::write(master.path().join("data/profiles.index"), "profiles:\n Broken:\n file: broken.profile\n checksum: deadbeef\n") + .expect("profiles index should be overwritten"); let (port, server) = start_fileserver(master.path().join("data")).await; let minion = tempfile::tempdir().expect("minion tempdir should be created"); diff --git a/libsysinspect/src/cfg/mmconf.rs b/libsysinspect/src/cfg/mmconf.rs index c3ae5c80..f9018c9b 100644 --- a/libsysinspect/src/cfg/mmconf.rs +++ b/libsysinspect/src/cfg/mmconf.rs @@ -112,6 +112,10 @@ pub static CFG_CONSOLE_KEY_PRI: &str = "console.rsa"; pub static CFG_CONSOLE_KEYS: &str = "console-keys"; pub static CFG_MINION_RSA_PUB: &str = "minion.rsa.pub"; pub static CFG_MINION_RSA_PRV: &str = "minion.rsa"; +pub static CFG_TRANSPORT_ROOT: &str = "transport"; +pub static CFG_TRANSPORT_MASTER: &str = "master"; +pub static CFG_TRANSPORT_MINIONS: &str = "minions"; +pub static CFG_TRANSPORT_STATE: &str = "state.json"; // Sync // ---- @@ -525,6 +529,21 @@ impl MinionConfig { self.root_dir().join(CFG_PROFILES_ROOT) } + /// Root for managed secure transport metadata on the minion. + pub fn transport_root(&self) -> PathBuf { + self.root_dir().join(CFG_TRANSPORT_ROOT) + } + + /// Managed transport metadata for the current master/minion relationship. + pub fn transport_master_root(&self) -> PathBuf { + self.transport_root().join(CFG_TRANSPORT_MASTER) + } + + /// Managed transport state file for the current master/minion relationship. + pub fn transport_state_file(&self) -> PathBuf { + self.transport_master_root().join(CFG_TRANSPORT_STATE) + } + /// Return machine Id path pub fn machine_id_path(&self) -> PathBuf { if let Some(mid) = self.machine_id.clone() { @@ -806,11 +825,11 @@ pub struct MasterConfig { #[serde(rename = "api.auth")] pam_enabled: Option, - /// Disable libsodium crypto and authentication. - /// Still need auth, but can be just empty strings passed - /// and development mode token used. + /// Enable development-only Web API shortcuts. /// - /// WARNING: **DO NOT USE IN PRODUCTION! IT FULLY DISABLES ENCRYPTION!!!** + /// This keeps the normal Web API enabled, but allows the authentication + /// endpoint to return a static token and exposes the development query + /// endpoint for debugging. #[serde(rename = "api.devmode")] dev_mode: Option, @@ -938,11 +957,7 @@ impl MasterConfig { /// Return console listener address for `sysmaster`. pub fn console_listen_addr(&self) -> String { - format!( - "{}:{}", - self.console_ip.to_owned().unwrap_or("127.0.0.1".to_string()), - self.console_port.unwrap_or(DEFAULT_CONSOLE_PORT) - ) + format!("{}:{}", self.console_ip.to_owned().unwrap_or("127.0.0.1".to_string()), self.console_port.unwrap_or(DEFAULT_CONSOLE_PORT)) } /// Return console connect address for `sysinspect`. @@ -952,12 +967,15 @@ impl MasterConfig { pub fn console_connect_addr(&self) -> String { format!( "{}:{}", - if self.console_ip.as_deref() == Some("0.0.0.0") { "127.0.0.1".to_string() } else { self.console_ip.to_owned().unwrap_or("127.0.0.1".to_string()) }, + if self.console_ip.as_deref() == Some("0.0.0.0") { + "127.0.0.1".to_string() + } else { + self.console_ip.to_owned().unwrap_or("127.0.0.1".to_string()) + }, self.console_port.unwrap_or(DEFAULT_CONSOLE_PORT) ) } - /// Get API enabled status (default: true) pub fn api_enabled(&self) -> bool { self.api_enabled.unwrap_or(true) @@ -987,12 +1005,10 @@ impl MasterConfig { } } - /// Get API development mode - /// This is a special mode for development purposes only. - /// It disables all crypto and authentication, so it is not secure. - /// Use it only for development and testing purposes! + /// Get API development mode. /// - /// WARNING: **DO NOT USE DEVMODE IN PRODUCTION! IT FULLY DISABLES ENCRYPTION!!!** + /// When enabled, the Web API exposes additional development helpers such as + /// authentication bypass and the development query endpoint. pub fn api_devmode(&self) -> bool { self.dev_mode.unwrap_or(false) } @@ -1067,6 +1083,16 @@ impl MasterConfig { self.root_dir().join(CFG_CONSOLE_KEYS) } + /// Root for managed secure transport metadata on the master. + pub fn transport_root(&self) -> PathBuf { + self.root_dir().join(CFG_TRANSPORT_ROOT) + } + + /// Root for per-minion managed secure transport metadata on the master. + pub fn transport_minions_root(&self) -> PathBuf { + self.transport_root().join(CFG_TRANSPORT_MINIONS) + } + pub fn console_privkey(&self) -> PathBuf { self.root_dir().join(CFG_CONSOLE_KEY_PRI) } diff --git a/libsysinspect/src/cfg/mmconf_ut.rs b/libsysinspect/src/cfg/mmconf_ut.rs index 29219ff5..5ac84f22 100644 --- a/libsysinspect/src/cfg/mmconf_ut.rs +++ b/libsysinspect/src/cfg/mmconf_ut.rs @@ -1,5 +1,10 @@ -use super::mmconf::{DEFAULT_CONSOLE_PORT, MasterConfig}; -use std::{fs, time::{SystemTime, UNIX_EPOCH}}; +use super::mmconf::{ + CFG_TRANSPORT_MASTER, CFG_TRANSPORT_MINIONS, CFG_TRANSPORT_ROOT, CFG_TRANSPORT_STATE, DEFAULT_CONSOLE_PORT, MasterConfig, MinionConfig, +}; +use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, +}; fn write_master_cfg(contents: &str) -> std::path::PathBuf { let base = std::env::temp_dir().join(format!( @@ -42,3 +47,21 @@ fn master_console_connect_addr_rewrites_wildcard_bind_to_loopback() { assert_eq!(cfg.console_listen_addr(), "0.0.0.0:5511"); assert_eq!(cfg.console_connect_addr(), "127.0.0.1:5511"); } + +#[test] +fn master_transport_paths_are_under_managed_transport_root() { + let cfg = MasterConfig::new(write_master_cfg("config:\n master:\n fileserver.models: []\n")).unwrap(); + + assert_eq!(cfg.transport_root(), cfg.root_dir().join(CFG_TRANSPORT_ROOT)); + assert_eq!(cfg.transport_minions_root(), cfg.transport_root().join(CFG_TRANSPORT_MINIONS)); +} + +#[test] +fn minion_transport_paths_are_under_managed_transport_root() { + let mut cfg = MinionConfig::default(); + cfg.set_root_dir("/srv/sysinspect"); + + assert_eq!(cfg.transport_root(), cfg.root_dir().join(CFG_TRANSPORT_ROOT)); + assert_eq!(cfg.transport_master_root(), cfg.transport_root().join(CFG_TRANSPORT_MASTER)); + assert_eq!(cfg.transport_state_file(), cfg.transport_master_root().join(CFG_TRANSPORT_STATE)); +} diff --git a/libsysinspect/src/console/console_ut.rs b/libsysinspect/src/console/console_ut.rs index 9b2700c9..4cb28f0a 100644 --- a/libsysinspect/src/console/console_ut.rs +++ b/libsysinspect/src/console/console_ut.rs @@ -1,7 +1,10 @@ use super::{ConsoleBootstrap, ConsoleQuery, ConsoleSealed, ensure_console_keypair}; use crate::{ cfg::mmconf::{CFG_MASTER_KEY_PRI, CFG_MASTER_KEY_PUB}, - rsa::keys::{RsaKey::{Private, Public}, key_to_file, keygen}, + rsa::keys::{ + RsaKey::{Private, Public}, + key_to_file, keygen, + }, }; use rsa::traits::PublicKeyParts; use sodiumoxide::crypto::secretbox; @@ -62,11 +65,7 @@ fn ensure_console_keypair_sets_restrictive_permissions() { let _ = ensure_console_keypair(root.path()).unwrap(); let dir_mode = std::fs::metadata(root.path()).unwrap().permissions().mode() & 0o777; - let key_mode = std::fs::metadata(root.path().join(crate::cfg::mmconf::CFG_CONSOLE_KEY_PRI)) - .unwrap() - .permissions() - .mode() - & 0o777; + let key_mode = std::fs::metadata(root.path().join(crate::cfg::mmconf::CFG_CONSOLE_KEY_PRI)).unwrap().permissions().mode() & 0o777; assert_eq!(dir_mode, 0o700); assert_eq!(key_mode, 0o600); diff --git a/libsysinspect/src/console/mod.rs b/libsysinspect/src/console/mod.rs index 0993a8a3..6cc14c07 100644 --- a/libsysinspect/src/console/mod.rs +++ b/libsysinspect/src/console/mod.rs @@ -1,9 +1,11 @@ //! Encrypted console transport primitives shared by `sysinspect` and `sysmaster`. use base64::{Engine, engine::general_purpose::STANDARD}; +use chrono::{DateTime, Utc}; use libcommon::SysinspectError; use rsa::{RsaPrivateKey, RsaPublicKey}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::Value; use sodiumoxide::crypto::secretbox::{self, Key, Nonce}; use std::{ fs, @@ -17,6 +19,8 @@ use crate::{ RsaKey::{Private, Public}, decrypt, encrypt, key_from_file, key_to_file, keygen, sign_data, to_pem, verify_sign, }, + traits::TraitSource, + transport::TransportRotationStatus, }; #[cfg(test)] @@ -74,11 +78,138 @@ pub struct ConsoleQuery { pub struct ConsoleResponse { /// Response success flag. pub ok: bool, - /// Human-readable response message or payload. - pub message: String, + /// Error text when the response is not successful. + #[serde(default)] + pub error: String, + /// Structured response payload. + #[serde(default)] + pub payload: ConsolePayload, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ConsolePayload { + /// No structured payload content. + #[default] + Empty, + /// A small acknowledgement payload for successful mutating operations. + Ack { + /// Machine-readable action name, for example `create_profile`. + action: String, + #[serde(default)] + /// Primary target associated with the action, if any. + target: String, + #[serde(default)] + /// Count associated with the action result, such as affected minions. + count: usize, + #[serde(default)] + /// Additional string items associated with the action. + items: Vec, + }, + /// A plain text payload that should be printed as-is by the CLI. + Text { + /// Human-readable text body. + value: String, + }, + /// A list of strings that should be displayed one per line. + StringList { + /// Ordered list items. + items: Vec, + }, + /// Summary of how many rotation requests were dispatched immediately versus queued. + RotationSummary { + /// Number of online minions that received the rotation request immediately. + online_count: usize, + /// Number of offline minions for which the request was staged. + queued_count: usize, + }, + /// Raw online-minion rows for CLI or TUI rendering. + OnlineMinions { + /// One row per registered minion included in the summary. + rows: Vec, + }, + /// Raw transport-status rows for CLI or TUI rendering. + TransportStatus { + /// One row per selected minion. + rows: Vec, + }, + /// Raw minion-info rows for CLI or TUI rendering. + MinionInfo { + /// One row per key/value pair for one selected minion. + rows: Vec, + }, +} + +/// One online-minion summary row returned by the master. +/// +/// The master returns raw fields only. Consumers such as the CLI formatter or a +/// future TUI decide how to order, color, shorten, or hide them. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ConsoleOnlineMinionRow { + /// Fully qualified host name reported by the minion traits, if present. + pub fqdn: String, + /// Short host name reported by the minion traits, if present. + pub hostname: String, + /// Last known IP address trait for the minion. + pub ip: String, + /// Stable minion system id used by the transport and registry layers. + pub minion_id: String, + /// Whether the master currently considers the minion online. + pub alive: bool, +} + +/// One transport-status row returned by the master. +/// +/// This contains raw state needed to render transport summaries in a CLI or a +/// TUI without requiring the consumer to query extra state from the master. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ConsoleTransportStatusRow { + /// Fully qualified host name reported by the minion traits, if present. + pub fqdn: String, + /// Short host name reported by the minion traits, if present. + pub hostname: String, + /// Stable minion system id used by the transport and registry layers. + pub minion_id: String, + /// Currently active managed transport key id, if one exists. + pub active_key_id: Option, + /// Timestamp of the last successful secure bootstrap handshake. + pub last_handshake_at: Option>, + /// Timestamp when the currently active transport key became active. + pub last_rotated_at: Option>, + /// Current rotation state for this minion, or `None` when no managed state exists. + pub rotation: Option, +} + +/// One minion-info key/value row returned by the master. +/// +/// The master returns raw values only. The CLI or TUI owns ordering, +/// formatting, colors, and human-readable conversions. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ConsoleMinionInfoRow { + /// Logical field name. + pub key: String, + /// Raw value associated with the field. + pub value: Value, + /// Origin of this trait so the client can group and style it. + pub source: TraitSource, +} + +impl ConsoleResponse { + /// Construct a successful console response carrying structured payload data. + pub fn ok(payload: ConsolePayload) -> Self { + Self { ok: true, error: String::new(), payload } + } + + /// Construct an unsuccessful console response with an error string. + pub fn err(error: impl Into) -> Self { + Self { ok: false, error: error.into(), payload: ConsolePayload::Empty } + } } /// Ensure the local libsodium state is initialised once for console sealing operations. +/// +/// This function is cheap to call repeatedly; after successful initialisation +/// it becomes a fast no-op. fn sodium_ready() -> Result<(), SysinspectError> { if SODIUM_INIT.get().is_some() { return Ok(()); @@ -90,10 +221,19 @@ fn sodium_ready() -> Result<(), SysinspectError> { Ok(()) } +/// Return the private and public console key paths under the provided root. +/// +/// The first element is the private key path and the second is the public key +/// path. fn console_keypair(root: &Path) -> (PathBuf, PathBuf) { (root.join(CFG_CONSOLE_KEY_PRI), root.join(CFG_CONSOLE_KEY_PUB)) } +/// Apply restrictive filesystem permissions to the console key directory and +/// private key file on Unix platforms. +/// +/// The directory is forced to `0700` and the private key file, when present, is +/// forced to `0600`. fn ensure_console_permissions(root: &Path, prk_path: &Path) -> Result<(), SysinspectError> { #[cfg(unix)] { @@ -157,6 +297,10 @@ pub fn load_master_private_key(cfg: &MasterConfig) -> Result Result { match key_from_file(path.to_str().unwrap_or_default())? { Some(Private(prk)) => Ok(prk), @@ -165,6 +309,10 @@ fn load_private_key(path: &Path) -> Result { } } +/// Load an RSA public key from the given filesystem path. +/// +/// Returns an error when the file is missing, unreadable, or does not contain a +/// public key. fn load_public_key(path: &Path) -> Result { match key_from_file(path.to_str().unwrap_or_default())? { Some(Public(pbk)) => Ok(pbk), @@ -175,24 +323,31 @@ fn load_public_key(path: &Path) -> Result { impl ConsoleBootstrap { /// Build bootstrap material for a new console session. + /// + /// The client public key is embedded in the bootstrap, the symmetric session + /// key is encrypted to the master's RSA public key, and the raw symmetric key + /// bytes are signed with the client's RSA private key. pub fn new(client_prk: &RsaPrivateKey, client_pbk: &RsaPublicKey, master_pbk: &RsaPublicKey, symkey: &Key) -> Result { Ok(Self { - client_pubkey: to_pem(None, Some(client_pbk)) - .map_err(|e| SysinspectError::RSAError(e.to_string()))? - .1 - .unwrap_or_default(), + client_pubkey: to_pem(None, Some(client_pbk)).map_err(|e| SysinspectError::RSAError(e.to_string()))?.1.unwrap_or_default(), symkey_cipher: STANDARD.encode( encrypt(master_pbk.clone(), symkey.0.to_vec()) .map_err(|_| SysinspectError::RSAError("Failed to encrypt console session key".to_string()))?, ), symkey_sign: STANDARD.encode( - sign_data(client_prk.clone(), &symkey.0) - .map_err(|_| SysinspectError::RSAError("Failed to sign console session key".to_string()))?, + sign_data(client_prk.clone(), &symkey.0).map_err(|_| SysinspectError::RSAError("Failed to sign console session key".to_string()))?, ), }) } /// Recover and verify the console session key from the bootstrap payload. + /// + /// The master decrypts the symmetric key with its private key and verifies + /// that the client signed the same raw key bytes with the embedded client + /// public key. + /// + /// Returns the recovered symmetric key together with the parsed client + /// public key. pub fn session_key(&self, master_prk: &RsaPrivateKey) -> Result<(Key, RsaPublicKey), SysinspectError> { let client_pbk = crate::rsa::keys::from_pem(None, Some(&self.client_pubkey)) .map_err(|e| SysinspectError::RSAError(e.to_string()))? @@ -213,15 +368,15 @@ impl ConsoleBootstrap { return Err(SysinspectError::RSAError("Console session signature verification failed".to_string())); } - Ok(( - Key::from_slice(&symkey).ok_or_else(|| SysinspectError::RSAError("Console session key has invalid size".to_string()))?, - client_pbk, - )) + Ok((Key::from_slice(&symkey).ok_or_else(|| SysinspectError::RSAError("Console session key has invalid size".to_string()))?, client_pbk)) } } impl ConsoleSealed { /// Seal a serializable console payload with the given symmetric session key. + /// + /// The payload is JSON-serialized and then encrypted with libsodium + /// `secretbox` using a fresh nonce. pub fn seal(payload: &T, key: &Key) -> Result { sodium_ready()?; let nonce = secretbox::gen_nonce(); @@ -236,18 +391,17 @@ impl ConsoleSealed { } /// Open a sealed console payload with the given symmetric session key. + /// + /// The payload is decrypted with libsodium `secretbox` and then + /// deserialized into the requested type `T`. pub fn open(&self, key: &Key) -> Result { sodium_ready()?; let nonce = Nonce::from_slice( - &STANDARD - .decode(&self.nonce) - .map_err(|e| SysinspectError::SerializationError(format!("Failed to decode console nonce: {e}")))?, + &STANDARD.decode(&self.nonce).map_err(|e| SysinspectError::SerializationError(format!("Failed to decode console nonce: {e}")))?, ) .ok_or_else(|| SysinspectError::SerializationError("Console nonce has invalid size".to_string()))?; let payload = secretbox::open( - &STANDARD - .decode(&self.payload) - .map_err(|e| SysinspectError::SerializationError(format!("Failed to decode console payload: {e}")))?, + &STANDARD.decode(&self.payload).map_err(|e| SysinspectError::SerializationError(format!("Failed to decode console payload: {e}")))?, &nonce, key, ) @@ -257,11 +411,13 @@ impl ConsoleSealed { } /// Check whether the provided client console public key is authorised by the master. +/// +/// The key is considered authorised when it matches either the primary console +/// client key configured in the master config or one of the extra keys stored +/// in the console keys directory. pub fn authorised_console_client(cfg: &MasterConfig, client_pem: &str) -> Result { let client_pem = client_pem.trim(); - if cfg.console_pubkey().exists() - && fs::read_to_string(cfg.console_pubkey()).map_err(SysinspectError::IoErr)?.trim() == client_pem - { + if cfg.console_pubkey().exists() && fs::read_to_string(cfg.console_pubkey()).map_err(SysinspectError::IoErr)?.trim() == client_pem { return Ok(true); } @@ -272,9 +428,7 @@ pub fn authorised_console_client(cfg: &MasterConfig, client_pem: &str) -> Result for entry in fs::read_dir(root).map_err(SysinspectError::IoErr)? { let path = entry.map_err(SysinspectError::IoErr)?.path(); - if path.is_file() - && fs::read_to_string(&path).map_err(SysinspectError::IoErr)?.trim() == client_pem - { + if path.is_file() && fs::read_to_string(&path).map_err(SysinspectError::IoErr)?.trim() == client_pem { return Ok(true); } } @@ -283,16 +437,18 @@ pub fn authorised_console_client(cfg: &MasterConfig, client_pem: &str) -> Result } /// Build a fully bootstrapped encrypted console request envelope for the given query. +/// +/// This helper generates or loads the local console client keypair, loads the +/// trusted master public key, creates a fresh symmetric session key, and returns +/// both the encrypted request envelope and the raw symmetric key needed to open +/// the master's reply. pub fn build_console_query(root: &Path, cfg: &MasterConfig, query: &ConsoleQuery) -> Result<(ConsoleEnvelope, Key), SysinspectError> { sodium_ready()?; let (client_prk, client_pbk) = ensure_console_keypair(root)?; let master_pbk = load_master_public_key(cfg)?; let key = secretbox::gen_key(); Ok(( - ConsoleEnvelope { - bootstrap: ConsoleBootstrap::new(&client_prk, &client_pbk, &master_pbk, &key)?, - sealed: ConsoleSealed::seal(query, &key)?, - }, + ConsoleEnvelope { bootstrap: ConsoleBootstrap::new(&client_prk, &client_pbk, &master_pbk, &key)?, sealed: ConsoleSealed::seal(query, &key)? }, key, )) } diff --git a/libsysinspect/src/lib.rs b/libsysinspect/src/lib.rs index 44ee13a8..7a673f4f 100644 --- a/libsysinspect/src/lib.rs +++ b/libsysinspect/src/lib.rs @@ -9,4 +9,5 @@ pub mod reactor; pub mod rsa; pub mod tmpl; pub mod traits; +pub mod transport; pub mod util; diff --git a/libsysinspect/src/rsa/mod.rs b/libsysinspect/src/rsa/mod.rs index 703bc087..2ee30f66 100644 --- a/libsysinspect/src/rsa/mod.rs +++ b/libsysinspect/src/rsa/mod.rs @@ -1 +1,2 @@ pub mod keys; +pub mod rotation; diff --git a/libsysinspect/src/rsa/rotation.rs b/libsysinspect/src/rsa/rotation.rs new file mode 100644 index 00000000..74231e55 --- /dev/null +++ b/libsysinspect/src/rsa/rotation.rs @@ -0,0 +1,461 @@ +use chrono::{DateTime, Duration, Utc}; +use libcommon::SysinspectError; +use rsa::{RsaPrivateKey, RsaPublicKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::rsa::keys::{get_fingerprint, sign_data, verify_sign}; +use crate::transport::{TransportKeyStatus, TransportPeerState, TransportRotationStatus, TransportStore}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use sodiumoxide::crypto::secretbox; + +#[cfg(test)] +#[path = "rotation_ut.rs"] +mod rotation_ut; + +/// Role of the process performing rotation orchestration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RotationActor { + Master, + Minion, +} + +/// Planned key-rotation operation for one master/minion relationship. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RotationPlan { + minion_id: String, + previous_key_id: Option, + next_key_id: String, + next_key_fingerprint: String, + reason: String, + requested_at: DateTime, +} + +impl RotationPlan { + pub fn minion_id(&self) -> &str { + &self.minion_id + } + + pub fn previous_key_id(&self) -> Option<&str> { + self.previous_key_id.as_deref() + } + + pub fn next_key_id(&self) -> &str { + &self.next_key_id + } + + pub fn next_key_fingerprint(&self) -> &str { + &self.next_key_fingerprint + } + + pub fn reason(&self) -> &str { + &self.reason + } + + pub fn requested_at(&self) -> DateTime { + self.requested_at + } +} + +/// Result details returned after a successful execute operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RotationResult { + minion_id: String, + active_key_id: String, + active_key_fingerprint: String, + rotated_at: DateTime, + purged_keys: usize, +} + +impl RotationResult { + pub fn minion_id(&self) -> &str { + &self.minion_id + } + + pub fn active_key_id(&self) -> &str { + &self.active_key_id + } + + pub fn active_key_fingerprint(&self) -> &str { + &self.active_key_fingerprint + } + + pub fn rotated_at(&self) -> DateTime { + self.rotated_at + } + + pub fn purged_keys(&self) -> usize { + self.purged_keys + } +} + +/// Rollback ticket containing pre-rotation state and success details. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RotationTicket { + previous_state: TransportPeerState, + result: RotationResult, +} + +/// Unsigned transport-key rotation intent to be authenticated with the RSA trust anchor. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RotationIntent { + minion_id: String, + previous_key_id: Option, + next_key_id: String, + next_key_fingerprint: String, + requested_at: DateTime, + reason: String, +} + +impl RotationIntent { + pub fn minion_id(&self) -> &str { + &self.minion_id + } + + pub fn previous_key_id(&self) -> Option<&str> { + self.previous_key_id.as_deref() + } + + pub fn next_key_id(&self) -> &str { + &self.next_key_id + } + + pub fn next_key_fingerprint(&self) -> &str { + &self.next_key_fingerprint + } + + pub fn requested_at(&self) -> DateTime { + self.requested_at + } + + pub fn reason(&self) -> &str { + &self.reason + } +} + +/// RSA-signed transport-key rotation intent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignedRotationIntent { + signer_fingerprint: String, + intent: RotationIntent, + signature: String, +} + +impl SignedRotationIntent { + pub fn signer_fingerprint(&self) -> &str { + &self.signer_fingerprint + } + + pub fn intent(&self) -> &RotationIntent { + &self.intent + } + + pub fn signature(&self) -> &str { + &self.signature + } +} + +impl RotationTicket { + pub fn previous_state(&self) -> &TransportPeerState { + &self.previous_state + } + + pub fn result(&self) -> &RotationResult { + &self.result + } +} + +/// Reusable object that coordinates transport-key rotation state transitions. +pub struct RsaTransportRotator { + actor: RotationActor, + store: TransportStore, + state: TransportPeerState, +} + +impl RsaTransportRotator { + /// Create a reusable rotator and load (or initialise) managed transport state. + pub fn new( + actor: RotationActor, store: TransportStore, minion_id: &str, master_rsa_fingerprint: &str, minion_rsa_fingerprint: &str, + protocol_version: u16, + ) -> Result { + let state = store.ensure_automatic_peer(minion_id, master_rsa_fingerprint, minion_rsa_fingerprint, protocol_version)?; + Ok(Self { actor, store, state }) + } + + /// Return the actor role for this rotator. + pub fn actor(&self) -> RotationActor { + self.actor + } + + /// Return the current managed transport state snapshot. + pub fn state(&self) -> &TransportPeerState { + &self.state + } + + /// Return the timestamp of the currently active key as a practical "last rotated" value. + pub fn last_rotated_at(&self) -> Option> { + let active_key = self.state.active_key_id.as_ref()?; + self.state.keys.iter().find(|record| record.key_id == *active_key).and_then(|record| record.activated_at.or(Some(record.created_at))) + } + + /// Return whether rotation is due according to the active key timestamp and provided interval. + pub fn rotation_due(&self, interval: Duration, now: DateTime) -> bool { + match self.last_rotated_at() { + Some(last) => last + interval <= now, + None => true, + } + } + + /// Mark this peer as pending rotation when due and persist the state. + pub fn queue_if_due(&mut self, interval: Duration, now: DateTime) -> Result { + if !self.rotation_due(interval, now) { + return Ok(false); + } + + if matches!(self.state.rotation, TransportRotationStatus::Pending) { + return Ok(true); + } + + self.state.rotation = TransportRotationStatus::Pending; + self.state.updated_at = now; + self.store.save(&self.state)?; + Ok(true) + } + + /// Build a rotation plan with a fresh key identifier and deterministic fingerprint. + pub fn plan(&self, reason: impl Into) -> RotationPlan { + let next_key_id = format!("trk-{}", Uuid::new_v4()); + RotationPlan { + minion_id: self.state.minion_id.clone(), + previous_key_id: self.state.active_key_id.clone(), + next_key_fingerprint: Self::fingerprint_for_key_id(&next_key_id), + next_key_id, + reason: reason.into(), + requested_at: Utc::now(), + } + } + + /// Build an unsigned rotation intent from a plan. + pub fn intent_from_plan(&self, plan: &RotationPlan) -> Result { + if plan.minion_id() != self.state.minion_id { + return Err(SysinspectError::ConfigError(format!( + "Rotation plan minion id {} does not match managed state {}", + plan.minion_id(), + self.state.minion_id + ))); + } + + Ok(RotationIntent { + minion_id: plan.minion_id().to_string(), + previous_key_id: plan.previous_key_id().map(str::to_string), + next_key_id: plan.next_key_id().to_string(), + next_key_fingerprint: plan.next_key_fingerprint().to_string(), + requested_at: plan.requested_at(), + reason: plan.reason().to_string(), + }) + } + + /// Sign a rotation intent with the actor private key. + pub fn sign_intent(&self, intent: &RotationIntent, signer_prk: &RsaPrivateKey) -> Result { + let signer_fingerprint = get_fingerprint(&RsaPublicKey::from(signer_prk)).map_err(|err| SysinspectError::RSAError(err.to_string()))?; + let signature = STANDARD + .encode(sign_data(signer_prk.clone(), &Self::intent_material(intent)?).map_err(|err| SysinspectError::RSAError(err.to_string()))?); + + Ok(SignedRotationIntent { signer_fingerprint, intent: intent.clone(), signature }) + } + + /// Build and sign a rotation intent from one plan. + pub fn sign_plan(&self, plan: &RotationPlan, signer_prk: &RsaPrivateKey) -> Result { + self.sign_intent(&self.intent_from_plan(plan)?, signer_prk) + } + + /// Verify RSA signature and trust-anchor binding for a signed rotation intent. + pub fn verify_signed_intent(&self, signed: &SignedRotationIntent, signer_pbk: &RsaPublicKey) -> Result<(), SysinspectError> { + let expected_fingerprint = self.expected_signer_fingerprint(); + let actual_fingerprint = get_fingerprint(signer_pbk).map_err(|err| SysinspectError::RSAError(err.to_string()))?; + if actual_fingerprint != expected_fingerprint { + return Err(SysinspectError::RSAError(format!( + "Signed rotation intent fingerprint mismatch: expected {}, got {}", + expected_fingerprint, actual_fingerprint + ))); + } + if signed.signer_fingerprint() != expected_fingerprint { + return Err(SysinspectError::RSAError(format!( + "Signed rotation intent claims signer {} but expected {}", + signed.signer_fingerprint(), + expected_fingerprint + ))); + } + + let intent = signed.intent(); + if intent.minion_id() != self.state.minion_id { + return Err(SysinspectError::ConfigError(format!( + "Signed rotation intent minion id {} does not match managed state {}", + intent.minion_id(), + self.state.minion_id + ))); + } + if intent.next_key_fingerprint() != Self::fingerprint_for_key_id(intent.next_key_id()) { + return Err(SysinspectError::ConfigError(format!("Signed rotation intent fingerprint for key {} is invalid", intent.next_key_id()))); + } + + let signature = STANDARD + .decode(signed.signature()) + .map_err(|err| SysinspectError::SerializationError(format!("Failed to decode rotation signature: {err}")))?; + if !verify_sign(signer_pbk, &Self::intent_material(intent)?, signature).map_err(|err| SysinspectError::RSAError(err.to_string()))? { + return Err(SysinspectError::RSAError("Signed rotation intent signature verification failed".to_string())); + } + + Ok(()) + } + + /// Apply a verified signed intent and execute rotation atomically. + pub fn execute_signed_intent(&mut self, signed: &SignedRotationIntent, signer_pbk: &RsaPublicKey) -> Result { + self.execute_signed_intent_with_overlap(signed, signer_pbk, Duration::zero()) + } + + /// Apply a verified signed intent and execute rotation atomically while preserving retiring keys for the overlap window. + pub fn execute_signed_intent_with_overlap( + &mut self, signed: &SignedRotationIntent, signer_pbk: &RsaPublicKey, overlap_window: Duration, + ) -> Result { + self.verify_signed_intent(signed, signer_pbk)?; + let plan = RotationPlan { + minion_id: signed.intent().minion_id().to_string(), + previous_key_id: signed.intent().previous_key_id().map(str::to_string), + next_key_id: signed.intent().next_key_id().to_string(), + next_key_fingerprint: signed.intent().next_key_fingerprint().to_string(), + reason: signed.intent().reason().to_string(), + requested_at: signed.intent().requested_at(), + }; + + self.execute_with_overlap(&plan, overlap_window) + } + + /// Execute a planned rotation atomically in state storage and return a rollback ticket. + pub fn execute(&mut self, plan: &RotationPlan) -> Result { + self.execute_with_overlap(plan, Duration::zero()) + } + + /// Execute a planned rotation and keep retiring keys during the overlap window. + pub fn execute_with_overlap(&mut self, plan: &RotationPlan, overlap_window: Duration) -> Result { + if plan.minion_id() != self.state.minion_id { + return Err(SysinspectError::ConfigError(format!( + "Rotation plan minion id {} does not match managed state {}", + plan.minion_id(), + self.state.minion_id + ))); + } + + let previous_state = self.state.clone(); + let rotated_at = Utc::now(); + + let mut next = previous_state.clone(); + next.rotation = TransportRotationStatus::InProgress; + next.updated_at = rotated_at; + + if let Some(active_key) = previous_state.active_key_id.as_deref() + && active_key != plan.next_key_id() + { + next.upsert_key(active_key, TransportKeyStatus::Retiring); + } + + let new_material = secretbox::gen_key(); + next.upsert_key_with_material(plan.next_key_id(), TransportKeyStatus::Proposed, Some(&new_material.0)); + next.upsert_key_with_material(plan.next_key_id(), TransportKeyStatus::Active, Some(&new_material.0)); + + let purged_keys = self.retire_elapsed_keys_inner(&mut next, rotated_at, overlap_window); + + next.rotation = TransportRotationStatus::Idle; + next.updated_at = rotated_at; + + self.store.save(&next)?; + self.state = next; + + Ok(RotationTicket { + previous_state, + result: RotationResult { + minion_id: self.state.minion_id.clone(), + active_key_id: plan.next_key_id().to_string(), + active_key_fingerprint: plan.next_key_fingerprint().to_string(), + rotated_at, + purged_keys, + }, + }) + } + + /// Retire and purge keys that have exceeded the overlap window. + pub fn retire_elapsed_keys(&mut self, now: DateTime, overlap_window: Duration) -> Result { + let mut next = self.state.clone(); + let purged = self.retire_elapsed_keys_inner(&mut next, now, overlap_window); + next.updated_at = now; + self.store.save(&next)?; + self.state = next; + Ok(purged) + } + + /// Restore pre-rotation state from a rollback ticket. + pub fn rollback(&mut self, ticket: &RotationTicket) -> Result { + self.store.save(ticket.previous_state())?; + self.state = ticket.previous_state().clone(); + Ok(self.state.clone()) + } + + /// Reconcile with the expected active key fingerprint and queue rotation when mismatched. + pub fn reconcile_required_fingerprint(&mut self, required_fingerprint: &str, now: DateTime) -> Result { + let local_fingerprint = self.state.active_key_id.as_ref().map(|key_id| Self::fingerprint_for_key_id(key_id)).unwrap_or_default(); + + if local_fingerprint == required_fingerprint { + return Ok(false); + } + + self.state.rotation = TransportRotationStatus::Pending; + self.state.updated_at = now; + self.store.save(&self.state)?; + Ok(true) + } + + /// Build a deterministic key fingerprint string from a key id. + pub fn fingerprint_for_key_id(key_id: &str) -> String { + hex::encode(Sha256::digest(key_id.as_bytes())) + } + + fn expected_signer_fingerprint(&self) -> &str { + match self.actor { + RotationActor::Master => &self.state.master_rsa_fingerprint, + RotationActor::Minion => &self.state.master_rsa_fingerprint, + } + } + + fn intent_material(intent: &RotationIntent) -> Result, SysinspectError> { + serde_json::to_vec(intent).map_err(|err| SysinspectError::SerializationError(format!("Failed to serialize rotation intent: {err}"))) + } + + fn retire_elapsed_keys_inner(&self, state: &mut TransportPeerState, now: DateTime, overlap_window: Duration) -> usize { + if overlap_window <= Duration::zero() { + let before = state.keys.len(); + let active = state.active_key_id.clone().unwrap_or_default(); + state.keys.retain(|record| record.key_id == active); + return before.saturating_sub(state.keys.len()); + } + + let active_key = state.active_key_id.clone().unwrap_or_default(); + for key in state.keys.iter_mut() { + if key.key_id == active_key { + continue; + } + + if matches!(key.status, TransportKeyStatus::Retiring) { + let base = key.activated_at.unwrap_or(key.created_at); + if base + overlap_window <= now { + key.status = TransportKeyStatus::Retired; + key.retired_at = Some(now); + } + } + } + + let before = state.keys.len(); + state.keys.retain(|record| !matches!(record.status, TransportKeyStatus::Retired)); + before.saturating_sub(state.keys.len()) + } +} diff --git a/libsysinspect/src/rsa/rotation_ut.rs b/libsysinspect/src/rsa/rotation_ut.rs new file mode 100644 index 00000000..952a2e04 --- /dev/null +++ b/libsysinspect/src/rsa/rotation_ut.rs @@ -0,0 +1,253 @@ +use super::{RotationActor, RsaTransportRotator}; +use crate::rsa::keys::keygen; +use crate::transport::{TransportKeyStatus, TransportStore}; +use chrono::{Duration, Utc}; + +fn init_rotator() -> (tempfile::TempDir, RsaTransportRotator) { + let tmp = tempfile::tempdir().unwrap(); + let store = TransportStore::new(tmp.path().join("transport/minions/mid-1/state.json")).unwrap(); + let rotator = RsaTransportRotator::new(RotationActor::Master, store, "mid-1", "master-fp", "minion-fp", 1).unwrap(); + + let mut state = rotator.state().clone(); + state.upsert_key("kid-old", TransportKeyStatus::Active); + let old = Utc::now() - Duration::days(5); + if let Some(record) = state.keys.iter_mut().find(|record| record.key_id == "kid-old") { + record.created_at = old; + record.activated_at = Some(old); + } + state.updated_at = old; + state.rotation = crate::transport::TransportRotationStatus::Idle; + TransportStore::new(tmp.path().join("transport/minions/mid-1/state.json")).unwrap().save(&state).unwrap(); + + let rotator = RsaTransportRotator::new( + RotationActor::Master, + TransportStore::new(tmp.path().join("transport/minions/mid-1/state.json")).unwrap(), + "mid-1", + "master-fp", + "minion-fp", + 1, + ) + .unwrap(); + + (tmp, rotator) +} + +#[test] +fn plan_generates_fingerprint_for_new_key() { + let (_tmp, rotator) = init_rotator(); + let plan = rotator.plan("scheduled"); + + assert_eq!(plan.minion_id(), "mid-1"); + assert!(plan.next_key_id().starts_with("trk-")); + assert_eq!(plan.next_key_fingerprint(), RsaTransportRotator::fingerprint_for_key_id(plan.next_key_id())); +} + +#[test] +fn execute_activates_new_key_and_purges_old_keys() { + let (_tmp, mut rotator) = init_rotator(); + let plan = rotator.plan("manual"); + let ticket = rotator.execute(&plan).unwrap(); + + assert_eq!(ticket.result().active_key_id(), plan.next_key_id()); + assert_eq!(rotator.state().active_key_id.as_deref(), Some(plan.next_key_id())); + assert_eq!(rotator.state().keys.len(), 1); + assert_eq!(rotator.state().keys[0].status, TransportKeyStatus::Active); + assert!(ticket.result().purged_keys() >= 1); +} + +#[test] +fn execute_with_overlap_keeps_retiring_key_until_window_expires() { + let (tmp, mut rotator) = init_rotator(); + let mut state = rotator.state().clone(); + let now = Utc::now(); + if let Some(record) = state.keys.iter_mut().find(|record| record.key_id == "kid-old") { + record.created_at = now; + record.activated_at = Some(now); + } + TransportStore::new(tmp.path().join("transport/minions/mid-1/state.json")).unwrap().save(&state).unwrap(); + rotator = RsaTransportRotator::new( + RotationActor::Master, + TransportStore::new(tmp.path().join("transport/minions/mid-1/state.json")).unwrap(), + "mid-1", + "master-fp", + "minion-fp", + 1, + ) + .unwrap(); + + let plan = rotator.plan("manual"); + let ticket = rotator.execute_with_overlap(&plan, Duration::hours(1)).unwrap(); + + assert_eq!(ticket.result().active_key_id(), plan.next_key_id()); + assert!(rotator.state().keys.len() >= 2); + assert!(rotator.state().keys.iter().any(|k| k.key_id == plan.next_key_id() && k.status == TransportKeyStatus::Active)); + assert!(rotator.state().keys.iter().any(|k| k.key_id == "kid-old" && k.status == TransportKeyStatus::Retiring)); +} + +#[test] +fn retire_elapsed_keys_purges_retired_material_after_overlap() { + let (tmp, mut rotator) = init_rotator(); + let mut state = rotator.state().clone(); + let now = Utc::now(); + if let Some(record) = state.keys.iter_mut().find(|record| record.key_id == "kid-old") { + record.created_at = now; + record.activated_at = Some(now); + } + TransportStore::new(tmp.path().join("transport/minions/mid-1/state.json")).unwrap().save(&state).unwrap(); + rotator = RsaTransportRotator::new( + RotationActor::Master, + TransportStore::new(tmp.path().join("transport/minions/mid-1/state.json")).unwrap(), + "mid-1", + "master-fp", + "minion-fp", + 1, + ) + .unwrap(); + + let plan = rotator.plan("manual"); + let _ = rotator.execute_with_overlap(&plan, Duration::hours(2)).unwrap(); + + let purged = rotator.retire_elapsed_keys(Utc::now() + Duration::hours(3), Duration::hours(2)).unwrap(); + + assert!(purged >= 1); + assert_eq!(rotator.state().active_key_id.as_deref(), Some(plan.next_key_id())); + assert!(rotator.state().keys.iter().all(|key| !matches!(key.status, TransportKeyStatus::Retired))); +} + +#[test] +fn rollback_restores_previous_state() { + let (_tmp, mut rotator) = init_rotator(); + let previous = rotator.state().clone(); + let plan = rotator.plan("manual"); + let ticket = rotator.execute(&plan).unwrap(); + + let restored = rotator.rollback(&ticket).unwrap(); + + assert_eq!(restored.active_key_id, previous.active_key_id); + assert_eq!(restored.keys, previous.keys); +} + +#[test] +fn queue_if_due_sets_pending_rotation() { + let (_tmp, mut rotator) = init_rotator(); + let queued = rotator.queue_if_due(Duration::hours(48), Utc::now()).unwrap(); + + assert!(queued); + assert_eq!(rotator.state().rotation, crate::transport::TransportRotationStatus::Pending); +} + +#[test] +fn signed_rotation_intent_verifies_against_master_trust_anchor() { + let (_tmp, rotator) = init_rotator(); + let (master_prk, master_pbk) = keygen(2048).unwrap(); + + let mut state = rotator.state().clone(); + state.master_rsa_fingerprint = crate::rsa::keys::get_fingerprint(&master_pbk).unwrap(); + TransportStore::new(_tmp.path().join("transport/minions/mid-1/state.json")).unwrap().save(&state).unwrap(); + let rotator = RsaTransportRotator::new( + RotationActor::Master, + TransportStore::new(_tmp.path().join("transport/minions/mid-1/state.json")).unwrap(), + "mid-1", + &crate::rsa::keys::get_fingerprint(&master_pbk).unwrap(), + "minion-fp", + 1, + ) + .unwrap(); + + let plan = rotator.plan("manual"); + let signed = rotator.sign_plan(&plan, &master_prk).unwrap(); + + rotator.verify_signed_intent(&signed, &master_pbk).unwrap(); +} + +#[test] +fn minion_side_signed_rotation_intent_verifies_against_master_trust_anchor() { + let (_tmp, rotator) = init_rotator(); + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (_minion_prk, minion_pbk) = keygen(2048).unwrap(); + + let master_fp = crate::rsa::keys::get_fingerprint(&master_pbk).unwrap(); + let minion_fp = crate::rsa::keys::get_fingerprint(&minion_pbk).unwrap(); + let mut state = rotator.state().clone(); + state.master_rsa_fingerprint = master_fp.clone(); + state.minion_rsa_fingerprint = minion_fp.clone(); + TransportStore::new(_tmp.path().join("transport/minions/mid-1/state.json")).unwrap().save(&state).unwrap(); + + let master_rotator = RsaTransportRotator::new( + RotationActor::Master, + TransportStore::new(_tmp.path().join("transport/minions/mid-1/state.json")).unwrap(), + "mid-1", + &master_fp, + &minion_fp, + 1, + ) + .unwrap(); + let signed = master_rotator.sign_plan(&master_rotator.plan("manual"), &master_prk).unwrap(); + + let minion_rotator = RsaTransportRotator::new( + RotationActor::Minion, + TransportStore::new(_tmp.path().join("transport/minions/mid-1/state.json")).unwrap(), + "mid-1", + &master_fp, + &minion_fp, + 1, + ) + .unwrap(); + + minion_rotator.verify_signed_intent(&signed, &master_pbk).unwrap(); +} + +#[test] +fn signed_rotation_intent_rejects_wrong_signer() { + let (_tmp, rotator) = init_rotator(); + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (_other_prk, other_pbk) = keygen(2048).unwrap(); + + let mut state = rotator.state().clone(); + state.master_rsa_fingerprint = crate::rsa::keys::get_fingerprint(&master_pbk).unwrap(); + TransportStore::new(_tmp.path().join("transport/minions/mid-1/state.json")).unwrap().save(&state).unwrap(); + let rotator = RsaTransportRotator::new( + RotationActor::Master, + TransportStore::new(_tmp.path().join("transport/minions/mid-1/state.json")).unwrap(), + "mid-1", + &crate::rsa::keys::get_fingerprint(&master_pbk).unwrap(), + "minion-fp", + 1, + ) + .unwrap(); + + let signed = rotator.sign_plan(&rotator.plan("manual"), &master_prk).unwrap(); + + assert!(rotator.verify_signed_intent(&signed, &other_pbk).is_err()); +} + +#[test] +fn execute_signed_intent_with_overlap_keeps_retiring_key_until_window_expires() { + let (tmp, mut rotator) = init_rotator(); + let (master_prk, master_pbk) = keygen(2048).unwrap(); + + let mut state = rotator.state().clone(); + let now = Utc::now(); + state.master_rsa_fingerprint = crate::rsa::keys::get_fingerprint(&master_pbk).unwrap(); + if let Some(record) = state.keys.iter_mut().find(|record| record.key_id == "kid-old") { + record.created_at = now; + record.activated_at = Some(now); + } + TransportStore::new(tmp.path().join("transport/minions/mid-1/state.json")).unwrap().save(&state).unwrap(); + rotator = RsaTransportRotator::new( + RotationActor::Master, + TransportStore::new(tmp.path().join("transport/minions/mid-1/state.json")).unwrap(), + "mid-1", + &crate::rsa::keys::get_fingerprint(&master_pbk).unwrap(), + "minion-fp", + 1, + ) + .unwrap(); + + let plan = rotator.plan("manual"); + let signed = rotator.sign_plan(&plan, &master_prk).unwrap(); + let _ = rotator.execute_signed_intent_with_overlap(&signed, &master_pbk, Duration::hours(1)).unwrap(); + + assert!(rotator.state().keys.iter().any(|k| k.key_id == plan.next_key_id() && k.status == TransportKeyStatus::Active)); + assert!(rotator.state().keys.iter().any(|k| k.key_id == "kid-old" && k.status == TransportKeyStatus::Retiring)); +} diff --git a/libsysinspect/src/traits/mod.rs b/libsysinspect/src/traits/mod.rs index 6fd411b1..4816f509 100644 --- a/libsysinspect/src/traits/mod.rs +++ b/libsysinspect/src/traits/mod.rs @@ -8,7 +8,7 @@ use libcommon::SysinspectError; use once_cell::sync::OnceCell; use pest::Parser; use pest_derive::Parser; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::fs; use systraits::SystemTraits; @@ -50,7 +50,54 @@ pub static HW_CPU_VENDOR: &str = "hardware.cpu.vendor"; pub static HW_CPU_CORES: &str = "hardware.cpu.cores"; /// Reserved master-managed traits overlay filename. pub static MASTER_TRAITS_FILE: &str = "master.cfg"; -static MASTER_TRAITS_FILE_HEADER: &str = "# THIS FILE IS AUTOGENERATED BY SYSINSPECT MASTER.\n# DO NOT EDIT THIS FILE MANUALLY.\n# LOCAL CUSTOM TRAITS BELONG IN SEPARATE *.cfg FILES.\n"; +static MASTER_TRAITS_FILE_HEADER: &str = + "# THIS FILE IS AUTOGENERATED BY SYSINSPECT MASTER.\n# DO NOT EDIT THIS FILE MANUALLY.\n# LOCAL CUSTOM TRAITS BELONG IN SEPARATE *.cfg FILES.\n"; + +/// Origin category for one trait item as observed on the minion. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum TraitSource { + /// Trait was produced from system inspection + Preset, + /// Trait was produced from YAML drop-in configuration files. + Static, + /// Trait was produced by an executable trait function. + Function, +} + +/// Structured minion-to-master trait sync payload. +/// +/// Older peers may still send a plain object of traits only. Consumers should +/// use `from_json_str` so both payload shapes are accepted. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct TraitsTransportPayload { + /// Flattened trait map as seen by the minion after all sources are merged. + #[serde(default)] + pub traits: IndexMap, + /// Keys that originated from YAML drop-in trait files. + #[serde(default)] + pub static_keys: Vec, + /// Keys that originated from executable trait functions. + #[serde(default)] + pub fn_keys: Vec, +} + +impl TraitsTransportPayload { + /// Parse either the structured trait-sync payload or the legacy flat map. + pub fn from_json_str(payload: &str) -> Result { + let value = serde_json::from_str::(payload)?; + match value { + Value::Object(map) if map.contains_key("traits") || map.contains_key("static_keys") || map.contains_key("fn_keys") => { + serde_json::from_value(Value::Object(map)).map_err(Into::into) + } + Value::Object(map) => { + let traits = serde_json::from_value(Value::Object(map))?; + Ok(Self { traits, static_keys: vec![], fn_keys: vec![] }) + } + _ => Err(SysinspectError::SerializationError("Traits payload must be a JSON object".to_string())), + } + } +} #[derive(Parser)] #[grammar = "traits/traits_query.pest"] diff --git a/libsysinspect/src/traits/systraits.rs b/libsysinspect/src/traits/systraits.rs index 589336bd..35042a1e 100644 --- a/libsysinspect/src/traits/systraits.rs +++ b/libsysinspect/src/traits/systraits.rs @@ -2,11 +2,11 @@ use crate::{ cfg::mmconf::MinionConfig, traits::{ HW_CPU_BRAND, HW_CPU_CORES, HW_CPU_FREQ, HW_CPU_TOTAL, HW_CPU_VENDOR, HW_MEM, HW_SWAP, MASTER_TRAITS_FILE, SYS_ID, SYS_NET_HOSTNAME, - SYS_NET_HOSTNAME_FQDN, SYS_NET_HOSTNAME_IP, SYS_OS_DISTRO, SYS_OS_KERNEL, SYS_OS_NAME, SYS_OS_VERSION, + SYS_NET_HOSTNAME_FQDN, SYS_NET_HOSTNAME_IP, SYS_OS_DISTRO, SYS_OS_KERNEL, SYS_OS_NAME, SYS_OS_VERSION, TraitSource, TraitsTransportPayload, }, util::sys::to_fqdn_ip, }; -use indexmap::IndexMap; +use indexmap::{IndexMap, IndexSet}; use libcommon::SysinspectError; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; @@ -20,6 +20,8 @@ use std::{ #[derive(Debug, Clone, Default)] pub struct SystemTraits { data: IndexMap, + yaml_keys: IndexSet, + function_keys: IndexSet, cfg: MinionConfig, checksum: String, quiet: bool, @@ -61,6 +63,22 @@ impl SystemTraits { /// Put a JSON value into traits structure pub fn put(&mut self, path: String, data: Value) { + self.yaml_keys.shift_remove(&path); + self.function_keys.shift_remove(&path); + self.data.insert(path, data); + } + + /// Put a JSON value into traits structure and mark it as YAML drop-in generated. + pub fn put_yaml(&mut self, path: String, data: Value) { + self.function_keys.shift_remove(&path); + self.yaml_keys.insert(path.clone()); + self.data.insert(path, data); + } + + /// Put a JSON value into traits structure and mark it as function-generated. + pub fn put_function(&mut self, path: String, data: Value) { + self.yaml_keys.shift_remove(&path); + self.function_keys.insert(path.clone()); self.data.insert(path, data); } @@ -96,6 +114,16 @@ impl SystemTraits { self.data.clone() } + /// Return the sorted list of keys that originated from trait functions. + pub fn function_keys(&self) -> Vec { + self.function_keys.iter().cloned().collect() + } + + /// Return the sorted list of keys that originated from YAML drop-in files. + pub fn yaml_keys(&self) -> Vec { + self.yaml_keys.iter().cloned().collect() + } + /// Return checksum of traits. /// This is done by calculating checksum of the *keys*, as values can change on every restart, /// e.g. IPv6 data, which is usually irrelevant or any other things that *meant* to change. @@ -217,9 +245,7 @@ impl SystemTraits { let content: Option = content.as_ref().and_then(|v| Self::proxy_log_error(serde_json::to_value(v), "Unable to convert existing YAML to JSON format")); - if fname == MASTER_TRAITS_FILE - && content.as_ref().is_none_or(serde_json::Value::is_null) - { + if fname == MASTER_TRAITS_FILE && content.as_ref().is_none_or(serde_json::Value::is_null) { continue; } @@ -228,12 +254,13 @@ impl SystemTraits { continue; } - let content = - content.as_ref().and_then(|v| Self::proxy_log_error(serde_json::from_value::>(v.clone()), "Unable to parse JSON")); + let content = content.as_ref().and_then(|v| { + Self::proxy_log_error(serde_json::from_value::>(v.clone()), "Unable to parse JSON") + }); if let Some(content) = content { for (k, v) in content { - self.put(k, json!(v)); + self.put_yaml(k, json!(v)); } } else { log::error!("Custom traits data is empty or in a wrong format"); @@ -281,7 +308,7 @@ impl SystemTraits { ); if let Some(data) = data { for (k, v) in data { - self.put(k, json!(v)); + self.put_function(k, json!(v)); } } else { log::error!("Custom traits data is empty or in a wrong format"); @@ -305,4 +332,20 @@ impl SystemTraits { pub fn to_json_value(&self) -> Result { Ok(json!(self.data)) } + + /// Convert traits into the structured transport payload used for sync with the master. + pub fn to_transport_value(&self) -> Result { + Ok(serde_json::to_value(TraitsTransportPayload { traits: self.data.clone(), static_keys: self.yaml_keys(), fn_keys: self.function_keys() })?) + } + + /// Return the origin category for one trait key. + pub fn trait_source(&self, key: &str) -> TraitSource { + if self.function_keys.contains(key) { + TraitSource::Function + } else if self.yaml_keys.contains(key) { + TraitSource::Static + } else { + TraitSource::Preset + } + } } diff --git a/libsysinspect/src/traits/traits_ut.rs b/libsysinspect/src/traits/traits_ut.rs index 324f83ac..c462730c 100644 --- a/libsysinspect/src/traits/traits_ut.rs +++ b/libsysinspect/src/traits/traits_ut.rs @@ -1,6 +1,6 @@ use crate::cfg::mmconf::MinionConfig; -use crate::traits::{MASTER_TRAITS_FILE, TraitUpdateRequest, current_os_type, effective_profiles, ensure_master_traits_file, os_display_name}; use crate::traits::systraits::SystemTraits; +use crate::traits::{MASTER_TRAITS_FILE, TraitUpdateRequest, current_os_type, effective_profiles, ensure_master_traits_file, os_display_name}; use std::fs; #[test] @@ -61,12 +61,12 @@ fn trait_update_request_reset_clears_master_managed_traits_file_body() { let mut cfg = MinionConfig::default(); cfg.set_root_dir(root.path().to_str().unwrap_or_default()); - let set = TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"foo":"bar"}}"#) - .unwrap_or_else(|err| panic!("failed to parse set request: {err}")); + let set = + TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"foo":"bar"}}"#).unwrap_or_else(|err| panic!("failed to parse set request: {err}")); set.apply(&cfg).unwrap_or_else(|err| panic!("failed to apply set request: {err}")); - let reset = TraitUpdateRequest::from_context(r#"{"op":"reset","traits":{}}"#) - .unwrap_or_else(|err| panic!("failed to parse reset request: {err}")); + let reset = + TraitUpdateRequest::from_context(r#"{"op":"reset","traits":{}}"#).unwrap_or_else(|err| panic!("failed to parse reset request: {err}")); reset.apply(&cfg).unwrap_or_else(|err| panic!("failed to apply reset request: {err}")); let content = diff --git a/libsysinspect/src/transport/README.txt b/libsysinspect/src/transport/README.txt new file mode 100644 index 00000000..070e69ca --- /dev/null +++ b/libsysinspect/src/transport/README.txt @@ -0,0 +1,67 @@ +Managed transport metadata +========================== + +This module stores managed Master/Minion transport metadata for the secure +protocol work. + +Design rules +------------ + +1. RSA remains the registration and rotation trust anchor. +2. Steady-state libsodium session keys are ephemeral per connection. +3. Operators should not copy raw transport key material around by hand. +4. Registration should auto-provision transport metadata by default. +5. Explicit admin approval workflows must still be representable in metadata. + +Managed paths +------------- + +Minion: + +- `transport/master/state.json` + +Master: + +- `transport/minions//state.json` + +State contents +-------------- + +Each state file records: + +- bound minion id +- master RSA fingerprint +- minion RSA fingerprint +- secure protocol version +- key exchange model +- provisioning mode +- approval timestamp +- active and last key ids +- last handshake time +- rotation status +- per-key lifecycle metadata + +Automation behavior +------------------- + +- Minion startup refreshes local transport metadata when a trusted master + public key already exists locally. +- Master registration refreshes per-minion transport metadata automatically. +- Master startup backfills transport metadata for already-registered minions. + +Approval and rotation +--------------------- + +The default provisioning mode is `automatic`. +The default key exchange model is `ephemeral_session_keys`. + +That means: + +- each secure Master/Minion session gets a fresh libsodium session key +- RSA bootstraps and authenticates that session key +- no long-lived libsodium steady-state secret is persisted today +- if persisted transport keys are ever introduced later, they must be + relationship-specific to one master/minion pair + +Future admin workflows may switch a peer state to `explicit_approval` and +later approve activation without changing the on-disk layout. diff --git a/libsysinspect/src/transport/mod.rs b/libsysinspect/src/transport/mod.rs new file mode 100644 index 00000000..88e5b155 --- /dev/null +++ b/libsysinspect/src/transport/mod.rs @@ -0,0 +1,359 @@ +#![doc = include_str!("README.txt")] + +pub mod secure_bootstrap; +pub mod secure_channel; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use chrono::{DateTime, Utc}; +use libcommon::SysinspectError; +use serde::{Deserialize, Serialize}; +use sodiumoxide::crypto::secretbox; +use std::{ + fs, + path::{Component, Path, PathBuf}, +}; +use uuid::Uuid; + +use crate::cfg::mmconf::{CFG_TRANSPORT_MINIONS, CFG_TRANSPORT_STATE, MasterConfig, MinionConfig}; + +#[cfg(test)] +mod secure_channel_ut; + +#[cfg(test)] +mod secure_bootstrap_ut; + +#[cfg(test)] +mod transport_ut; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TransportKeyStatus { + Proposed, + Active, + Retiring, + Retired, + Broken, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TransportRotationStatus { + Idle, + Pending, + InProgress, + RollbackReady, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TransportProvisioningMode { + Automatic, + ExplicitApproval, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TransportKeyExchangeModel { + EphemeralSessionKeys, + PersistedRelationshipKeys, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportKeyRecord { + pub key_id: String, + pub status: TransportKeyStatus, + pub protocol_version: u16, + pub created_at: DateTime, + pub activated_at: Option>, + pub retired_at: Option>, + #[serde(default)] + pub material_b64: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TransportPeerState { + pub minion_id: String, + pub master_rsa_fingerprint: String, + pub minion_rsa_fingerprint: String, + pub protocol_version: u16, + pub key_exchange: TransportKeyExchangeModel, + pub provisioning: TransportProvisioningMode, + pub approved_at: Option>, + pub active_key_id: Option, + pub last_key_id: Option, + pub last_handshake_at: Option>, + pub rotation: TransportRotationStatus, + #[serde(default)] + pub pending_rotation_context: Option, + pub updated_at: DateTime, + pub keys: Vec, +} + +impl TransportPeerState { + /// Create a new managed transport state record for one master/minion relationship. + pub fn new(minion_id: String, master_rsa_fingerprint: String, minion_rsa_fingerprint: String, protocol_version: u16) -> Self { + Self { + minion_id, + master_rsa_fingerprint, + minion_rsa_fingerprint, + protocol_version, + key_exchange: TransportKeyExchangeModel::EphemeralSessionKeys, + provisioning: TransportProvisioningMode::Automatic, + approved_at: Some(Utc::now()), + active_key_id: None, + last_key_id: None, + last_handshake_at: None, + rotation: TransportRotationStatus::Idle, + pending_rotation_context: None, + updated_at: Utc::now(), + keys: Vec::new(), + } + } + + /// Insert or update one tracked transport key and refresh the relationship metadata around it. + pub fn upsert_key(&mut self, key_id: &str, status: TransportKeyStatus) { + self.upsert_key_with_material(key_id, status, None) + } + + /// Insert or update one tracked transport key and optionally refresh its persisted key material. + pub fn upsert_key_with_material(&mut self, key_id: &str, status: TransportKeyStatus, material: Option<&[u8]>) { + self.last_key_id = Some(key_id.to_string()); + self.updated_at = Utc::now(); + if matches!(status, TransportKeyStatus::Active) { + self.active_key_id = Some(key_id.to_string()); + self.last_handshake_at = Some(self.updated_at); + } else if self.active_key_id.as_deref() == Some(key_id) { + self.active_key_id = None; + } + if let Some(key) = self.keys.iter_mut().find(|key| key.key_id.eq(key_id)) { + key.status = status.clone(); + if let Some(material) = material { + key.material_b64 = Some(STANDARD.encode(material)); + } + if matches!(status, TransportKeyStatus::Active) && key.activated_at.is_none() { + key.activated_at = Some(self.updated_at); + } + if matches!(status, TransportKeyStatus::Retired) { + key.retired_at = Some(self.updated_at); + } + return; + } + self.keys.push(TransportKeyRecord { + key_id: key_id.to_string(), + status: status.clone(), + protocol_version: self.protocol_version, + created_at: self.updated_at, + activated_at: matches!(status, TransportKeyStatus::Active).then_some(self.updated_at), + retired_at: None, + material_b64: material.map(|m| STANDARD.encode(m)), + }); + } + + /// Read decoded material bytes for a specific key id if managed secret material exists. + pub fn key_material(&self, key_id: &str) -> Option> { + self.keys + .iter() + .find(|record| record.key_id == key_id) + .and_then(|record| record.material_b64.as_ref()) + .and_then(|encoded| STANDARD.decode(encoded).ok()) + } + + /// Read decoded material bytes for the currently active key. + pub fn active_key_material(&self) -> Option> { + self.active_key_id.as_deref().and_then(|key_id| self.key_material(key_id)) + } + + /// Persist one staged rotation command context to keep operator intent across reconnects. + pub fn set_pending_rotation_context(&mut self, context: Option) { + self.pending_rotation_context = context; + self.updated_at = Utc::now(); + if self.pending_rotation_context.is_some() { + self.rotation = TransportRotationStatus::Pending; + } else if !matches!(self.rotation, TransportRotationStatus::InProgress) { + self.rotation = TransportRotationStatus::Idle; + } + } + + /// Mark the currently tracked transport key as broken after a failed secure bootstrap or session validation. + pub fn mark_current_key_broken(&mut self) -> bool { + if let Some(key_id) = self.active_key_id.clone().or_else(|| self.last_key_id.clone()) { + self.upsert_key(&key_id, TransportKeyStatus::Broken); + return true; + } + false + } + + /// Set the provisioning mode used for this relationship. + pub fn set_provisioning(&mut self, provisioning: TransportProvisioningMode) { + self.provisioning = provisioning.clone(); + self.updated_at = Utc::now(); + self.approved_at = match provisioning { + TransportProvisioningMode::Automatic => Some(self.updated_at), + TransportProvisioningMode::ExplicitApproval => None, + }; + } + + /// Approve this peer for secure bootstrap. + pub fn approve(&mut self) { + self.updated_at = Utc::now(); + self.approved_at = Some(self.updated_at); + } + + /// Record which transport key-exchange model this relationship uses. + pub fn set_key_exchange(&mut self, key_exchange: TransportKeyExchangeModel) { + self.key_exchange = key_exchange; + self.updated_at = Utc::now(); + } +} + +pub struct TransportStore { + state_path: PathBuf, +} + +impl TransportStore { + /// Open the managed transport state store for a minion's trusted master relationship. + pub fn for_minion(cfg: &MinionConfig) -> Result { + Self::new(cfg.transport_master_root().join(CFG_TRANSPORT_STATE)) + } + + /// Open the managed transport state store for one registered minion on the master. + pub fn for_master_minion(cfg: &MasterConfig, minion_id: &str) -> Result { + Self::new(cfg.transport_minions_root().join(Self::safe_peer_dir(minion_id)?).join(CFG_TRANSPORT_STATE)) + } + + /// Create a transport store rooted at the provided managed state path. + pub fn new(state_path: PathBuf) -> Result { + ensure_secure_parent( + state_path + .parent() + .ok_or_else(|| SysinspectError::ConfigError(format!("Transport state path {} has no parent", state_path.display())))?, + )?; + Ok(Self { state_path }) + } + + /// Return the managed state file path used by this store. + pub fn state_path(&self) -> &Path { + &self.state_path + } + + /// Load the transport state from disk when it exists. + pub fn load(&self) -> Result, SysinspectError> { + if !self.state_path.exists() { + return Ok(None); + } + serde_json::from_str::(&fs::read_to_string(&self.state_path)?) + .map(Some) + .map_err(|err| SysinspectError::DeserializationError(format!("Failed to read transport state from {}: {err}", self.state_path.display()))) + } + + /// Save the transport state to disk with private filesystem permissions. + pub fn save(&self, state: &TransportPeerState) -> Result<(), SysinspectError> { + ensure_secure_parent(self.state_path.parent().unwrap_or_else(|| Path::new(".")))?; + let tmp = self.state_path.with_extension("json.tmp"); + fs::write(&tmp, serde_json::to_vec_pretty(state)?)?; + set_file_private(&tmp)?; + fs::rename(tmp, &self.state_path)?; + set_file_private(&self.state_path)?; + Ok(()) + } + + /// Load the transport state or create a new one from the provided initializer. + pub fn load_or_init(&self, init: impl FnOnce() -> TransportPeerState) -> Result { + self.load().map(|state| state.unwrap_or_else(init)) + } + + /// Ensure that an automatically provisioned peer state exists and matches the current trusted RSA identities. + pub fn ensure_automatic_peer( + &self, minion_id: &str, master_rsa_fingerprint: &str, minion_rsa_fingerprint: &str, protocol_version: u16, + ) -> Result { + let mut state = self.load_or_init(|| { + TransportPeerState::new(minion_id.to_string(), master_rsa_fingerprint.to_string(), minion_rsa_fingerprint.to_string(), protocol_version) + })?; + + state.minion_id = minion_id.to_string(); + state.master_rsa_fingerprint = master_rsa_fingerprint.to_string(); + state.minion_rsa_fingerprint = minion_rsa_fingerprint.to_string(); + state.protocol_version = protocol_version; + state.key_exchange = TransportKeyExchangeModel::EphemeralSessionKeys; + if matches!(state.provisioning, TransportProvisioningMode::Automatic) { + state.approved_at.get_or_insert_with(Utc::now); + state.updated_at = Utc::now(); + } + if state.active_key_id.is_none() { + let key_id = format!("trk-{}", Uuid::new_v4()); + let material = secretbox::gen_key(); + state.upsert_key_with_material(&key_id, TransportKeyStatus::Active, Some(&material.0)); + } + self.save(&state)?; + Ok(state) + } + + /// Convert the current peer state to explicit approval mode after initial provisioning. + pub fn require_explicit_approval( + &self, minion_id: &str, master_rsa_fingerprint: &str, minion_rsa_fingerprint: &str, protocol_version: u16, + ) -> Result { + let mut state = self.ensure_automatic_peer(minion_id, master_rsa_fingerprint, minion_rsa_fingerprint, protocol_version)?; + state.set_provisioning(TransportProvisioningMode::ExplicitApproval); + self.save(&state)?; + Ok(state) + } + + /// Mark the stored peer state as approved for secure bootstrap. + pub fn approve_peer(&self) -> Result { + let mut state = + self.load()?.ok_or_else(|| SysinspectError::ConfigError(format!("Transport state does not exist at {}", self.state_path.display())))?; + state.approve(); + self.save(&state)?; + Ok(state) + } + + /// Validate and normalize a transport peer identifier before it is used as a directory name. + pub fn safe_peer_dir(peer_id: &str) -> Result { + let peer_id = peer_id.trim(); + if peer_id.is_empty() + || matches!(peer_id, "." | "..") + || peer_id.contains('/') + || peer_id.contains('\\') + || !peer_id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + { + return Err(SysinspectError::ConfigError(format!("Invalid transport peer id: {peer_id}"))); + } + Ok(peer_id.to_string()) + } +} + +pub fn transport_minion_root(root: &Path, minion_id: &str) -> Result { + Ok(root.join(CFG_TRANSPORT_MINIONS).join(TransportStore::safe_peer_dir(minion_id)?)) +} + +fn ensure_secure_parent(path: &Path) -> Result<(), SysinspectError> { + fs::create_dir_all(path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + for entry in path.ancestors().take_while(|ancestor| ancestor.components().all(|component| !matches!(component, Component::RootDir))) { + if entry.exists() { + let mut perms = fs::metadata(entry)?.permissions(); + perms.set_mode(0o700); + fs::set_permissions(entry, perms)?; + } + } + let mut perms = fs::metadata(path)?.permissions(); + perms.set_mode(0o700); + fs::set_permissions(path, perms)?; + } + Ok(()) +} + +fn set_file_private(path: &Path) -> Result<(), SysinspectError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mut perms = fs::metadata(path)?.permissions(); + perms.set_mode(0o600); + fs::set_permissions(path, perms)?; + } + Ok(()) +} diff --git a/libsysinspect/src/transport/secure_bootstrap.rs b/libsysinspect/src/transport/secure_bootstrap.rs new file mode 100644 index 00000000..e625ec74 --- /dev/null +++ b/libsysinspect/src/transport/secure_bootstrap.rs @@ -0,0 +1,358 @@ +use super::TransportPeerState; +use base64::{Engine, engine::general_purpose::STANDARD}; +use chrono::Utc; +use libcommon::SysinspectError; +use libsysproto::secure::{ + SECURE_PROTOCOL_VERSION, SecureBootstrapAck, SecureBootstrapDiagnostic, SecureBootstrapHello, SecureDiagnosticCode, SecureFailureSemantics, + SecureFrame, SecureRotationMode, SecureSessionBinding, +}; +use rsa::{RsaPrivateKey, RsaPublicKey}; +use sha2::{Digest, Sha256}; +use sodiumoxide::crypto::secretbox::{self, Key}; +use std::sync::OnceLock; +use uuid::Uuid; + +use crate::rsa::keys::{decrypt, encrypt, get_fingerprint, sign_data, verify_sign}; + +static SODIUM_INIT: OnceLock<()> = OnceLock::new(); + +/// Bootstrap session state created while negotiating a secure Master/Minion link. +#[derive(Debug, Clone)] +pub struct SecureBootstrapSession { + binding: SecureSessionBinding, + session_key: Key, + key_id: String, + session_id: Option, +} + +/// Factory for the plaintext bootstrap diagnostics allowed before a secure session exists. +pub struct SecureBootstrapDiagnostics; + +impl SecureBootstrapSession { + /// Build the opening bootstrap frame from trusted transport state on the minion side. + pub fn open(state: &TransportPeerState, minion_prk: &RsaPrivateKey, master_pbk: &RsaPublicKey) -> Result<(Self, SecureFrame), SysinspectError> { + sodium_ready()?; + Self::ready(state)?; + Self::fingerprint("master", master_pbk, &state.master_rsa_fingerprint)?; + let key_id = state.active_key_id.clone().or_else(|| state.last_key_id.clone()).unwrap_or_else(|| Uuid::new_v4().to_string()); + let binding = SecureSessionBinding::bootstrap_opening( + state.minion_id.clone(), + state.minion_rsa_fingerprint.clone(), + state.master_rsa_fingerprint.clone(), + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + Utc::now().timestamp(), + ); + Self::hello( + binding.clone(), + state.key_material(&key_id).as_deref().map(|material| Self::derive_session_key(material, &binding)).unwrap_or_else(secretbox::gen_key), + key_id, + minion_prk, + master_pbk, + ) + } + + /// Validate a bootstrap hello on the master side and return a signed acknowledgement frame. + pub fn accept( + state: &TransportPeerState, hello: &SecureBootstrapHello, master_prk: &RsaPrivateKey, minion_pbk: &RsaPublicKey, session_id: Option, + key_id: Option, rotation: Option, + ) -> Result<(Self, SecureFrame), SysinspectError> { + Self::ready(state)?; + Self::opening(state, &hello.binding)?; + Self::fingerprint("minion", minion_pbk, &state.minion_rsa_fingerprint)?; + Self::fingerprint("master", &RsaPublicKey::from(master_prk), &state.master_rsa_fingerprint)?; + Self::ack( + hello.binding.clone(), + Self::verify_hello(hello, minion_pbk, master_prk)?, + hello + .key_id + .clone() + .or_else(|| key_id.clone()) + .or_else(|| state.active_key_id.clone()) + .or_else(|| state.last_key_id.clone()) + .unwrap_or_else(|| Uuid::new_v4().to_string()), + master_prk, + session_id.unwrap_or_else(|| Uuid::new_v4().to_string()), + rotation.unwrap_or(SecureRotationMode::None), + ) + } + + /// Verify the master's bootstrap acknowledgement and finalize the negotiated bootstrap session. + pub fn verify_ack(mut self, state: &TransportPeerState, ack: &SecureBootstrapAck, master_pbk: &RsaPublicKey) -> Result { + Self::ready(state)?; + Self::fingerprint("master", master_pbk, &state.master_rsa_fingerprint)?; + Self::accepted(state, &self.binding, &ack.binding)?; + if ack.session_id.trim().is_empty() { + return Err(SysinspectError::ProtoError("Secure bootstrap ack has an empty session id".to_string())); + } + if ack.key_id.trim().is_empty() { + return Err(SysinspectError::ProtoError("Secure bootstrap ack has an empty key id".to_string())); + } + if !verify_sign( + master_pbk, + &Self::ack_material(&ack.binding, &ack.session_id, &ack.key_id, &ack.rotation)?, + STANDARD + .decode(&ack.binding_signature) + .map_err(|err| SysinspectError::SerializationError(format!("Failed to decode secure bootstrap ack signature: {err}")))?, + ) + .map_err(|err| SysinspectError::RSAError(err.to_string()))? + { + return Err(SysinspectError::RSAError("Secure bootstrap ack signature verification failed".to_string())); + } + self.binding = ack.binding.clone(); + self.key_id = ack.key_id.clone(); + self.session_id = Some(ack.session_id.clone()); + Ok(self) + } + + /// Return the identity binding attached to this bootstrap session. + pub fn binding(&self) -> &SecureSessionBinding { + &self.binding + } + + /// Return the established session identifier once the bootstrap acknowledgement was accepted. + pub fn session_id(&self) -> Option<&str> { + self.session_id.as_deref() + } + + /// Return the transport key identifier associated with this bootstrap attempt. + pub fn key_id(&self) -> &str { + &self.key_id + } + + /// Return the negotiated libsodium session key for the secure channel. + pub fn session_key(&self) -> &Key { + &self.session_key + } + + /// Encode the minion's opening bootstrap frame and keep the local bootstrap state. + fn hello( + binding: SecureSessionBinding, session_key: Key, key_id: String, minion_prk: &RsaPrivateKey, master_pbk: &RsaPublicKey, + ) -> Result<(Self, SecureFrame), SysinspectError> { + let session_key_cipher = STANDARD.encode( + encrypt(master_pbk.clone(), session_key.0.to_vec()) + .map_err(|_| SysinspectError::RSAError("Failed to encrypt secure session key for the master".to_string()))?, + ); + let binding_signature = STANDARD.encode( + sign_data(minion_prk.clone(), &Self::hello_material(&binding, &session_key_cipher, Some(&key_id))?) + .map_err(|_| SysinspectError::RSAError("Failed to sign secure bootstrap binding".to_string()))?, + ); + Ok(( + Self { binding: binding.clone(), session_key: session_key.clone(), key_id: key_id.clone(), session_id: None }, + SecureFrame::BootstrapHello(SecureBootstrapHello { + binding: binding.clone(), + session_key_cipher, + binding_signature, + key_id: Some(key_id), + }), + )) + } + + /// Encode the master's bootstrap acknowledgement after the hello was authenticated successfully. + fn ack( + mut binding: SecureSessionBinding, session_key: Key, key_id: String, master_prk: &RsaPrivateKey, session_id: String, + rotation: SecureRotationMode, + ) -> Result<(Self, SecureFrame), SysinspectError> { + binding.master_nonce = Uuid::new_v4().to_string(); + let binding_signature = STANDARD.encode( + sign_data(master_prk.clone(), &Self::ack_material(&binding, &session_id, &key_id, &rotation)?) + .map_err(|_| SysinspectError::RSAError("Failed to sign secure bootstrap acknowledgement".to_string()))?, + ); + Ok(( + Self { binding: binding.clone(), session_key, key_id: key_id.clone(), session_id: Some(session_id.clone()) }, + SecureFrame::BootstrapAck(SecureBootstrapAck { binding, session_id: session_id.clone(), key_id, rotation, binding_signature }), + )) + } + + /// Check that the stored peer state is approved and matches the active protocol version. + fn ready(state: &TransportPeerState) -> Result<(), SysinspectError> { + if state.protocol_version != SECURE_PROTOCOL_VERSION { + return Err(SysinspectError::ProtoError(format!( + "Secure transport version mismatch in state: expected {}, found {}", + SECURE_PROTOCOL_VERSION, state.protocol_version + ))); + } + if state.approved_at.is_none() { + return Err(SysinspectError::ProtoError("Secure transport peer is not approved for bootstrap".to_string())); + } + Ok(()) + } + + /// Verify that an opening bootstrap binding matches the trusted peer state before accepting it. + fn opening(state: &TransportPeerState, binding: &SecureSessionBinding) -> Result<(), SysinspectError> { + if binding.protocol_version != SECURE_PROTOCOL_VERSION { + return Err(SysinspectError::ProtoError(format!("Unsupported secure bootstrap version {}", binding.protocol_version))); + } + if binding.master_nonce.is_empty() + && binding.minion_id == state.minion_id + && binding.minion_rsa_fingerprint == state.minion_rsa_fingerprint + && binding.master_rsa_fingerprint == state.master_rsa_fingerprint + && !binding.connection_id.trim().is_empty() + && !binding.client_nonce.trim().is_empty() + { + return Ok(()); + } + Err(SysinspectError::ProtoError("Secure bootstrap hello binding does not match the trusted peer state".to_string())) + } + + /// Verify that an acknowledgement binding is still tied to the same handshake attempt and peer identities. + fn accepted(state: &TransportPeerState, opening: &SecureSessionBinding, binding: &SecureSessionBinding) -> Result<(), SysinspectError> { + if binding.protocol_version != SECURE_PROTOCOL_VERSION { + return Err(SysinspectError::ProtoError(format!("Unsupported secure bootstrap ack version {}", binding.protocol_version))); + } + if binding.master_nonce.trim().is_empty() { + return Err(SysinspectError::ProtoError("Secure bootstrap ack is missing the master nonce".to_string())); + } + if binding.minion_id == state.minion_id + && binding.minion_rsa_fingerprint == state.minion_rsa_fingerprint + && binding.master_rsa_fingerprint == state.master_rsa_fingerprint + && binding.connection_id == opening.connection_id + && binding.client_nonce == opening.client_nonce + { + return Ok(()); + } + Err(SysinspectError::ProtoError("Secure bootstrap ack does not match the opening handshake binding".to_string())) + } + + /// Decrypt and authenticate the opening bootstrap frame sent by the minion. + fn verify_hello(hello: &SecureBootstrapHello, minion_pbk: &RsaPublicKey, master_prk: &RsaPrivateKey) -> Result { + if !verify_sign( + minion_pbk, + &Self::hello_material(&hello.binding, &hello.session_key_cipher, hello.key_id.as_deref())?, + STANDARD + .decode(&hello.binding_signature) + .map_err(|err| SysinspectError::SerializationError(format!("Failed to decode secure bootstrap signature: {err}")))?, + ) + .map_err(|err| SysinspectError::RSAError(err.to_string()))? + { + return Err(SysinspectError::RSAError("Secure bootstrap hello signature verification failed".to_string())); + } + let session_key = Self::key(&hello.session_key_cipher, master_prk)?; + Ok(session_key) + } + + /// Decrypt the RSA-wrapped libsodium session key from the opening bootstrap frame. + fn key(cipher: &str, master_prk: &RsaPrivateKey) -> Result { + Key::from_slice( + &decrypt( + master_prk.clone(), + STANDARD + .decode(cipher) + .map_err(|err| SysinspectError::SerializationError(format!("Failed to decode secure bootstrap session key: {err}")))?, + ) + .map_err(|_| SysinspectError::RSAError("Failed to decrypt secure bootstrap session key".to_string()))?, + ) + .ok_or_else(|| SysinspectError::RSAError("Secure bootstrap session key has invalid size".to_string())) + } + + /// Derive a fresh bootstrap session key from persisted transport material and the opening handshake tuple. + /// Derive a per-bootstrap session key from persisted transport material and the unique opening binding. + fn derive_session_key(material: &[u8], binding: &SecureSessionBinding) -> Key { + let mut digest = Sha256::new(); + digest.update(b"sysinspect-secure-bootstrap"); + digest.update(material); + digest.update(binding.minion_id.as_bytes()); + digest.update(binding.minion_rsa_fingerprint.as_bytes()); + digest.update(binding.master_rsa_fingerprint.as_bytes()); + digest.update(binding.connection_id.as_bytes()); + digest.update(binding.client_nonce.as_bytes()); + digest.update(binding.protocol_version.to_be_bytes()); + Key::from_slice(&digest.finalize()).unwrap_or_else(secretbox::gen_key) + } + + /// Build the signed material for a bootstrap hello from the binding, ciphered session key bytes and negotiated key id. + fn hello_material(binding: &SecureSessionBinding, session_key_cipher: &str, key_id: Option<&str>) -> Result, SysinspectError> { + Self::material(binding, Some(session_key_cipher.as_bytes()), key_id, None, None) + } + + /// Build the signed material for a bootstrap acknowledgement from the binding, session id, activated key id and rotation state. + fn ack_material( + binding: &SecureSessionBinding, session_id: &str, key_id: &str, rotation: &SecureRotationMode, + ) -> Result, SysinspectError> { + Self::material(binding, None, Some(key_id), Some(session_id), Some(rotation)) + } + + /// Serialize the binding and append the extra authenticated bootstrap material used for signatures. + fn material( + binding: &SecureSessionBinding, session_key: Option<&[u8]>, key_id: Option<&str>, session_id: Option<&str>, + rotation: Option<&SecureRotationMode>, + ) -> Result, SysinspectError> { + serde_json::to_vec(binding) + .map(|mut out| { + if let Some(chunk) = session_key { + out.extend_from_slice(chunk); + } + if let Some(chunk) = key_id { + out.extend_from_slice(chunk.as_bytes()); + } + if let Some(chunk) = session_id { + out.extend_from_slice(chunk.as_bytes()); + } + if let Some(chunk) = rotation { + out.extend_from_slice(format!("{chunk:?}").as_bytes()); + } + out + }) + .map_err(|err| SysinspectError::SerializationError(format!("Failed to serialise secure bootstrap material: {err}"))) + } + + /// Verify that the presented RSA public key fingerprint matches the trusted transport state. + fn fingerprint(label: &str, pbk: &RsaPublicKey, expected: &str) -> Result<(), SysinspectError> { + if get_fingerprint(pbk).map_err(|err| SysinspectError::RSAError(err.to_string()))? == expected { + return Ok(()); + } + Err(SysinspectError::ProtoError(format!("Trusted {label} fingerprint does not match the secure transport state"))) + } +} + +impl SecureBootstrapDiagnostics { + /// Build a diagnostic for peers that speak an unsupported secure transport version. + pub fn unsupported_version(message: impl Into) -> SecureFrame { + Self::frame(SecureDiagnosticCode::UnsupportedVersion, message.into(), true, false) + } + + /// Build a diagnostic for a bootstrap attempt that was rejected after validation. + pub fn bootstrap_rejected(message: impl Into) -> SecureFrame { + Self::frame(SecureDiagnosticCode::BootstrapRejected, message.into(), false, false) + } + + /// Build a diagnostic for a replayed or otherwise non-fresh bootstrap attempt. + pub fn replay_rejected(message: impl Into) -> SecureFrame { + Self::frame(SecureDiagnosticCode::ReplayRejected, message.into(), true, true) + } + + /// Build a diagnostic for a bootstrap peer that has been rate-limited. + pub fn rate_limited(message: impl Into) -> SecureFrame { + Self::frame(SecureDiagnosticCode::RateLimited, message.into(), true, true) + } + + /// Build a diagnostic for malformed bootstrap input received before a secure session exists. + pub fn malformed(message: impl Into) -> SecureFrame { + Self::frame(SecureDiagnosticCode::MalformedFrame, message.into(), false, true) + } + + /// Build a diagnostic for a peer that attempts to open a duplicate active secure session. + pub fn duplicate_session(message: impl Into) -> SecureFrame { + Self::frame(SecureDiagnosticCode::DuplicateSession, message.into(), true, false) + } + + /// Wrap a plaintext bootstrap diagnostic in the transport frame enum. + fn frame(code: SecureDiagnosticCode, message: String, retryable: bool, rate_limit: bool) -> SecureFrame { + SecureFrame::BootstrapDiagnostic(SecureBootstrapDiagnostic { + code, + message, + failure: SecureFailureSemantics::diagnostic(retryable, rate_limit), + }) + } +} + +/// Initialise libsodium exactly once before generating or validating bootstrap session material. +fn sodium_ready() -> Result<(), SysinspectError> { + if SODIUM_INIT.get().is_none() { + if sodiumoxide::init().is_err() { + return Err(SysinspectError::ConfigError("Failed to initialise libsodium".to_string())); + } + let _ = SODIUM_INIT.set(()); + } + Ok(()) +} diff --git a/libsysinspect/src/transport/secure_bootstrap_ut.rs b/libsysinspect/src/transport/secure_bootstrap_ut.rs new file mode 100644 index 00000000..e8684754 --- /dev/null +++ b/libsysinspect/src/transport/secure_bootstrap_ut.rs @@ -0,0 +1,145 @@ +use super::{ + TransportKeyExchangeModel, TransportPeerState, TransportProvisioningMode, TransportRotationStatus, + secure_bootstrap::{SecureBootstrapDiagnostics, SecureBootstrapSession}, +}; +use crate::rsa::keys::{get_fingerprint, keygen}; +use chrono::Utc; +use libsysproto::secure::{SECURE_PROTOCOL_VERSION, SecureDiagnosticCode, SecureFrame, SecureRotationMode}; +use rsa::RsaPublicKey; +use sodiumoxide::crypto::secretbox; + +fn state(master_pbk: &RsaPublicKey, minion_pbk: &RsaPublicKey) -> TransportPeerState { + TransportPeerState { + minion_id: "mid-1".to_string(), + master_rsa_fingerprint: get_fingerprint(master_pbk).unwrap(), + minion_rsa_fingerprint: get_fingerprint(minion_pbk).unwrap(), + protocol_version: SECURE_PROTOCOL_VERSION, + key_exchange: TransportKeyExchangeModel::EphemeralSessionKeys, + provisioning: TransportProvisioningMode::Automatic, + approved_at: Some(Utc::now()), + active_key_id: Some("kid-1".to_string()), + last_key_id: Some("kid-1".to_string()), + last_handshake_at: None, + rotation: TransportRotationStatus::Idle, + pending_rotation_context: None, + updated_at: Utc::now(), + keys: vec![], + } +} + +#[test] +fn diagnostics_keep_disconnect_semantics() { + match SecureBootstrapDiagnostics::malformed("bad frame") { + SecureFrame::BootstrapDiagnostic(frame) => { + assert!(matches!(frame.code, SecureDiagnosticCode::MalformedFrame)); + assert!(frame.failure.disconnect); + assert!(frame.failure.rate_limit); + } + _ => panic!("expected bootstrap diagnostic frame"), + } +} + +#[test] +fn open_rejects_unapproved_state() { + let (minion_prk, _) = keygen(2048).unwrap(); + let (_, master_pbk) = keygen(2048).unwrap(); + let (_, minion_pbk) = keygen(2048).unwrap(); + + assert!( + SecureBootstrapSession::open(&TransportPeerState { approved_at: None, ..state(&master_pbk, &minion_pbk) }, &minion_prk, &master_pbk).is_err() + ); +} + +#[test] +fn hello_and_ack_complete_a_bound_session() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (opening, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + + let ack = match SecureBootstrapSession::accept( + &state, + match &hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + Some(SecureRotationMode::None), + ) + .unwrap() + .1 + { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + + let established = opening.verify_ack(&state, &ack, &master_pbk).unwrap(); + + assert_eq!(established.session_id(), Some("sid-1")); + assert_eq!(established.key_id(), "kid-1"); + assert!(!established.binding().master_nonce.is_empty()); +} + +#[test] +fn tampered_hello_key_id_is_rejected() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (_, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + + let mut hello = match hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }; + hello.key_id = Some("kid-tampered".to_string()); + + assert!(SecureBootstrapSession::accept(&state, &hello, &master_prk, &minion_pbk, None, Some("kid-1".to_string()), None).is_err()); +} + +#[test] +fn tampered_ack_key_id_is_rejected() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (opening, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + + let mut ack = match SecureBootstrapSession::accept( + &state, + match &hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + Some(SecureRotationMode::None), + ) + .unwrap() + .1 + { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + ack.key_id = "kid-tampered".to_string(); + + assert!(opening.verify_ack(&state, &ack, &master_pbk).is_err()); +} + +#[test] +fn persisted_material_derives_distinct_session_keys_for_distinct_openings() { + let (_, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let mut state = state(&master_pbk, &minion_pbk); + let material = secretbox::gen_key(); + state.upsert_key_with_material("kid-1", super::TransportKeyStatus::Active, Some(&material.0)); + + let first = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap().0; + let second = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap().0; + + assert_ne!(first.binding().connection_id, second.binding().connection_id); + assert_ne!(first.session_key().0.to_vec(), second.session_key().0.to_vec()); +} diff --git a/libsysinspect/src/transport/secure_channel.rs b/libsysinspect/src/transport/secure_channel.rs new file mode 100644 index 00000000..151e9004 --- /dev/null +++ b/libsysinspect/src/transport/secure_channel.rs @@ -0,0 +1,195 @@ +use base64::{Engine, engine::general_purpose::STANDARD}; +use libcommon::SysinspectError; +use libsysproto::secure::{SECURE_PROTOCOL_VERSION, SecureDataFrame, SecureFrame}; +use serde::{Serialize, de::DeserializeOwned}; +use sha2::{Digest, Sha256}; +use sodiumoxide::crypto::secretbox::{self, Key, Nonce}; +use std::sync::OnceLock; + +use super::secure_bootstrap::SecureBootstrapSession; + +static SODIUM_INIT: OnceLock<()> = OnceLock::new(); + +/// Maximum accepted secure frame size on the wire. +pub const SECURE_MAX_FRAME_SIZE: usize = 1024 * 1024; + +/// Maximum accepted plaintext payload size after decryption. +pub const SECURE_MAX_PAYLOAD_SIZE: usize = 512 * 1024; + +/// Direction role used to derive unique per-direction nonces from the session counter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SecurePeerRole { + Master, + Minion, +} + +/// Stateful secure transport channel used after the bootstrap handshake succeeds. +#[derive(Debug, Clone)] +pub struct SecureChannel { + session_id: String, + key_id: String, + key: Key, + role: SecurePeerRole, + tx_counter: u64, + rx_counter: u64, + base_nonce: [u8; secretbox::NONCEBYTES], +} + +impl SecureChannel { + /// Create a steady-state secure channel from an accepted bootstrap session. + pub fn new(role: SecurePeerRole, bootstrap: &SecureBootstrapSession) -> Result { + sodium_ready()?; + let session_id = bootstrap + .session_id() + .ok_or_else(|| SysinspectError::ProtoError("Secure channel requires an established bootstrap session id".to_string()))? + .to_string(); + + let mut digest = Sha256::new(); + digest.update(session_id.as_bytes()); + digest.update(bootstrap.session_key().0); + let hash = digest.finalize(); + let mut base_nonce = [0u8; secretbox::NONCEBYTES]; + base_nonce.copy_from_slice(&hash[..secretbox::NONCEBYTES]); + + Ok(Self { + session_id, + key_id: bootstrap.key_id().to_string(), + key: bootstrap.session_key().clone(), + role, + tx_counter: 0, + rx_counter: 0, + base_nonce, + }) + } + + /// Return the established secure session identifier used by this channel. + pub fn session_id(&self) -> &str { + &self.session_id + } + + /// Return the active transport key identifier used by this channel. + pub fn key_id(&self) -> &str { + &self.key_id + } + + /// Seal a serializable payload into a `data` secure frame encoded as JSON bytes. + pub fn seal(&mut self, payload: &T) -> Result, SysinspectError> { + self.seal_bytes( + &serde_json::to_vec(payload) + .map_err(|err| SysinspectError::SerializationError(format!("Failed to serialize secure channel payload: {err}")))?, + ) + } + + /// Seal raw payload bytes into a `data` secure frame encoded as JSON bytes. + pub fn seal_bytes(&mut self, payload: &[u8]) -> Result, SysinspectError> { + if payload.len() > SECURE_MAX_PAYLOAD_SIZE { + return Err(SysinspectError::ProtoError(format!("Secure payload exceeds maximum size of {SECURE_MAX_PAYLOAD_SIZE} bytes"))); + } + self.tx_counter = + self.tx_counter.checked_add(1).ok_or_else(|| SysinspectError::ProtoError("Secure transmit counter overflow".to_string()))?; + serde_json::to_vec(&SecureFrame::Data(SecureDataFrame { + protocol_version: SECURE_PROTOCOL_VERSION, + session_id: self.session_id.clone(), + key_id: self.key_id.clone(), + counter: self.tx_counter, + nonce: STANDARD.encode(Self::nonce(self.role, self.tx_counter, &self.base_nonce).0), + payload: STANDARD.encode(secretbox::seal(payload, &Self::nonce(self.role, self.tx_counter, &self.base_nonce), &self.key)), + })) + .map_err(|err| SysinspectError::SerializationError(format!("Failed to encode secure data frame: {err}"))) + } + + /// Open a `data` secure frame from JSON bytes and deserialize it to the requested payload type. + pub fn open(&mut self, frame: &[u8]) -> Result { + serde_json::from_slice(&self.open_bytes(frame)?) + .map_err(|err| SysinspectError::DeserializationError(format!("Failed to decode secure channel payload: {err}"))) + } + + /// Open a `data` secure frame from JSON bytes and return the decrypted raw payload. + pub fn open_bytes(&mut self, frame: &[u8]) -> Result, SysinspectError> { + if frame.len() > SECURE_MAX_FRAME_SIZE { + return Err(SysinspectError::ProtoError(format!("Secure frame exceeds maximum size of {SECURE_MAX_FRAME_SIZE} bytes"))); + } + match serde_json::from_slice::(frame) + .map_err(|err| SysinspectError::DeserializationError(format!("Failed to decode secure frame: {err}")))? + { + SecureFrame::Data(data) => self.open_data(data), + _ => Err(SysinspectError::ProtoError("Expected encrypted secure data frame".to_string())), + } + } + + /// Reset the receive counter for a fresh reconnect-driven channel replacement. + pub fn reset_rx(&mut self) { + self.rx_counter = 0; + } + + /// Reset the transmit counter for a fresh reconnect-driven channel replacement. + pub fn reset_tx(&mut self) { + self.tx_counter = 0; + } + + /// Decrypt and validate an incoming secure `data` frame. + fn open_data(&mut self, frame: SecureDataFrame) -> Result, SysinspectError> { + if frame.protocol_version != SECURE_PROTOCOL_VERSION { + return Err(SysinspectError::ProtoError(format!("Unsupported secure data frame version {}", frame.protocol_version))); + } + if frame.session_id != self.session_id { + return Err(SysinspectError::ProtoError("Secure data frame session id does not match the active secure channel".to_string())); + } + if frame.key_id != self.key_id { + return Err(SysinspectError::ProtoError("Secure data frame key id does not match the active secure channel".to_string())); + } + if frame.counter <= self.rx_counter { + return Err(SysinspectError::ProtoError(format!("Replay rejected for secure frame counter {}", frame.counter))); + } + if frame.counter != self.rx_counter.saturating_add(1) { + return Err(SysinspectError::ProtoError(format!("Secure frame counter {} is out of sequence after {}", frame.counter, self.rx_counter))); + } + let expected_nonce = Self::nonce(Self::peer_role(self.role), frame.counter, &self.base_nonce); + if STANDARD.encode(expected_nonce.0) != frame.nonce { + return Err(SysinspectError::ProtoError("Secure data frame nonce does not match the expected counter-derived nonce".to_string())); + } + let payload = secretbox::open( + &STANDARD.decode(&frame.payload).map_err(|err| SysinspectError::SerializationError(format!("Failed to decode secure payload: {err}")))?, + &expected_nonce, + &self.key, + ) + .map_err(|_| SysinspectError::ProtoError("Failed to authenticate or decrypt secure payload".to_string()))?; + if payload.len() > SECURE_MAX_PAYLOAD_SIZE { + return Err(SysinspectError::ProtoError(format!("Decrypted secure payload exceeds maximum size of {SECURE_MAX_PAYLOAD_SIZE} bytes"))); + } + self.rx_counter = frame.counter; + Ok(payload) + } + + /// Derive a deterministic nonce from the sender role, base_nonce and monotonic counter. + fn nonce(role: SecurePeerRole, counter: u64, base_nonce: &[u8; secretbox::NONCEBYTES]) -> Nonce { + let mut nonce = *base_nonce; + nonce[0] ^= match role { + SecurePeerRole::Master => 1, + SecurePeerRole::Minion => 2, + }; + let counter_bytes = counter.to_be_bytes(); + for i in 0..8 { + nonce[secretbox::NONCEBYTES - 8 + i] ^= counter_bytes[i]; + } + Nonce(nonce) + } + + /// Return the opposite role used to validate the sender side of an incoming frame. + fn peer_role(role: SecurePeerRole) -> SecurePeerRole { + match role { + SecurePeerRole::Master => SecurePeerRole::Minion, + SecurePeerRole::Minion => SecurePeerRole::Master, + } + } +} + +fn sodium_ready() -> Result<(), SysinspectError> { + if SODIUM_INIT.get().is_none() { + if sodiumoxide::init().is_err() { + return Err(SysinspectError::ConfigError("Failed to initialise libsodium".to_string())); + } + let _ = SODIUM_INIT.set(()); + } + Ok(()) +} diff --git a/libsysinspect/src/transport/secure_channel_ut.rs b/libsysinspect/src/transport/secure_channel_ut.rs new file mode 100644 index 00000000..1631cd8c --- /dev/null +++ b/libsysinspect/src/transport/secure_channel_ut.rs @@ -0,0 +1,161 @@ +use super::{ + TransportKeyExchangeModel, TransportPeerState, TransportProvisioningMode, TransportRotationStatus, + secure_bootstrap::SecureBootstrapSession, + secure_channel::{SECURE_MAX_PAYLOAD_SIZE, SecureChannel, SecurePeerRole}, +}; +use crate::rsa::keys::{get_fingerprint, keygen}; +use chrono::Utc; +use libsysproto::secure::{SECURE_PROTOCOL_VERSION, SecureFrame}; +use rsa::RsaPublicKey; +use sodiumoxide::crypto::secretbox; + +fn state(master_pbk: &RsaPublicKey, minion_pbk: &RsaPublicKey) -> TransportPeerState { + TransportPeerState { + minion_id: "mid-1".to_string(), + master_rsa_fingerprint: get_fingerprint(master_pbk).unwrap(), + minion_rsa_fingerprint: get_fingerprint(minion_pbk).unwrap(), + protocol_version: SECURE_PROTOCOL_VERSION, + key_exchange: TransportKeyExchangeModel::EphemeralSessionKeys, + provisioning: TransportProvisioningMode::Automatic, + approved_at: Some(Utc::now()), + active_key_id: Some("kid-1".to_string()), + last_key_id: Some("kid-1".to_string()), + last_handshake_at: None, + rotation: TransportRotationStatus::Idle, + pending_rotation_context: None, + updated_at: Utc::now(), + keys: vec![], + } +} + +fn channels() -> (SecureChannel, SecureChannel) { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (opening, hello) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + let ack = match SecureBootstrapSession::accept( + &state, + match &hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + None, + ) + .unwrap() + .1 + { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + let minion = opening.verify_ack(&state, &ack, &master_pbk).unwrap(); + let master = SecureBootstrapSession::accept( + &state, + match &hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + None, + ) + .unwrap() + .0; + + (SecureChannel::new(SecurePeerRole::Master, &master).unwrap(), SecureChannel::new(SecurePeerRole::Minion, &minion).unwrap()) +} + +#[test] +fn secure_channel_roundtrips_json_payloads() { + let (mut master, mut minion) = channels(); + let frame = minion.seal(&serde_json::json!({"hello":"world"})).unwrap(); + let payload: serde_json::Value = master.open(&frame).unwrap(); + + assert_eq!(payload["hello"], "world"); +} + +#[test] +fn secure_channel_rejects_replayed_frames() { + let (mut master, mut minion) = channels(); + let frame = minion.seal(&serde_json::json!({"hello":"world"})).unwrap(); + + let _: serde_json::Value = master.open(&frame).unwrap(); + assert!(master.open::(&frame).is_err()); +} + +#[test] +fn secure_channel_rejects_out_of_sequence_frames() { + let (mut master, mut minion) = channels(); + let _ = minion.seal(&serde_json::json!({"first":1})).unwrap(); + let frame = minion.seal(&serde_json::json!({"second":2})).unwrap(); + + assert!(master.open::(&frame).is_err()); +} + +#[test] +fn secure_channel_rejects_oversized_payloads() { + let (_, mut minion) = channels(); + + assert!(minion.seal_bytes(&vec![0u8; SECURE_MAX_PAYLOAD_SIZE + 1]).is_err()); +} + +#[test] +fn secure_channel_first_frame_differs_across_reconnects_with_same_persisted_material() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let mut state = state(&master_pbk, &minion_pbk); + let material = secretbox::gen_key(); + state.upsert_key_with_material("kid-1", super::TransportKeyStatus::Active, Some(&material.0)); + + let (opening_one, hello_one) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + let ack_one = match SecureBootstrapSession::accept( + &state, + match &hello_one { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-1".to_string()), + Some("kid-1".to_string()), + None, + ) + .unwrap() + .1 + { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + let (opening_two, hello_two) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + let ack_two = match SecureBootstrapSession::accept( + &state, + match &hello_two { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &minion_pbk, + Some("sid-2".to_string()), + Some("kid-1".to_string()), + None, + ) + .unwrap() + .1 + { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }; + + let mut minion_one = SecureChannel::new(SecurePeerRole::Minion, &opening_one.verify_ack(&state, &ack_one, &master_pbk).unwrap()).unwrap(); + let mut minion_two = SecureChannel::new(SecurePeerRole::Minion, &opening_two.verify_ack(&state, &ack_two, &master_pbk).unwrap()).unwrap(); + + let frame_one = minion_one.seal(&serde_json::json!({"hello":"world"})).unwrap(); + let frame_two = minion_two.seal(&serde_json::json!({"hello":"world"})).unwrap(); + + assert_ne!(frame_one, frame_two); +} diff --git a/libsysinspect/src/transport/transport_ut.rs b/libsysinspect/src/transport/transport_ut.rs new file mode 100644 index 00000000..d71b790f --- /dev/null +++ b/libsysinspect/src/transport/transport_ut.rs @@ -0,0 +1,116 @@ +use super::{TransportKeyExchangeModel, TransportKeyStatus, TransportPeerState, TransportProvisioningMode, TransportStore, transport_minion_root}; +use crate::cfg::mmconf::{MasterConfig, MinionConfig}; + +#[test] +fn minion_transport_store_uses_managed_state_path() { + let mut cfg = MinionConfig::default(); + let root = tempfile::tempdir().unwrap(); + cfg.set_root_dir(root.path().to_str().unwrap()); + + let store = TransportStore::for_minion(&cfg).unwrap(); + + assert_eq!(store.state_path(), &cfg.transport_state_file()); +} + +#[test] +fn master_transport_store_rejects_traversing_minion_id() { + let cfg = MasterConfig::default(); + + assert!(TransportStore::for_master_minion(&cfg, "../escape").is_err()); +} + +#[test] +fn transport_state_roundtrips_through_managed_file() { + let mut cfg = MinionConfig::default(); + let root = tempfile::tempdir().unwrap(); + cfg.set_root_dir(root.path().to_str().unwrap()); + let store = TransportStore::for_minion(&cfg).unwrap(); + let mut state = TransportPeerState::new("mid-1".to_string(), "master-fp".to_string(), "minion-fp".to_string(), 1); + + state.upsert_key("k1", TransportKeyStatus::Proposed); + state.upsert_key("k1", TransportKeyStatus::Active); + store.save(&state).unwrap(); + + let loaded = store.load().unwrap().unwrap(); + assert_eq!(loaded.minion_id, "mid-1"); + assert_eq!(loaded.active_key_id.as_deref(), Some("k1")); + assert_eq!(loaded.last_key_id.as_deref(), Some("k1")); + assert_eq!(loaded.keys.len(), 1); + assert_eq!(loaded.keys[0].status, TransportKeyStatus::Active); +} + +#[test] +fn transport_minion_root_uses_safe_peer_ids() { + let root = tempfile::tempdir().unwrap(); + + assert_eq!(transport_minion_root(root.path(), "node-1").unwrap(), root.path().join("minions").join("node-1")); +} + +#[test] +fn transport_state_defaults_to_automatic_provisioning() { + let state = TransportPeerState::new("mid-1".to_string(), "master-fp".to_string(), "minion-fp".to_string(), 1); + + assert_eq!(state.key_exchange, TransportKeyExchangeModel::EphemeralSessionKeys); + assert_eq!(state.provisioning, TransportProvisioningMode::Automatic); + assert!(state.approved_at.is_some()); +} + +#[test] +fn transport_state_can_switch_to_explicit_approval() { + let mut state = TransportPeerState::new("mid-1".to_string(), "master-fp".to_string(), "minion-fp".to_string(), 1); + + state.set_provisioning(TransportProvisioningMode::ExplicitApproval); + assert_eq!(state.provisioning, TransportProvisioningMode::ExplicitApproval); + assert!(state.approved_at.is_none()); + state.approve(); + assert!(state.approved_at.is_some()); +} + +#[test] +fn store_can_require_explicit_approval_for_a_peer() { + let root = tempfile::tempdir().unwrap(); + let store = super::TransportStore::new(root.path().join("transport/master/state.json")).unwrap(); + + let state = store.require_explicit_approval("mid-1", "master-fp", "minion-fp", 1).unwrap(); + + assert_eq!(state.provisioning, TransportProvisioningMode::ExplicitApproval); + assert!(state.approved_at.is_none()); +} + +#[test] +fn store_can_approve_existing_peer_state() { + let root = tempfile::tempdir().unwrap(); + let store = super::TransportStore::new(root.path().join("transport/master/state.json")).unwrap(); + + store.require_explicit_approval("mid-1", "master-fp", "minion-fp", 1).unwrap(); + let approved = store.approve_peer().unwrap(); + + assert_eq!(approved.provisioning, TransportProvisioningMode::ExplicitApproval); + assert!(approved.approved_at.is_some()); +} + +#[test] +fn store_keeps_ephemeral_session_key_model_for_automatic_peers() { + let root = tempfile::tempdir().unwrap(); + let store = super::TransportStore::new(root.path().join("transport/master/state.json")).unwrap(); + + let state = store.ensure_automatic_peer("mid-1", "master-fp", "minion-fp", 1).unwrap(); + + assert_eq!(state.key_exchange, TransportKeyExchangeModel::EphemeralSessionKeys); +} + +#[cfg(unix)] +#[test] +fn transport_state_file_is_private_on_unix() { + use std::os::unix::fs::PermissionsExt; + + let mut cfg = MinionConfig::default(); + let root = tempfile::tempdir().unwrap(); + cfg.set_root_dir(root.path().to_str().unwrap()); + let store = TransportStore::for_minion(&cfg).unwrap(); + + store.save(&TransportPeerState::new("mid-1".to_string(), "master-fp".to_string(), "minion-fp".to_string(), 1)).unwrap(); + + assert_eq!(std::fs::metadata(cfg.transport_master_root()).unwrap().permissions().mode() & 0o777, 0o700); + assert_eq!(std::fs::metadata(cfg.transport_state_file()).unwrap().permissions().mode() & 0o777, 0o600); +} diff --git a/libsysinspect/tests/rsa_rotation.rs b/libsysinspect/tests/rsa_rotation.rs new file mode 100644 index 00000000..5fea9b1c --- /dev/null +++ b/libsysinspect/tests/rsa_rotation.rs @@ -0,0 +1,163 @@ +use chrono::{Duration, Utc}; +use libsysinspect::{ + rsa::keys::{get_fingerprint, keygen}, + rsa::rotation::{RotationActor, RsaTransportRotator}, + transport::{TransportKeyStatus, TransportRotationStatus, TransportStore}, +}; + +#[test] +fn rotation_execute_persists_new_active_key_and_purges_old_material() { + let tmp = tempfile::tempdir().unwrap(); + let store_path = tmp.path().join("transport/minions/mid-1/state.json"); + let store = TransportStore::new(store_path.clone()).unwrap(); + + let rotator = RsaTransportRotator::new(RotationActor::Master, store, "mid-1", "master-fp", "minion-fp", 1).unwrap(); + + let mut state = rotator.state().clone(); + state.upsert_key("kid-old", TransportKeyStatus::Active); + let old = Utc::now() - Duration::days(3); + if let Some(record) = state.keys.iter_mut().find(|record| record.key_id == "kid-old") { + record.created_at = old; + record.activated_at = Some(old); + } + state.updated_at = old; + TransportStore::new(store_path.clone()).unwrap().save(&state).unwrap(); + + let mut rotator = + RsaTransportRotator::new(RotationActor::Master, TransportStore::new(store_path.clone()).unwrap(), "mid-1", "master-fp", "minion-fp", 1) + .unwrap(); + + let plan = rotator.plan("scheduled"); + let ticket = rotator.execute(&plan).unwrap(); + + let persisted = TransportStore::new(store_path).unwrap().load().unwrap().unwrap(); + assert_eq!(persisted.active_key_id.as_deref(), Some(plan.next_key_id())); + assert_eq!(persisted.keys.len(), 1); + assert_eq!(persisted.keys[0].status, TransportKeyStatus::Active); + assert!(ticket.result().purged_keys() >= 1); +} + +#[test] +fn pending_rotation_survives_reload_and_clears_after_execute() { + let tmp = tempfile::tempdir().unwrap(); + let store_path = tmp.path().join("transport/minions/mid-2/state.json"); + + let rotator = + RsaTransportRotator::new(RotationActor::Master, TransportStore::new(store_path.clone()).unwrap(), "mid-2", "master-fp", "minion-fp", 1) + .unwrap(); + + let mut state = rotator.state().clone(); + state.upsert_key("kid-current", TransportKeyStatus::Active); + let old = Utc::now() - Duration::days(5); + if let Some(record) = state.keys.iter_mut().find(|record| record.key_id == "kid-current") { + record.created_at = old; + record.activated_at = Some(old); + } + state.updated_at = old; + TransportStore::new(store_path.clone()).unwrap().save(&state).unwrap(); + + let mut rotator = + RsaTransportRotator::new(RotationActor::Master, TransportStore::new(store_path.clone()).unwrap(), "mid-2", "master-fp", "minion-fp", 1) + .unwrap(); + + assert!(rotator.queue_if_due(Duration::hours(48), Utc::now()).unwrap()); + + let reloaded = + RsaTransportRotator::new(RotationActor::Master, TransportStore::new(store_path.clone()).unwrap(), "mid-2", "master-fp", "minion-fp", 1) + .unwrap(); + assert_eq!(reloaded.state().rotation, TransportRotationStatus::Pending); + + let plan = reloaded.plan("pending-online"); + let mut reloaded = reloaded; + let _ = reloaded.execute(&plan).unwrap(); + assert_eq!(reloaded.state().rotation, TransportRotationStatus::Idle); +} + +#[test] +fn rollback_restores_previous_state_on_failure_path() { + let tmp = tempfile::tempdir().unwrap(); + let store_path = tmp.path().join("transport/minions/mid-3/state.json"); + + let rotator = + RsaTransportRotator::new(RotationActor::Master, TransportStore::new(store_path.clone()).unwrap(), "mid-3", "master-fp", "minion-fp", 1) + .unwrap(); + + let mut state = rotator.state().clone(); + state.upsert_key("kid-base", TransportKeyStatus::Active); + TransportStore::new(store_path.clone()).unwrap().save(&state).unwrap(); + + let mut rotator = + RsaTransportRotator::new(RotationActor::Master, TransportStore::new(store_path.clone()).unwrap(), "mid-3", "master-fp", "minion-fp", 1) + .unwrap(); + + let previous = rotator.state().clone(); + let plan = rotator.plan("manual"); + let ticket = rotator.execute(&plan).unwrap(); + let restored = rotator.rollback(&ticket).unwrap(); + + assert_eq!(restored.active_key_id, previous.active_key_id); + assert_eq!(restored.keys, previous.keys); + + let persisted = TransportStore::new(store_path).unwrap().load().unwrap().unwrap(); + assert_eq!(persisted.active_key_id, previous.active_key_id); + assert_eq!(persisted.keys, previous.keys); +} + +#[test] +fn execute_signed_intent_rotates_only_when_rsa_trust_anchor_verifies() { + let tmp = tempfile::tempdir().unwrap(); + let store_path = tmp.path().join("transport/minions/mid-4/state.json"); + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let master_fp = get_fingerprint(&master_pbk).unwrap(); + + let rotator = + RsaTransportRotator::new(RotationActor::Master, TransportStore::new(store_path.clone()).unwrap(), "mid-4", &master_fp, "minion-fp", 1) + .unwrap(); + + let mut state = rotator.state().clone(); + state.upsert_key("kid-old", TransportKeyStatus::Active); + TransportStore::new(store_path.clone()).unwrap().save(&state).unwrap(); + + let mut rotator = + RsaTransportRotator::new(RotationActor::Master, TransportStore::new(store_path.clone()).unwrap(), "mid-4", &master_fp, "minion-fp", 1) + .unwrap(); + + let plan = rotator.plan("scheduled"); + let signed = rotator.sign_plan(&plan, &master_prk).unwrap(); + let _ = rotator.execute_signed_intent(&signed, &master_pbk).unwrap(); + + let persisted = TransportStore::new(store_path).unwrap().load().unwrap().unwrap(); + assert_eq!(persisted.active_key_id.as_deref(), Some(plan.next_key_id())); + assert_eq!(persisted.keys.len(), 1); +} + +#[test] +fn execute_signed_intent_with_overlap_persists_retiring_key_until_grace_expires() { + let tmp = tempfile::tempdir().unwrap(); + let store_path = tmp.path().join("transport/minions/mid-5/state.json"); + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let master_fp = get_fingerprint(&master_pbk).unwrap(); + + let rotator = + RsaTransportRotator::new(RotationActor::Master, TransportStore::new(store_path.clone()).unwrap(), "mid-5", &master_fp, "minion-fp", 1) + .unwrap(); + + let mut state = rotator.state().clone(); + state.upsert_key("kid-old", TransportKeyStatus::Active); + TransportStore::new(store_path.clone()).unwrap().save(&state).unwrap(); + + let mut rotator = + RsaTransportRotator::new(RotationActor::Master, TransportStore::new(store_path.clone()).unwrap(), "mid-5", &master_fp, "minion-fp", 1) + .unwrap(); + + let plan = rotator.plan("scheduled"); + let signed = rotator.sign_plan(&plan, &master_prk).unwrap(); + let _ = rotator.execute_signed_intent_with_overlap(&signed, &master_pbk, Duration::hours(1)).unwrap(); + + let persisted = TransportStore::new(store_path.clone()).unwrap().load().unwrap().unwrap(); + assert!(persisted.keys.iter().any(|key| key.key_id == "kid-old" && key.status == TransportKeyStatus::Retiring)); + + let _ = rotator.retire_elapsed_keys(Utc::now() + Duration::hours(2), Duration::hours(1)).unwrap(); + let persisted = TransportStore::new(store_path).unwrap().load().unwrap().unwrap(); + assert!(persisted.keys.iter().all(|key| key.key_id != "kid-old")); +} diff --git a/libsysproto/Cargo.toml b/libsysproto/Cargo.toml index 3c630b35..588c91c0 100644 --- a/libsysproto/Cargo.toml +++ b/libsysproto/Cargo.toml @@ -11,4 +11,4 @@ serde_json = "1.0.149" strum = "0.27.2" strum_macros = "0.27.2" tokio = { version = "1.49.0", features = ["full"] } -uuid = "1.20.0" +uuid = { version = "1.20.0", features = ["v4"] } diff --git a/libsysproto/src/README.md b/libsysproto/src/README.md index 6cf345c5..3317f449 100644 --- a/libsysproto/src/README.md +++ b/libsysproto/src/README.md @@ -2,6 +2,128 @@ Protocol description about message exchange between master and a minion. +## Secure transport design + +This document is the Phase 1 source of truth for the secure Master/Minion transport. +The concrete shared protocol types live in `libsysproto::secure`. + +### Transport goals + +The secure Master/Minion transport must: + +- avoid TLS for Master/Minion links +- avoid DNS assumptions +- tolerate reconnects, address changes, and unstable embedded networks +- keep frames bounded +- reject replayed frames +- support explicit key rotation +- keep only the minimum bootstrap metadata in plaintext +- reject every non-bootstrap plaintext frame once secure transport exists +- allow only one active secure session per minion at a time + +### Wire shape + +The outer wire shape stays length-prefixed, matching the current transport: + +1. write a big-endian `u32` frame length +2. write one serialized secure frame + +The secure frame itself is versioned JSON and uses one of four shapes: + +- `bootstrap_hello` +- `bootstrap_ack` +- `bootstrap_diagnostic` +- `data` + +### Identity binding + +Every secure session is bound to all of the following: + +- minion id +- minion RSA fingerprint +- master RSA fingerprint +- secure protocol version +- connection id +- client nonce +- master nonce + +This binding is represented by `SecureSessionBinding` and must be authenticated during bootstrap. + +### Plaintext bootstrap frames + +Only these frames may ever be plaintext: + +#### `bootstrap_hello` + +Sent by the minion to begin a new secure session. + +Fields: + +- `binding`: initial `SecureSessionBinding` +- `session_key_cipher`: fresh symmetric session key encrypted to the master's registered RSA key +- `binding_signature`: minion RSA signature over the binding and raw session key +- `key_id`: optional transport key identifier for reconnect or rotation continuity + +#### `bootstrap_ack` + +Sent by the master after validating the registered minion RSA identity and accepting the new secure session. + +Fields: + +- `binding`: completed `SecureSessionBinding` with the master nonce filled in +- `session_id`: master-assigned secure session id +- `key_id`: accepted transport key id +- `rotation`: `none`, `rekey`, or `reregister` +- `binding_signature`: master RSA signature over the completed binding and accepted session id + +#### `bootstrap_diagnostic` + +Plaintext rejection or negotiation failure emitted before a secure session exists. + +Fields: + +- `code`: `unsupported_version`, `bootstrap_rejected`, `replay_rejected`, `rate_limited`, `malformed_frame`, or `duplicate_session` +- `message`: human-readable diagnostic +- `failure`: retry and disconnect semantics + +### Encrypted steady-state frame + +After bootstrap succeeds, every Master/Minion frame must use `data`. +No other plaintext frame is valid anymore on that connection. + +#### `data` + +Fields: + +- `protocol_version`: secure transport version +- `session_id`: established secure session id +- `key_id`: active transport key id +- `counter`: monotonic per-direction counter +- `nonce`: libsodium nonce for the sealed payload +- `payload`: authenticated encrypted payload + +### Failure semantics + +Unsupported or malformed peers must not silently fall back to plaintext behavior. + +Rules: + +- if it is safe to do so, emit `bootstrap_diagnostic` +- disconnect after the diagnostic +- rate-limit malformed bootstrap attempts +- reject duplicate active sessions for the same minion +- reject any post-bootstrap plaintext frame immediately + +### Session semantics + +Rules: + +- only one active secure session may exist per minion +- reconnects must create a new connection id and fresh nonces +- replay protection is per direction and tied to the session id and active key id +- RSA remains only the bootstrap and rotation trust anchor +- steady-state traffic uses libsodium-protected frames only + ## Message Structure ### Master message structure diff --git a/libsysproto/src/lib.rs b/libsysproto/src/lib.rs index e22bb2a4..b5c4ccc0 100644 --- a/libsysproto/src/lib.rs +++ b/libsysproto/src/lib.rs @@ -2,6 +2,7 @@ pub mod errcodes; pub mod payload; pub mod query; pub mod rqtypes; +pub mod secure; use std::collections::HashSet; diff --git a/libsysproto/src/mod.rs b/libsysproto/src/mod.rs index 7b07f483..71f7e584 100644 --- a/libsysproto/src/mod.rs +++ b/libsysproto/src/mod.rs @@ -2,6 +2,7 @@ pub mod errcodes; pub mod payload; pub mod query; pub mod rqtypes; +pub mod secure; use std::collections::HashSet; diff --git a/libsysproto/src/query.rs b/libsysproto/src/query.rs index 5b886493..33616604 100644 --- a/libsysproto/src/query.rs +++ b/libsysproto/src/query.rs @@ -18,15 +18,20 @@ pub mod commands { pub const CLUSTER_REBOOT: &str = "cluster/reboot"; // Rotate RSA/AES on the entire cluster - // TODO: Not implemented yet pub const CLUSTER_ROTATE: &str = "cluster/rotate"; + // Report transport status for one or more minions + pub const CLUSTER_TRANSPORT_STATUS: &str = "cluster/transport/status"; + // Remove minion (unregister) pub const CLUSTER_REMOVE_MINION: &str = "cluster/minion/remove"; // Get online minions pub const CLUSTER_ONLINE_MINIONS: &str = "cluster/minion/online"; + // Get detailed minion registry information + pub const CLUSTER_MINION_INFO: &str = "cluster/minion/info"; + // Update master-managed static traits on minions pub const CLUSTER_TRAITS_UPDATE: &str = "cluster/traits/update"; @@ -38,8 +43,10 @@ pub mod commands { /// Query parser (scheme). /// It has the following format: /// -/// /[entity]/[state] -/// :[checkbook labels] +/// ```text +/// /[entity]/[state] +/// :[checkbook labels] +/// ``` /// /// If `"entity"` and/or `"state"` are omitted, they are globbed to `"$"` (all). #[derive(Debug, Clone, Default)] diff --git a/libsysproto/src/secure.rs b/libsysproto/src/secure.rs new file mode 100644 index 00000000..96c348e1 --- /dev/null +++ b/libsysproto/src/secure.rs @@ -0,0 +1,201 @@ +use serde::{Deserialize, Serialize}; + +/// Version of the planned secure Master/Minion transport protocol. +pub const SECURE_PROTOCOL_VERSION: u16 = 1; + +/// Master/Minion transport goals fixed by Phase 1 decisions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SecureTransportGoals { + /// Master/Minion transport must not depend on TLS. + pub no_tls_dependency: bool, + /// Transport must not depend on DNS. + pub no_dns_dependency: bool, + /// Reconnects over unstable links must be a first-class behavior. + pub reconnect_tolerant: bool, + /// Steady-state frames must be bounded and authenticated. + pub bounded_frames: bool, + /// Replayed secure frames must be rejected. + pub replay_protection: bool, + /// Rotation must be supported explicitly. + pub explicit_rotation: bool, + /// Only the minimum bootstrap metadata may remain plaintext. + pub minimal_plaintext_bootstrap: bool, + /// Non-bootstrap plaintext is rejected once secure framing exists. + pub reject_non_bootstrap_plaintext: bool, + /// Only one active secure session may exist for a minion at a time. + pub single_active_session_per_minion: bool, +} + +impl SecureTransportGoals { + /// Return the fixed Phase 1 transport-goal set for secure Master/Minion communication. + pub fn master_minion() -> Self { + Self { + no_tls_dependency: true, + no_dns_dependency: true, + reconnect_tolerant: true, + bounded_frames: true, + replay_protection: true, + explicit_rotation: true, + minimal_plaintext_bootstrap: true, + reject_non_bootstrap_plaintext: true, + single_active_session_per_minion: true, + } + } +} + +/// Identity binding that ties a secure session to both peers and one connection attempt. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SecureSessionBinding { + /// Registered minion identity. + pub minion_id: String, + /// Fingerprint of the minion RSA identity already registered on the master. + pub minion_rsa_fingerprint: String, + /// Fingerprint of the master's RSA identity already trusted by the minion. + pub master_rsa_fingerprint: String, + /// Secure transport protocol version. + pub protocol_version: u16, + /// Per-connection identifier generated for each new handshake attempt. + pub connection_id: String, + /// Fresh randomness from the minion side. + pub client_nonce: String, + /// Fresh randomness from the master side. Empty in the first bootstrap frame. + pub master_nonce: String, + /// UTC timestamp of the bootstrap opening, used to prevent replay attacks across cache purges. + pub timestamp: i64, +} + +impl SecureSessionBinding { + /// Build the opening binding sent by the minion before the master nonce is known. + pub fn bootstrap_opening( + minion_id: String, minion_rsa_fingerprint: String, master_rsa_fingerprint: String, connection_id: String, client_nonce: String, + timestamp: i64, + ) -> Self { + Self { + minion_id, + minion_rsa_fingerprint, + master_rsa_fingerprint, + protocol_version: SECURE_PROTOCOL_VERSION, + connection_id, + client_nonce, + master_nonce: String::new(), + timestamp, + } + } +} + +/// Plaintext diagnostic codes permitted before a secure session exists. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SecureDiagnosticCode { + UnsupportedVersion, + BootstrapRejected, + ReplayRejected, + RateLimited, + MalformedFrame, + DuplicateSession, +} + +/// Rotation mode attached to bootstrap acknowledgements. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SecureRotationMode { + None, + Rekey, + Reregister, +} + +/// Failure behavior used by the secure transport. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SecureFailureSemantics { + /// Whether the peer may retry without operator action. + pub retryable: bool, + /// Whether the receiver should disconnect immediately after emitting the diagnostic. + pub disconnect: bool, + /// Whether the event should contribute to peer-side rate limiting. + pub rate_limit: bool, +} + +impl SecureFailureSemantics { + /// Build the failure flags used for a plaintext bootstrap diagnostic. + pub fn diagnostic(retryable: bool, rate_limit: bool) -> Self { + Self { retryable, disconnect: true, rate_limit } + } +} + +/// First plaintext bootstrap frame sent by a minion. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SecureBootstrapHello { + /// Session binding input that must later be authenticated and echoed. + pub binding: SecureSessionBinding, + /// Fresh symmetric session key encrypted to the master's registered RSA key. + pub session_key_cipher: String, + /// RSA signature over the bootstrap binding, wrapped session key ciphertext and negotiated key id. + pub binding_signature: String, + /// Optional transport key identifier when reconnecting or rotating. + pub key_id: Option, +} + +/// Plaintext bootstrap acknowledgement returned by the master. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SecureBootstrapAck { + /// Session binding echoed back with the master's nonce filled in. + pub binding: SecureSessionBinding, + /// Server-assigned secure session identifier. + pub session_id: String, + /// Activated transport key identifier. + pub key_id: String, + /// Rotation state communicated during handshake. + pub rotation: SecureRotationMode, + /// RSA signature over the completed binding, accepted session identifier, activated key id and rotation state. + pub binding_signature: String, +} + +/// Plaintext bootstrap diagnostic frame. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SecureBootstrapDiagnostic { + pub code: SecureDiagnosticCode, + pub message: String, + pub failure: SecureFailureSemantics, +} + +/// Encrypted steady-state frame. Every frame after bootstrap must use this shape. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SecureDataFrame { + /// Fixed protocol version. + pub protocol_version: u16, + /// Established session identifier. + pub session_id: String, + /// Transport key identifier active for this frame. + pub key_id: String, + /// Monotonic per-direction counter used for replay rejection. + pub counter: u64, + /// Libsodium nonce encoded for transport. + pub nonce: String, + /// Authenticated encrypted payload. + pub payload: String, +} + +/// Versioned secure Master/Minion frame. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SecureFrame { + BootstrapHello(SecureBootstrapHello), + BootstrapAck(SecureBootstrapAck), + BootstrapDiagnostic(SecureBootstrapDiagnostic), + Data(SecureDataFrame), +} + +impl SecureFrame { + /// Only bootstrap frames may remain plaintext. + pub fn is_plaintext_bootstrap(&self) -> bool { + matches!(self, Self::BootstrapHello(_) | Self::BootstrapAck(_) | Self::BootstrapDiagnostic(_)) + } + + /// All normal post-bootstrap traffic must be encrypted. + pub fn requires_established_session(&self) -> bool { + matches!(self, Self::Data(_)) + } +} + +#[cfg(test)] +mod secure_ut; diff --git a/libsysproto/src/secure/secure_ut.rs b/libsysproto/src/secure/secure_ut.rs new file mode 100644 index 00000000..ea266033 --- /dev/null +++ b/libsysproto/src/secure/secure_ut.rs @@ -0,0 +1,126 @@ +use super::{ + SECURE_PROTOCOL_VERSION, SecureBootstrapAck, SecureBootstrapDiagnostic, SecureBootstrapHello, SecureDataFrame, SecureDiagnosticCode, + SecureFailureSemantics, SecureFrame, SecureRotationMode, SecureSessionBinding, SecureTransportGoals, +}; + +fn binding() -> SecureSessionBinding { + SecureSessionBinding { + minion_id: "minion-a".to_string(), + minion_rsa_fingerprint: "minion-fp".to_string(), + master_rsa_fingerprint: "master-fp".to_string(), + protocol_version: SECURE_PROTOCOL_VERSION, + connection_id: "conn-1".to_string(), + client_nonce: "client-nonce".to_string(), + master_nonce: "master-nonce".to_string(), + timestamp: 1672531200, + } +} + +#[test] +fn master_minion_goals_match_phase_one_decisions() { + assert_eq!( + SecureTransportGoals::master_minion(), + SecureTransportGoals { + no_tls_dependency: true, + no_dns_dependency: true, + reconnect_tolerant: true, + bounded_frames: true, + replay_protection: true, + explicit_rotation: true, + minimal_plaintext_bootstrap: true, + reject_non_bootstrap_plaintext: true, + single_active_session_per_minion: true, + } + ); +} + +#[test] +fn only_bootstrap_frames_may_stay_plaintext() { + assert!( + SecureFrame::BootstrapHello(SecureBootstrapHello { + binding: binding(), + session_key_cipher: "cipher".to_string(), + binding_signature: "sig".to_string(), + key_id: None, + }) + .is_plaintext_bootstrap() + ); + assert!( + SecureFrame::BootstrapAck(SecureBootstrapAck { + binding: binding(), + session_id: "sid".to_string(), + key_id: "kid".to_string(), + rotation: SecureRotationMode::None, + binding_signature: "sig".to_string(), + }) + .is_plaintext_bootstrap() + ); + assert!( + SecureFrame::BootstrapDiagnostic(SecureBootstrapDiagnostic { + code: SecureDiagnosticCode::MalformedFrame, + message: "bad".to_string(), + failure: SecureFailureSemantics::diagnostic(false, true), + }) + .is_plaintext_bootstrap() + ); + assert!( + !SecureFrame::Data(SecureDataFrame { + protocol_version: SECURE_PROTOCOL_VERSION, + session_id: "sid".to_string(), + key_id: "kid".to_string(), + counter: 1, + nonce: "nonce".to_string(), + payload: "payload".to_string(), + }) + .is_plaintext_bootstrap() + ); +} + +#[test] +fn data_frames_require_established_session_state() { + assert!( + SecureFrame::Data(SecureDataFrame { + protocol_version: SECURE_PROTOCOL_VERSION, + session_id: "sid".to_string(), + key_id: "kid".to_string(), + counter: 7, + nonce: "nonce".to_string(), + payload: "payload".to_string(), + }) + .requires_established_session() + ); + assert!( + !SecureFrame::BootstrapDiagnostic(SecureBootstrapDiagnostic { + code: SecureDiagnosticCode::UnsupportedVersion, + message: "old peer".to_string(), + failure: SecureFailureSemantics::diagnostic(true, false), + }) + .requires_established_session() + ); +} + +#[test] +fn secure_frame_serde_uses_stable_kind_tags() { + assert_eq!( + serde_json::to_value(SecureFrame::BootstrapHello(SecureBootstrapHello { + binding: binding(), + session_key_cipher: "cipher".to_string(), + binding_signature: "sig".to_string(), + key_id: Some("kid".to_string()), + })) + .unwrap()["kind"], + "bootstrap_hello" + ); + assert_eq!( + serde_json::to_value(SecureFrame::Data(SecureDataFrame { + protocol_version: SECURE_PROTOCOL_VERSION, + session_id: "sid".to_string(), + key_id: "kid".to_string(), + counter: 9, + nonce: "nonce".to_string(), + payload: "payload".to_string(), + })) + .unwrap()["kind"], + "data" + ); +} diff --git a/libwebapi/Cargo.toml b/libwebapi/Cargo.toml index 192955d4..bc3f3338 100644 --- a/libwebapi/Cargo.toml +++ b/libwebapi/Cargo.toml @@ -17,7 +17,11 @@ libsysinspect = { path = "../libsysinspect" } libcommon = { path = "../libcommon" } libdatastore = { path = "../libdatastore" } async-trait = "0.1.89" -utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web", "vendored", "debug-embed"] } +utoipa-swagger-ui = { version = "9.0.2", features = [ + "actix-web", + "vendored", + "debug-embed", +] } utoipa = { version = "5.4.0", features = ["actix_extras"] } pam = "0.8.0" uuid = "1.19.0" @@ -29,3 +33,4 @@ indexmap = { version = "2.13.0", features = ["serde"] } actix-files = "0.6.10" tempfile = "3.25.0" futures-util = "0.3.32" +hostname = "0.4.1" diff --git a/libwebapi/src/api/mod.rs b/libwebapi/src/api/mod.rs index f182d96b..56763f77 100644 --- a/libwebapi/src/api/mod.rs +++ b/libwebapi/src/api/mod.rs @@ -14,8 +14,8 @@ pub trait ApiVersion { } /// Get the API version implementation based on the requested version. -pub fn get(dev_mode: bool, port: u16, version: ApiVersions) -> Option> { +pub fn get(dev_mode: bool, version: ApiVersions) -> Option> { match version { - ApiVersions::V1 => Some(Box::new(V1::new(dev_mode, port))), + ApiVersions::V1 => Some(Box::new(V1::new(dev_mode))), } } diff --git a/libwebapi/src/api/v1/minions.rs b/libwebapi/src/api/v1/minions.rs index 60d39681..72504537 100644 --- a/libwebapi/src/api/v1/minions.rs +++ b/libwebapi/src/api/v1/minions.rs @@ -1,16 +1,12 @@ pub use crate::api::v1::system::health_handler; -use crate::{MasterInterfaceType, api::v1::TAG_MINIONS, keystore::get_webapi_keystore, sessions::get_session_store}; +use crate::{MasterInterfaceType, api::v1::TAG_MINIONS, sessions::get_session_store}; use actix_web::{ Result, post, web::{Data, Json}, }; -use base64::{Engine, engine::general_purpose::STANDARD}; -use colored::Colorize; use libcommon::SysinspectError; -use libsysinspect::cfg::mmconf::MasterConfig; use serde::{Deserialize, Serialize}; -use sodiumoxide::crypto::secretbox::Nonce; -use std::{collections::HashMap, fmt::Display, str::from_utf8}; +use std::{collections::HashMap, fmt::Display}; use utoipa::ToSchema; #[derive(Deserialize, Serialize, ToSchema)] @@ -37,36 +33,32 @@ impl QueryPayloadRequest { #[derive(Deserialize, Serialize, ToSchema)] pub struct QueryRequest { - pub sid_rsa: String, // RSA encrypted session ID - pub nonce: String, // Nonce for symmetric encryption - pub payload: String, // Base64-encoded, symmetric-encrypted JSON payload + pub sid: String, + pub model: String, + pub query: String, + pub traits: String, + pub mid: String, + pub context: HashMap, } impl QueryRequest { - pub fn to_query_request(&self, cfg: &MasterConfig) -> Result { - if self.sid_rsa.is_empty() { - return Err(SysinspectError::RSAError("Session ID cannot be empty".to_string())); + pub fn to_query_request(&self) -> Result { + if self.sid.trim().is_empty() { + return Err(SysinspectError::WebAPIError("Session ID cannot be empty".to_string())); } - let keystore = get_webapi_keystore(cfg)?; let mut sessions = get_session_store().lock().unwrap(); - let sid = keystore.decrypt_user_data( - &STANDARD.decode(&self.sid_rsa).map_err(|e| SysinspectError::RSAError(format!("Failed to decode sid_rsa from base64: {e}")))?, - )?; - - match sessions.decrypt( - from_utf8(&sid).map_err(|_| SysinspectError::WebAPIError("Session ID is not valid UTF-8".to_string()))?, - &Nonce::from_slice( - &STANDARD.decode(&self.nonce).map_err(|e| SysinspectError::WebAPIError(format!("Failed to decode nonce from base64: {e}")))?, - ) - .ok_or(SysinspectError::WebAPIError("Invalid nonce length".to_string()))? - .0, - &STANDARD.decode(&self.payload).map_err(|e| SysinspectError::WebAPIError(format!("Failed to decode payload from base64: {e}")))?, - ) { - Ok(data) => Ok(data), - Err(e) => { - log::debug!("{}: Failed to decrypt payload: {}", "ERROR".bright_red(), e); - Err(SysinspectError::WebAPIError(format!("Failed to decrypt payload: {}", e))) + match sessions.uid(&self.sid) { + Some(_) => Ok(QueryPayloadRequest { + model: self.model.clone(), + query: self.query.clone(), + traits: self.traits.clone(), + mid: self.mid.clone(), + context: self.context.clone(), + }), + None => { + log::debug!("Session {} is missing or expired", self.sid); + Err(SysinspectError::WebAPIError("Invalid or expired session".to_string())) } } } @@ -102,8 +94,7 @@ impl Display for QueryError { #[post("/api/v1/query")] async fn query_handler(master: Data, body: Json) -> Result> { let mut master = master.lock().await; - let cfg = master.cfg().await; - let qpr = match body.to_query_request(cfg) { + let qpr = match body.to_query_request() { Ok(q) => q, Err(e) => { use actix_web::http::StatusCode; diff --git a/libwebapi/src/api/v1/mod.rs b/libwebapi/src/api/v1/mod.rs index 3532ed55..74c00007 100644 --- a/libwebapi/src/api/v1/mod.rs +++ b/libwebapi/src/api/v1/mod.rs @@ -2,22 +2,18 @@ pub use crate::api::v1::system::health_handler; use crate::api::v1::{ minions::{QueryError, QueryPayloadRequest, QueryRequest, QueryResponse, query_handler, query_handler_dev}, model::{ModelNameResponse, model_descr_handler, model_names_handler}, - pkeys::{MasterKeyError, MasterKeyResponse, PubKeyError, PubKeyRequest, PubKeyResponse, masterkey_handler, pushkey_handler}, store::{ StoreListQuery, StoreMetaResponse, StoreResolveQuery, store_blob_handler, store_list_handler, store_meta_handler, store_resolve_handler, store_upload_handler, }, - system::{AuthInnerRequest, AuthRequest, AuthResponse, HealthInfo, HealthResponse, authenticate_handler}, + system::{AuthRequest, AuthResponse, HealthInfo, HealthResponse, authenticate_handler}, }; use actix_web::Scope; -use colored::Colorize; -use once_cell::sync::OnceCell; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; pub mod minions; pub mod model; -pub mod pkeys; pub mod store; pub mod system; @@ -26,29 +22,16 @@ const API_VERSION: &str = "0.1.1"; /// API Tags pub static TAG_MINIONS: &str = "Minions"; pub static TAG_SYSTEM: &str = "System"; -pub static TAG_RSAKEYS: &str = "RSA Keys"; pub static TAG_MODELS: &str = "Models"; -static SWAGGER_DEVMODE: OnceCell> = OnceCell::new(); - -/// Get the Swagger UI development mode status. -fn get_is_devmode() -> bool { - if let Some(mode) = SWAGGER_DEVMODE.get() { - return *mode.lock().unwrap(); - } - - false -} - /// API Version 1 implementation pub struct V1 { dev_mode: bool, - swagger_port: u16, } impl V1 { - pub fn new(dev_mode: bool, swagger_port: u16) -> Self { - V1 { dev_mode, swagger_port } + pub fn new(dev_mode: bool) -> Self { + V1 { dev_mode } } } @@ -59,30 +42,21 @@ impl super::ApiVersion for V1 { .service(query_handler) .service(health_handler) .service(authenticate_handler) - .service(pushkey_handler) - .service(masterkey_handler) .service(model_names_handler) .service(model_descr_handler) .service(store_resolve_handler) .service(store_list_handler) .service(store_meta_handler) .service(store_blob_handler) - .service(store_upload_handler); + .service(store_upload_handler) + .service(if self.dev_mode { + SwaggerUi::new("/doc/{_:.*}").url("/api-doc/openapi.json", ApiDocDev::openapi()) + } else { + SwaggerUi::new("/doc/{_:.*}").url("/api-doc/openapi.json", ApiDoc::openapi()) + }); if self.dev_mode { - scope = scope.service(SwaggerUi::new("/doc/{_:.*}").url("/api-doc/openapi.json", ApiDoc::openapi())).service(query_handler_dev); - let mode = SWAGGER_DEVMODE.get_or_init(|| std::sync::Mutex::new(false)); - let mut mode = mode.lock().unwrap(); - if !*mode { - *mode = self.dev_mode; - log::info!( - "{} In development mode {} is enabled at http://{}:{}/doc/", - "WARNING:".bright_red().bold(), - "API Swagger UI".bright_yellow(), - "", - self.swagger_port - ); - } + scope = scope.service(query_handler_dev); } scope @@ -95,8 +69,6 @@ impl super::ApiVersion for V1 { crate::api::v1::minions::query_handler_dev, crate::api::v1::system::health_handler, crate::api::v1::system::authenticate_handler, - crate::api::v1::pkeys::pushkey_handler, - crate::api::v1::pkeys::masterkey_handler, crate::api::v1::model::model_names_handler, crate::api::v1::model::model_descr_handler, crate::api::v1::store::store_meta_handler, @@ -106,8 +78,27 @@ impl super::ApiVersion for V1 { crate::api::v1::store::store_list_handler, ), components(schemas(QueryRequest, QueryResponse, QueryError, QueryPayloadRequest, - PubKeyRequest, PubKeyResponse, PubKeyError, MasterKeyResponse, MasterKeyError, - HealthInfo, HealthResponse, AuthRequest, AuthResponse, AuthInnerRequest, + HealthInfo, HealthResponse, AuthRequest, AuthResponse, ModelNameResponse, StoreMetaResponse, StoreResolveQuery, StoreListQuery)), info(title = "SysInspect API", version = API_VERSION, description = "SysInspect Web API for interacting with the master interface."))] pub struct ApiDoc; + +#[derive(OpenApi)] +#[openapi(paths( + crate::api::v1::minions::query_handler, + crate::api::v1::minions::query_handler_dev, + crate::api::v1::system::health_handler, + crate::api::v1::system::authenticate_handler, + crate::api::v1::model::model_names_handler, + crate::api::v1::model::model_descr_handler, + crate::api::v1::store::store_meta_handler, + crate::api::v1::store::store_blob_handler, + crate::api::v1::store::store_upload_handler, + crate::api::v1::store::store_resolve_handler, + crate::api::v1::store::store_list_handler, +), + components(schemas(QueryRequest, QueryResponse, QueryError, QueryPayloadRequest, + HealthInfo, HealthResponse, AuthRequest, AuthResponse, + ModelNameResponse, StoreMetaResponse, StoreResolveQuery, StoreListQuery)), +info(title = "SysInspect API", version = API_VERSION, description = "SysInspect Web API for interacting with the master interface."))] +pub struct ApiDocDev; diff --git a/libwebapi/src/api/v1/pkeys.rs b/libwebapi/src/api/v1/pkeys.rs deleted file mode 100644 index bbb9f446..00000000 --- a/libwebapi/src/api/v1/pkeys.rs +++ /dev/null @@ -1,108 +0,0 @@ -use crate::{MasterInterfaceType, api::v1::TAG_RSAKEYS, keystore::get_webapi_keystore, sessions::get_session_store}; -use actix_web::{HttpResponse, Responder, post, web}; -use base64::Engine; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -/// Push to push a user public key to store on the server. -#[derive(Deserialize, ToSchema)] -pub struct PubKeyRequest { - pub sid_cipher: String, - pub key: String, -} - -#[derive(Serialize, ToSchema)] -pub struct PubKeyResponse { - pub message: String, -} - -#[derive(Serialize, ToSchema)] -pub struct PubKeyError { - pub error: String, -} - -#[utoipa::path( - post, - path = "/api/v1/pushkey", - request_body = PubKeyRequest, - operation_id = "pushkey", - tag = TAG_RSAKEYS, - description = "Push a public key for a user. Requires an authenticated session ID.", - responses( - (status = 200, description = "Public key saved successfully", body = PubKeyResponse, example = json!({"message": "Public key saved successfully"})), - (status = 400, description = "Bad Request", body = PubKeyError, example = json!({"error": "Invalid session ID"})) - ) -)] -#[post("/api/v1/pushkey")] -pub async fn pushkey_handler(master: web::Data, body: web::Json) -> impl Responder { - let master = master.lock().await; - let cfg = master.cfg().await; - let keystore = match get_webapi_keystore(cfg) { - Ok(path) => path, - Err(err) => { - return HttpResponse::BadRequest().json(PubKeyError { error: format!("Internal error. Failed to init keystore: {err}") }); - } - }; - let sid = match base64::engine::general_purpose::STANDARD.decode(&body.sid_cipher) { - Ok(bytes) => match String::from_utf8(bytes) { - Ok(s) => s, - Err(_) => { - return HttpResponse::BadRequest().json(PubKeyError { error: "Invalid base64 encoding in sid_cipher".to_string() }); - } - }, - Err(_) => { - return HttpResponse::BadRequest().json(PubKeyError { error: "Failed to decode base64 sid_cipher".to_string() }); - } - }; - - // Check if API is in dev mode or SID is valid - let uid = get_session_store().lock().unwrap().uid(&sid); - if !cfg.api_devmode() && uid.is_none() { - return HttpResponse::BadRequest().json(PubKeyError { error: "Invalid session ID".to_string() }); - } - - if let Err(err) = keystore.save_key(&uid.unwrap_or_else(|| "developer".to_string()), &body.key) { - return HttpResponse::BadRequest().json(PubKeyError { error: format!("Failed to save public key: {err}") }); - } - - HttpResponse::Ok().json(PubKeyResponse { message: "Public key saved successfully".to_string() }) -} - -#[derive(Serialize, ToSchema)] -pub struct MasterKeyResponse { - pub key: String, -} - -#[derive(Serialize, ToSchema)] -pub struct MasterKeyError { - pub error: String, -} - -#[utoipa::path( - post, - path = "/api/v1/masterkey", - tag = TAG_RSAKEYS, - description = "Retrieve the master public key from the keystore.", - operation_id = "masterKey", - responses( - (status = 200, description = "Public key operations", body = MasterKeyResponse), - (status = 400, description = "Error retrieving master key", body = MasterKeyError) - ) -)] -#[post("/api/v1/masterkey")] -pub async fn masterkey_handler(master: web::Data) -> impl Responder { - let master = master.lock().await; - let cfg = master.cfg().await; - - let keystore = match get_webapi_keystore(cfg) { - Ok(path) => path, - Err(err) => { - return HttpResponse::BadRequest().json(MasterKeyError { error: format!("Internal error. Failed to init keystore: {err}") }); - } - }; - - match keystore.get_master_key() { - Ok(key) => HttpResponse::Ok().json(MasterKeyResponse { key }), - Err(err) => HttpResponse::BadRequest().json(MasterKeyError { error: format!("Failed to retrieve master key: {err}") }), - } -} diff --git a/libwebapi/src/api/v1/system.rs b/libwebapi/src/api/v1/system.rs index 29230483..87fef617 100644 --- a/libwebapi/src/api/v1/system.rs +++ b/libwebapi/src/api/v1/system.rs @@ -1,6 +1,5 @@ -use crate::{MasterInterfaceType, api::v1::TAG_SYSTEM, keystore::get_webapi_keystore, pamauth, sessions::get_session_store}; +use crate::{MasterInterfaceType, api::v1::TAG_SYSTEM, pamauth, sessions::get_session_store}; use actix_web::{HttpResponse, Responder, post, web}; -use base64::{Engine, engine::general_purpose::STANDARD}; use libsysinspect::cfg::mmconf::AuthMethod::Pam; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -62,9 +61,8 @@ pub async fn health_handler(master: web::Data, _body: ()) - #[derive(ToSchema, Deserialize, Serialize)] pub struct AuthRequest { - /// Base64-encoded, RSA-encrypted JSON: {"username": "...", "password": "...", "pubkey": "..."} - pub payload: String, - pub pubkey: String, + pub username: String, + pub password: String, } impl AuthRequest { @@ -74,142 +72,58 @@ impl AuthRequest { } } -#[derive(ToSchema, Deserialize, Serialize)] -pub struct AuthInnerRequest { - pub username: Option, - pub password: Option, -} - #[derive(ToSchema, Deserialize, Serialize)] pub struct AuthResponse { pub status: String, - pub sid_cipher: String, - pub symkey_cipher: String, + pub sid: String, pub error: String, } impl AuthResponse { pub(crate) fn error(error: &str) -> Self { - AuthResponse { status: "error".into(), sid_cipher: String::new(), symkey_cipher: String::new(), error: error.into() } + AuthResponse { status: "error".into(), sid: String::new(), error: error.into() } } } #[utoipa::path( post, path = "/api/v1/authenticate", - request_body( - content = AuthRequest, - description = "Base64-encoded, RSA-encrypted JSON containing username and password. See description for details.", - content_type = "application/json" - ), + request_body = AuthRequest, responses( - (status = 200, description = "Authentication successful. Returns a session ID (sid) if credentials are valid.", - body = AuthResponse, example = json!({"status": "authenticated", "sid": "session-id"})), + (status = 200, description = "Authentication successful. Returns a plain session identifier.", + body = AuthResponse, example = json!({"status": "authenticated", "sid": "session-id", "error": ""})), (status = 400, description = "Bad Request. Returned if payload is missing, invalid, or credentials are incorrect.", - body = AuthResponse, example = json!({"status": "error", "sid": null, "error": "Invalid payload"}))), + body = AuthResponse, example = json!({"status": "error", "sid": "", "error": "Invalid payload"}))), tag = TAG_SYSTEM, operation_id = "authenticateUser", description = "Authenticates a user using configured authentication method. The payload \ - must be a base64-encoded, RSA-encrypted JSON object with username and \ - password fields as follows:\n\n\ + is a plain JSON object with username and password fields as follows:\n\n\ ```json\n\ {\n\ \"username\": \"darth_vader\",\n\ - \"password\": \"I am your father\",\n\ - \"pubkey\": \"...\"\n\ + \"password\": \"I am your father\"\n\ }\n\ - ```\n\n\ - If the API is in development mode, it will return a static token without \ - actual authentication.", + ```\n\n\ + If `api.devmode` is enabled, the handler returns a static token without \ + performing authentication.", )] #[post("/api/v1/authenticate")] pub async fn authenticate_handler(master: web::Data, body: web::Json) -> impl Responder { let master = master.lock().await; let cfg = master.cfg().await; if cfg.api_devmode() { - log::warn!("API is in development mode, returning static token!"); - return HttpResponse::Ok().json(AuthResponse { - status: "authenticated".into(), - sid_cipher: "dev-token".into(), - symkey_cipher: String::new(), - error: String::new(), - }); - } - - if body.payload.is_empty() { - return HttpResponse::BadRequest().json(serde_json::json!(AuthResponse::error("Payload is missing"))); + log::warn!("Web API development auth bypass is enabled, returning static token."); + return HttpResponse::Ok().json(AuthResponse { status: "authenticated".into(), sid: "dev-token".into(), error: String::new() }); } - let payload = match STANDARD.decode(body.payload.as_bytes()) { - Ok(d) => d, - Err(_) => { - log::debug!("Failed to decode payload, expecting base64-encoded encrypted data"); - return HttpResponse::BadRequest().json(serde_json::json!(AuthResponse::error("Invalid payload format"))); - } - }; - - let keystore = match get_webapi_keystore(cfg) { - Ok(k) => k, - Err(e) => { - return HttpResponse::InternalServerError().json(AuthResponse::error(&format!("Keystore error: {e}"))); - } - }; - - let decrypted = match keystore.decrypt_user_data(&payload) { - Ok(d) => d, - Err(e) => { - log::error!("Failed to decrypt user data: {e}"); - return HttpResponse::BadRequest().json(AuthResponse::error(&format!("Decryption error: {e}"))); - } - }; - - let creds: AuthInnerRequest = match serde_json::from_slice(&decrypted) { - Ok(c) => c, - Err(e) => { - log::error!("Failed to parse decrypted user data: {e}"); - return HttpResponse::BadRequest().json(AuthResponse::error(&format!("Invalid credentials format: {e}"))); - } - }; - - if creds.username.as_deref().unwrap_or("").is_empty() || creds.password.as_deref().unwrap_or("").is_empty() { + if body.username.trim().is_empty() || body.password.trim().is_empty() { return HttpResponse::BadRequest().json(AuthResponse::error("Username and/or password are required")); } if cfg.api_auth() == Pam { - let uid = creds.username.unwrap(); - match AuthRequest::pam_auth(uid.clone(), creds.password.unwrap()) { - Ok(sid) => { - keystore.save_key(&uid, &body.pubkey).unwrap_or_else(|e| { - log::error!("Failed to save public key: {e}"); - }); - - let mut session = get_session_store().lock().unwrap(); - match session.key(&sid) { - Some(key) => { - // Encrypt the session key with the user's RSA public key - let symkey_cipher = match keystore.encrypt_user_data(&uid, &hex::encode(&key.0)) { - Ok(enc) => STANDARD.encode(&enc), - Err(e) => { - log::error!("Failed to encrypt session key: {e}"); - return HttpResponse::InternalServerError().json(AuthResponse::error("Failed to encrypt session key")); - } - }; - - // Encode the session ID as base64 - let sid_cipher = match keystore.encrypt_user_data(&uid, &sid) { - Ok(enc) => STANDARD.encode(&enc), - Err(e) => { - log::error!("Failed to encrypt session ID: {e}"); - return HttpResponse::InternalServerError().json(AuthResponse::error("Failed to encrypt session ID")); - } - }; - - HttpResponse::Ok().json(AuthResponse { status: "authenticated".into(), sid_cipher, symkey_cipher, error: String::new() }) - } - None => HttpResponse::InternalServerError().json(AuthResponse::error("Session key not found")), - } - } + match AuthRequest::pam_auth(body.username.clone(), body.password.clone()) { + Ok(sid) => HttpResponse::Ok().json(AuthResponse { status: "authenticated".into(), sid, error: String::new() }), Err(err) => HttpResponse::BadRequest().json(AuthResponse::error(&err)), } } else { diff --git a/libwebapi/src/keystore.rs b/libwebapi/src/keystore.rs deleted file mode 100644 index c0023589..00000000 --- a/libwebapi/src/keystore.rs +++ /dev/null @@ -1,169 +0,0 @@ -use libcommon::SysinspectError; -use libsysinspect::{ - cfg::mmconf::{CFG_MASTER_KEY_PRI, CFG_MASTER_KEY_PUB, MasterConfig}, - rsa::keys::{ - RsaKey::{Private, Public}, - decrypt, encrypt, key_from_file, - }, -}; -use std::{ - fs::read_to_string, - io::{ErrorKind, Write}, -}; -use std::{ - fs::{OpenOptions, create_dir_all}, - sync::OnceLock, -}; -use std::{io::Error, path::PathBuf}; - -/// SysInspect API Keystore keeps client PKCS8 keys. -pub struct SysInspectAPIKeystore { - keystore: PathBuf, - sysinspect_root: PathBuf, -} - -impl SysInspectAPIKeystore { - /// Create a new SysInspect API Keystore. - /// - /// # Arguments - /// * `keystore` - The path to the keystore directory. - /// - /// # Returns - /// * A new instance of `SysInspectAPIKeystore`. - /// - pub fn new(keystore: PathBuf, sysinspect_root: PathBuf) -> Result { - if keystore.exists() { - if !keystore.is_dir() { - return Err(SysinspectError::ObjectNotFound(format!("Keystore path '{}' exists but is not a directory", keystore.display()))); - } - } else { - create_dir_all(&keystore).expect("Failed to create keystore directory"); - } - - Ok(Self { keystore, sysinspect_root }) - } - - /// Save a public key to the keystore. - /// - /// # Arguments - /// * `key` - The public key to save. - /// * `uid` - The user ID associated with the key. - /// - /// # Returns - /// * `Ok(())` if the key was saved successfully. - /// * `Err(std::io::Error)` if there was an error saving the key. - /// - pub fn save_key(&self, uid: &str, key: &str) -> Result<(), SysinspectError> { - let mut f = OpenOptions::new().create(true).write(true).truncate(true).open(self.keystore.join(format!("{uid}_public_key.pem")))?; - f.write_all(key.as_bytes())?; - - Ok(()) - } - - /// Decrypt user data using the master private key. - /// User supposed to send encrypted data to the server, using the master public key. - /// - /// # Arguments - /// * `cipher` - The encrypted data to decrypt. - /// - /// # Returns - /// * `Ok(Vec)` containing the decrypted data. - /// * `Err(SysinspectError)` if there was an error decrypting the data. - /// - /// This function retrieves the master private key from the keystore and uses it to decrypt the provided cipher text. - /// If the master key is not found or is not a private key, it returns an error. - /// - /// # Errors - /// * Returns `SysinspectError::ObjectNotFound` if the master key is not found or is not a private key. - /// * Returns `SysinspectError::RSAError` if there is an error during decryption. - /// - pub fn decrypt_user_data(&self, cipher: &[u8]) -> Result, SysinspectError> { - if let Some(prk) = key_from_file( - self.sysinspect_root - .join(CFG_MASTER_KEY_PRI) - .to_str() - .ok_or_else(|| SysinspectError::ObjectNotFound("Master key not found. :-(".to_string()))?, - )? { - match prk { - Private(prk) => decrypt(prk, cipher.to_vec()).map_err(|_| SysinspectError::RSAError("Failed to decrypt data".to_string())), - _ => Err(SysinspectError::ObjectNotFound("Master key is not a private key".to_string())), - } - } else { - Err(SysinspectError::ObjectNotFound("Master key not found. :-(".to_string())) - } - } - - /// Encrypt user data using the user's public key. - /// # Arguments - /// * `uid` - The user ID for which the public key is used. - /// * `data` - The data to encrypt. - /// # Returns - /// * `Ok(Vec)` containing the encrypted data. - /// * `Err(SysinspectError)` if there was an error encrypting the data. - /// - /// This function retrieves the user's public key from the keystore and uses it to encrypt the provided data. - /// If the public key is not found or is not a public key, it returns an error. - /// - /// # Errors - /// * Returns `SysinspectError::ObjectNotFound` if the public key file is not found or is not a public key. - /// * Returns `SysinspectError::RSAError` if there is an error during encryption. - /// - pub fn encrypt_user_data(&self, uid: &str, data: &str) -> Result, SysinspectError> { - let pkey = self.keystore.join(format!("{uid}_public_key.pem")); - if !pkey.exists() { - return Err(SysinspectError::ObjectNotFound(format!("Public key file not found at {}", pkey.display()))); - } - let pbk = - key_from_file(pkey.to_str().ok_or_else(|| SysinspectError::ObjectNotFound(format!("Invalid public key path: {}", pkey.display())))?)? - .ok_or(SysinspectError::ObjectNotFound(format!("Public key file not found at {}", pkey.display())))?; - match pbk { - Public(pbk) => encrypt(pbk, data.as_bytes().to_vec()).map_err(|_| SysinspectError::RSAError("Failed to encrypt data".to_string())), - _ => Err(SysinspectError::ObjectNotFound("Expected a public key".to_string())), - } - } - - /// Get the master public key from the keystore. - /// # Returns - /// * `Ok(String)` containing the master public key if it exists. - /// * `Err(SysinspectError)` if there was an error reading the key file or if the key file does not exist. - /// - /// This function reads the master public key from the file specified by `CFG_MASTER_KEY_PUB`. - /// If the file does not exist, it returns an error. - /// If the file exists but cannot be read, it returns an `IoErr` error. - /// - /// # Errors - /// * Returns `SysinspectError::ObjectNotFound` if the master key file is not found. - /// * Returns `SysinspectError::IoErr` if there is an error reading the master key file. - /// - pub fn get_master_key(&self) -> Result { - let keypath = self.sysinspect_root.join(CFG_MASTER_KEY_PUB); - if !keypath.exists() { - return Err(SysinspectError::ObjectNotFound(format!("Master key file not found at {}", keypath.display()))); - } - let body = read_to_string(&keypath) - .map_err(|e| SysinspectError::IoErr(Error::new(ErrorKind::NotFound, format!("Failed to read master key file: {e}"))))?; - Ok(body) - } -} - -static KEYSTORE: OnceLock = OnceLock::new(); - -/// Get the global SysInspect API Keystore instance. -/// -/// # Arguments -/// * `path` - Optional path to the keystore directory. If not provided, defaults to the default SysInspect root directory with `CFG_API_KEYS`. -/// -/// # Returns -/// * A reference to the global SysInspect API Keystore instance. -/// -/// # Errors -/// * Returns an error if the keystore could not be initialized or opened. -/// -pub fn get_webapi_keystore(cfg: &MasterConfig) -> Result<&'static SysInspectAPIKeystore, SysinspectError> { - if let Some(ks) = KEYSTORE.get() { - return Ok(ks); - } - - let ks = SysInspectAPIKeystore::new(cfg.api_keys_root(), cfg.root_dir())?; - Ok(KEYSTORE.get_or_init(|| ks)) -} diff --git a/libwebapi/src/lib.rs b/libwebapi/src/lib.rs index 3558fc1c..6572046f 100644 --- a/libwebapi/src/lib.rs +++ b/libwebapi/src/lib.rs @@ -8,7 +8,6 @@ use std::{sync::Arc, thread}; use tokio::sync::Mutex; pub mod api; -pub mod keystore; pub mod pamauth; pub mod sessions; @@ -21,9 +20,26 @@ pub trait MasterInterface: Send + Sync { pub type MasterInterfaceType = Arc>; +fn advertised_api_host(bind_addr: &str) -> String { + match bind_addr { + "0.0.0.0" | "::" | "[::]" => hostname::get() + .ok() + .and_then(|value| { + let host = value.to_string_lossy().trim().to_string(); + if host.is_empty() { None } else { Some(host) } + }) + .unwrap_or_else(|| "localhost".to_string()), + _ => bind_addr.to_string(), + } +} + +fn advertised_doc_url(bind_addr: &str, bind_port: u32) -> String { + format!("http://{}:{bind_port}/doc/", advertised_api_host(bind_addr)) +} + pub fn start_webapi(cfg: MasterConfig, master: MasterInterfaceType) -> Result<(), SysinspectError> { if !cfg.api_enabled() { - log::warn!("Web API is {} in the configuration.", "disabled".bright_yellow().bold()); + log::info!("Web API disabled."); return Ok(()); } @@ -32,32 +48,26 @@ pub fn start_webapi(cfg: MasterConfig, master: MasterInterfaceType) -> Result<() thread::spawn(move || { let devmode = ccfg.api_devmode(); - let swagger_port = ccfg.api_bind_port(); + let bind_addr = ccfg.api_bind_addr(); + let bind_port = ccfg.api_bind_port(); + let listen_addr = format!("{}:{}", bind_addr, bind_port); let version = match ccfg.api_version() { 1 => ApiVersions::V1, _ => ApiVersions::V1, }; - if devmode { - log::info!("{} *** {} ***", "WARNING:".bright_red().bold(), "Web API is running in development mode".red()); - } else { - log::info!( - "{} is running in {}. Swagger UI is {}.", - "Web API".yellow(), - "production mode".bright_green(), - "disabled".bright_white().bold() - ); - } + log::info!("Starting Web API at {}", listen_addr.bright_yellow()); + log::info!("Web API enabled. Swagger UI available at {}", advertised_doc_url(&bind_addr, bind_port)); actix_web::rt::System::new().block_on(async move { HttpServer::new(move || { let mut scope = web::scope(""); - if let Some(ver) = api::get(devmode, swagger_port as u16, version) { + if let Some(ver) = api::get(devmode, version) { scope = ver.load(scope); } App::new().app_data(web::Data::new(cmaster.clone())).service(scope) }) - .bind((ccfg.api_bind_addr(), ccfg.api_bind_port() as u16)) + .bind((bind_addr.as_str(), bind_port as u16)) .map_err(SysinspectError::from)? .run() .await diff --git a/libwebapi/src/sessions.rs b/libwebapi/src/sessions.rs index 8f7e0a01..edca911e 100644 --- a/libwebapi/src/sessions.rs +++ b/libwebapi/src/sessions.rs @@ -1,23 +1,16 @@ -//! Memory sessions -//! -//! This module provides an in-memory session store for user sessions. -//! It is designed for simplicity and ease of use, but is not suitable for production use. +//! In-memory Web API sessions. -use libcommon::SysinspectError; use once_cell::sync::OnceCell; -use serde::Serialize; -use serde::de::DeserializeOwned; -use sodiumoxide::crypto::secretbox; -use sodiumoxide::crypto::secretbox::Key; use std::collections::HashMap; use std::sync::Mutex; use std::time::{Duration, Instant}; + pub struct Session { pub uid: String, pub created: Instant, pub timeout: Duration, - pub symkey: Key, // Sodium symmetric key for data encryption } + macro_rules! reap_expired { ($self:ident) => { $self.sessions.retain(|_, session| session.created.elapsed() < session.timeout); @@ -51,7 +44,7 @@ impl SessionStore { /// # Returns /// * `Result` - The session ID if successful, or an error if the session could not be created. /// - pub fn open(&mut self, uid: String) -> Result { + pub fn open(&mut self, uid: String) -> Result { reap_expired!(self); if let Some(esid) = self.sessions.iter().find_map(|(sid, s)| if s.uid == uid { Some(sid.clone()) } else { None }) { @@ -59,7 +52,7 @@ impl SessionStore { } let sid = uuid::Uuid::new_v4().to_string(); - self.sessions.insert(sid.clone(), Session { uid, created: Instant::now(), timeout: self.default_timeout, symkey: secretbox::gen_key() }); + self.sessions.insert(sid.clone(), Session { uid, created: Instant::now(), timeout: self.default_timeout }); Ok(sid) } @@ -85,79 +78,6 @@ impl SessionStore { None } - /// Returns the symmetric key for the session ID, if it exists and not expired. - /// The key is used for encrypting/decrypting data associated with the session. - /// If the session is expired, it will be removed from the store. - /// If the session does not exist, returns None. - /// If the session exists but is expired, it will be removed and None will be returned. - /// If the session exists and is valid, returns Some(Key). - /// - /// # Arguments - /// * `sid` - The session ID for which to retrieve the symmetric key. - /// # Returns - /// * `Some(Key)` if the session exists and is valid. - /// * `None` if the session does not exist or is expired. - /// # Errors - /// * If the session ID is not found or is expired, it will be removed from the store and `None` will be returned. - /// * If the session ID is not valid, it will return `None`. - /// - pub(crate) fn key(&mut self, sid: &str) -> Option { - reap_expired!(self); - if let Some(session) = self.sessions.get(sid) { - if session.created.elapsed() < session.timeout { - return Some(session.symkey.clone()); - } else { - self.sessions.remove(sid); - } - } - None - } - - /// Encrypts a value using the session's symmetric key and returns the nonce and ciphertext. - /// The nonce is used to ensure that the same plaintext encrypted multiple times will yield different ciphertext. - /// The ciphertext is the encrypted form of the serialized value. - /// # Arguments - /// * `value` - The value to encrypt, which must implement the `Serialize` trait. - /// * `key` - The symmetric key to use for encryption, which is derived from the session. - /// # Returns - /// * `(Vec, Vec)` - A tuple containing the nonce and the ciphertext. - /// # Errors - /// * If the value cannot be serialized, it will panic. - /// * If the key is not valid, it will panic. - /// - pub fn encrypt(&mut self, sid: &str, value: &T) -> Result<(Vec, Vec), SysinspectError> { - let key = self.key(sid).ok_or(SysinspectError::ObjectNotFound("Session key not found".to_string()))?; - let nonce = secretbox::gen_nonce(); - let data = serde_json::to_vec(value).map_err(|e| SysinspectError::SerializationError(e.to_string()))?; - Ok((nonce.0.to_vec(), secretbox::seal(&data, &nonce, &key))) - } - - /// Decrypts the ciphertext using the session's symmetric key and returns the deserialized value. - /// The nonce is used to ensure that the ciphertext can be decrypted correctly. - /// # Arguments - /// * `sid` - The session ID to use for retrieving the symmetric key. - /// * `nonce` - The nonce used during encryption, which is required for decryption. - /// * `ct` - The ciphertext to decrypt, which must be the result of a previous encryption operation. - /// # Returns - /// * `T` - The deserialized value of type `T`, which must implement the `DeserializeOwned` trait. - /// # Errors - /// * If the session key is not found, it will return an error. - /// * If the ciphertext cannot be decrypted, it will panic. - /// * If the deserialization fails, it will panic. - /// # Panics - /// * If the ciphertext is invalid or cannot be decrypted, it will panic. - /// * If the deserialization fails, it will panic. - /// - pub fn decrypt(&mut self, sid: &str, nonce: &[u8], ct: &[u8]) -> Result { - let nonce = secretbox::Nonce::from_slice(nonce).unwrap(); - let key = self.key(sid).ok_or(SysinspectError::ObjectNotFound("Session key not found".to_string()))?; - let pt = match secretbox::open(ct, &nonce, &key) { - Ok(pt) => pt, - Err(_) => return Err(SysinspectError::ObjectNotFound("Failed to decrypt data for whatever reasons".to_string())), - }; - serde_json::from_slice(&pt).map_err(|e| SysinspectError::DeserializationError(e.to_string())) - } - /// Updates the session's last activity time to prevent it from expiring. /// This method should be called periodically to keep the session alive. /// It resets the session's `created` time to the current time. diff --git a/man/sysinspect.8.md b/man/sysinspect.8.md index 9a294735..60a3f14f 100644 --- a/man/sysinspect.8.md +++ b/man/sysinspect.8.md @@ -14,6 +14,7 @@ SYNOPSIS | **sysinspect** **--model** *path* \[**--entities** *list* | **--labels** *list*] \[**--state** *name*] | **sysinspect** **traits** \[**--set** *k:v,...* | **--unset** *k,...* | **--reset**] \[**--id** *id* | **--query** *glob* | *glob*] \[**--traits** *query*] | **sysinspect** **profile** \[**--new** | **--delete** | **--list** | **-A** | **-R** | **--tag** | **--untag**] ... +| **sysinspect** **network** \[**--rotate** | **--status** | **--online** | **--info**] \[**--id** *id* | **--query** *glob* | *glob*] \[**--traits** *query*] | **sysinspect** **module** \[**-A** | **-R** | **-L** | **-i**] ... DESCRIPTION @@ -29,6 +30,46 @@ The command-line tool talks to the local or configured master instance, submits model requests, manages the module repository, updates master-managed static traits on minions, and manages deployment profiles. +NETWORK OPERATIONS +================== + +The **network** subcommand groups cluster transport and minion-presence +operations. + +Examples: + +| **sysinspect** **network** **--status** +| **sysinspect** **network** **--status** **--pending** +| **sysinspect** **network** **--status** **--idle** "db*" +| **sysinspect** **network** **--rotate** "web*" +| **sysinspect** **network** **--rotate** **--id** 30006546535e428aba0a0caa6712e225 +| **sysinspect** **network** **--online** +| **sysinspect** **network** **--online** **--traits** "system.os.name:Ubuntu" +| **sysinspect** **network** **--info** **--id** 30006546535e428aba0a0caa6712e225 +| **sysinspect** **network** **--info** db01.example.net + +Supported operations: + +- **--status** prints managed transport status for the selected minions +- **--rotate** stages or dispatches transport key rotation for the selected minions +- **--online** prints online-state summaries for the selected minions +- **--info** prints detailed registry-backed minion information for exactly one minion + +Supported selectors: + +- **--id** target one minion by System Id +- **--query** or trailing positional query target minions by hostname glob +- **--traits** further narrow the target set by traits query +- if no query is provided, the default selector is **\*** + +For **--info**, broad selectors are rejected. Use either one hostname/FQDN or **--id**. + +Transport status filters: + +- **--all** show all selected minions; this is the default +- **--pending** show only minions with a non-idle rotation state +- **--idle** show only minions with an idle rotation state + RUNNING MODELS REMOTELY ======================= @@ -75,7 +116,6 @@ CLUSTER COMMANDS - **--sync** refreshes cluster artefacts and triggers minions to report fresh traits back to the master -- **--online** prints the current online-minion summary to standard output - **--shutdown** asks the master to stop - **--unregister** *id* unregisters a minion by System Id diff --git a/src/clidef.rs b/src/clidef.rs index a7b05cb7..f9f6ff6b 100644 --- a/src/clidef.rs +++ b/src/clidef.rs @@ -59,6 +59,22 @@ pub fn cli(version: &'static str) -> Command { .arg(Arg::new("query-pos").help("Target minions by hostname glob or query").required(false).index(1)) .arg(Arg::new("help").short('h').long("help").action(ArgAction::SetTrue).help("Display help for this command")) ) + .subcommand(Command::new("network").about("Manage cluster transport state and rotation").styles(styles.clone()).disable_help_flag(true) + .arg(Arg::new("rotate").short('r').long("rotate").action(ArgAction::SetTrue).help("Rotate managed transport keys for the selected minions").conflicts_with("status")) + .arg(Arg::new("status").short('s').long("status").action(ArgAction::SetTrue).help("Show managed transport key status for the selected minions").conflicts_with("rotate")) + .arg(Arg::new("online").short('o').long("online").action(ArgAction::SetTrue).help("Show online minions for the current selection").conflicts_with_all(["rotate", "status", "info"])) + .arg(Arg::new("info").long("info").action(ArgAction::SetTrue).help("Show detailed minion registry information for exactly one minion selected by name or --id").conflicts_with_all(["rotate", "status", "online"])) + .arg(Arg::new("all").short('a').long("all").action(ArgAction::SetTrue).help("Show all transport states (default)").conflicts_with_all(["pending", "idle"])) + .arg(Arg::new("pending").short('p').long("pending").action(ArgAction::SetTrue).help("Show only minions with non-idle rotation state").conflicts_with_all(["all", "idle"])) + .arg(Arg::new("idle").short('i').long("idle").action(ArgAction::SetTrue).help("Show only minions with idle rotation state").conflicts_with_all(["all", "pending"])) + .arg(Arg::new("id").long("id").help("Target a specific minion by its system id").conflicts_with_all(["query", "query-pos"])) + .arg(Arg::new("query").long("query").help("Target minions by hostname glob or query").conflicts_with("query-pos")) + .arg(Arg::new("select-traits").long("traits").help("Target minions by traits query")) + .arg(Arg::new("rotate-overlap").long("rotate-overlap").help("Rotation grace overlap in seconds before retiring old keys").default_value("900")) + .arg(Arg::new("rotate-reason").long("rotate-reason").help("Operator-visible reason attached to rotation intents").default_value("manual")) + .arg(Arg::new("query-pos").help("Target minions by hostname glob or query").required(false).index(1).default_value("*")) + .arg(Arg::new("help").short('h').long("help").action(ArgAction::SetTrue).help("Display help for this command")) + ) // Sysinspect .next_help_heading("Main") @@ -133,12 +149,6 @@ pub fn cli(version: &'static str) -> Command { .long("unregister") .help("Unregister a minion by its System ID. A new registration will be required.") ) - .arg( - Arg::new("online") - .long("online") - .action(ArgAction::SetTrue) - .help("Show online minions in the cluster" ) - ) .arg( Arg::new("sync") .long("sync") diff --git a/src/clifmt.rs b/src/clifmt.rs new file mode 100644 index 00000000..2376fd9a --- /dev/null +++ b/src/clifmt.rs @@ -0,0 +1,372 @@ +use chrono::{DateTime, Utc}; +use colored::Colorize; +use libsysinspect::{ + console::{ConsoleMinionInfoRow, ConsoleOnlineMinionRow, ConsolePayload, ConsoleTransportStatusRow}, + traits::TraitSource, + transport::TransportRotationStatus, + util::pad_visible, +}; +use serde_json::Value; +use std::{net::IpAddr, str::FromStr}; + +/// Shorten a display string by preserving the leading and trailing `edge` +/// characters and replacing the removed middle section with `...`. +/// +/// This is used for long identifiers such as minion ids and transport key ids, +/// where operators still need enough prefix and suffix characters to visually +/// distinguish adjacent values in a table. +/// +/// Returns the original string unchanged when the input is already short enough. +fn shorten_middle(value: &str, edge: usize) -> String { + let chars: Vec = value.chars().collect(); + if chars.len() <= (edge * 2) + 3 { + return value.to_string(); + } + + let prefix: String = chars.iter().take(edge).collect(); + let suffix: String = chars[chars.len().saturating_sub(edge)..].iter().collect(); + format!("{prefix}...{suffix}") +} + +/// Convert an optional timestamp into a compact relative age label. +/// +/// The label is rendered relative to `now` using the shortest useful unit for +/// the console tables: seconds, minutes, hours, days, or weeks. Missing values +/// are rendered as `-`. +/// +/// This keeps CLI output stable and compact while still being easy to scan in +/// both plain terminal and future TUI views. +fn relative_label(ts: Option>, now: DateTime) -> String { + let Some(ts) = ts else { + return "-".to_string(); + }; + + let seconds = (now - ts).num_seconds().max(0); + if seconds < 60 { + return format!("{seconds}s"); + } + + let minutes = seconds / 60; + if minutes < 60 { + return format!("{minutes}m"); + } + + let hours = minutes / 60; + if hours < 24 { + return format!("{hours}h"); + } + + let days = hours / 24; + if days < 7 { + return format!("{days}d"); + } + + format!("{}w", days / 7) +} + +/// Choose the preferred host label for an online-minion row. +/// +/// The formatter prefers the fully qualified domain name when available, then +/// falls back to the short hostname, and finally to `unknown` when neither was +/// recorded by the master. +fn online_host(row: &ConsoleOnlineMinionRow) -> String { + if !row.fqdn.trim().is_empty() { + return row.fqdn.clone(); + } + if !row.hostname.trim().is_empty() { + return row.hostname.clone(); + } + "unknown".to_string() +} + +/// Choose the preferred host label for a transport-status row. +/// +/// The formatter prefers the fully qualified domain name, then the short host +/// name. If no host traits were persisted, it falls back to the raw minion id +/// so the row still has a stable label. +fn transport_host(row: &ConsoleTransportStatusRow) -> String { + if !row.fqdn.trim().is_empty() { + return row.fqdn.clone(); + } + if !row.hostname.trim().is_empty() { + return row.hostname.clone(); + } + row.minion_id.clone() +} + +/// Convert a raw transport rotation state into a plain label and a colorized +/// label suitable for terminal output. +/// +/// The plain label is used for width calculations so ANSI color codes do not +/// distort the table layout. The colorized label is used when emitting the +/// final rendered row. +fn rotation_label(rotation: Option) -> (&'static str, String) { + match rotation { + Some(TransportRotationStatus::Idle) => ("Idle", "Idle".bright_green().to_string()), + Some(TransportRotationStatus::Pending) => ("Pending", "Pending".yellow().to_string()), + Some(TransportRotationStatus::InProgress) => ("InProgress", "InProgress".bright_yellow().to_string()), + Some(TransportRotationStatus::RollbackReady) => ("RollbackReady", "RollbackReady".bright_blue().to_string()), + None => ("Missing", "Missing".red().to_string()), + } +} + +fn is_mac_address(value: &str) -> bool { + let parts = value.split([':', '-']).collect::>(); + (parts.len() == 6 || parts.len() == 8) && parts.iter().all(|part| part.len() == 2 && part.chars().all(|ch| ch.is_ascii_hexdigit())) +} + +fn human_size(bytes: u64) -> String { + const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; + let mut value = bytes as f64; + let mut unit = 0usize; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + + if unit == 0 { format!("{} {}", bytes, UNITS[unit]) } else { format!("{value:.1} {}", UNITS[unit]) } +} + +fn format_minion_info_value(key: &str, value: &Value) -> (String, String) { + match value { + Value::Null => ("null".to_string(), "null".bright_white().to_string()), + Value::Bool(flag) => { + let plain = if *flag { "yes" } else { "no" }.to_string(); + let colored = if *flag { plain.clone().bright_green().to_string() } else { plain.clone().red().to_string() }; + (plain, colored) + } + Value::Number(number) => { + if (key == "hardware.memory" || key == "hardware.swap") + && let Some(bytes) = number.as_u64() + { + let plain = human_size(bytes); + return (plain.clone(), plain.bright_white().to_string()); + } + + let plain = number.to_string(); + (plain.clone(), plain.bright_white().to_string()) + } + Value::String(text) => { + let plain = text.clone(); + let colored = if IpAddr::from_str(text).is_ok() { + plain.clone().bright_blue().to_string() + } else if is_mac_address(text) { + plain.clone().blue().to_string() + } else { + plain.clone().bright_green().to_string() + }; + (plain, colored) + } + _ => { + let plain = value.to_string(); + (plain.clone(), plain.bright_green().to_string()) + } + } +} + +fn format_minion_info_lines(key: &str, value: &Value) -> Vec<(String, String)> { + match value { + Value::Array(items) => { + if items.is_empty() { + return vec![("[]".to_string(), "[]".bright_white().to_string())]; + } + + let mut lines = Vec::new(); + for item in items { + match item { + Value::Array(_) => lines.extend(format_minion_info_lines(key, item)), + Value::String(text) => lines.push((text.clone(), text.green().to_string())), + _ => { + let plain = item.to_string(); + lines.push((plain.clone(), plain.bright_green().to_string())); + } + } + } + lines + } + Value::Object(_) => { + let plain = serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()); + plain.lines().map(|line| (line.to_string(), line.bright_green().to_string())).collect() + } + _ => vec![format_minion_info_value(key, value)], + } +} + +fn minion_info_key_label(key: &str, source: TraitSource) -> String { + match source { + TraitSource::Preset => key.bright_yellow().bold().to_string(), + TraitSource::Static => key.bright_cyan().bold().to_string(), + TraitSource::Function => key.bright_white().bold().to_string(), + } +} + +fn render_minion_info(rows: &[ConsoleMinionInfoRow]) -> String { + let mut rows = rows.to_vec(); + rows.sort_by(|a, b| a.key.cmp(&b.key)); + + let widths = ( + rows.iter().map(|row| row.key.chars().count()).max().unwrap_or(3).max("KEY".chars().count()), + rows.iter() + .flat_map(|row| format_minion_info_lines(&row.key, &row.value).into_iter().map(|(plain, _)| plain.chars().count())) + .max() + .unwrap_or(5) + .max("VALUE".chars().count()), + ); + + let mut out = vec![ + format!("{} {}", pad_visible(&"KEY".yellow().to_string(), widths.0), pad_visible(&"VALUE".yellow().to_string(), widths.1)), + format!("{} {}", "─".repeat(widths.0), "─".repeat(widths.1)), + ]; + for row in rows { + let values = format_minion_info_lines(&row.key, &row.value); + let key_label = minion_info_key_label(&row.key, row.source); + + for (index, (_plain, colored)) in values.into_iter().enumerate() { + if index == 0 { + out.push(format!("{} {}", pad_visible(&key_label, widths.0), pad_visible(&colored, widths.1))); + } else { + out.push(format!("{} {}", " ".repeat(widths.0), pad_visible(&format!(" {colored}"), widths.1))); + } + } + } + + out.join("\n") +} + +/// Render the `ConsolePayload::OnlineMinions` rows as a width-aware CLI table. +/// +/// The output intentionally mirrors the existing terminal style used elsewhere +/// in the CLI: colored headers, aligned columns, green for healthy values, and +/// shortened minion ids for readability. +fn render_online_minions(rows: &[ConsoleOnlineMinionRow]) -> String { + let widths = ( + rows.iter().map(online_host).map(|v| v.chars().count()).max().unwrap_or(4).max("HOST".chars().count()), + rows.iter().map(|row| if row.ip.is_empty() { "unknown".len() } else { row.ip.chars().count() }).max().unwrap_or(2).max("IP".chars().count()), + rows.iter().map(|row| shorten_middle(&row.minion_id, 4).chars().count()).max().unwrap_or(2).max("ID".chars().count()), + ); + + let mut out = vec![ + format!( + "{} {} {}", + pad_visible(&"HOST".bright_yellow().to_string(), widths.0), + pad_visible(&"IP".bright_yellow().to_string(), widths.1), + pad_visible(&"ID".bright_yellow().to_string(), widths.2), + ), + format!("{} {} {}", "─".repeat(widths.0), "─".repeat(widths.1), "─".repeat(widths.2)), + ]; + + for row in rows { + let host_plain = online_host(row); + let ip_plain = if row.ip.is_empty() { "unknown".to_string() } else { row.ip.clone() }; + let id_plain = shorten_middle(&row.minion_id, 4); + let host = if row.alive { host_plain.bright_green().to_string() } else { host_plain.red().to_string() }; + let ip = if row.alive { ip_plain.bright_blue().to_string() } else { ip_plain.blue().to_string() }; + let id = if row.alive { id_plain.bright_green().to_string() } else { id_plain.green().to_string() }; + out.push(format!("{} {} {}", pad_visible(&host, widths.0), pad_visible(&ip, widths.1), pad_visible(&id, widths.2))); + } + + out.join("\n") +} + +/// Render the `ConsolePayload::TransportStatus` rows as a width-aware CLI table. +/// +/// This function owns all transport-status presentation concerns on the CLI +/// side: hostname selection, id shortening, relative time formatting, color +/// coding for rotation state, and aligned column layout. +/// +/// The master deliberately does not perform any of this formatting anymore and +/// only supplies typed row data. +fn render_transport_status(rows: &[ConsoleTransportStatusRow]) -> String { + let now = Utc::now(); + let widths = ( + rows.iter().map(transport_host).map(|v| v.chars().count()).max().unwrap_or(4).max("HOST".chars().count()), + rows.iter() + .map(|row| row.active_key_id.as_deref().map(|id| shorten_middle(id, 3).chars().count()).unwrap_or(1)) + .max() + .unwrap_or(3) + .max("KEY".chars().count()), + rows.iter().map(|row| relative_label(row.last_rotated_at, now).chars().count()).max().unwrap_or(3).max("AGE".chars().count()), + rows.iter().map(|row| relative_label(row.last_handshake_at, now).chars().count()).max().unwrap_or(4).max("SEEN".chars().count()), + rows.iter().map(|row| relative_label(row.last_rotated_at, now).chars().count()).max().unwrap_or(7).max("ROTATED".chars().count()), + rows.iter().map(|row| rotation_label(row.rotation.clone()).0.chars().count()).max().unwrap_or(8).max("ROTATION".chars().count()), + ); + + let mut out = vec![ + format!( + "{} {} {} {} {} {}", + pad_visible(&"HOST".bright_yellow().to_string(), widths.0), + pad_visible(&"KEY".bright_yellow().to_string(), widths.1), + pad_visible(&"AGE".bright_yellow().to_string(), widths.2), + pad_visible(&"SEEN".bright_yellow().to_string(), widths.3), + pad_visible(&"ROTATED".bright_yellow().to_string(), widths.4), + pad_visible(&"ROTATION".bright_yellow().to_string(), widths.5), + ), + format!( + "{} {} {} {} {} {}", + "─".repeat(widths.0), + "─".repeat(widths.1), + "─".repeat(widths.2), + "─".repeat(widths.3), + "─".repeat(widths.4), + "─".repeat(widths.5), + ), + ]; + + for row in rows { + let host = transport_host(row).bright_green().to_string(); + let key = row.active_key_id.as_deref().map(|id| shorten_middle(id, 3).green().to_string()).unwrap_or_else(|| "-".to_string()); + let age = relative_label(row.last_rotated_at, now); + let seen = relative_label(row.last_handshake_at, now); + let rotated = relative_label(row.last_rotated_at, now); + let rotation = rotation_label(row.rotation.clone()).1; + out.push(format!( + "{} {} {} {} {} {}", + pad_visible(&host, widths.0), + pad_visible(&key, widths.1), + pad_visible(&age, widths.2), + pad_visible(&seen, widths.3), + pad_visible(&rotated, widths.4), + pad_visible(&rotation, widths.5), + )); + } + + out.join("\n") +} + +/// Render a structured console payload into the current stdout-oriented CLI +/// representation. +/// +/// The returned string is intentionally display-ready for the command-line +/// client. Empty payloads and acknowledgement payloads that are not meant to be +/// shown return an empty string. +pub fn render_console_payload(payload: &ConsolePayload) -> String { + match payload { + ConsolePayload::Empty => String::new(), + ConsolePayload::Text { value } => value.clone(), + ConsolePayload::StringList { items } => items.join("\n"), + ConsolePayload::RotationSummary { online_count, queued_count } => format!( + "Rotation staged: {} online dispatch{}, {} pending for offline minion{}", + online_count, + if *online_count == 1 { "" } else { "es" }, + queued_count, + if *queued_count == 1 { "" } else { "s" } + ), + ConsolePayload::Ack { action, target, count, items } => match action.as_str() { + "create_profile" => format!("Created profile {}", target.bright_yellow()), + "delete_profile" => format!("Deleted profile {}", target.bright_yellow()), + "update_profile" => format!("Updated profile {}", target.bright_yellow()), + "remove_minion" => format!("Unregistered minion {}", target.bright_yellow()), + "apply_profiles" => { + format!("Applied profiles {} on {} minion{}", items.join(", ").bright_yellow(), count, if *count == 1 { "" } else { "s" }) + } + "remove_profiles" => { + format!("Removed profiles {} on {} minion{}", items.join(", ").bright_yellow(), count, if *count == 1 { "" } else { "s" }) + } + "accepted_console_command" => String::new(), + _ => action.clone(), + }, + ConsolePayload::OnlineMinions { rows } => render_online_minions(rows), + ConsolePayload::TransportStatus { rows } => render_transport_status(rows), + ConsolePayload::MinionInfo { rows } => render_minion_info(rows), + } +} diff --git a/src/main.rs b/src/main.rs index ecdf7fd6..21e6f5ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,8 @@ use libsysinspect::{ }; use libsysproto::query::SCHEME_COMMAND; use libsysproto::query::commands::{ - CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_REMOVE_MINION, CLUSTER_SHUTDOWN, CLUSTER_SYNC, CLUSTER_TRAITS_UPDATE, + CLUSTER_MINION_INFO, CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_REMOVE_MINION, CLUSTER_ROTATE, CLUSTER_SHUTDOWN, CLUSTER_SYNC, + CLUSTER_TRAITS_UPDATE, CLUSTER_TRANSPORT_STATUS, }; use log::LevelFilter; use serde_json::json; @@ -35,6 +36,7 @@ use tokio::{ }; mod clidef; +mod clifmt; mod ui; static VERSION: &str = "0.4.0"; @@ -76,25 +78,26 @@ async fn call_master_console( .await .map_err(|_| std::io::Error::new(ErrorKind::TimedOut, "timeout while reading response from master console"))??; if reply.len() as u64 > MAX_CONSOLE_RESPONSE_SIZE || !reply.ends_with('\n') { - return Err(SysinspectError::SerializationError(format!( - "Console response exceeds {} bytes", - MAX_CONSOLE_RESPONSE_SIZE - ))); + return Err(SysinspectError::SerializationError(format!("Console response exceeds {} bytes", MAX_CONSOLE_RESPONSE_SIZE))); } let response: ConsoleResponse = match serde_json::from_str::(reply.trim()) { Ok(sealed) => sealed.open(&key)?, Err(_) => serde_json::from_str(reply.trim())?, }; if !response.ok { - return Err(SysinspectError::MasterGeneralError(response.message)); + return Err(SysinspectError::MasterGeneralError(if response.error.is_empty() { + "Master returned an unspecified console error".to_string() + } else { + response.error + })); } Ok(response) } fn traits_update_context(am: &ArgMatches) -> Result, SysinspectError> { if let Some(setv) = am.get_one::("set") { - let traits = context::get_context(setv) - .ok_or_else(|| SysinspectError::InvalidQuery("Trait values must be in key:value format".to_string()))?; + let traits = + context::get_context(setv).ok_or_else(|| SysinspectError::InvalidQuery("Trait values must be in key:value format".to_string()))?; return Ok(Some(json!({"op": "set", "traits": traits}).to_string())); } @@ -116,9 +119,7 @@ fn traits_update_context(am: &ArgMatches) -> Result, SysinspectEr fn profile_update_context(am: &ArgMatches) -> Result, SysinspectError> { let invalid_name = |name: &str| { let name = name.trim(); - name.is_empty() - || matches!(name, "." | "..") - || !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + name.is_empty() || matches!(name, "." | "..") || !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) }; if am.get_flag("new") { if am.get_one::("name").is_none() { @@ -260,6 +261,15 @@ fn help(cli: &mut Command, params: &ArgMatches) -> bool { } return false; } + if let Some(sub) = params.subcommand_matches("network") + && (sub.get_flag("help") || !(sub.get_flag("rotate") || sub.get_flag("status") || sub.get_flag("online") || sub.get_flag("info"))) + { + if let Some(s_cli) = cli.find_subcommand_mut("network") { + _ = s_cli.print_help(); + return true; + } + return false; + } if params.get_flag("help") { _ = &cli.print_long_help(); return true; @@ -392,11 +402,7 @@ async fn main() { if let Some(sub) = params.subcommand_matches("traits") { let target_id = sub.get_one::("id").map(String::as_str); - let target_query = sub - .get_one::("query") - .or_else(|| sub.get_one::("query-pos")) - .map(String::as_str) - .unwrap_or("*"); + let target_query = sub.get_one::("query").or_else(|| sub.get_one::("query-pos")).map(String::as_str).unwrap_or("*"); let target_traits = sub.get_one::("select-traits"); let scheme = format!("{SCHEME_COMMAND}{CLUSTER_TRAITS_UPDATE}"); @@ -416,11 +422,7 @@ async fn main() { if let Some(sub) = params.subcommand_matches("profile") { let target_id = sub.get_one::("id").map(String::as_str); - let target_query = sub - .get_one::("query") - .or_else(|| sub.get_one::("query-pos")) - .map(String::as_str) - .unwrap_or("*"); + let target_query = sub.get_one::("query").or_else(|| sub.get_one::("query-pos")).map(String::as_str).unwrap_or("*"); let target_traits = sub.get_one::("select-traits"); let context = match profile_update_context(sub) { Ok(ctx) => ctx, @@ -430,10 +432,12 @@ async fn main() { } }; - match call_master_console(&cfg, &format!("{SCHEME_COMMAND}{CLUSTER_PROFILE}"), target_query, target_traits, target_id, context.as_ref()).await { + match call_master_console(&cfg, &format!("{SCHEME_COMMAND}{CLUSTER_PROFILE}"), target_query, target_traits, target_id, context.as_ref()).await + { Ok(resp) => { - if !resp.message.is_empty() { - println!("{}", resp.message); + let rendered = clifmt::render_console_payload(&resp.payload); + if !rendered.is_empty() { + println!("{}", rendered); } } Err(err) => log::error!("Cannot reach master: {err}"), @@ -460,6 +464,156 @@ async fn main() { return; } + if let Some(network) = params.subcommand_matches("network") { + let query = network.get_one::("query").or_else(|| network.get_one::("query-pos")).cloned().unwrap_or("*".to_string()); + let direct_id = network.get_one::("id").map(String::as_str); + + if network.get_flag("rotate") { + let by_query = direct_id.is_none() && (query.contains('*') || query.contains(',')); + let overlap = network.get_one::("rotate-overlap").and_then(|v| v.parse::().ok()).unwrap_or(900); + let reason = network.get_one::("rotate-reason").cloned().unwrap_or("manual".to_string()); + let context = json!({ + "op": "rotate", + "reason": reason, + "grace_seconds": overlap, + "reconnect": true, + "reregister": true, + }) + .to_string(); + if let Err(err) = call_master_console( + &cfg, + &format!("{SCHEME_COMMAND}{CLUSTER_ROTATE}"), + if direct_id.is_some() { + "*" + } else if by_query { + &query + } else { + "*" + }, + network.get_one::("select-traits"), + direct_id.or(if by_query { None } else { Some(query.as_str()) }), + Some(&context), + ) + .await + { + log::error!("Cannot reach master: {err}"); + } + return; + } + + if network.get_flag("status") { + let by_query = direct_id.is_none() && (query.contains('*') || query.contains(',')); + let filter = if network.get_flag("pending") { + "pending" + } else if network.get_flag("idle") { + "idle" + } else { + "all" + }; + let context = json!({ "filter": filter }).to_string(); + match call_master_console( + &cfg, + &format!("{SCHEME_COMMAND}{CLUSTER_TRANSPORT_STATUS}"), + if direct_id.is_some() { + "*" + } else if by_query { + &query + } else { + "*" + }, + network.get_one::("select-traits"), + direct_id.or(if by_query { None } else { Some(query.as_str()) }), + Some(&context), + ) + .await + { + Ok(response) => { + let rendered = clifmt::render_console_payload(&response.payload); + if !rendered.is_empty() { + println!("{}", rendered); + } + } + Err(err) => log::error!("Cannot reach master: {err}"), + } + return; + } + + if network.get_flag("online") { + let by_query = direct_id.is_none() && (query.contains('*') || query.contains(',')); + match call_master_console( + &cfg, + &format!("{SCHEME_COMMAND}{CLUSTER_ONLINE_MINIONS}"), + if direct_id.is_some() { + "*" + } else if by_query { + &query + } else { + "*" + }, + network.get_one::("select-traits"), + direct_id.or(if by_query { None } else { Some(query.as_str()) }), + None, + ) + .await + { + Ok(response) => { + let rendered = clifmt::render_console_payload(&response.payload); + if !rendered.is_empty() { + println!("{}", rendered); + } + } + Err(err) => log::error!("Cannot reach master: {err}"), + } + return; + } + + if network.get_flag("info") { + if network.get_one::("select-traits").is_some() { + log::error!( + "{} requires exactly one minion name or {} and does not support {} selectors", + "--info".bright_yellow(), + "--id".bright_yellow(), + "--traits".bright_yellow() + ); + return; + } + if direct_id.is_none() && (query.trim().is_empty() || query == "*" || query.contains('*') || query.contains(',')) { + log::error!( + "{} requires exactly one minion name or {}. Broad selectors are not allowed", + "--info".bright_yellow(), + "--id".bright_yellow() + ); + return; + } + let by_query = direct_id.is_none() && (query.contains('*') || query.contains(',')); + match call_master_console( + &cfg, + &format!("{SCHEME_COMMAND}{CLUSTER_MINION_INFO}"), + if direct_id.is_some() { + "*" + } else if by_query { + &query + } else { + "*" + }, + network.get_one::("select-traits"), + direct_id.or(if by_query { None } else { Some(query.as_str()) }), + None, + ) + .await + { + Ok(response) => { + let rendered = clifmt::render_console_payload(&response.payload); + if !rendered.is_empty() { + println!("{}", rendered); + } + } + Err(err) => log::error!("Cannot reach master: {err}"), + } + return; + } + } + if let Some(model) = params.get_one::("path") { let query = params.get_one::("query"); let traits = params.get_one::("traits"); @@ -479,12 +633,6 @@ async fn main() { if let Err(err) = call_master_console(&cfg, &format!("{SCHEME_COMMAND}{CLUSTER_REMOVE_MINION}"), "", None, Some(mid), None).await { log::error!("Cannot reach master: {err}"); } - } else if params.get_flag("online") { - match call_master_console(&cfg, &format!("{SCHEME_COMMAND}{CLUSTER_ONLINE_MINIONS}"), "", None, None, None).await { - Ok(response) if !response.message.is_empty() => println!("{}", response.message), - Ok(_) => {} - Err(err) => log::error!("Cannot reach master: {err}"), - } } else if let Some(mpath) = params.get_one::("model") { let mut sr = SysInspectRunner::new(&MinionConfig::default()); sr.set_model_path(mpath); diff --git a/sysclient/Cargo.toml b/sysclient/Cargo.toml index 264efb05..77b7cb56 100644 --- a/sysclient/Cargo.toml +++ b/sysclient/Cargo.toml @@ -4,15 +4,11 @@ version = "0.1.0" edition = "2024" [dependencies] -syswebclient = {path = "./sysinspect-client"} # That generated stuff, just don't touch it libsysinspect = { path = "../libsysinspect" } libcommon = { path = "../libcommon" } tokio = { version = "1.49.0", features = ["full"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" -base64 = "0.22.1" rpassword = "7.4.0" log = "0.4.29" -reqwest = { version = "0.12.28", features = ["blocking", "json"] } -hex = "0.4.3" -sodiumoxide = "0.2.7" +reqwest = { version = "0.12.28", features = ["json"] } diff --git a/sysclient/Makefile b/sysclient/Makefile deleted file mode 100644 index f661ced7..00000000 --- a/sysclient/Makefile +++ /dev/null @@ -1,52 +0,0 @@ -.DEFAULT_GOAL := generate - -OPENAPI_JSON=openapi.json -OPENAPI_URL=http://localhost:4202/api-doc/openapi.json -RUST_CLIENT_OUT=./sysinspect-client -PROJECT_NAME=syswebclient -GENERATOR_BIN=.bin -GENERATOR_JAR=$(GENERATOR_BIN)/openapi-generator-cli.jar -GENERATOR_VERSION=7.14.0 -DOCS_MD=$(wildcard $(RUST_CLIENT_OUT)/docs/*.md) -DOCS_HTML=$(patsubst %.md,%.html,$(DOCS_MD)) - - -.PHONY: generate clean ensure_codegen docs - -generate: $(OPENAPI_JSON) ensure_codegen - @if [ -d $(RUST_CLIENT_OUT) ]; then \ - rm -rf $(RUST_CLIENT_OUT); \ - fi - - jq '.info.license.identifier = "Apache-2.0"' $(OPENAPI_JSON) > $(OPENAPI_JSON).patched - mv $(OPENAPI_JSON).patched $(OPENAPI_JSON) - java -jar $(GENERATOR_JAR) generate \ - -i $(OPENAPI_JSON) \ - -g rust \ - -o $(RUST_CLIENT_OUT) \ - --skip-validate-spec \ - --additional-properties packageName=$(PROJECT_NAME) - cd $(RUST_CLIENT_OUT) && cargo clippy --fix --allow-dirty -- -Dwarnings - $(MAKE) docs - -$(OPENAPI_JSON): - @if [ -f $(OPENAPI_JSON) ]; then rm -f $(OPENAPI_JSON); fi - curl -fsSL -o $(OPENAPI_JSON) $(OPENAPI_URL) - -ensure_codegen: - @if [ ! -d $(GENERATOR_BIN) ]; then \ - mkdir -p $(GENERATOR_BIN); \ - fi - @if [ ! -f $(GENERATOR_JAR) ]; then \ - echo "Downloading openapi-generator-cli.jar..."; \ - curl -L "https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/$(GENERATOR_VERSION)/openapi-generator-cli-$(GENERATOR_VERSION).jar" -o $(GENERATOR_JAR); \ - fi - -docs: - python3 -m venv .venv && \ - .venv/bin/pip install --upgrade pip && \ - .venv/bin/pip install mkdocs mkdocs-material && \ - .venv/bin/mkdocs build --site-dir ./html-docs - -clean: - rm -rf html-docs .venv .bin *json diff --git a/sysclient/README.md b/sysclient/README.md index b21166ad..411be8db 100644 --- a/sysclient/README.md +++ b/sysclient/README.md @@ -1,16 +1,9 @@ # sysclient -Auto-generated SDK client using openapi generator. To re-generate the SDK, simply start -current version of the `sysmaster` locally with API server enabled, and then just run this: +Small handwritten example client for the SysInspect Web API. - make +The legacy generated OpenAPI client was removed together with the old Web API +application-layer crypto scheme. This crate now talks to the API directly over +plain JSON requests. -That's all. It will download OpenAPI spec, validate it and then generate you SDK for Rust -into `sysinslect-client` sub-directory. - -## Usage & Docs - -[Re]Generate the client, and then navigate to the `html-docs` directory and open any HTML -file with your web browser. - -See a practical example in `src/main.rs` how to use the client. +See a practical example in `src/main.rs`. diff --git a/sysclient/mkdocs.yml b/sysclient/mkdocs.yml deleted file mode 100644 index 189bec71..00000000 --- a/sysclient/mkdocs.yml +++ /dev/null @@ -1,8 +0,0 @@ -site_name: "SysInspect Rust Client Docs" -docs_dir: sysinspect-client/docs -site_dir: html-docs - -theme: - name: material - -use_directory_urls: false diff --git a/sysclient/openapi.json b/sysclient/openapi.json deleted file mode 100644 index bbab6de5..00000000 --- a/sysclient/openapi.json +++ /dev/null @@ -1,647 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "SysInspect API", - "description": "SysInspect Web API for interacting with the master interface.", - "license": { - "name": "", - "identifier": "Apache-2.0" - }, - "version": "0.1.1" - }, - "paths": { - "/api/v1/authenticate": { - "post": { - "tags": [ - "System" - ], - "description": "Authenticates a user using configured authentication method. The payload must be a base64-encoded, RSA-encrypted JSON object with username and password fields as follows:\n\n```json\n{\n\"username\": \"darth_vader\",\n\"password\": \"I am your father\",\n\"pubkey\": \"...\"\n}\n```\n\nIf the API is in development mode, it will return a static token without actual authentication.", - "operationId": "authenticateUser", - "requestBody": { - "description": "Base64-encoded, RSA-encrypted JSON containing username and password. See description for details.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuthRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Authentication successful. Returns a session ID (sid) if credentials are valid.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuthResponse" - }, - "example": { - "status": "authenticated", - "sid": "session-id" - } - } - } - }, - "400": { - "description": "Bad Request. Returned if payload is missing, invalid, or credentials are incorrect.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuthResponse" - }, - "example": { - "status": "error", - "sid": null, - "error": "Invalid payload" - } - } - } - } - } - } - }, - "/api/v1/dev_query": { - "post": { - "tags": [ - "Minions" - ], - "description": "Development endpoint for querying minions. FOR DEVELOPMENT AND DEBUGGING PURPOSES ONLY!", - "operationId": "query_handler_dev", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QueryPayloadRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QueryResponse" - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QueryError" - } - } - } - } - } - } - }, - "/api/v1/health": { - "post": { - "tags": [ - "System" - ], - "description": "Checks the health of the SysInspect API. Returns basic information about the API status, telemetry, and scheduler tasks.", - "operationId": "healthCheck", - "responses": { - "200": { - "description": "Health status", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HealthResponse" - }, - "example": { - "status": "healthy", - "info": { - "telemetry_enabled": true, - "scheduler_tasks": 5, - "api_version": "0.1.0" - } - } - } - } - }, - "500": { - "description": "Internal Server Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HealthResponse" - }, - "example": { - "status": "unhealthy", - "info": { - "telemetry_enabled": false, - "scheduler_tasks": 0, - "api_version": "0.1.0" - } - } - } - } - } - } - } - }, - "/api/v1/masterkey": { - "post": { - "tags": [ - "RSA Keys" - ], - "description": "Retrieve the master public key from the keystore.", - "operationId": "masterKey", - "responses": { - "200": { - "description": "Public key operations", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MasterKeyResponse" - } - } - } - }, - "400": { - "description": "Error retrieving master key", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MasterKeyError" - } - } - } - } - } - } - }, - "/api/v1/model/descr": { - "get": { - "tags": [ - "Models" - ], - "description": "Retrieves detailed information about a specific model in the SysInspect system. The model includes its name, description, version, maintainer, and statistics about its entities, actions, constraints, and events.", - "operationId": "getModelDetails", - "parameters": [ - { - "name": "name", - "in": "query", - "description": "Name of the model to retrieve details for", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Detailed information about the model", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ModelResponse" - } - } - } - } - } - } - }, - "/api/v1/model/names": { - "get": { - "tags": [ - "Models" - ], - "description": "Lists all available models in the SysInspect system. Each model includes details such as its name, description, version, maintainer, and statistics about its entities, actions, constraints, and events.", - "operationId": "listModels", - "responses": { - "200": { - "description": "List of available models", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ModelNameResponse" - } - } - } - } - } - } - }, - "/api/v1/pushkey": { - "post": { - "tags": [ - "RSA Keys" - ], - "description": "Push a public key for a user. Requires an authenticated session ID.", - "operationId": "pushkey", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PubKeyRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Public key saved successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PubKeyResponse" - }, - "example": { - "message": "Public key saved successfully" - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PubKeyError" - }, - "example": { - "error": "Invalid session ID" - } - } - } - } - } - } - }, - "/api/v1/query": { - "post": { - "tags": [ - "Minions" - ], - "operationId": "query_handler", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QueryRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QueryResponse" - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QueryError" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "AuthInnerRequest": { - "type": "object", - "properties": { - "password": { - "type": [ - "string", - "null" - ] - }, - "username": { - "type": [ - "string", - "null" - ] - } - } - }, - "AuthRequest": { - "type": "object", - "required": [ - "payload", - "pubkey" - ], - "properties": { - "payload": { - "type": "string", - "description": "Base64-encoded, RSA-encrypted JSON: {\"username\": \"...\", \"password\": \"...\", \"pubkey\": \"...\"}" - }, - "pubkey": { - "type": "string" - } - } - }, - "AuthResponse": { - "type": "object", - "required": [ - "status", - "sid_cipher", - "symkey_cipher", - "error" - ], - "properties": { - "error": { - "type": "string" - }, - "sid_cipher": { - "type": "string" - }, - "status": { - "type": "string" - }, - "symkey_cipher": { - "type": "string" - } - } - }, - "HealthInfo": { - "type": "object", - "required": [ - "telemetry_enabled", - "scheduler_tasks", - "api_version" - ], - "properties": { - "api_version": { - "type": "string" - }, - "scheduler_tasks": { - "type": "integer", - "minimum": 0 - }, - "telemetry_enabled": { - "type": "boolean" - } - } - }, - "HealthResponse": { - "type": "object", - "required": [ - "status", - "info" - ], - "properties": { - "info": { - "$ref": "#/components/schemas/HealthInfo" - }, - "status": { - "type": "string" - } - } - }, - "MasterKeyError": { - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string" - } - } - }, - "MasterKeyResponse": { - "type": "object", - "required": [ - "key" - ], - "properties": { - "key": { - "type": "string" - } - } - }, - "ModelInfo": { - "type": "object", - "required": [ - "id", - "name", - "description", - "version", - "maintainer", - "entity-states" - ], - "properties": { - "description": { - "type": "string", - "description": "A brief description of the model" - }, - "entity-states": { - "type": "object", - "description": "Entity to a vector of bound actions", - "additionalProperties": { - "type": "array", - "items": { - "type": "array", - "items": false, - "prefixItems": [ - { - "type": "string" - }, - { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "propertyNames": { - "type": "string" - } - } - ] - } - }, - "propertyNames": { - "type": "string" - } - }, - "id": { - "type": "string", - "description": "The unique identifier of the model (Id)" - }, - "maintainer": { - "type": "string", - "description": "The author of the model" - }, - "name": { - "type": "string", - "description": "The name of the model" - }, - "version": { - "type": "string", - "description": "The version of the model" - } - } - }, - "ModelNameResponse": { - "type": "object", - "required": [ - "models" - ], - "properties": { - "models": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "ModelResponse": { - "type": "object", - "required": [ - "model" - ], - "properties": { - "model": { - "$ref": "#/components/schemas/ModelInfo" - } - } - }, - "PubKeyError": { - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string" - } - } - }, - "PubKeyRequest": { - "type": "object", - "description": "Push to push a user public key to store on the server.", - "required": [ - "sid_cipher", - "key" - ], - "properties": { - "key": { - "type": "string" - }, - "sid_cipher": { - "type": "string" - } - } - }, - "PubKeyResponse": { - "type": "object", - "required": [ - "message" - ], - "properties": { - "message": { - "type": "string" - } - } - }, - "QueryError": { - "type": "object", - "required": [ - "status", - "error" - ], - "properties": { - "error": { - "type": "string" - }, - "status": { - "type": "string" - } - } - }, - "QueryPayloadRequest": { - "type": "object", - "required": [ - "model", - "query", - "traits", - "mid", - "context" - ], - "properties": { - "context": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "propertyNames": { - "type": "string" - } - }, - "mid": { - "type": "string" - }, - "model": { - "type": "string" - }, - "query": { - "type": "string" - }, - "traits": { - "type": "string" - } - } - }, - "QueryRequest": { - "type": "object", - "required": [ - "sid_rsa", - "nonce", - "payload" - ], - "properties": { - "nonce": { - "type": "string" - }, - "payload": { - "type": "string" - }, - "sid_rsa": { - "type": "string" - } - } - }, - "QueryResponse": { - "type": "object", - "required": [ - "status", - "message" - ], - "properties": { - "message": { - "type": "string" - }, - "status": { - "type": "string" - } - } - } - } - } -} diff --git a/sysclient/scripts/stupid-downgrader.jq b/sysclient/scripts/stupid-downgrader.jq deleted file mode 100755 index ce72e935..00000000 --- a/sysclient/scripts/stupid-downgrader.jq +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/jq -f - -def oneof_null: - if type == "object" then - with_entries( - if .key == "oneOf" and (.value | type == "array") then - .value |= map(select(. != {"type": "null"})) | .value |= map(oneof_null) - else - .value |= oneof_null - end - ) - elif type == "array" then - map(oneof_null) - else - . - end; - - -def itemsfalse: - if type == "object" then - with_entries( - select(.key != "items" or .value != false) - | .value |= itemsfalse - ) - elif type == "array" then - map(itemsfalse) - else - . - end; - -itemsfalse | oneof_null diff --git a/sysclient/src/lib.rs b/sysclient/src/lib.rs index 4c32ee04..a694a549 100644 --- a/sysclient/src/lib.rs +++ b/sysclient/src/lib.rs @@ -1,92 +1,81 @@ -use base64::{Engine, engine::general_purpose::STANDARD}; use libcommon::SysinspectError; -use libsysinspect::rsa::keys::{ - RsaKey::{Private, Public}, - decrypt, encrypt, key_from_file, key_to_file, keygen, -}; -use serde_json::{Value, json}; -use sodiumoxide::crypto::secretbox::{self, Key, Nonce, gen_nonce}; -use std::{fs, path::PathBuf}; -use syswebclient::{ - apis::{ - configuration::Configuration, minions_api::query_handler, rsa_keys_api::master_key, - system_api::authenticate_user, - }, - models::{AuthRequest, QueryResponse}, -}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{collections::BTreeMap, collections::HashMap}; /// SysClient Configuration /// This struct holds the configuration for the SysClient, including the root directory. /// It can be extended in the future to include more configuration options. /// /// # Fields -/// * `root` - The root directory where keys and other files are stored. -/// * `private_key` - The filename of the private key. -/// * `public_key` - The filename of the public key. -/// * `master_public_key` - The filename of the master public key. /// * `master_url` - The URL of the SysInspect master server. #[derive(Debug, Clone)] pub struct SysClientConfiguration { - pub root: PathBuf, - pub private_key: String, - pub public_key: String, - pub master_public_key: String, pub master_url: String, } impl SysClientConfiguration { - /// Returns the path to the private key file, joined with the root directory. - /// - /// # Returns - /// A `PathBuf` representing the full path to the private key file. - pub fn privkey_path(&self) -> PathBuf { - self.root.join(&self.private_key) + fn client(&self) -> Client { + Client::builder().user_agent("sysinspect-client/0.1.0").build().unwrap_or_else(|_| Client::new()) } +} - /// Returns the path to the public key file, joined with the root directory. - /// - /// # Returns - /// A `PathBuf` representing the full path to the public key file. - pub fn pubkey_path(&self) -> PathBuf { - self.root.join(&self.public_key) +impl Default for SysClientConfiguration { + fn default() -> Self { + SysClientConfiguration { master_url: "http://localhost:4202".to_string() } } +} - /// Returns the path to the master public key file, joined with the root directory. - /// - /// # Returns - /// A `PathBuf` representing the full path to the master public key file. - pub fn master_pubkey_path(&self) -> PathBuf { - self.root.join(&self.master_public_key) - } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuthRequest { + pub username: String, + pub password: String, +} - /// Return API configuration for the SysClient. - /// This method constructs a `Configuration` object that is used to interact with the SysInspect API. - /// # Returns - /// A `Configuration` object with the base path set to the master URL, - /// user agent set to "sysinspect-client/0.1.0", and a new `reqwest::Client`. - pub fn get_api_config(&self) -> Configuration { - Configuration { - base_path: self.master_url.clone(), - user_agent: Some("sysinspect-client/0.1.0".to_string()), - client: reqwest::Client::new(), - basic_auth: None, - oauth_access_token: None, - bearer_access_token: None, - api_key: None, - } - } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuthResponse { + pub status: String, + pub sid: String, + pub error: String, } -impl Default for SysClientConfiguration { - fn default() -> Self { - SysClientConfiguration { - root: PathBuf::from("."), - private_key: "private.key".to_string(), - public_key: "public.key".to_string(), - master_public_key: "master_public.key".to_string(), - master_url: "http://localhost:4202".to_string(), - } - } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct QueryRequest { + pub sid: String, + pub model: String, + pub query: String, + pub traits: String, + pub mid: String, + pub context: HashMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct QueryResponse { + pub status: String, + pub message: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ModelNameResponse { + pub models: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ModelInfo { + pub id: String, + pub name: String, + pub description: String, + pub version: String, + pub maintainer: String, + #[allow(clippy::type_complexity)] + #[serde(rename = "entity-states")] + pub entity_states: BTreeMap)>>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ModelResponse { + pub model: ModelInfo, } /// SysClient is the main client for interacting with the SysInspect system. @@ -94,226 +83,17 @@ impl Default for SysClientConfiguration { /// It handles user authentication, key management, and data encryption/decryption. /// /// # Fields -/// * `cfg` - The configuration for the SysClient, which includes paths to keys and the master URL. +/// * `cfg` - The configuration for the SysClient, which includes the master URL. /// * `sid` - The session ID for the authenticated user. -/// * `symkey` - The symmetric key used for encrypting and decrypting data after authentication. #[derive(Debug, Clone)] pub struct SysClient { cfg: SysClientConfiguration, sid: String, - symkey: Vec, } impl SysClient { pub fn new(cfg: SysClientConfiguration) -> Self { - SysClient { cfg, sid: String::new(), symkey: Vec::new() } - } - - /// Setup the SysClient by generating RSA keypair and download Master RSA public key. - /// Keys are stored where the configuration specifies. - /// - /// # Returns - /// A `Result` that is `Ok(())` if the setup is successful, - /// or an `Err(SysinspectError)` if there is an error during the setup. - pub(crate) async fn setup(&self) -> Result<(), SysinspectError> { - if !self.cfg.privkey_path().exists() || !self.cfg.pubkey_path().exists() { - log::debug!("Generating RSA keys..."); - - let (prk, pbk) = keygen(2048)?; - key_to_file(&Private(prk), "./", self.cfg.privkey_path().to_str().unwrap())?; - key_to_file(&Public(pbk), "./", self.cfg.pubkey_path().to_str().unwrap())?; - log::debug!("RSA keys generated successfully."); - } - - if !self.cfg.master_pubkey_path().exists() { - let r = master_key(&self.cfg.get_api_config()).await.map_err(|e| { - SysinspectError::MasterGeneralError(format!("Failed to retrieve master public key (network): {e}")) - })?; - - if r.key.is_empty() { - return Err(SysinspectError::MasterGeneralError("Master public key is empty".to_string())); - } - fs::write(self.cfg.master_pubkey_path(), r.key.as_bytes()).map_err(SysinspectError::IoErr)?; - } - - Ok(()) - } - - /// Encode data to Base64 format. - /// This method uses the `base64` crate to encode the provided data into a Base64 string. - /// # Arguments - /// * `data` - The data to encode, provided as a byte slice. - /// # Returns - /// A `String` containing the Base64-encoded representation of the input data. - /// # Errors - /// * This function does not return an error; it will always return a valid Base64 string. - pub(crate) fn b64encode(&self, data: &[u8]) -> String { - STANDARD.encode(data) - } - - /// Decode Base64-encoded data. - /// This method uses the `base64` crate to decode the provided Base64 string into a byte vector. - /// # Arguments - /// * `data` - The Base64-encoded data to decode, provided as a string. - /// # Returns - /// A `Result` that is `Ok(Vec)` containing the decoded data, - /// or an `Err(SysinspectError)` if there is an error during the decoding process. - /// # Errors - /// * Returns `SysinspectError::SerializationError` if the provided data is not valid Base64. - /// - /// This function will return an error if the input string cannot be decoded into valid Base64 data. - pub(crate) fn b64decode(&self, data: &str) -> Result, SysinspectError> { - STANDARD - .decode(data) - .map_err(|e| SysinspectError::SerializationError(format!("Failed to decode base64 data: {e}"))) - } - - /// Encrypt data using a public key (master or own). - /// This method reads the public key from the file system and uses it to encrypt the provided data. - /// # Arguments - /// * `data` - The data to encrypt, provided as a string. - /// # Returns - /// A `Result` that is `Ok(Vec)` containing the encrypted data, - /// or an `Err(SysinspectError)` if there is an error during the encryption process. - pub(crate) fn encrypt(&self, data: &str, pkey: &str) -> Result, SysinspectError> { - let pbk = match key_from_file(pkey)? - .ok_or(SysinspectError::RSAError("Failed to load RSA key from file".to_string()))? - { - Public(ref k) => k.clone(), - _ => return Err(SysinspectError::RSAError("Expected a public key".to_string())), - }; - - encrypt(pbk, data.as_bytes().to_vec()) - .map_err(|_| SysinspectError::RSAError("Failed to encrypt data".to_string())) - } - - /// Decrypt data using the private key. - /// This method reads the private key from the file system and uses it to decrypt the provided data. - /// # Arguments - /// * `data` - The encrypted data to decrypt, provided as a byte slice. - /// # Returns - /// A `Result` that is `Ok(String)` containing the decrypted data as a string, - /// or an `Err(SysinspectError)` if there is an error during the decryption process. - /// - /// # Errors - /// * Returns `SysinspectError::RSAError` if there is an error during decryption, - /// * Returns `SysinspectError::SerializationError` if the decrypted data cannot be converted to a string. - /// - /// This function expects the private key to be in PEM format and stored in the file specified - /// by `self.cfg.privkey_path()`. If the file does not exist or is not a valid private key, - /// it will return an error. - pub(crate) fn decrypt(&self, data: &[u8]) -> Result { - let prk = match key_from_file(self.cfg.privkey_path().to_str().unwrap())? - .ok_or(SysinspectError::RSAError("Failed to load RSA key from file".to_string()))? - { - Private(ref k) => k.clone(), - _ => return Err(SysinspectError::RSAError("Expected a private key".to_string())), - }; - - let data = - decrypt(prk, data.to_vec()).map_err(|_| SysinspectError::RSAError("Failed to decrypt data".to_string()))?; - - String::from_utf8(data) - .map_err(|_| SysinspectError::SerializationError("Failed to decode decrypted data".to_string())) - } - - /// Read the client's public key from the file system. - /// - /// # Returns - /// A `Result` that is `Ok(String)` containing the client's public key if successful, - /// or an `Err(SysinspectError)` if there is an error reading the file. - pub async fn client_pubkey_pem(&self) -> Result { - fs::read_to_string(self.cfg.pubkey_path()).map_err(SysinspectError::IoErr) - } - - /// Convert a JSON payload to a symmetric encrypted payload. - /// This method generates a nonce, serializes the payload to JSON, and encrypts it - /// using the symmetric key stored in `self.symkey`. - /// - /// # Arguments - /// * `payload` - The JSON payload to encrypt, provided as a `serde_json::Value`. - /// # Returns - /// A `Result` that is `Ok((Vec, Vec))` containing the nonce and the encrypted data, - /// or an `Err(SysinspectError)` if there is an error during serialization or encryption. - /// - /// # Errors - /// * Returns `SysinspectError::SerializationError` if the payload cannot be serialized to JSON, - /// * Returns `SysinspectError::SerializationError` if the symmetric key is not valid (i.e., not 32 bytes long), - /// * Returns `SysinspectError::SerializationError` if the encryption fails for any reason. - /// - /// This function uses the `sodiumoxide` library for encryption, - /// specifically the `secretbox` module for symmetric encryption. - /// It expects the symmetric key to be 32 bytes long, - /// which is the required length for the `secretbox::Key`. - /// - /// The nonce is generated using `secretbox::gen_nonce()`, which creates a new nonce for each encryption operation. - /// The payload is serialized to a byte vector using `serde_json::to_vec()`. - /// If the serialization fails, it returns a `SysinspectError::SerializationError`. - /// The symmetric key is created from the `self.symkey` field, which is expected to be a 32-byte slice. - /// If the key is not valid, it returns a `SysinspectError::SerializationError`. - /// The encrypted data is produced using `secretbox::seal()`, which takes the serialized data, nonce, and symmetric key. - /// If the encryption fails, it returns a `SysinspectError::SerializationError`. - /// The function returns a tuple containing the nonce and the encrypted data as byte vectors. - /// The nonce is returned as a `Vec` for easy transmission, and the encrypted data is also returned as a `Vec`. - /// This allows the caller to use the nonce and encrypted data for further processing, - /// such as sending it over a network or storing it securely. - pub async fn to_payload(&self, payload: &Value) -> Result<(Vec, Vec), SysinspectError> { - let nonce = gen_nonce(); - Ok(( - nonce.0.to_vec(), - secretbox::seal( - &serde_json::to_vec(payload).map_err(|e| SysinspectError::SerializationError(e.to_string()))?, - &nonce, - &Key::from_slice(&self.symkey) - .ok_or_else(|| SysinspectError::SerializationError("Invalid symmetric key length".to_string()))?, - ), - )) - } - - /// Decrypt a payload using the symmetric key and nonce. - /// This method takes a nonce and a payload, decrypts the payload using the symmetric key, - /// and deserializes the decrypted data into a `serde_json::Value`. - /// # Arguments - /// * `nonce` - The nonce used for decryption, provided as a byte slice. - /// * `payload` - The encrypted payload to decrypt, provided as a byte slice. - /// # Returns - /// A `Result` that is `Ok(Value)` containing the deserialized JSON value if successful, - /// or an `Err(SysinspectError)` if there is an error during decryption or deserialization. - /// - /// # Errors - /// * Returns `SysinspectError::SerializationError` if the nonce is not valid (i.e., not 24 bytes long), - /// * Returns `SysinspectError::SerializationError` if the symmetric key is not valid (i.e., not 32 bytes long), - /// * Returns `SysinspectError::SerializationError` if the decryption fails, - /// * Returns `SysinspectError::DeserializationError` if the decrypted data cannot be deserialized into a `serde_json::Value`. - /// - /// This function uses the `sodiumoxide` library for decryption, - /// specifically the `secretbox` module for symmetric decryption. - /// It expects the nonce to be 24 bytes long, which is the required length for the `secretbox::Nonce`. - /// The symmetric key is expected to be 32 bytes long, - /// which is the required length for the `secretbox::Key`. - /// The function first checks the length of the nonce and symmetric key, - /// and if they are not valid, it returns a `SysinspectError::SerializationError`. - /// It then attempts to decrypt the payload using `secretbox::open()`, which takes the payload, nonce, and symmetric key. - /// If the decryption fails, it returns a `SysinspectError::SerializationError`. - /// Finally, it deserializes the decrypted data into a `serde_json::Value` using `serde_json::from_slice()`. - /// If the deserialization fails, it returns a `SysinspectError::DeserializationError`. - /// If all operations are successful, it returns the deserialized `Value` as a result. - /// This allows the caller to retrieve the original JSON data that was encrypted and sent as a payload. - /// The decrypted data is expected to be in JSON format, - /// and the function will return a `serde_json::Value` that can be used for further processing or analysis. - /// This is useful for applications that need to securely transmit JSON data, - /// such as configuration settings, user data, or other structured information, - /// while ensuring that the data remains confidential and tamper-proof during transmission. - pub async fn from_payload(&self, nonce: &[u8], payload: &[u8]) -> Result { - let nonce = - Nonce::from_slice(nonce).ok_or(SysinspectError::SerializationError("Invalid nonce length".to_string()))?; - let key = Key::from_slice(&self.symkey) - .ok_or_else(|| SysinspectError::SerializationError("Invalid symmetric key length".to_string()))?; - - let data = secretbox::open(payload, &nonce, &key) - .map_err(|_| SysinspectError::SerializationError("Failed to decrypt payload".to_string()))?; - - serde_json::from_slice(&data).map_err(|e| SysinspectError::DeserializationError(e.to_string())) + SysClient { cfg, sid: String::new() } } /// Authenticate a user with the SysInspect system. @@ -328,42 +108,32 @@ impl SysClient { /// or `Ok(false)` if authentication fails. /// If there is an error during the setup or authentication process, it returns an `Err(SysinspectError)`. pub async fn authenticate(&mut self, uid: &str, pwd: &str) -> Result { - // Setup the client first - self.setup().await?; - - // Authenticate the user log::debug!("Authenticating user: {uid}"); - let r = authenticate_user( - &self.cfg.get_api_config(), - AuthRequest { - payload: STANDARD.encode(&self.encrypt( - &json!({"username": uid, "password": pwd}).to_string(), - self.cfg.master_pubkey_path().to_str().unwrap(), - )?), - pubkey: self.client_pubkey_pem().await?, - }, - ) - .await - .map_err(|e| SysinspectError::MasterGeneralError(format!("Authentication error: {e}")))?; - - self.symkey = - hex::decode( - self.decrypt(&STANDARD.decode(&r.symkey_cipher).map_err(|e| { - SysinspectError::SerializationError(format!("Failed to decode base64 symkey: {e}")) - })?) - .map_err(|e| SysinspectError::SerializationError(format!("Failed to decrypt symkey: {e}")))?, - ) - .map_err(|e| SysinspectError::SerializationError(format!("Failed to decode hex symkey: {e}")))?; - - self.sid = self - .decrypt( - &STANDARD - .decode(&r.sid_cipher) - .map_err(|e| SysinspectError::SerializationError(format!("Failed to decode base64 SID: {e}")))?, - ) - .map_err(|e| SysinspectError::SerializationError(format!("Failed to decrypt SID: {e}")))?; + let response = self + .cfg + .client() + .post(format!("{}/api/v1/authenticate", self.cfg.master_url.trim_end_matches('/'))) + .json(&AuthRequest { username: uid.to_string(), password: pwd.to_string() }) + .send() + .await + .map_err(|e| SysinspectError::MasterGeneralError(format!("Authentication error: {e}")))?; + let response = response + .error_for_status() + .map_err(|e| SysinspectError::MasterGeneralError(format!("Authentication error: {e}")))? + .json::() + .await + .map_err(|e| SysinspectError::MasterGeneralError(format!("Authentication decode error: {e}")))?; + + if response.status != "authenticated" || response.sid.trim().is_empty() { + return Err(SysinspectError::MasterGeneralError(if response.error.is_empty() { + "Authentication failed".to_string() + } else { + response.error + })); + } - log::debug!("Authenticated user: {uid}, session ID: {}, symmetric key: {:x?}", self.sid, self.symkey); + self.sid = response.sid; + log::debug!("Authenticated user: {uid}, session ID: {}", self.sid); Ok(self.sid.clone()) } @@ -392,24 +162,28 @@ impl SysClient { return Err(SysinspectError::MasterGeneralError("Client is not authenticated".to_string())); } - let payload = json!({ - "model": model, - "query": query, - "traits": traits, - "mid": mid, - "context": context, - }); - - let (nonce, payload) = self.to_payload(&payload).await?; - let query_request = syswebclient::models::QueryRequest { - nonce: STANDARD.encode(nonce), - payload: STANDARD.encode(payload), - sid_rsa: self.b64encode(&self.encrypt(&self.sid.clone(), self.cfg.master_pubkey_path().to_str().unwrap())?), + let query_request = QueryRequest { + sid: self.sid.clone(), + model: model.to_string(), + query: query.to_string(), + traits: traits.to_string(), + mid: mid.to_string(), + context: Self::context_map(context)?, }; - let response = query_handler(&self.cfg.get_api_config(), query_request) + let response = self + .cfg + .client() + .post(format!("{}/api/v1/query", self.cfg.master_url.trim_end_matches('/'))) + .json(&query_request) + .send() + .await + .map_err(|e| SysinspectError::MasterGeneralError(format!("Query error: {e}")))? + .error_for_status() + .map_err(|e| SysinspectError::MasterGeneralError(format!("Query error: {e}")))? + .json::() .await - .map_err(|e| SysinspectError::MasterGeneralError(format!("Query error: {e}")))?; + .map_err(|e| SysinspectError::MasterGeneralError(format!("Query decode error: {e}")))?; Ok(response) } @@ -425,27 +199,49 @@ impl SysClient { /// Calls the `list_models` API to fetch available models from the SysInspect system. /// Returns a `ModelNameResponse` containing the list of models on success, or a `SysinspectError` if the API call fails. /// This enables the caller to access the models provided by the SysInspect system. - pub async fn models(&self) -> Result { - if self.sid.is_empty() { - return Err(SysinspectError::MasterGeneralError("Client is not authenticated".to_string())); - } + pub async fn models(&self) -> Result { + self.cfg + .client() + .get(format!("{}/api/v1/model/names", self.cfg.master_url.trim_end_matches('/'))) + .send() + .await + .map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to list models: {e}")))? + .error_for_status() + .map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to list models: {e}")))? + .json::() + .await + .map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to decode model list: {e}"))) + } - let response = syswebclient::apis::models_api::list_models(&self.cfg.get_api_config()).await; - match response { - Err(e) => Err(SysinspectError::MasterGeneralError(format!("Failed to list models: {e}"))), - Ok(r) => Ok(r), - } + pub async fn model_descr(&self, name: &str) -> Result { + self.cfg + .client() + .get(format!("{}/api/v1/model/descr", self.cfg.master_url.trim_end_matches('/'))) + .query(&[("name", name)]) + .send() + .await + .map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to get model details: {e}")))? + .error_for_status() + .map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to get model details: {e}")))? + .json::() + .await + .map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to decode model details: {e}"))) } - pub async fn model_descr(&self, name: &str) -> Result { - if self.sid.is_empty() { - return Err(SysinspectError::MasterGeneralError("Client is not authenticated".to_string())); - } + fn context_map(context: Value) -> Result, SysinspectError> { + let Value::Object(map) = context else { + return Err(SysinspectError::SerializationError("Query context must be a JSON object".to_string())); + }; - let response = syswebclient::apis::models_api::get_model_details(&self.cfg.get_api_config(), name).await; - match response { - Err(e) => Err(SysinspectError::MasterGeneralError(format!("Failed to get model details: {e}"))), - Ok(r) => Ok(r), - } + Ok(map + .into_iter() + .map(|(key, value)| { + let value = match value { + Value::String(text) => text, + other => other.to_string(), + }; + (key, value) + }) + .collect()) } } diff --git a/sysclient/sysinspect-client/.openapi-generator-ignore b/sysclient/sysinspect-client/.openapi-generator-ignore deleted file mode 100644 index 7484ee59..00000000 --- a/sysclient/sysinspect-client/.openapi-generator-ignore +++ /dev/null @@ -1,23 +0,0 @@ -# OpenAPI Generator Ignore -# Generated by openapi-generator https://github.com/openapitools/openapi-generator - -# Use this file to prevent files from being overwritten by the generator. -# The patterns follow closely to .gitignore or .dockerignore. - -# As an example, the C# client generator defines ApiClient.cs. -# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: -#ApiClient.cs - -# You can match any string of characters against a directory, file or extension with a single asterisk (*): -#foo/*/qux -# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux - -# You can recursively match patterns against a directory, file or extension with a double asterisk (**): -#foo/**/qux -# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux - -# You can also negate patterns with an exclamation (!). -# For example, you can ignore all files in a docs folder with the file extension .md: -#docs/*.md -# Then explicitly reverse the ignore rule for a single file: -#!docs/README.md diff --git a/sysclient/sysinspect-client/.openapi-generator/FILES b/sysclient/sysinspect-client/.openapi-generator/FILES index dd95123d..e8f813bf 100644 --- a/sysclient/sysinspect-client/.openapi-generator/FILES +++ b/sysclient/sysinspect-client/.openapi-generator/FILES @@ -3,7 +3,6 @@ .travis.yml Cargo.toml README.md -docs/AuthInnerRequest.md docs/AuthRequest.md docs/AuthResponse.md docs/HealthInfo.md @@ -15,9 +14,6 @@ docs/ModelInfo.md docs/ModelNameResponse.md docs/ModelResponse.md docs/ModelsApi.md -docs/PubKeyError.md -docs/PubKeyRequest.md -docs/PubKeyResponse.md docs/QueryError.md docs/QueryPayloadRequest.md docs/QueryRequest.md @@ -32,7 +28,6 @@ src/apis/models_api.rs src/apis/rsa_keys_api.rs src/apis/system_api.rs src/lib.rs -src/models/auth_inner_request.rs src/models/auth_request.rs src/models/auth_response.rs src/models/health_info.rs @@ -43,9 +38,6 @@ src/models/mod.rs src/models/model_info.rs src/models/model_name_response.rs src/models/model_response.rs -src/models/pub_key_error.rs -src/models/pub_key_request.rs -src/models/pub_key_response.rs src/models/query_error.rs src/models/query_payload_request.rs src/models/query_request.rs diff --git a/sysclient/sysinspect-client/.openapi-generator/VERSION b/sysclient/sysinspect-client/.openapi-generator/VERSION deleted file mode 100644 index e465da43..00000000 --- a/sysclient/sysinspect-client/.openapi-generator/VERSION +++ /dev/null @@ -1 +0,0 @@ -7.14.0 diff --git a/sysclient/sysinspect-client/.travis.yml b/sysclient/sysinspect-client/.travis.yml deleted file mode 100644 index 22761ba7..00000000 --- a/sysclient/sysinspect-client/.travis.yml +++ /dev/null @@ -1 +0,0 @@ -language: rust diff --git a/sysclient/sysinspect-client/Cargo.toml b/sysclient/sysinspect-client/Cargo.toml deleted file mode 100644 index 77cf660d..00000000 --- a/sysclient/sysinspect-client/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "syswebclient" -version = "0.1.1" -authors = ["OpenAPI Generator team and contributors"] -description = "SysInspect Web API for interacting with the master interface." -license = "" -edition = "2021" - -[dependencies] -serde = { version = "^1.0", features = ["derive"] } -serde_with = { version = "^3.16", default-features = false, features = ["base64", "std", "macros"] } -serde_json = "^1.0" -serde_repr = "^0.1" -url = "^2.5" -reqwest = { version = "^0.12", default-features = false, features = ["json", "multipart"] } diff --git a/sysclient/sysinspect-client/README.md b/sysclient/sysinspect-client/README.md deleted file mode 100644 index e2d27775..00000000 --- a/sysclient/sysinspect-client/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Rust API client for syswebclient - -SysInspect Web API for interacting with the master interface. - - -## Overview - -This API client was generated by the [OpenAPI Generator](https://openapi-generator.tech) project. By using the [openapi-spec](https://openapis.org) from a remote server, you can easily generate an API client. - -- API version: 0.1.1 -- Package version: 0.1.1 -- Generator version: 7.14.0 -- Build package: `org.openapitools.codegen.languages.RustClientCodegen` - -## Installation - -Put the package under your project folder in a directory named `syswebclient` and add the following to `Cargo.toml` under `[dependencies]`: - -``` -syswebclient = { path = "./syswebclient" } -``` - -## Documentation for API Endpoints - -All URIs are relative to *http://localhost* - -Class | Method | HTTP request | Description ------------- | ------------- | ------------- | ------------- -*MinionsApi* | [**query_handler**](docs/MinionsApi.md#query_handler) | **POST** /api/v1/query | -*MinionsApi* | [**query_handler_dev**](docs/MinionsApi.md#query_handler_dev) | **POST** /api/v1/dev_query | -*ModelsApi* | [**get_model_details**](docs/ModelsApi.md#get_model_details) | **GET** /api/v1/model/descr | -*ModelsApi* | [**list_models**](docs/ModelsApi.md#list_models) | **GET** /api/v1/model/names | -*RsaKeysApi* | [**master_key**](docs/RsaKeysApi.md#master_key) | **POST** /api/v1/masterkey | -*RsaKeysApi* | [**pushkey**](docs/RsaKeysApi.md#pushkey) | **POST** /api/v1/pushkey | -*SystemApi* | [**authenticate_user**](docs/SystemApi.md#authenticate_user) | **POST** /api/v1/authenticate | -*SystemApi* | [**health_check**](docs/SystemApi.md#health_check) | **POST** /api/v1/health | - - -## Documentation For Models - - - [AuthInnerRequest](docs/AuthInnerRequest.md) - - [AuthRequest](docs/AuthRequest.md) - - [AuthResponse](docs/AuthResponse.md) - - [HealthInfo](docs/HealthInfo.md) - - [HealthResponse](docs/HealthResponse.md) - - [MasterKeyError](docs/MasterKeyError.md) - - [MasterKeyResponse](docs/MasterKeyResponse.md) - - [ModelInfo](docs/ModelInfo.md) - - [ModelNameResponse](docs/ModelNameResponse.md) - - [ModelResponse](docs/ModelResponse.md) - - [PubKeyError](docs/PubKeyError.md) - - [PubKeyRequest](docs/PubKeyRequest.md) - - [PubKeyResponse](docs/PubKeyResponse.md) - - [QueryError](docs/QueryError.md) - - [QueryPayloadRequest](docs/QueryPayloadRequest.md) - - [QueryRequest](docs/QueryRequest.md) - - [QueryResponse](docs/QueryResponse.md) - - -To get access to the crate's generated documentation, use: - -``` -cargo doc --open -``` - -## Author - - - diff --git a/sysclient/sysinspect-client/docs/AuthInnerRequest.md b/sysclient/sysinspect-client/docs/AuthInnerRequest.md deleted file mode 100644 index a3a07a80..00000000 --- a/sysclient/sysinspect-client/docs/AuthInnerRequest.md +++ /dev/null @@ -1,12 +0,0 @@ -# AuthInnerRequest - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**password** | Option<**String**> | | [optional] -**username** | Option<**String**> | | [optional] - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/sysclient/sysinspect-client/docs/AuthRequest.md b/sysclient/sysinspect-client/docs/AuthRequest.md deleted file mode 100644 index f497d7a2..00000000 --- a/sysclient/sysinspect-client/docs/AuthRequest.md +++ /dev/null @@ -1,12 +0,0 @@ -# AuthRequest - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**payload** | **String** | Base64-encoded, RSA-encrypted JSON: {\"username\": \"...\", \"password\": \"...\", \"pubkey\": \"...\"} | -**pubkey** | **String** | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/sysclient/sysinspect-client/docs/AuthResponse.md b/sysclient/sysinspect-client/docs/AuthResponse.md deleted file mode 100644 index 789357b8..00000000 --- a/sysclient/sysinspect-client/docs/AuthResponse.md +++ /dev/null @@ -1,14 +0,0 @@ -# AuthResponse - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**error** | **String** | | -**sid_cipher** | **String** | | -**status** | **String** | | -**symkey_cipher** | **String** | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/sysclient/sysinspect-client/docs/HealthInfo.md b/sysclient/sysinspect-client/docs/HealthInfo.md deleted file mode 100644 index 0fe0bb41..00000000 --- a/sysclient/sysinspect-client/docs/HealthInfo.md +++ /dev/null @@ -1,13 +0,0 @@ -# HealthInfo - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**api_version** | **String** | | -**scheduler_tasks** | **i32** | | -**telemetry_enabled** | **bool** | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/sysclient/sysinspect-client/docs/HealthResponse.md b/sysclient/sysinspect-client/docs/HealthResponse.md deleted file mode 100644 index 21141daa..00000000 --- a/sysclient/sysinspect-client/docs/HealthResponse.md +++ /dev/null @@ -1,12 +0,0 @@ -# HealthResponse - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**info** | [**models::HealthInfo**](HealthInfo.md) | | -**status** | **String** | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/sysclient/sysinspect-client/docs/MasterKeyError.md b/sysclient/sysinspect-client/docs/MasterKeyError.md deleted file mode 100644 index 9e3a68d3..00000000 --- a/sysclient/sysinspect-client/docs/MasterKeyError.md +++ /dev/null @@ -1,11 +0,0 @@ -# MasterKeyError - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**error** | **String** | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/sysclient/sysinspect-client/docs/MasterKeyResponse.md b/sysclient/sysinspect-client/docs/MasterKeyResponse.md deleted file mode 100644 index 573a51af..00000000 --- a/sysclient/sysinspect-client/docs/MasterKeyResponse.md +++ /dev/null @@ -1,11 +0,0 @@ -# MasterKeyResponse - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**key** | **String** | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/sysclient/sysinspect-client/docs/MinionsApi.md b/sysclient/sysinspect-client/docs/MinionsApi.md deleted file mode 100644 index 3516a137..00000000 --- a/sysclient/sysinspect-client/docs/MinionsApi.md +++ /dev/null @@ -1,68 +0,0 @@ -# \MinionsApi - -All URIs are relative to *http://localhost* - -Method | HTTP request | Description -------------- | ------------- | ------------- -[**query_handler**](MinionsApi.md#query_handler) | **POST** /api/v1/query | -[**query_handler_dev**](MinionsApi.md#query_handler_dev) | **POST** /api/v1/dev_query | - - - -## query_handler - -> models::QueryResponse query_handler(query_request) - - -### Parameters - - -Name | Type | Description | Required | Notes -------------- | ------------- | ------------- | ------------- | ------------- -**query_request** | [**QueryRequest**](QueryRequest.md) | | [required] | - -### Return type - -[**models::QueryResponse**](QueryResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - -- **Content-Type**: application/json -- **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - - -## query_handler_dev - -> models::QueryResponse query_handler_dev(query_payload_request) - - -Development endpoint for querying minions. FOR DEVELOPMENT AND DEBUGGING PURPOSES ONLY! - -### Parameters - - -Name | Type | Description | Required | Notes -------------- | ------------- | ------------- | ------------- | ------------- -**query_payload_request** | [**QueryPayloadRequest**](QueryPayloadRequest.md) | | [required] | - -### Return type - -[**models::QueryResponse**](QueryResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - -- **Content-Type**: application/json -- **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - diff --git a/sysclient/sysinspect-client/docs/ModelInfo.md b/sysclient/sysinspect-client/docs/ModelInfo.md deleted file mode 100644 index 7fbc9688..00000000 --- a/sysclient/sysinspect-client/docs/ModelInfo.md +++ /dev/null @@ -1,16 +0,0 @@ -# ModelInfo - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**description** | **String** | A brief description of the model | -**entity_states** | [**std::collections::HashMap>>**](Vec.md) | Entity to a vector of bound actions | -**id** | **String** | The unique identifier of the model (Id) | -**maintainer** | **String** | The author of the model | -**name** | **String** | The name of the model | -**version** | **String** | The version of the model | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/sysclient/sysinspect-client/docs/ModelNameResponse.md b/sysclient/sysinspect-client/docs/ModelNameResponse.md deleted file mode 100644 index fa62c250..00000000 --- a/sysclient/sysinspect-client/docs/ModelNameResponse.md +++ /dev/null @@ -1,11 +0,0 @@ -# ModelNameResponse - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**models** | **Vec** | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/sysclient/sysinspect-client/docs/ModelResponse.md b/sysclient/sysinspect-client/docs/ModelResponse.md deleted file mode 100644 index a87a5800..00000000 --- a/sysclient/sysinspect-client/docs/ModelResponse.md +++ /dev/null @@ -1,11 +0,0 @@ -# ModelResponse - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**model** | [**models::ModelInfo**](ModelInfo.md) | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/sysclient/sysinspect-client/docs/ModelsApi.md b/sysclient/sysinspect-client/docs/ModelsApi.md deleted file mode 100644 index 496274e1..00000000 --- a/sysclient/sysinspect-client/docs/ModelsApi.md +++ /dev/null @@ -1,67 +0,0 @@ -# \ModelsApi - -All URIs are relative to *http://localhost* - -Method | HTTP request | Description -------------- | ------------- | ------------- -[**get_model_details**](ModelsApi.md#get_model_details) | **GET** /api/v1/model/descr | -[**list_models**](ModelsApi.md#list_models) | **GET** /api/v1/model/names | - - - -## get_model_details - -> models::ModelResponse get_model_details(name) - - -Retrieves detailed information about a specific model in the SysInspect system. The model includes its name, description, version, maintainer, and statistics about its entities, actions, constraints, and events. - -### Parameters - - -Name | Type | Description | Required | Notes -------------- | ------------- | ------------- | ------------- | ------------- -**name** | **String** | Name of the model to retrieve details for | [required] | - -### Return type - -[**models::ModelResponse**](ModelResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - -- **Content-Type**: Not defined -- **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - - -## list_models - -> models::ModelNameResponse list_models() - - -Lists all available models in the SysInspect system. Each model includes details such as its name, description, version, maintainer, and statistics about its entities, actions, constraints, and events. - -### Parameters - -This endpoint does not need any parameter. - -### Return type - -[**models::ModelNameResponse**](ModelNameResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - -- **Content-Type**: Not defined -- **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - diff --git a/sysclient/sysinspect-client/docs/PubKeyError.md b/sysclient/sysinspect-client/docs/PubKeyError.md deleted file mode 100644 index 82e4a9a3..00000000 --- a/sysclient/sysinspect-client/docs/PubKeyError.md +++ /dev/null @@ -1,11 +0,0 @@ -# PubKeyError - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**error** | **String** | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/sysclient/sysinspect-client/docs/PubKeyRequest.md b/sysclient/sysinspect-client/docs/PubKeyRequest.md deleted file mode 100644 index ef0772cb..00000000 --- a/sysclient/sysinspect-client/docs/PubKeyRequest.md +++ /dev/null @@ -1,12 +0,0 @@ -# PubKeyRequest - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**key** | **String** | | -**sid_cipher** | **String** | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/sysclient/sysinspect-client/docs/PubKeyResponse.md b/sysclient/sysinspect-client/docs/PubKeyResponse.md deleted file mode 100644 index e279c9e6..00000000 --- a/sysclient/sysinspect-client/docs/PubKeyResponse.md +++ /dev/null @@ -1,11 +0,0 @@ -# PubKeyResponse - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**message** | **String** | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/sysclient/sysinspect-client/docs/QueryError.md b/sysclient/sysinspect-client/docs/QueryError.md deleted file mode 100644 index 5190d0cc..00000000 --- a/sysclient/sysinspect-client/docs/QueryError.md +++ /dev/null @@ -1,12 +0,0 @@ -# QueryError - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**error** | **String** | | -**status** | **String** | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/sysclient/sysinspect-client/docs/QueryPayloadRequest.md b/sysclient/sysinspect-client/docs/QueryPayloadRequest.md deleted file mode 100644 index 3b3d52d0..00000000 --- a/sysclient/sysinspect-client/docs/QueryPayloadRequest.md +++ /dev/null @@ -1,15 +0,0 @@ -# QueryPayloadRequest - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**context** | **std::collections::HashMap** | | -**mid** | **String** | | -**model** | **String** | | -**query** | **String** | | -**traits** | **String** | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/sysclient/sysinspect-client/docs/QueryRequest.md b/sysclient/sysinspect-client/docs/QueryRequest.md deleted file mode 100644 index 2873c49d..00000000 --- a/sysclient/sysinspect-client/docs/QueryRequest.md +++ /dev/null @@ -1,13 +0,0 @@ -# QueryRequest - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**nonce** | **String** | | -**payload** | **String** | | -**sid_rsa** | **String** | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/sysclient/sysinspect-client/docs/QueryResponse.md b/sysclient/sysinspect-client/docs/QueryResponse.md deleted file mode 100644 index 80d416d8..00000000 --- a/sysclient/sysinspect-client/docs/QueryResponse.md +++ /dev/null @@ -1,12 +0,0 @@ -# QueryResponse - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**message** | **String** | | -**status** | **String** | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/sysclient/sysinspect-client/docs/RsaKeysApi.md b/sysclient/sysinspect-client/docs/RsaKeysApi.md deleted file mode 100644 index f477a377..00000000 --- a/sysclient/sysinspect-client/docs/RsaKeysApi.md +++ /dev/null @@ -1,67 +0,0 @@ -# \RsaKeysApi - -All URIs are relative to *http://localhost* - -Method | HTTP request | Description -------------- | ------------- | ------------- -[**master_key**](RsaKeysApi.md#master_key) | **POST** /api/v1/masterkey | -[**pushkey**](RsaKeysApi.md#pushkey) | **POST** /api/v1/pushkey | - - - -## master_key - -> models::MasterKeyResponse master_key() - - -Retrieve the master public key from the keystore. - -### Parameters - -This endpoint does not need any parameter. - -### Return type - -[**models::MasterKeyResponse**](MasterKeyResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - -- **Content-Type**: Not defined -- **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - - -## pushkey - -> models::PubKeyResponse pushkey(pub_key_request) - - -Push a public key for a user. Requires an authenticated session ID. - -### Parameters - - -Name | Type | Description | Required | Notes -------------- | ------------- | ------------- | ------------- | ------------- -**pub_key_request** | [**PubKeyRequest**](PubKeyRequest.md) | | [required] | - -### Return type - -[**models::PubKeyResponse**](PubKeyResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - -- **Content-Type**: application/json -- **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - diff --git a/sysclient/sysinspect-client/docs/SystemApi.md b/sysclient/sysinspect-client/docs/SystemApi.md deleted file mode 100644 index 1cb17b03..00000000 --- a/sysclient/sysinspect-client/docs/SystemApi.md +++ /dev/null @@ -1,67 +0,0 @@ -# \SystemApi - -All URIs are relative to *http://localhost* - -Method | HTTP request | Description -------------- | ------------- | ------------- -[**authenticate_user**](SystemApi.md#authenticate_user) | **POST** /api/v1/authenticate | -[**health_check**](SystemApi.md#health_check) | **POST** /api/v1/health | - - - -## authenticate_user - -> models::AuthResponse authenticate_user(auth_request) - - -Authenticates a user using configured authentication method. The payload must be a base64-encoded, RSA-encrypted JSON object with username and password fields as follows: ```json { \"username\": \"darth_vader\", \"password\": \"I am your father\", \"pubkey\": \"...\" } ``` If the API is in development mode, it will return a static token without actual authentication. - -### Parameters - - -Name | Type | Description | Required | Notes -------------- | ------------- | ------------- | ------------- | ------------- -**auth_request** | [**AuthRequest**](AuthRequest.md) | Base64-encoded, RSA-encrypted JSON containing username and password. See description for details. | [required] | - -### Return type - -[**models::AuthResponse**](AuthResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - -- **Content-Type**: application/json -- **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - - -## health_check - -> models::HealthResponse health_check() - - -Checks the health of the SysInspect API. Returns basic information about the API status, telemetry, and scheduler tasks. - -### Parameters - -This endpoint does not need any parameter. - -### Return type - -[**models::HealthResponse**](HealthResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - -- **Content-Type**: Not defined -- **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - diff --git a/sysclient/sysinspect-client/git_push.sh b/sysclient/sysinspect-client/git_push.sh deleted file mode 100644 index f53a75d4..00000000 --- a/sysclient/sysinspect-client/git_push.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/sh -# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/ -# -# Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com" - -git_user_id=$1 -git_repo_id=$2 -release_note=$3 -git_host=$4 - -if [ "$git_host" = "" ]; then - git_host="github.com" - echo "[INFO] No command line input provided. Set \$git_host to $git_host" -fi - -if [ "$git_user_id" = "" ]; then - git_user_id="GIT_USER_ID" - echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id" -fi - -if [ "$git_repo_id" = "" ]; then - git_repo_id="GIT_REPO_ID" - echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id" -fi - -if [ "$release_note" = "" ]; then - release_note="Minor update" - echo "[INFO] No command line input provided. Set \$release_note to $release_note" -fi - -# Initialize the local directory as a Git repository -git init - -# Adds the files in the local repository and stages them for commit. -git add . - -# Commits the tracked changes and prepares them to be pushed to a remote repository. -git commit -m "$release_note" - -# Sets the new remote -git_remote=$(git remote) -if [ "$git_remote" = "" ]; then # git remote not defined - - if [ "$GIT_TOKEN" = "" ]; then - echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment." - git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git - else - git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git - fi - -fi - -git pull origin master - -# Pushes (Forces) the changes in the local repository up to the remote repository -echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git" -git push origin master 2>&1 | grep -v 'To https' diff --git a/sysclient/sysinspect-client/src/apis/configuration.rs b/sysclient/sysinspect-client/src/apis/configuration.rs deleted file mode 100644 index aa95d8a7..00000000 --- a/sysclient/sysinspect-client/src/apis/configuration.rs +++ /dev/null @@ -1,48 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -#[derive(Debug, Clone)] -pub struct Configuration { - pub base_path: String, - pub user_agent: Option, - pub client: reqwest::Client, - pub basic_auth: Option, - pub oauth_access_token: Option, - pub bearer_access_token: Option, - pub api_key: Option, -} - -pub type BasicAuth = (String, Option); - -#[derive(Debug, Clone)] -pub struct ApiKey { - pub prefix: Option, - pub key: String, -} - -impl Configuration { - pub fn new() -> Configuration { - Configuration::default() - } -} - -impl Default for Configuration { - fn default() -> Self { - Configuration { - base_path: "http://localhost".to_owned(), - user_agent: Some("OpenAPI-Generator/0.1.1/rust".to_owned()), - client: reqwest::Client::new(), - basic_auth: None, - oauth_access_token: None, - bearer_access_token: None, - api_key: None, - } - } -} diff --git a/sysclient/sysinspect-client/src/apis/minions_api.rs b/sysclient/sysinspect-client/src/apis/minions_api.rs deleted file mode 100644 index a0ec01f4..00000000 --- a/sysclient/sysinspect-client/src/apis/minions_api.rs +++ /dev/null @@ -1,111 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use super::{configuration, ContentType, Error}; -use crate::{apis::ResponseContent, models}; -use reqwest; -use serde::{de::Error as _, Deserialize, Serialize}; - -/// struct for typed errors of method [`query_handler`] -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum QueryHandlerError { - Status400(models::QueryError), - UnknownValue(serde_json::Value), -} - -/// struct for typed errors of method [`query_handler_dev`] -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum QueryHandlerDevError { - Status400(models::QueryError), - UnknownValue(serde_json::Value), -} - -pub async fn query_handler( - configuration: &configuration::Configuration, query_request: models::QueryRequest, -) -> Result> { - // add a prefix to parameters to efficiently prevent name collisions - let p_query_request = query_request; - - let uri_str = format!("{}/api/v1/query", configuration.base_path); - let mut req_builder = configuration.client.request(reqwest::Method::POST, &uri_str); - - if let Some(ref user_agent) = configuration.user_agent { - req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); - } - req_builder = req_builder.json(&p_query_request); - - let req = req_builder.build()?; - let resp = configuration.client.execute(req).await?; - - let status = resp.status(); - let content_type = - resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("application/octet-stream"); - let content_type = super::ContentType::from(content_type); - - if !status.is_client_error() && !status.is_server_error() { - let content = resp.text().await?; - match content_type { - ContentType::Json => serde_json::from_str(&content).map_err(Error::from), - ContentType::Text => Err(Error::from(serde_json::Error::custom( - "Received `text/plain` content type response that cannot be converted to `models::QueryResponse`", - ))), - ContentType::Unsupported(unknown_type) => Err(Error::from(serde_json::Error::custom(format!( - "Received `{unknown_type}` content type response that cannot be converted to `models::QueryResponse`" - )))), - } - } else { - let content = resp.text().await?; - let entity: Option = serde_json::from_str(&content).ok(); - Err(Error::ResponseError(ResponseContent { status, content, entity })) - } -} - -/// Development endpoint for querying minions. FOR DEVELOPMENT AND DEBUGGING PURPOSES ONLY! -pub async fn query_handler_dev( - configuration: &configuration::Configuration, query_payload_request: models::QueryPayloadRequest, -) -> Result> { - // add a prefix to parameters to efficiently prevent name collisions - let p_query_payload_request = query_payload_request; - - let uri_str = format!("{}/api/v1/dev_query", configuration.base_path); - let mut req_builder = configuration.client.request(reqwest::Method::POST, &uri_str); - - if let Some(ref user_agent) = configuration.user_agent { - req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); - } - req_builder = req_builder.json(&p_query_payload_request); - - let req = req_builder.build()?; - let resp = configuration.client.execute(req).await?; - - let status = resp.status(); - let content_type = - resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("application/octet-stream"); - let content_type = super::ContentType::from(content_type); - - if !status.is_client_error() && !status.is_server_error() { - let content = resp.text().await?; - match content_type { - ContentType::Json => serde_json::from_str(&content).map_err(Error::from), - ContentType::Text => Err(Error::from(serde_json::Error::custom( - "Received `text/plain` content type response that cannot be converted to `models::QueryResponse`", - ))), - ContentType::Unsupported(unknown_type) => Err(Error::from(serde_json::Error::custom(format!( - "Received `{unknown_type}` content type response that cannot be converted to `models::QueryResponse`" - )))), - } - } else { - let content = resp.text().await?; - let entity: Option = serde_json::from_str(&content).ok(); - Err(Error::ResponseError(ResponseContent { status, content, entity })) - } -} diff --git a/sysclient/sysinspect-client/src/apis/mod.rs b/sysclient/sysinspect-client/src/apis/mod.rs deleted file mode 100644 index 8ab14f83..00000000 --- a/sysclient/sysinspect-client/src/apis/mod.rs +++ /dev/null @@ -1,115 +0,0 @@ -use std::error; -use std::fmt; - -#[derive(Debug, Clone)] -pub struct ResponseContent { - pub status: reqwest::StatusCode, - pub content: String, - pub entity: Option, -} - -#[derive(Debug)] -pub enum Error { - Reqwest(reqwest::Error), - Serde(serde_json::Error), - Io(std::io::Error), - ResponseError(ResponseContent), -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let (module, e) = match self { - Error::Reqwest(e) => ("reqwest", e.to_string()), - Error::Serde(e) => ("serde", e.to_string()), - Error::Io(e) => ("IO", e.to_string()), - Error::ResponseError(e) => ("response", format!("status code {}", e.status)), - }; - write!(f, "error in {module}: {e}") - } -} - -impl error::Error for Error { - fn source(&self) -> Option<&(dyn error::Error + 'static)> { - Some(match self { - Error::Reqwest(e) => e, - Error::Serde(e) => e, - Error::Io(e) => e, - Error::ResponseError(_) => return None, - }) - } -} - -impl From for Error { - fn from(e: reqwest::Error) -> Self { - Error::Reqwest(e) - } -} - -impl From for Error { - fn from(e: serde_json::Error) -> Self { - Error::Serde(e) - } -} - -impl From for Error { - fn from(e: std::io::Error) -> Self { - Error::Io(e) - } -} - -pub fn urlencode>(s: T) -> String { - ::url::form_urlencoded::byte_serialize(s.as_ref().as_bytes()).collect() -} - -pub fn parse_deep_object(prefix: &str, value: &serde_json::Value) -> Vec<(String, String)> { - if let serde_json::Value::Object(object) = value { - let mut params = vec![]; - - for (key, value) in object { - match value { - serde_json::Value::Object(_) => { - params.append(&mut parse_deep_object(&format!("{prefix}[{key}]"), value)) - } - serde_json::Value::Array(array) => { - for (i, value) in array.iter().enumerate() { - params.append(&mut parse_deep_object(&format!("{prefix}[{key}][{i}]"), value)); - } - } - serde_json::Value::String(s) => params.push((format!("{prefix}[{key}]"), s.clone())), - _ => params.push((format!("{prefix}[{key}]"), value.to_string())), - } - } - - return params; - } - - unimplemented!("Only objects are supported with style=deepObject") -} - -/// Internal use only -/// A content type supported by this client. -#[allow(dead_code)] -enum ContentType { - Json, - Text, - Unsupported(String), -} - -impl From<&str> for ContentType { - fn from(content_type: &str) -> Self { - if content_type.starts_with("application") && content_type.contains("json") { - Self::Json - } else if content_type.starts_with("text/plain") { - Self::Text - } else { - Self::Unsupported(content_type.to_string()) - } - } -} - -pub mod minions_api; -pub mod models_api; -pub mod rsa_keys_api; -pub mod system_api; - -pub mod configuration; diff --git a/sysclient/sysinspect-client/src/apis/models_api.rs b/sysclient/sysinspect-client/src/apis/models_api.rs deleted file mode 100644 index 6cf0d084..00000000 --- a/sysclient/sysinspect-client/src/apis/models_api.rs +++ /dev/null @@ -1,102 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use super::{configuration, ContentType, Error}; -use crate::{apis::ResponseContent, models}; -use reqwest; -use serde::{de::Error as _, Deserialize, Serialize}; - -/// struct for typed errors of method [`get_model_details`] -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum GetModelDetailsError { - UnknownValue(serde_json::Value), -} - -/// struct for typed errors of method [`list_models`] -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum ListModelsError { - UnknownValue(serde_json::Value), -} - -/// Retrieves detailed information about a specific model in the SysInspect system. The model includes its name, description, version, maintainer, and statistics about its entities, actions, constraints, and events. -pub async fn get_model_details( - configuration: &configuration::Configuration, name: &str, -) -> Result> { - // add a prefix to parameters to efficiently prevent name collisions - let p_name = name; - - let uri_str = format!("{}/api/v1/model/descr", configuration.base_path); - let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str); - - req_builder = req_builder.query(&[("name", &p_name.to_string())]); - if let Some(ref user_agent) = configuration.user_agent { - req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); - } - - let req = req_builder.build()?; - let resp = configuration.client.execute(req).await?; - - let status = resp.status(); - let content_type = - resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("application/octet-stream"); - let content_type = super::ContentType::from(content_type); - - if !status.is_client_error() && !status.is_server_error() { - let content = resp.text().await?; - match content_type { - ContentType::Json => serde_json::from_str(&content).map_err(Error::from), - ContentType::Text => Err(Error::from(serde_json::Error::custom( - "Received `text/plain` content type response that cannot be converted to `models::ModelResponse`", - ))), - ContentType::Unsupported(unknown_type) => Err(Error::from(serde_json::Error::custom(format!( - "Received `{unknown_type}` content type response that cannot be converted to `models::ModelResponse`" - )))), - } - } else { - let content = resp.text().await?; - let entity: Option = serde_json::from_str(&content).ok(); - Err(Error::ResponseError(ResponseContent { status, content, entity })) - } -} - -/// Lists all available models in the SysInspect system. Each model includes details such as its name, description, version, maintainer, and statistics about its entities, actions, constraints, and events. -pub async fn list_models( - configuration: &configuration::Configuration, -) -> Result> { - let uri_str = format!("{}/api/v1/model/names", configuration.base_path); - let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str); - - if let Some(ref user_agent) = configuration.user_agent { - req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); - } - - let req = req_builder.build()?; - let resp = configuration.client.execute(req).await?; - - let status = resp.status(); - let content_type = - resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("application/octet-stream"); - let content_type = super::ContentType::from(content_type); - - if !status.is_client_error() && !status.is_server_error() { - let content = resp.text().await?; - match content_type { - ContentType::Json => serde_json::from_str(&content).map_err(Error::from), - ContentType::Text => Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::ModelNameResponse`"))), - ContentType::Unsupported(unknown_type) => Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::ModelNameResponse`")))), - } - } else { - let content = resp.text().await?; - let entity: Option = serde_json::from_str(&content).ok(); - Err(Error::ResponseError(ResponseContent { status, content, entity })) - } -} diff --git a/sysclient/sysinspect-client/src/apis/rsa_keys_api.rs b/sysclient/sysinspect-client/src/apis/rsa_keys_api.rs deleted file mode 100644 index 9ca70a24..00000000 --- a/sysclient/sysinspect-client/src/apis/rsa_keys_api.rs +++ /dev/null @@ -1,104 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use super::{configuration, ContentType, Error}; -use crate::{apis::ResponseContent, models}; -use reqwest; -use serde::{de::Error as _, Deserialize, Serialize}; - -/// struct for typed errors of method [`master_key`] -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum MasterKeyError { - Status400(models::MasterKeyError), - UnknownValue(serde_json::Value), -} - -/// struct for typed errors of method [`pushkey`] -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum PushkeyError { - Status400(models::PubKeyError), - UnknownValue(serde_json::Value), -} - -/// Retrieve the master public key from the keystore. -pub async fn master_key( - configuration: &configuration::Configuration, -) -> Result> { - let uri_str = format!("{}/api/v1/masterkey", configuration.base_path); - let mut req_builder = configuration.client.request(reqwest::Method::POST, &uri_str); - - if let Some(ref user_agent) = configuration.user_agent { - req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); - } - - let req = req_builder.build()?; - let resp = configuration.client.execute(req).await?; - - let status = resp.status(); - let content_type = - resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("application/octet-stream"); - let content_type = super::ContentType::from(content_type); - - if !status.is_client_error() && !status.is_server_error() { - let content = resp.text().await?; - match content_type { - ContentType::Json => serde_json::from_str(&content).map_err(Error::from), - ContentType::Text => Err(Error::from(serde_json::Error::custom("Received `text/plain` content type response that cannot be converted to `models::MasterKeyResponse`"))), - ContentType::Unsupported(unknown_type) => Err(Error::from(serde_json::Error::custom(format!("Received `{unknown_type}` content type response that cannot be converted to `models::MasterKeyResponse`")))), - } - } else { - let content = resp.text().await?; - let entity: Option = serde_json::from_str(&content).ok(); - Err(Error::ResponseError(ResponseContent { status, content, entity })) - } -} - -/// Push a public key for a user. Requires an authenticated session ID. -pub async fn pushkey( - configuration: &configuration::Configuration, pub_key_request: models::PubKeyRequest, -) -> Result> { - // add a prefix to parameters to efficiently prevent name collisions - let p_pub_key_request = pub_key_request; - - let uri_str = format!("{}/api/v1/pushkey", configuration.base_path); - let mut req_builder = configuration.client.request(reqwest::Method::POST, &uri_str); - - if let Some(ref user_agent) = configuration.user_agent { - req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); - } - req_builder = req_builder.json(&p_pub_key_request); - - let req = req_builder.build()?; - let resp = configuration.client.execute(req).await?; - - let status = resp.status(); - let content_type = - resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("application/octet-stream"); - let content_type = super::ContentType::from(content_type); - - if !status.is_client_error() && !status.is_server_error() { - let content = resp.text().await?; - match content_type { - ContentType::Json => serde_json::from_str(&content).map_err(Error::from), - ContentType::Text => Err(Error::from(serde_json::Error::custom( - "Received `text/plain` content type response that cannot be converted to `models::PubKeyResponse`", - ))), - ContentType::Unsupported(unknown_type) => Err(Error::from(serde_json::Error::custom(format!( - "Received `{unknown_type}` content type response that cannot be converted to `models::PubKeyResponse`" - )))), - } - } else { - let content = resp.text().await?; - let entity: Option = serde_json::from_str(&content).ok(); - Err(Error::ResponseError(ResponseContent { status, content, entity })) - } -} diff --git a/sysclient/sysinspect-client/src/apis/system_api.rs b/sysclient/sysinspect-client/src/apis/system_api.rs deleted file mode 100644 index 15990ab5..00000000 --- a/sysclient/sysinspect-client/src/apis/system_api.rs +++ /dev/null @@ -1,108 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use super::{configuration, ContentType, Error}; -use crate::{apis::ResponseContent, models}; -use reqwest; -use serde::{de::Error as _, Deserialize, Serialize}; - -/// struct for typed errors of method [`authenticate_user`] -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum AuthenticateUserError { - Status400(models::AuthResponse), - UnknownValue(serde_json::Value), -} - -/// struct for typed errors of method [`health_check`] -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum HealthCheckError { - Status500(models::HealthResponse), - UnknownValue(serde_json::Value), -} - -/// Authenticates a user using configured authentication method. The payload must be a base64-encoded, RSA-encrypted JSON object with username and password fields as follows: ```json { \"username\": \"darth_vader\", \"password\": \"I am your father\", \"pubkey\": \"...\" } ``` If the API is in development mode, it will return a static token without actual authentication. -pub async fn authenticate_user( - configuration: &configuration::Configuration, auth_request: models::AuthRequest, -) -> Result> { - // add a prefix to parameters to efficiently prevent name collisions - let p_auth_request = auth_request; - - let uri_str = format!("{}/api/v1/authenticate", configuration.base_path); - let mut req_builder = configuration.client.request(reqwest::Method::POST, &uri_str); - - if let Some(ref user_agent) = configuration.user_agent { - req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); - } - req_builder = req_builder.json(&p_auth_request); - - let req = req_builder.build()?; - let resp = configuration.client.execute(req).await?; - - let status = resp.status(); - let content_type = - resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("application/octet-stream"); - let content_type = super::ContentType::from(content_type); - - if !status.is_client_error() && !status.is_server_error() { - let content = resp.text().await?; - match content_type { - ContentType::Json => serde_json::from_str(&content).map_err(Error::from), - ContentType::Text => Err(Error::from(serde_json::Error::custom( - "Received `text/plain` content type response that cannot be converted to `models::AuthResponse`", - ))), - ContentType::Unsupported(unknown_type) => Err(Error::from(serde_json::Error::custom(format!( - "Received `{unknown_type}` content type response that cannot be converted to `models::AuthResponse`" - )))), - } - } else { - let content = resp.text().await?; - let entity: Option = serde_json::from_str(&content).ok(); - Err(Error::ResponseError(ResponseContent { status, content, entity })) - } -} - -/// Checks the health of the SysInspect API. Returns basic information about the API status, telemetry, and scheduler tasks. -pub async fn health_check( - configuration: &configuration::Configuration, -) -> Result> { - let uri_str = format!("{}/api/v1/health", configuration.base_path); - let mut req_builder = configuration.client.request(reqwest::Method::POST, &uri_str); - - if let Some(ref user_agent) = configuration.user_agent { - req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); - } - - let req = req_builder.build()?; - let resp = configuration.client.execute(req).await?; - - let status = resp.status(); - let content_type = - resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("application/octet-stream"); - let content_type = super::ContentType::from(content_type); - - if !status.is_client_error() && !status.is_server_error() { - let content = resp.text().await?; - match content_type { - ContentType::Json => serde_json::from_str(&content).map_err(Error::from), - ContentType::Text => Err(Error::from(serde_json::Error::custom( - "Received `text/plain` content type response that cannot be converted to `models::HealthResponse`", - ))), - ContentType::Unsupported(unknown_type) => Err(Error::from(serde_json::Error::custom(format!( - "Received `{unknown_type}` content type response that cannot be converted to `models::HealthResponse`" - )))), - } - } else { - let content = resp.text().await?; - let entity: Option = serde_json::from_str(&content).ok(); - Err(Error::ResponseError(ResponseContent { status, content, entity })) - } -} diff --git a/sysclient/sysinspect-client/src/lib.rs b/sysclient/sysinspect-client/src/lib.rs deleted file mode 100644 index 9556a0a1..00000000 --- a/sysclient/sysinspect-client/src/lib.rs +++ /dev/null @@ -1,11 +0,0 @@ -#![allow(unused_imports)] -#![allow(clippy::too_many_arguments)] - -extern crate reqwest; -extern crate serde; -extern crate serde_json; -extern crate serde_repr; -extern crate url; - -pub mod apis; -pub mod models; diff --git a/sysclient/sysinspect-client/src/models/auth_inner_request.rs b/sysclient/sysinspect-client/src/models/auth_inner_request.rs deleted file mode 100644 index 17a00440..00000000 --- a/sysclient/sysinspect-client/src/models/auth_inner_request.rs +++ /dev/null @@ -1,36 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use crate::models; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct AuthInnerRequest { - #[serde( - rename = "password", - default, - with = "::serde_with::rust::double_option", - skip_serializing_if = "Option::is_none" - )] - pub password: Option>, - #[serde( - rename = "username", - default, - with = "::serde_with::rust::double_option", - skip_serializing_if = "Option::is_none" - )] - pub username: Option>, -} - -impl AuthInnerRequest { - pub fn new() -> AuthInnerRequest { - AuthInnerRequest { password: None, username: None } - } -} diff --git a/sysclient/sysinspect-client/src/models/auth_request.rs b/sysclient/sysinspect-client/src/models/auth_request.rs deleted file mode 100644 index 82a22a1c..00000000 --- a/sysclient/sysinspect-client/src/models/auth_request.rs +++ /dev/null @@ -1,27 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use crate::models; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct AuthRequest { - /// Base64-encoded, RSA-encrypted JSON: {\"username\": \"...\", \"password\": \"...\", \"pubkey\": \"...\"} - #[serde(rename = "payload")] - pub payload: String, - #[serde(rename = "pubkey")] - pub pubkey: String, -} - -impl AuthRequest { - pub fn new(payload: String, pubkey: String) -> AuthRequest { - AuthRequest { payload, pubkey } - } -} diff --git a/sysclient/sysinspect-client/src/models/auth_response.rs b/sysclient/sysinspect-client/src/models/auth_response.rs deleted file mode 100644 index 7669e778..00000000 --- a/sysclient/sysinspect-client/src/models/auth_response.rs +++ /dev/null @@ -1,30 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use crate::models; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct AuthResponse { - #[serde(rename = "error")] - pub error: String, - #[serde(rename = "sid_cipher")] - pub sid_cipher: String, - #[serde(rename = "status")] - pub status: String, - #[serde(rename = "symkey_cipher")] - pub symkey_cipher: String, -} - -impl AuthResponse { - pub fn new(error: String, sid_cipher: String, status: String, symkey_cipher: String) -> AuthResponse { - AuthResponse { error, sid_cipher, status, symkey_cipher } - } -} diff --git a/sysclient/sysinspect-client/src/models/health_info.rs b/sysclient/sysinspect-client/src/models/health_info.rs deleted file mode 100644 index bbb6e3c9..00000000 --- a/sysclient/sysinspect-client/src/models/health_info.rs +++ /dev/null @@ -1,28 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use crate::models; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct HealthInfo { - #[serde(rename = "api_version")] - pub api_version: String, - #[serde(rename = "scheduler_tasks")] - pub scheduler_tasks: i32, - #[serde(rename = "telemetry_enabled")] - pub telemetry_enabled: bool, -} - -impl HealthInfo { - pub fn new(api_version: String, scheduler_tasks: i32, telemetry_enabled: bool) -> HealthInfo { - HealthInfo { api_version, scheduler_tasks, telemetry_enabled } - } -} diff --git a/sysclient/sysinspect-client/src/models/health_response.rs b/sysclient/sysinspect-client/src/models/health_response.rs deleted file mode 100644 index 8cf8dd93..00000000 --- a/sysclient/sysinspect-client/src/models/health_response.rs +++ /dev/null @@ -1,26 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use crate::models; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct HealthResponse { - #[serde(rename = "info")] - pub info: Box, - #[serde(rename = "status")] - pub status: String, -} - -impl HealthResponse { - pub fn new(info: models::HealthInfo, status: String) -> HealthResponse { - HealthResponse { info: Box::new(info), status } - } -} diff --git a/sysclient/sysinspect-client/src/models/master_key_error.rs b/sysclient/sysinspect-client/src/models/master_key_error.rs deleted file mode 100644 index 574b4418..00000000 --- a/sysclient/sysinspect-client/src/models/master_key_error.rs +++ /dev/null @@ -1,24 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use crate::models; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct MasterKeyError { - #[serde(rename = "error")] - pub error: String, -} - -impl MasterKeyError { - pub fn new(error: String) -> MasterKeyError { - MasterKeyError { error } - } -} diff --git a/sysclient/sysinspect-client/src/models/master_key_response.rs b/sysclient/sysinspect-client/src/models/master_key_response.rs deleted file mode 100644 index 387b0e26..00000000 --- a/sysclient/sysinspect-client/src/models/master_key_response.rs +++ /dev/null @@ -1,24 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use crate::models; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct MasterKeyResponse { - #[serde(rename = "key")] - pub key: String, -} - -impl MasterKeyResponse { - pub fn new(key: String) -> MasterKeyResponse { - MasterKeyResponse { key } - } -} diff --git a/sysclient/sysinspect-client/src/models/mod.rs b/sysclient/sysinspect-client/src/models/mod.rs deleted file mode 100644 index e0ebff34..00000000 --- a/sysclient/sysinspect-client/src/models/mod.rs +++ /dev/null @@ -1,34 +0,0 @@ -pub mod auth_inner_request; -pub use self::auth_inner_request::AuthInnerRequest; -pub mod auth_request; -pub use self::auth_request::AuthRequest; -pub mod auth_response; -pub use self::auth_response::AuthResponse; -pub mod health_info; -pub use self::health_info::HealthInfo; -pub mod health_response; -pub use self::health_response::HealthResponse; -pub mod master_key_error; -pub use self::master_key_error::MasterKeyError; -pub mod master_key_response; -pub use self::master_key_response::MasterKeyResponse; -pub mod model_info; -pub use self::model_info::ModelInfo; -pub mod model_name_response; -pub use self::model_name_response::ModelNameResponse; -pub mod model_response; -pub use self::model_response::ModelResponse; -pub mod pub_key_error; -pub use self::pub_key_error::PubKeyError; -pub mod pub_key_request; -pub use self::pub_key_request::PubKeyRequest; -pub mod pub_key_response; -pub use self::pub_key_response::PubKeyResponse; -pub mod query_error; -pub use self::query_error::QueryError; -pub mod query_payload_request; -pub use self::query_payload_request::QueryPayloadRequest; -pub mod query_request; -pub use self::query_request::QueryRequest; -pub mod query_response; -pub use self::query_response::QueryResponse; diff --git a/sysclient/sysinspect-client/src/models/model_info.rs b/sysclient/sysinspect-client/src/models/model_info.rs deleted file mode 100644 index 27ca4bb1..00000000 --- a/sysclient/sysinspect-client/src/models/model_info.rs +++ /dev/null @@ -1,43 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use crate::models; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct ModelInfo { - /// A brief description of the model - #[serde(rename = "description")] - pub description: String, - /// Entity to a vector of bound actions - #[serde(rename = "entity-states")] - pub entity_states: std::collections::HashMap>>, - /// The unique identifier of the model (Id) - #[serde(rename = "id")] - pub id: String, - /// The author of the model - #[serde(rename = "maintainer")] - pub maintainer: String, - /// The name of the model - #[serde(rename = "name")] - pub name: String, - /// The version of the model - #[serde(rename = "version")] - pub version: String, -} - -impl ModelInfo { - pub fn new( - description: String, entity_states: std::collections::HashMap>>, id: String, - maintainer: String, name: String, version: String, - ) -> ModelInfo { - ModelInfo { description, entity_states, id, maintainer, name, version } - } -} diff --git a/sysclient/sysinspect-client/src/models/model_name_response.rs b/sysclient/sysinspect-client/src/models/model_name_response.rs deleted file mode 100644 index 658265b7..00000000 --- a/sysclient/sysinspect-client/src/models/model_name_response.rs +++ /dev/null @@ -1,24 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use crate::models; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct ModelNameResponse { - #[serde(rename = "models")] - pub models: Vec, -} - -impl ModelNameResponse { - pub fn new(models: Vec) -> ModelNameResponse { - ModelNameResponse { models } - } -} diff --git a/sysclient/sysinspect-client/src/models/model_response.rs b/sysclient/sysinspect-client/src/models/model_response.rs deleted file mode 100644 index 927ee1fb..00000000 --- a/sysclient/sysinspect-client/src/models/model_response.rs +++ /dev/null @@ -1,24 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use crate::models; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct ModelResponse { - #[serde(rename = "model")] - pub model: Box, -} - -impl ModelResponse { - pub fn new(model: models::ModelInfo) -> ModelResponse { - ModelResponse { model: Box::new(model) } - } -} diff --git a/sysclient/sysinspect-client/src/models/pub_key_error.rs b/sysclient/sysinspect-client/src/models/pub_key_error.rs deleted file mode 100644 index b2501bfe..00000000 --- a/sysclient/sysinspect-client/src/models/pub_key_error.rs +++ /dev/null @@ -1,24 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use crate::models; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct PubKeyError { - #[serde(rename = "error")] - pub error: String, -} - -impl PubKeyError { - pub fn new(error: String) -> PubKeyError { - PubKeyError { error } - } -} diff --git a/sysclient/sysinspect-client/src/models/pub_key_request.rs b/sysclient/sysinspect-client/src/models/pub_key_request.rs deleted file mode 100644 index d7ab6a9c..00000000 --- a/sysclient/sysinspect-client/src/models/pub_key_request.rs +++ /dev/null @@ -1,28 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use crate::models; -use serde::{Deserialize, Serialize}; - -/// PubKeyRequest : Push to push a user public key to store on the server. -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct PubKeyRequest { - #[serde(rename = "key")] - pub key: String, - #[serde(rename = "sid_cipher")] - pub sid_cipher: String, -} - -impl PubKeyRequest { - /// Push to push a user public key to store on the server. - pub fn new(key: String, sid_cipher: String) -> PubKeyRequest { - PubKeyRequest { key, sid_cipher } - } -} diff --git a/sysclient/sysinspect-client/src/models/pub_key_response.rs b/sysclient/sysinspect-client/src/models/pub_key_response.rs deleted file mode 100644 index 29cb3f27..00000000 --- a/sysclient/sysinspect-client/src/models/pub_key_response.rs +++ /dev/null @@ -1,24 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use crate::models; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct PubKeyResponse { - #[serde(rename = "message")] - pub message: String, -} - -impl PubKeyResponse { - pub fn new(message: String) -> PubKeyResponse { - PubKeyResponse { message } - } -} diff --git a/sysclient/sysinspect-client/src/models/query_error.rs b/sysclient/sysinspect-client/src/models/query_error.rs deleted file mode 100644 index 00878257..00000000 --- a/sysclient/sysinspect-client/src/models/query_error.rs +++ /dev/null @@ -1,26 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use crate::models; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct QueryError { - #[serde(rename = "error")] - pub error: String, - #[serde(rename = "status")] - pub status: String, -} - -impl QueryError { - pub fn new(error: String, status: String) -> QueryError { - QueryError { error, status } - } -} diff --git a/sysclient/sysinspect-client/src/models/query_payload_request.rs b/sysclient/sysinspect-client/src/models/query_payload_request.rs deleted file mode 100644 index 4f6dc5c2..00000000 --- a/sysclient/sysinspect-client/src/models/query_payload_request.rs +++ /dev/null @@ -1,34 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use crate::models; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct QueryPayloadRequest { - #[serde(rename = "context")] - pub context: std::collections::HashMap, - #[serde(rename = "mid")] - pub mid: String, - #[serde(rename = "model")] - pub model: String, - #[serde(rename = "query")] - pub query: String, - #[serde(rename = "traits")] - pub traits: String, -} - -impl QueryPayloadRequest { - pub fn new( - context: std::collections::HashMap, mid: String, model: String, query: String, traits: String, - ) -> QueryPayloadRequest { - QueryPayloadRequest { context, mid, model, query, traits } - } -} diff --git a/sysclient/sysinspect-client/src/models/query_request.rs b/sysclient/sysinspect-client/src/models/query_request.rs deleted file mode 100644 index 74c2d500..00000000 --- a/sysclient/sysinspect-client/src/models/query_request.rs +++ /dev/null @@ -1,28 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use crate::models; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct QueryRequest { - #[serde(rename = "nonce")] - pub nonce: String, - #[serde(rename = "payload")] - pub payload: String, - #[serde(rename = "sid_rsa")] - pub sid_rsa: String, -} - -impl QueryRequest { - pub fn new(nonce: String, payload: String, sid_rsa: String) -> QueryRequest { - QueryRequest { nonce, payload, sid_rsa } - } -} diff --git a/sysclient/sysinspect-client/src/models/query_response.rs b/sysclient/sysinspect-client/src/models/query_response.rs deleted file mode 100644 index e590ae1c..00000000 --- a/sysclient/sysinspect-client/src/models/query_response.rs +++ /dev/null @@ -1,26 +0,0 @@ -/* - * SysInspect API - * - * SysInspect Web API for interacting with the master interface. - * - * The version of the OpenAPI document: 0.1.1 - * - * Generated by: https://openapi-generator.tech - */ - -use crate::models; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] -pub struct QueryResponse { - #[serde(rename = "message")] - pub message: String, - #[serde(rename = "status")] - pub status: String, -} - -impl QueryResponse { - pub fn new(message: String, status: String) -> QueryResponse { - QueryResponse { message, status } - } -} diff --git a/sysmaster/Cargo.toml b/sysmaster/Cargo.toml index 9fbd2d84..abce05b6 100644 --- a/sysmaster/Cargo.toml +++ b/sysmaster/Cargo.toml @@ -47,3 +47,6 @@ indexmap = { version = "2.13.0", features = ["serde"] } chrono = { version = "0.4.43", features = ["serde"] } async-trait = "0.1.89" globset = "0.4.18" + +[dev-dependencies] +tempfile = "3.25.0" diff --git a/sysmaster/src/console.rs b/sysmaster/src/console.rs new file mode 100644 index 00000000..0af3635a --- /dev/null +++ b/sysmaster/src/console.rs @@ -0,0 +1,747 @@ +//! Console-specific request handling for `SysMaster`. +//! +//! This module owns the encrypted local console listener, request decoding, +//! console-command dispatch, and the typed response builders used by the CLI. +//! It deliberately stops at typed payloads and outbound `MasterMessage` +//! construction; any human-facing formatting remains on the client side. + +use super::*; + +use libsysinspect::{ + console::{ + ConsoleEnvelope, ConsoleMinionInfoRow, ConsoleOnlineMinionRow, ConsolePayload, ConsoleQuery, ConsoleResponse, ConsoleSealed, + ConsoleTransportStatusRow, authorised_console_client, load_master_private_key, + }, + context::get_context, + traits::TraitSource, +}; +use tokio::net::{TcpStream, tcp::OwnedReadHalf}; +use tokio::time; + +/// Maximum single-line console request size accepted from the local TCP console. +/// +/// Requests are newline-delimited JSON frames, so the limit is applied before +/// parsing and before any cryptographic work is attempted. +const MAX_CONSOLE_FRAME_SIZE: usize = 64 * 1024; + +/// Upper bound for reading one console request frame from a connected client. +/// +/// This prevents a local client from holding a console socket open forever +/// without completing a request. +const CONSOLE_READ_TIMEOUT: StdDuration = StdDuration::from_secs(5); + +/// Result returned by console helpers that both answer the caller and stage +/// follow-up cluster messages that still need to be broadcast. +type ConsoleOutcome = (ConsoleResponse, Vec); + +/// Parsed selector flags for `cluster/transport/status` console requests. +/// +/// The filter is optional because older or minimal clients may omit it, in +/// which case the request defaults to returning every selected minion. +#[derive(Debug, Clone, Deserialize)] +struct TransportStatusConsoleRequest { + filter: Option, +} + +impl TransportStatusConsoleRequest { + /// Parse the JSON request context for a transport-status console command. + /// + /// An empty context is treated as `all` to keep the server-side behavior + /// predictable for callers that do not send explicit filters. + fn from_context(context: &str) -> Result { + if context.trim().is_empty() { + return Ok(Self { filter: Some("all".to_string()) }); + } + + serde_json::from_str(context) + .map_err(|err| SysinspectError::DeserializationError(format!("Failed to parse transport status request context: {err}"))) + } + + /// Decide whether one transport-status row should be included in the reply. + /// + /// The filter is interpreted against the current rotation state already + /// loaded for the minion. Missing state is only included when the filter is + /// `all`. + fn include_row(&self, rotation: Option<&libsysinspect::transport::TransportRotationStatus>) -> bool { + match self.filter.as_deref().unwrap_or("all") { + "pending" => rotation.is_some_and(|status| *status != libsysinspect::transport::TransportRotationStatus::Idle), + "idle" => rotation.is_some_and(|status| *status == libsysinspect::transport::TransportRotationStatus::Idle), + _ => true, + } + } +} + +/// Count of immediate versus deferred rotation requests produced by one console +/// rotate operation. +#[derive(Debug, Default)] +struct RotationDispatchSummary { + online_count: usize, + queued_count: usize, +} + +impl RotationDispatchSummary { + /// Record that one selected minion received an immediate rotation message. + fn note_online_dispatch(&mut self) { + self.online_count += 1; + } + + /// Record that one selected minion was offline and had rotation staged for later replay. + fn note_queued_dispatch(&mut self) { + self.queued_count += 1; + } + + /// Convert the accumulated counters into the typed console payload expected by the CLI. + fn response(&self) -> ConsoleResponse { + ConsoleResponse::ok(ConsolePayload::RotationSummary { online_count: self.online_count, queued_count: self.queued_count }) + } +} + +impl SysMaster { + /// Serialize a console response into a plain JSON line for direct socket writes. + /// + /// This helper is used for pre-encryption validation errors and other cases + /// where the master must still answer the local client without building a + /// sealed response frame. + fn console_response_json(response: &ConsoleResponse) -> Option { + serde_json::to_string(response).ok() + } + + /// Build a JSON-encoded error reply for local console failures. + /// + /// Returning `Option` keeps the helper symmetric with + /// `console_response_json` and lets callers propagate a fully formed line or + /// drop the response if serialization unexpectedly fails. + fn console_error_json(error: impl Into) -> Option { + Self::console_response_json(&ConsoleResponse::err(error)) + } + + /// Build raw online-minion rows for the console `network --online` query. + /// + /// The returned rows only contain typed data assembled from registry traits + /// and the current session liveness table. No presentation formatting is + /// applied here. + async fn online_minions_data(&mut self, query: &str, traits: &str, mid: &str) -> Result, SysinspectError> { + let targets = self.selected_minions(query, traits, mid).await?; + let mut session = self.session.lock().await; + let mut rows = Vec::with_capacity(targets.len()); + + for minion in targets { + let minion_id = minion.id().to_string(); + let alive = session.alive(&minion_id); + let (fqdn, hostname) = Self::preferred_host(&minion); + rows.push(ConsoleOnlineMinionRow { + fqdn, + hostname, + ip: minion.get_traits().get("system.hostname.ip").and_then(|v| v.as_str()).unwrap_or_default().to_string(), + minion_id, + alive, + }); + } + + Ok(rows) + } + + /// Build the full trait-backed info payload for exactly one selected minion. + /// + /// The function enforces the single-target requirement server-side so the + /// console protocol never returns an ambiguous multi-minion info payload. + /// The response includes synthetic `minion.id` and `minion.online` rows in + /// addition to the stored traits. + async fn minion_info_rows(&mut self, query: &str, traits: &str, mid: &str) -> Result, SysinspectError> { + let targets = self.selected_minions(query, traits, mid).await?; + if targets.is_empty() { + return Err(SysinspectError::InvalidQuery("Minion info requires one matching minion, but none were found".to_string())); + } + if targets.len() > 1 { + return Err(SysinspectError::InvalidQuery(format!( + "Minion info requires exactly one matching minion, but {} were selected", + targets.len() + ))); + } + let mut session = self.session.lock().await; + let minion = targets.into_iter().next().expect("validated exactly one selected minion"); + let minion_id = minion.id().to_string(); + let mut rows = vec![ + ConsoleMinionInfoRow { key: "minion.id".to_string(), value: serde_json::Value::String(minion_id.clone()), source: TraitSource::Preset }, + ConsoleMinionInfoRow { + key: "minion.online".to_string(), + value: serde_json::Value::Bool(session.alive(&minion_id)), + source: TraitSource::Preset, + }, + ]; + + rows.extend(minion.get_traits().iter().map(|(name, value)| ConsoleMinionInfoRow { + key: name.clone(), + value: value.clone(), + source: if minion.is_function_trait(name) { + TraitSource::Function + } else if minion.is_yaml_trait(name) { + TraitSource::Static + } else { + TraitSource::Preset + }, + })); + + Ok(rows) + } + + /// Remove a minion from registry and key storage and prepare the matching console reply. + /// + /// When a command message can still be constructed for the target minion it + /// is returned alongside the response so the caller can broadcast the final + /// remove command over the cluster transport. + async fn unregister_console_response(&mut self, mid: &str) -> Result { + if mid.trim().is_empty() { + return Ok((ConsoleResponse::err("Unregister requires a minion id"), vec![])); + } + + let msg = self.msg_query_data(&format!("{SCHEME_COMMAND}{CLUSTER_REMOVE_MINION}"), "", "", mid, "").await; + + log::info!("Removing minion {}", mid); + if let Err(err) = self.mreg.lock().await.remove(mid) { + return Err(SysinspectError::MasterGeneralError(format!("Unable to remove minion {mid}: {err}"))); + } + if let Err(err) = self.mkr().remove_mn_key(mid) { + return Err(SysinspectError::MasterGeneralError(format!("Unable to unregister minion {mid}: {err}"))); + } + + Ok(( + ConsoleResponse::ok(ConsolePayload::Ack { action: "remove_minion".to_string(), target: mid.to_string(), count: 1, items: vec![] }), + msg.into_iter().collect(), + )) + } + + /// Register the concrete minion ids targeted by one outbound console message. + /// + /// This keeps task tracking aligned with console-initiated broadcasts so the + /// rest of the master can observe completion state the same way it does for + /// normal queued work. + async fn register_broadcast_targets(master: Arc>, msg: &MasterMessage) { + let guard = master.lock().await; + let ids = guard.mreg.lock().await.get_targeted_minions(msg.target(), false).await; + guard.taskreg.lock().await.register(msg.cycle(), ids); + } + + /// Broadcast the `MasterMessage`s produced by one console request. + /// + /// Some console commands, such as unregister, should not populate task + /// tracking, so the caller controls whether target registration is performed + /// for the dispatched messages. + async fn broadcast_console_messages( + master: Arc>, bcast: &broadcast::Sender, cfg: &MasterConfig, msgs: Vec, register_targets: bool, + ) { + for msg in msgs { + Self::bcast_master_msg(bcast, cfg.telemetry_enabled(), Arc::clone(&master), Some(msg.clone())).await; + if register_targets { + Self::register_broadcast_targets(Arc::clone(&master), &msg).await; + } + } + } + + /// Execute one decrypted console query and convert it into a typed console response. + /// + /// This is the central router for console-only commands. It handles typed + /// data requests directly and delegates cluster-affecting operations to the + /// existing `SysMaster` helpers that already know how to stage, persist, and + /// build outbound master messages. + async fn dispatch_console_query( + master: Arc>, bcast: &broadcast::Sender, cfg: &MasterConfig, query: ConsoleQuery, + ) -> ConsoleResponse { + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_ONLINE_MINIONS}")) { + return match master.lock().await.online_minions_data(&query.query, &query.traits, &query.mid).await { + Ok(rows) => ConsoleResponse::ok(ConsolePayload::OnlineMinions { rows }), + Err(err) => ConsoleResponse::err(format!("Unable to get online minions: {err}")), + }; + } + + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_MINION_INFO}")) { + return match master.lock().await.minion_info_rows(&query.query, &query.traits, &query.mid).await { + Ok(rows) => ConsoleResponse::ok(ConsolePayload::MinionInfo { rows }), + Err(err) => ConsoleResponse::err(format!("Unable to get minion info: {err}")), + }; + } + + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_TRANSPORT_STATUS}")) { + return match TransportStatusConsoleRequest::from_context(&query.context) { + Ok(request) => match master.lock().await.transport_status_data(&request, &query.query, &query.traits, &query.mid).await { + Ok(rows) => ConsoleResponse::ok(ConsolePayload::TransportStatus { rows }), + Err(err) => ConsoleResponse::err(format!("Unable to get transport status: {err}")), + }, + Err(err) => ConsoleResponse::err(format!("Failed to parse transport status request: {err}")), + }; + } + + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_ROTATE}")) { + let (response, msgs) = match RotationConsoleRequest::from_context(&query.context) { + Ok(request) => { + let mut guard = master.lock().await; + match guard.rotate_console_response(&request, &query.query, &query.traits, &query.mid).await { + Ok(data) => data, + Err(err) => (ConsoleResponse::err(err.to_string()), vec![]), + } + } + Err(err) => (ConsoleResponse::err(format!("Failed to parse rotate request: {err}")), vec![]), + }; + Self::broadcast_console_messages(Arc::clone(&master), bcast, cfg, msgs, true).await; + return response; + } + + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_REMOVE_MINION}")) { + let (response, msgs) = { + let mut guard = master.lock().await; + match guard.unregister_console_response(&query.mid).await { + Ok(data) => data, + Err(err) => (ConsoleResponse::err(err.to_string()), vec![]), + } + }; + Self::broadcast_console_messages(Arc::clone(&master), bcast, cfg, msgs, false).await; + return response; + } + + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_PROFILE}")) { + let (response, msgs) = match ProfileConsoleRequest::from_context(&query.context) { + Ok(request) => { + let mut guard = master.lock().await; + match guard.do_profile_console(&request, &query.query, &query.traits, &query.mid).await { + Ok(data) => data, + Err(err) => (ConsoleResponse::err(err.to_string()), vec![]), + } + } + Err(err) => (ConsoleResponse::err(format!("Failed to parse profile request: {err}")), vec![]), + }; + Self::broadcast_console_messages(Arc::clone(&master), bcast, cfg, msgs, true).await; + return response; + } + + let msg = { + let mut guard = master.lock().await; + guard.msg_query_data(&query.model, &query.query, &query.traits, &query.mid, &query.context).await + }; + if let Some(msg) = msg { + Self::broadcast_console_messages(Arc::clone(&master), bcast, cfg, vec![msg], true).await; + return ConsoleResponse { + ok: true, + error: String::new(), + payload: ConsolePayload::Ack { action: "accepted_console_command".to_string(), target: query.model, count: 0, items: vec![] }, + }; + } + + ConsoleResponse::err("No message constructed for the console query") + } + + /// Read exactly one newline-terminated request frame from a console socket. + /// + /// The function applies both a time limit and a size limit before any JSON + /// parsing occurs, and converts transport-level failures into plain JSON + /// error replies that can be sent back on the same connection. + async fn read_console_request(read_half: OwnedReadHalf) -> Option { + let reader = TokioBufReader::new(read_half); + let mut frame = Vec::new(); + let mut reader = reader.take((MAX_CONSOLE_FRAME_SIZE + 1) as u64); + match time::timeout(CONSOLE_READ_TIMEOUT, reader.read_until(b'\n', &mut frame)).await { + Err(_) => Self::console_error_json(format!("Console request timed out after {} seconds", CONSOLE_READ_TIMEOUT.as_secs())), + Ok(Ok(0)) => Self::console_error_json("Empty console request"), + Ok(Ok(_)) if frame.len() > MAX_CONSOLE_FRAME_SIZE || !frame.ends_with(b"\n") => { + Self::console_error_json(format!("Console request exceeds {} bytes", MAX_CONSOLE_FRAME_SIZE)) + } + Ok(Ok(_)) => String::from_utf8(frame) + .map(|line| line.trim().to_string()) + .map_err(|err| format!("Console request is not valid UTF-8: {err}")) + .map_or_else(Self::console_error_json, Some), + Ok(Err(err)) => Self::console_error_json(format!("Failed to read console request: {err}")), + } + } + + /// Validate, decrypt, dispatch, and reseal one console request envelope. + /// + /// The caller supplies the already-loaded master private key and broadcast + /// handle so this function can stay focused on the request lifecycle: + /// deserialize envelope, verify authorisation, derive the session key, + /// decrypt the query, dispatch it, and seal the response. + async fn process_console_envelope( + master: Arc>, cfg: &MasterConfig, bcast: &broadcast::Sender, master_prk: &rsa::RsaPrivateKey, line: &str, + ) -> Option { + let envelope = match serde_json::from_str::(line) { + Ok(envelope) => envelope, + Err(err) => return Self::console_error_json(format!("Failed to parse console request: {err}")), + }; + + if !authorised_console_client(cfg, &envelope.bootstrap.client_pubkey).unwrap_or(false) { + return Self::console_error_json("Console client key is not authorised"); + } + + let (key, _client_pkey) = match envelope.bootstrap.session_key(master_prk) { + Ok(data) => data, + Err(err) => return Self::console_error_json(format!("Console bootstrap failed: {err}")), + }; + + let query = match envelope.sealed.open::(&key) { + Ok(query) => query, + Err(err) => return Self::console_error_json(format!("Failed to open console query: {err}")), + }; + + let response = Self::dispatch_console_query(master, bcast, cfg, query).await; + match ConsoleSealed::seal(&response, &key) + .and_then(|sealed| serde_json::to_string(&sealed).map_err(|e| SysinspectError::SerializationError(e.to_string()))) + { + Ok(reply) => Some(reply), + Err(err) => { + log::error!("Failed to seal console response: {err}"); + Self::console_error_json(format!("Failed to seal console response: {err}")) + } + } + } + + /// Serve one accepted TCP console connection from initial read to final reply write. + /// + /// Non-JSON input is treated as a prebuilt plain response payload. JSON + /// input is handled as an encrypted console envelope and routed through the + /// authenticated console flow. + async fn handle_console_stream( + master: Arc>, cfg: MasterConfig, bcast: broadcast::Sender, master_prk: rsa::RsaPrivateKey, stream: TcpStream, + ) { + let (read_half, mut write_half) = stream.into_split(); + let reply = match Self::read_console_request(read_half).await { + Some(line) if !line.trim_start().starts_with('{') => Some(line), + Some(line) => Self::process_console_envelope(master, &cfg, &bcast, &master_prk, &line).await, + None => None, + }; + + if let Some(reply) = reply + && let Err(err) = write_half.write_all(format!("{reply}\n").as_bytes()).await + { + log::error!("Failed to write console response: {err}"); + } + } + + /// Resolve the minions targeted by a console command. + /// + /// Selection priority is explicit minion id, then hostname or IP fallback, + /// then general query lookup. If no explicit id is given, trait selectors + /// take precedence over the free-form query string. The resulting records are + /// always sorted by minion id to keep console output stable. + async fn selected_minions(&mut self, query: &str, traits: &str, mid: &str) -> Result, SysinspectError> { + let mut records = if !mid.is_empty() { + let mut registry = self.mreg.lock().await; + let mut records = registry.get(mid)?.into_iter().collect::>(); + if records.is_empty() { + records = registry.get_by_hostname_or_ip(mid)?; + } + if records.is_empty() { + records = registry.get_by_query(mid)?; + } + records + } else if !traits.trim().is_empty() { + let traits = get_context(traits) + .ok_or_else(|| SysinspectError::InvalidQuery("Traits selector must be in key:value format".to_string()))? + .into_iter() + .collect::>(); + self.mreg.lock().await.get_by_traits(traits)? + } else { + self.mreg.lock().await.get_by_query(if query.trim().is_empty() { "*" } else { query })? + }; + records.sort_by(|a, b| a.id().cmp(b.id())); + Ok(records) + } + + /// Require a profile name for profile operations that operate on a named profile object. + fn require_profile_name(request: &ProfileConsoleRequest) -> Result<(), SysinspectError> { + if !request.name().trim().is_empty() { + return Ok(()); + } + + Err(SysinspectError::InvalidQuery("Profile name cannot be empty".to_string())) + } + + /// Extract the currently assigned non-default profiles from one minion record. + /// + /// Profile metadata may be stored as either a scalar string or an array of + /// strings in traits. The default profile is intentionally filtered out so + /// console tag operations only manage explicit operator assignments. + fn current_profiles(minion: &crate::registry::rec::MinionRecord) -> Vec { + let mut profiles = match minion.get_traits().get("minion.profile") { + Some(serde_json::Value::String(name)) if !name.trim().is_empty() => vec![name.to_string()], + Some(serde_json::Value::Array(names)) => names.iter().filter_map(|name| name.as_str().map(str::to_string)).collect::>(), + _ => vec![], + }; + profiles.retain(|profile| profile != "default"); + profiles + } + + /// Apply or remove profile tags on the selected minions and build the reply. + /// + /// Before emitting any update messages, the function validates that all + /// requested profiles exist in the module repository so partial application + /// cannot occur. + async fn profile_tag_console_response( + &mut self, request: &ProfileConsoleRequest, query: &str, traits: &str, mid: &str, + ) -> Result { + let repo = SysInspectModPak::new(self.cfg.get_mod_repo_root())?; + let known_profiles = repo.list_profiles(None)?; + let missing = request.profiles().iter().filter(|name| !known_profiles.contains(name)).cloned().collect::>(); + if !missing.is_empty() { + return Ok(( + ConsoleResponse { + ok: false, + error: format!("Unknown profile{}: {}", if missing.len() == 1 { "" } else { "s" }, missing.join(", ")), + payload: ConsolePayload::Empty, + }, + vec![], + )); + } + + let mut msgs = Vec::new(); + for minion in self.selected_minions(query, traits, mid).await? { + let mut profiles = Self::current_profiles(&minion); + if request.op() == "tag" { + for profile in request.profiles() { + if !profiles.contains(profile) { + profiles.push(profile.to_string()); + } + } + } else { + profiles.retain(|profile| !request.profiles().contains(profile)); + } + + let context = if profiles.is_empty() { + json!({"op": "unset", "traits": {"minion.profile": null}}) + } else { + json!({"op": "set", "traits": {"minion.profile": profiles}}) + } + .to_string(); + + if let Some(msg) = self.msg_query_data(&format!("{SCHEME_COMMAND}{CLUSTER_TRAITS_UPDATE}"), "", "", minion.id(), &context).await { + msgs.push(msg); + } + } + + Ok(( + ConsoleResponse::ok(ConsolePayload::Ack { + action: if request.op() == "tag" { "apply_profiles".to_string() } else { "remove_profiles".to_string() }, + target: String::new(), + count: msgs.len(), + items: request.profiles().to_vec(), + }), + msgs, + )) + } + + /// Execute one profile-related console command. + /// + /// Pure repository operations return only a typed response, while tag and + /// untag operations also return the broadcast messages that will push trait + /// changes to the selected minions. + async fn do_profile_console( + &mut self, request: &ProfileConsoleRequest, query: &str, traits: &str, mid: &str, + ) -> Result { + let repo = SysInspectModPak::new(self.cfg.get_mod_repo_root())?; + + match request.op() { + "new" => Ok(( + { + Self::require_profile_name(request)?; + repo.new_profile(request.name())?; + ConsoleResponse::ok(ConsolePayload::Ack { + action: "create_profile".to_string(), + target: request.name().to_string(), + count: 0, + items: vec![], + }) + }, + vec![], + )), + "delete" => Ok(( + { + Self::require_profile_name(request)?; + repo.delete_profile(request.name())?; + ConsoleResponse::ok(ConsolePayload::Ack { + action: "delete_profile".to_string(), + target: request.name().to_string(), + count: 0, + items: vec![], + }) + }, + vec![], + )), + "list" => Ok(( + ConsoleResponse::ok(ConsolePayload::StringList { + items: if request.name().is_empty() { + repo.list_profiles(None)? + } else { + repo.list_profile_matches(Some(request.name()), request.library())? + }, + }), + vec![], + )), + "show" => Ok(( + { + Self::require_profile_name(request)?; + ConsoleResponse::ok(ConsolePayload::Text { value: repo.show_profile(request.name())? }) + }, + vec![], + )), + "add" => Ok(( + { + Self::require_profile_name(request)?; + repo.add_profile_matches(request.name(), request.matches().to_vec(), request.library())?; + ConsoleResponse::ok(ConsolePayload::Ack { + action: "update_profile".to_string(), + target: request.name().to_string(), + count: 0, + items: vec![], + }) + }, + vec![], + )), + "remove" => Ok(( + { + Self::require_profile_name(request)?; + repo.remove_profile_matches(request.name(), request.matches().to_vec(), request.library())?; + ConsoleResponse::ok(ConsolePayload::Ack { + action: "update_profile".to_string(), + target: request.name().to_string(), + count: 0, + items: vec![], + }) + }, + vec![], + )), + "tag" | "untag" => self.profile_tag_console_response(request, query, traits, mid).await, + _ => Ok((ConsoleResponse::err(format!("Unsupported profile operation {}", request.op())), vec![])), + } + } + + /// Execute a console-driven transport rotation request. + /// + /// Online minions receive immediate messages. Offline minions have the exact + /// serialized request persisted into transport state so it can be replayed on + /// reconnect without the CLI needing to resubmit the operation. + async fn rotate_console_response( + &mut self, request: &RotationConsoleRequest, query: &str, traits: &str, mid: &str, + ) -> Result { + if request.op() != "rotate" { + return Ok((ConsoleResponse::err(format!("Unsupported rotate operation {}", request.op())), vec![])); + } + + let mut online_msgs = Vec::new(); + let mut summary = RotationDispatchSummary::default(); + + let targets = self.selected_minions(query, traits, mid).await?; + for minion in targets { + let minion_id = minion.id().to_string(); + let online = self.session.lock().await.alive(&minion_id); + if online { + if let Some(msg) = self.build_rotation_message(&minion_id, request, None).await? { + online_msgs.push(msg); + summary.note_online_dispatch(); + } + } else { + let context = self.stage_rotation_context(&minion_id, request)?; + self.persist_pending_rotation_context(&minion_id, Some(context))?; + summary.note_queued_dispatch(); + } + } + + Ok((summary.response(), online_msgs)) + } + + /// Build raw transport-status rows for the selected minions. + /// + /// Each row captures host identity plus the currently persisted transport + /// state: active key id, handshake timestamp, derived last rotation time, + /// and the current rotation status. Consumers decide how to render or sort + /// the data. + async fn transport_status_data( + &mut self, request: &TransportStatusConsoleRequest, query: &str, traits: &str, mid: &str, + ) -> Result, SysinspectError> { + let targets = self.selected_minions(query, traits, mid).await?; + let mut rows = Vec::with_capacity(targets.len()); + + for minion in targets { + let minion_id = minion.id().to_string(); + let (fqdn, hostname) = Self::preferred_host(&minion); + let state = TransportStore::for_master_minion(&self.cfg, &minion_id)?.load()?; + if let Some(state) = state { + let last_rotated_at = state.active_key_id.as_ref().and_then(|active_key| { + state.keys.iter().find(|key| key.key_id == *active_key).map(|record| record.activated_at.unwrap_or(record.created_at)) + }); + let row = ConsoleTransportStatusRow { + fqdn, + hostname, + minion_id, + active_key_id: state.active_key_id.clone(), + last_handshake_at: state.last_handshake_at, + last_rotated_at, + rotation: Some(state.rotation), + }; + if request.include_row(row.rotation.as_ref()) { + rows.push(row); + } + } else { + let row = ConsoleTransportStatusRow { + fqdn, + hostname, + minion_id, + active_key_id: None, + last_handshake_at: None, + last_rotated_at: None, + rotation: None, + }; + if request.include_row(row.rotation.as_ref()) { + rows.push(row); + } + } + } + + Ok(rows) + } + + /// Start the local encrypted console listener used by the `sysinspect` CLI. + /// + /// The listener task owns the bound socket and accepts new local TCP + /// connections forever. Each accepted connection is handled in its own task + /// so slow or failing clients do not block later console operations. + pub async fn do_console(master: Arc>) { + log::trace!("Init local console channel"); + tokio::spawn({ + let master = Arc::clone(&master); + async move { + let (cfg, bcast) = { + let guard = master.lock().await; + (guard.cfg(), guard.broadcast().clone()) + }; + let master_prk = match load_master_private_key(&cfg) { + Ok(prk) => prk, + Err(err) => { + log::error!("Failed to load console private key: {err}"); + return; + } + }; + let listener = match TcpListener::bind(cfg.console_listen_addr()).await { + Ok(listener) => listener, + Err(err) => { + log::error!("Failed to bind console listener: {err}"); + return; + } + }; + loop { + match listener.accept().await { + Ok((stream, _peer)) => { + let master = Arc::clone(&master); + let cfg = cfg.clone(); + let bcast = bcast.clone(); + let master_prk = master_prk.clone(); + tokio::spawn(async move { + SysMaster::handle_console_stream(master, cfg, bcast, master_prk, stream).await; + }); + } + Err(err) => { + log::error!("Console listener accept error: {err}"); + sleep(Duration::from_secs(1)).await; + } + } + } + } + }); + } +} diff --git a/sysmaster/src/dataserv/fls.rs b/sysmaster/src/dataserv/fls.rs index 4e336cea..110a4104 100644 --- a/sysmaster/src/dataserv/fls.rs +++ b/sysmaster/src/dataserv/fls.rs @@ -2,7 +2,8 @@ use actix_web::{App, HttpResponse, HttpServer, Responder, rt::System, web}; use colored::Colorize; use libcommon::SysinspectError; use libsysinspect::cfg::mmconf::{ - CFG_FILESERVER_ROOT, CFG_MODELS_ROOT, CFG_MODREPO_ROOT, CFG_PROFILES_ROOT, CFG_SENSORS_ROOT, CFG_TRAIT_FUNCTIONS_ROOT, CFG_TRAITS_ROOT, MasterConfig, + CFG_FILESERVER_ROOT, CFG_MODELS_ROOT, CFG_MODREPO_ROOT, CFG_PROFILES_ROOT, CFG_SENSORS_ROOT, CFG_TRAIT_FUNCTIONS_ROOT, CFG_TRAITS_ROOT, + MasterConfig, }; use std::{fs, path::PathBuf, thread}; @@ -42,17 +43,17 @@ async fn serve_file(path: web::Path, cfg: web::Data) -> i /// Start fileserver pub async fn start(cfg: MasterConfig) -> Result<(), SysinspectError> { - log::info!("Starting file server"); - let cfg_clone = cfg.clone(); init_fs_env(&cfg)?; thread::spawn(move || { let c_cfg = cfg_clone.clone(); + let listen_addr = c_cfg.fileserver_bind_addr(); + log::info!("Starting file server at {}", listen_addr.bright_yellow()); System::new().block_on(async move { let server = HttpServer::new(move || App::new().app_data(web::Data::new(cfg_clone.clone())).service(web::resource("/{path:.*}").to(serve_file))) - .bind(c_cfg.fileserver_bind_addr()); + .bind(listen_addr.as_str()); match server { Ok(server) => { @@ -66,6 +67,5 @@ pub async fn start(cfg: MasterConfig) -> Result<(), SysinspectError> { } }) }); - log::info!("Fileserver started at address {}", cfg.fileserver_bind_addr()); Ok(()) } diff --git a/sysmaster/src/main.rs b/sysmaster/src/main.rs index 16aaf27a..1353a971 100644 --- a/sysmaster/src/main.rs +++ b/sysmaster/src/main.rs @@ -5,8 +5,12 @@ mod master; mod master_itf; mod registry; mod telemetry; +mod transport; mod util; +#[cfg(test)] +mod master_ut; + use clap::{ArgMatches, Command}; use clidef::cli; use daemonize::Daemonize; diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index 2f4cbdf7..630a4ae1 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -1,3 +1,6 @@ +#[path = "console.rs"] +mod console; + use crate::{ cluster::VirtualMinionsCluster, dataserv::fls, @@ -8,6 +11,7 @@ use crate::{ taskreg::TaskRegistry, }, telemetry::{otel::OtelLogger, rds::FunctionReducer}, + transport::{IncomingFrame, OutgoingFrame, PeerTransport}, }; use colored::Colorize; use indexmap::IndexMap; @@ -20,22 +24,30 @@ use libeventreg::{ use libmodpak::SysInspectModPak; use libsysinspect::{ cfg::mmconf::{CFG_MODELS_ROOT, MasterConfig}, - console::{ConsoleEnvelope, ConsoleQuery, ConsoleResponse, ConsoleSealed, authorised_console_client, ensure_console_keypair, load_master_private_key}, - context::{ProfileConsoleRequest, get_context}, + console::ensure_console_keypair, + context::ProfileConsoleRequest, mdescr::{mspec::MODEL_FILE_EXT, mspecdef::ModelSpec, telemetry::DataExportType}, - util::{self, iofs::scan_files_sha256, pad_visible}, + rsa::rotation::{RotationActor, RsaTransportRotator, SignedRotationIntent}, + traits::TraitsTransportPayload, + transport::TransportStore, + util::{self, iofs::scan_files_sha256}, }; use libsysproto::{ - self, MasterMessage, MinionMessage, MinionTarget, ProtoConversion, + self, MasterMessage, MinionMessage, MinionTarget, errcodes::ProtoErrorCode, payload::{ModStatePayload, PingData}, query::{ SCHEME_COMMAND, - commands::{CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_REMOVE_MINION, CLUSTER_TRAITS_UPDATE}, + commands::{ + CLUSTER_MINION_INFO, CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_REMOVE_MINION, CLUSTER_ROTATE, CLUSTER_TRAITS_UPDATE, + CLUSTER_TRANSPORT_STATUS, + }, }, rqtypes::{ProtoKey, ProtoValue, RequestType}, + secure::SECURE_PROTOCOL_VERSION, }; use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; use serde_json::json; use std::time::Duration as StdDuration; use std::{ @@ -44,25 +56,79 @@ use std::{ sync::{Arc, Weak}, vec, }; -use tokio::net::TcpListener; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader as TokioBufReader}; +use tokio::net::{ + TcpListener, + tcp::{OwnedReadHalf, OwnedWriteHalf}, +}; +use tokio::sync::Mutex; use tokio::sync::{broadcast, mpsc}; +use tokio::time; use tokio::time::{Duration, sleep}; -use tokio::sync::Mutex; -use tokio::{ - io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader as TokioBufReader}, - time, -}; // Session singleton pub static SHARED_SESSION: Lazy>> = Lazy::new(|| Arc::new(Mutex::new(SessionKeeper::new(30)))); static MODEL_CACHE: Lazy>>> = Lazy::new(|| Arc::new(Mutex::new(HashMap::new()))); -static MAX_CONSOLE_FRAME_SIZE: usize = 64 * 1024; -const CONSOLE_READ_TIMEOUT: StdDuration = StdDuration::from_secs(5); +const DEFAULT_ROTATION_OVERLAP_SECONDS: u64 = 900; + +#[derive(Debug, Clone, Deserialize)] +struct RotationConsoleRequest { + op: Option, + reason: Option, + grace_seconds: Option, + reconnect: Option, + reregister: Option, +} + +impl RotationConsoleRequest { + fn from_context(context: &str) -> Result { + if context.trim().is_empty() { + return Ok(Self { + op: Some("rotate".to_string()), + reason: Some("manual".to_string()), + grace_seconds: Some(DEFAULT_ROTATION_OVERLAP_SECONDS), + reconnect: Some(true), + reregister: Some(true), + }); + } + serde_json::from_str(context).map_err(|err| SysinspectError::DeserializationError(format!("Failed to parse rotate request context: {err}"))) + } + + fn reason(&self) -> &str { + self.reason.as_deref().unwrap_or("manual") + } + + fn grace_seconds(&self) -> u64 { + self.grace_seconds.unwrap_or(DEFAULT_ROTATION_OVERLAP_SECONDS) + } + + fn reconnect(&self) -> bool { + self.reconnect.unwrap_or(true) + } + + fn reregister(&self) -> bool { + self.reregister.unwrap_or(true) + } + + fn op(&self) -> &str { + self.op.as_deref().unwrap_or("rotate") + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct RotationCommandPayload { + pub(crate) op: String, + pub(crate) reason: String, + pub(crate) grace_seconds: u64, + pub(crate) reconnect: bool, + pub(crate) reregister: bool, + pub(crate) intent: SignedRotationIntent, +} #[derive(Debug)] pub struct SysMaster { cfg: MasterConfig, - broadcast: broadcast::Sender>, + broadcast: broadcast::Sender, mkr: MinionsKeyRegistry, mreg: Arc>, taskreg: Arc>, @@ -72,6 +138,7 @@ pub struct SysMaster { ptr: Option>>, vmcluster: VirtualMinionsCluster, conn_to_mid: HashMap, // Map connection addresses to minion IDs + peer_transport: PeerTransport, datastore: Arc>, } @@ -79,7 +146,7 @@ impl SysMaster { pub fn new(cfg: MasterConfig) -> Result { let _ = crate::util::log_sensors_export(&cfg, true); - let (tx, _) = broadcast::channel::>(100); + let (tx, _) = broadcast::channel::(100); let mkr = MinionsKeyRegistry::new(cfg.minion_keys_root())?; let mreg = Arc::new(Mutex::new(MinionRegistry::new(cfg.minion_registry_root())?)); let taskreg = Arc::new(Mutex::new(TaskRegistry::new())); @@ -105,6 +172,7 @@ impl SysMaster { ptr: None, vmcluster, conn_to_mid: HashMap::new(), + peer_transport: PeerTransport::new(), datastore: Arc::new(Mutex::new(DataStorage::new(ds_cfg, ds_path)?)), }) } @@ -123,9 +191,58 @@ impl SysMaster { None } + /// Build a plaintext bootstrap diagnostic from shared malformed-attempt state kept for framed transport probes. + pub(crate) fn secure_peer_diag_with_state( + failures: &mut HashMap, peer_addr: &str, data: &[u8], + ) -> Option> { + PeerTransport::bootstrap_diag_with_state(failures, peer_addr, data) + } + + /// Return a plaintext diagnostic when a minion sends normal protocol traffic before bootstrap. + pub(crate) fn plaintext_peer_diag(data: &[u8]) -> Option> { + PeerTransport::plaintext_diag(data) + } + + /// Return whether this connection may receive plaintext broadcast traffic before a secure session exists. + pub(crate) fn peer_can_receive_broadcast_state(has_secure_session: bool, plaintext_allowed: bool) -> bool { + PeerTransport::can_receive_broadcast_state(has_secure_session, plaintext_allowed) + } + + #[cfg(test)] + pub(crate) fn replay_cache_key_for_test(binding: &libsysproto::secure::SecureSessionBinding) -> String { + PeerTransport::replay_cache_key(binding) + } + + #[cfg(test)] + pub(crate) fn peer_rate_limit_key_for_test(peer_addr: &str) -> String { + PeerTransport::rate_limit_key(peer_addr) + } + + #[cfg(test)] + pub(crate) fn bootstrap_precheck_with_state_for_test( + cache: &mut HashMap, binding: &libsysproto::secure::SecureSessionBinding, now: std::time::Instant, + ) -> Option { + PeerTransport::bootstrap_precheck(cache, binding, now) + } + + #[cfg(test)] + pub(crate) fn record_bootstrap_replay_with_state_for_test( + cache: &mut HashMap, binding: &libsysproto::secure::SecureSessionBinding, now: std::time::Instant, + ) { + PeerTransport::record_bootstrap_replay(cache, binding, now) + } + + #[cfg(test)] + pub(crate) fn accept_bootstrap_auth_then_replay_for_test( + cache: &mut HashMap, state: &libsysinspect::transport::TransportPeerState, + hello: &libsysproto::secure::SecureBootstrapHello, master_prk: &rsa::RsaPrivateKey, minion_pbk: &rsa::RsaPublicKey, now: std::time::Instant, + ) -> Result { + PeerTransport::accept_bootstrap_auth_then_replay_for_test(cache, state, hello, master_prk, minion_pbk, now) + } + /// Start sysmaster pub async fn init(&mut self) -> Result<(), SysinspectError> { - log::info!("Starting master at {}", self.cfg.bind_addr()); + log::info!("Starting master at {}", self.cfg.bind_addr().bright_yellow()); ensure_console_keypair(&self.cfg.root_dir())?; std::fs::create_dir_all(self.cfg.console_keys_root()).map_err(SysinspectError::IoErr)?; self.vmcluster.init().await?; @@ -141,7 +258,7 @@ impl SysMaster { } /// Get broadcast sender for master messages - pub fn broadcast(&self) -> broadcast::Sender> { + pub fn broadcast(&self) -> broadcast::Sender { self.broadcast.clone() } @@ -164,6 +281,14 @@ impl SysMaster { &mut self.mkr } + /// Decode one raw peer frame through the transport manager using the current master configuration and key registry. + fn decode_peer_frame(&mut self, peer_addr: &str, raw: &[u8]) -> Result { + let cfg = self.cfg.clone(); + let peer_transport = &mut self.peer_transport; + let mkr = &mut self.mkr; + peer_transport.decode_frame(peer_addr, raw, &cfg, mkr) + } + /// XXX: That needs to be out to the telemetry::otel::OtelLogger instead! async fn on_log_previous_query(&mut self, msg: &MasterMessage) { let scheme = msg.target().scheme(); @@ -400,6 +525,260 @@ impl SysMaster { Arc::clone(&self.taskreg) } + /// Clear session and transport state for one disconnected peer address. + async fn on_peer_disconnect(&mut self, minion_addr: &str) { + if let Some(mid) = self.conn_to_mid.remove(minion_addr) { + log::info!("Minion connection {} dropped; clearing session for {}", minion_addr, mid); + self.get_session().lock().await.remove(&mid); + } else { + log::debug!("Disconnect from {}, but no minion id mapped yet", minion_addr); + } + self.peer_transport.remove_peer(minion_addr); + } + + /// Process a plaintext registration request and emit the one-shot registration response. + async fn on_registration_request(&mut self, minion_addr: &str, minion_id: &str, payload: &str, bcast: &broadcast::Sender) { + log::info!("Minion \"{minion_addr}\" requested registration"); + self.peer_transport.allow_plaintext(minion_addr); + let resp_msg = if !self.mkr().is_registered(minion_id) { + if let Err(err) = self.mkr().add_mn_key(minion_id, minion_addr, payload) { + log::error!("Unable to add minion RSA key: {err}"); + } + self.to_drop.insert(minion_addr.to_owned()); + log::info!("Registered a minion at {minion_addr} ({minion_id})"); + "Minion registration has been accepted" + } else { + log::warn!("Minion {minion_addr} ({minion_id}) is already registered"); + "Minion already registered" + }; + _ = bcast.send(self.msg_registered(minion_id.to_string(), resp_msg)); + } + + /// Process an `ehlo` request and either establish the runtime session or reject the peer. + async fn on_ehlo_request(&mut self, minion_addr: &str, minion_id: &str, payload: &str, bcast: &broadcast::Sender) { + log::info!("EHLO from {}", minion_id); + if !self.mkr().is_registered(minion_id) { + log::info!("Minion at {minion_addr} ({minion_id}) is not registered"); + self.to_drop.insert(minion_addr.to_owned()); + _ = bcast.send(self.msg_not_registered(minion_id.to_string())); + return; + } + if self.get_session().lock().await.exists(minion_id) { + log::info!("Minion at {minion_addr} ({minion_id}) is already connected"); + self.to_drop.insert(minion_addr.to_owned()); + _ = bcast.send(self.msg_already_connected(minion_id.to_string(), payload.to_string())); + return; + } + + log::info!("{minion_id} connected successfully"); + self.conn_to_mid.insert(minion_addr.to_string(), minion_id.to_string()); + self.get_session().lock().await.ping(minion_id, Some(payload)); + _ = bcast.send(self.msg_request_traits(minion_id.to_string(), payload.to_string())); + log::info!("Syncing traits with minion at {minion_id}"); + + match self.pending_rotation_message_for(minion_id).await { + Ok(Some(msg)) => { + log::info!("Dispatching deferred rotation to {} after reconnect", minion_id.bright_yellow()); + _ = bcast.send(msg); + } + Ok(None) => {} + Err(err) => { + log::error!("Unable to dispatch deferred rotation for {}: {err}", minion_id); + } + } + } + + /// Process a `pong` heartbeat update from one minion. + async fn on_pong_request(&mut self, minion_id: &str, payload: serde_json::Value) { + log::debug!("Received pong from {payload:#?}"); + let pm = match PingData::from_value(payload) { + Ok(pm) => pm, + Err(err) => { + log::error!("Unable to parse pong message: {err}"); + return; + } + }; + + self.get_session().lock().await.ping(minion_id, Some(pm.sid())); + self.vmcluster.update_stats(minion_id, pm.payload().load_average(), pm.payload().disk_write_bps(), pm.payload().cpu_usage()); + + let taskreg = self.get_task_registry(); + let mut taskreg = taskreg.lock().await; + taskreg.flush(minion_id, pm.payload().completed()); + } + + /// Process a `bye` request and acknowledge the disconnect. + async fn on_bye_request(&mut self, minion_addr: &str, minion_id: &str, payload: &str, bcast: &broadcast::Sender) { + log::info!("Minion {} disconnects", minion_id); + self.conn_to_mid.remove(minion_addr); + self.get_session().lock().await.remove(minion_id); + self.peer_transport.remove_peer(minion_addr); + _ = bcast.send(self.msg_bye_ack(minion_id.to_string(), payload.to_string())); + } + + /// Dispatch one parsed minion request into the dedicated async handler for that request family. + fn spawn_incoming_request(master: Arc>, bcast: broadcast::Sender, req: MinionMessage, minion_addr: String) { + match req.req_type() { + RequestType::Add => { + let c_master = Arc::clone(&master); + let c_bcast = bcast.clone(); + let c_mid = req.id().to_string(); + let c_payload = req.payload().to_string(); + tokio::spawn(async move { + c_master.lock().await.on_registration_request(&minion_addr, &c_mid, &c_payload, &c_bcast).await; + }); + } + RequestType::Response => { + log::info!("Response"); + } + RequestType::Ehlo => { + let c_master = Arc::clone(&master); + let c_bcast = bcast.clone(); + let c_id = req.id().to_string(); + let c_payload = req.payload().to_string(); + tokio::spawn(async move { + c_master.lock().await.on_ehlo_request(&minion_addr, &c_id, &c_payload, &c_bcast).await; + }); + } + RequestType::Pong => { + let c_master = Arc::clone(&master); + let c_id = req.id().to_string(); + let c_payload = req.payload().clone(); + tokio::spawn(async move { + c_master.lock().await.on_pong_request(&c_id, c_payload).await; + }); + } + RequestType::Traits => { + log::debug!("Syncing traits from {}", req.id()); + let c_master = Arc::clone(&master); + let c_id = req.id().to_string(); + let c_payload = req.payload().to_string(); + tokio::spawn(async move { + c_master.lock().await.on_traits(c_id, c_payload).await; + }); + } + RequestType::Bye => { + let c_master = Arc::clone(&master); + let c_bcast = bcast.clone(); + let c_id = req.id().to_string(); + let c_payload = req.payload().to_string(); + tokio::spawn(async move { + c_master.lock().await.on_bye_request(&minion_addr, &c_id, &c_payload, &c_bcast).await; + }); + } + RequestType::Event => { + let c_master = Arc::clone(&master); + tokio::spawn(async move { + log::debug!("Event for {}: {}", req.id(), req.payload()); + let d = req.get_data(); + let m = c_master.lock().await; + m.taskreg.lock().await.deregister(d.cid(), req.id()); + let mrec = m.mreg.lock().await.get(req.id()).unwrap_or_default().unwrap_or_default(); + + let pl = match serde_json::from_str::>(req.payload().to_string().as_str()) { + Ok(pl) => pl, + Err(err) => { + log::error!("An event message with the bogus payload: {err}"); + return; + } + }; + + if m.cfg().telemetry_enabled() { + OtelLogger::new(&pl).log(&mrec, DataExportType::Action); + } + + let sid = match m + .evtipc + .open_session( + util::dataconv::as_str(pl.get(&ProtoKey::EntityId.to_string()).cloned()), + util::dataconv::as_str(pl.get(&ProtoKey::CycleId.to_string()).cloned()), + util::dataconv::as_str(pl.get(&ProtoKey::Timestamp.to_string()).cloned()), + ) + .await + { + Ok(sid) => sid, + Err(err) => { + log::debug!("Unable to acquire session for this iteration: {err}"); + return; + } + }; + + let mid = match m.evtipc.ensure_minion(&sid, req.id().to_string(), mrec.get_traits().to_owned()).await { + Ok(mid) => mid, + Err(err) => { + log::error!("Unable to record a minion {}: {err}", req.id()); + return; + } + }; + + match m.evtipc.add_event(&sid, EventMinion::new(mid), pl).await { + Ok(_) => { + log::debug!("Event added for {} in {:#?}", req.id(), sid); + } + Err(err) => { + log::error!("Unable to add event: {err}"); + } + }; + }); + } + RequestType::ModelEvent => { + let c_master = Arc::clone(&master); + tokio::spawn(async move { + let master = c_master.lock().await; + let mrec = master.mreg.lock().await.get(req.id()).unwrap_or_default().unwrap_or_default(); + + let pl = match serde_json::from_str::>(req.payload().to_string().as_str()) { + Ok(pl) => pl, + Err(err) => { + log::error!("An event message with the bogus payload: {err}"); + return; + } + }; + let sid = match master.evtipc.get_session(&util::dataconv::as_str(pl.get(&ProtoKey::CycleId.to_string()).cloned())).await { + Ok(sid) => sid, + Err(err) => { + log::debug!("Unable to acquire session for this iteration: {err}"); + return; + } + }; + + if master.cfg().telemetry_enabled() { + let mut otel = OtelLogger::new(&pl); + otel.set_map(true); + match master.evtipc.get_events(sid.sid(), req.id()).await { + Ok(events) => match master.mreg.lock().await.get(req.id()) { + Ok(Some(mrec)) => { + otel.feed(events, mrec); + } + Ok(None) => { + log::error!("Unable to get minion record for {}", req.id()); + } + Err(err) => { + log::error!("Error retrieving minion record for {}: {}", req.id(), err); + } + }, + Err(err) => { + log::error!("Error retrieving events for minion {}: {}", req.id(), err); + } + } + otel.log(&mrec, DataExportType::Model); + } + }); + } + RequestType::SensorsSyncRequest => { + let c_master = Arc::clone(&master); + let c_bcast = bcast.clone(); + tokio::spawn(async move { + let mut guard = c_master.lock().await; + _ = c_bcast.send(guard.msg_sensors_files()); + }); + } + _ => { + log::error!("Minion sends unknown request type"); + } + } + } + /// Process incoming minion messages #[allow(clippy::while_let_loop)] pub async fn do_incoming(master: Arc>, mut rx: tokio::sync::mpsc::Receiver<(Vec, String)>) { @@ -412,14 +791,7 @@ impl SysMaster { if raw.is_empty() { let c_master = Arc::clone(&master); tokio::spawn(async move { - let mut guard = c_master.lock().await; - - if let Some(mid) = guard.conn_to_mid.remove(&minion_addr) { - log::info!("Minion connection {} dropped; clearing session for {}", minion_addr, mid); - guard.get_session().lock().await.remove(&mid); - } else { - log::debug!("Disconnect from {}, but no minion id mapped yet", minion_addr); - } + c_master.lock().await.on_peer_disconnect(&minion_addr).await; }); continue; } @@ -428,236 +800,7 @@ impl SysMaster { log::debug!("Minion response: {minion_addr}: {msg}"); if let Some(req) = master.lock().await.to_request(&msg) { - match req.req_type() { - RequestType::Add => { - let c_master = Arc::clone(&master); - let c_bcast = bcast.clone(); - let c_mid = req.id().to_string(); - tokio::spawn(async move { - log::info!("Minion \"{minion_addr}\" requested registration"); - let mut guard = c_master.lock().await; - let resp_msg: &str; - if !guard.mkr().is_registered(&c_mid) { - if let Err(err) = guard.mkr().add_mn_key(&c_mid, &minion_addr, &req.payload().to_string()) { - log::error!("Unable to add minion RSA key: {err}"); - } - guard.to_drop.insert(minion_addr.to_owned()); - resp_msg = "Minion registration has been accepted"; - log::info!("Registered a minion at {minion_addr} ({c_mid})"); - } else { - resp_msg = "Minion already registered"; - log::warn!("Minion {minion_addr} ({c_mid}) is already registered"); - } - _ = c_bcast.send(guard.msg_registered(req.id().to_string(), resp_msg).sendable().unwrap()); - }); - } - - RequestType::Response => { - log::info!("Response"); - } - - RequestType::Ehlo => { - log::info!("EHLO from {}", req.id()); - - let c_master = Arc::clone(&master); - let c_bcast = bcast.clone(); - let c_id = req.id().to_string(); - let c_payload = req.payload().to_string(); - tokio::spawn(async move { - let mut guard = c_master.lock().await; - if !guard.mkr().is_registered(&c_id) { - log::info!("Minion at {minion_addr} ({}) is not registered", req.id()); - guard.to_drop.insert(minion_addr); - _ = c_bcast.send(guard.msg_not_registered(req.id().to_string()).sendable().unwrap()); - } else if guard.get_session().lock().await.exists(&c_id) { - log::info!("Minion at {minion_addr} ({}) is already connected", req.id()); - guard.to_drop.insert(minion_addr); - _ = c_bcast.send(guard.msg_already_connected(req.id().to_string(), c_payload).sendable().unwrap()); - } else { - log::info!("{c_id} connected successfully"); - guard.conn_to_mid.insert(minion_addr.clone(), c_id.clone()); - guard.get_session().lock().await.ping(&c_id, Some(&c_payload)); - _ = c_bcast.send(guard.msg_request_traits(req.id().to_string(), c_payload).sendable().unwrap()); - log::info!("Syncing traits with minion at {c_id}"); - } - }); - } - - RequestType::Pong => { - let c_master = Arc::clone(&master); - let c_id = req.id().to_string(); - tokio::spawn(async move { - log::debug!("Received pong from {:#?}", req.payload()); - let mut guard = c_master.lock().await; - let pm = match PingData::from_value(req.payload().clone()) { - Ok(pm) => pm, - Err(err) => { - log::error!("Unable to parse pong message: {err}"); - return; - } - }; - - guard.get_session().lock().await.ping(&c_id, Some(pm.sid())); - guard.vmcluster.update_stats( - &c_id, - pm.payload().load_average(), - pm.payload().disk_write_bps(), - pm.payload().cpu_usage(), - ); - - // Update task tracker - let taskreg = guard.get_task_registry(); - let mut taskreg = taskreg.lock().await; - taskreg.flush(&c_id, pm.payload().completed()); - }); - } - - RequestType::Traits => { - log::debug!("Syncing traits from {}", req.id()); - let c_master = Arc::clone(&master); - let c_id = req.id().to_string(); - let c_payload = req.payload().to_string(); - tokio::spawn(async move { - let mut guard = c_master.lock().await; - guard.on_traits(c_id, c_payload).await; - }); - } - - RequestType::Bye => { - let c_master = Arc::clone(&master); - let c_bcast = bcast.clone(); - log::info!("Minion {} disconnects", req.id()); - tokio::spawn(async move { - let mut guard = c_master.lock().await; - guard.conn_to_mid.remove(&minion_addr); - guard.get_session().lock().await.remove(req.id()); - let m = guard.msg_bye_ack(req.id().to_string(), req.payload().to_string()); - _ = c_bcast.send(m.sendable().unwrap()); - }); - } - - RequestType::Event => { - log::debug!("Event for {}: {}", req.id(), req.payload()); - let d = req.get_data(); - let c_master = Arc::clone(&master); - tokio::spawn(async move { - let m = c_master.lock().await; - m.taskreg.lock().await.deregister(d.cid(), req.id()); - let mrec = m.mreg.lock().await.get(req.id()).unwrap_or_default().unwrap_or_default(); - - // XXX: Fix this nonsense - // This should use get_data() method to extract payload properly - // Also replace HashMap in evtipc.add_event with it. - let pl = match serde_json::from_str::>(req.payload().to_string().as_str()) { - Ok(pl) => pl, - Err(err) => { - log::error!("An event message with the bogus payload: {err}"); - return; - } - }; - - if m.cfg().telemetry_enabled() { - // Sent OTEL log entry - OtelLogger::new(&pl).log(&mrec, DataExportType::Action); - } - - let sid = match m - .evtipc - .open_session( - util::dataconv::as_str(pl.get(&ProtoKey::EntityId.to_string()).cloned()), - util::dataconv::as_str(pl.get(&ProtoKey::CycleId.to_string()).cloned()), - util::dataconv::as_str(pl.get(&ProtoKey::Timestamp.to_string()).cloned()), - ) - .await - { - Ok(sid) => sid, - Err(err) => { - log::debug!("Unable to acquire session for this iteration: {err}"); - return; - } - }; - - let mid = match m.evtipc.ensure_minion(&sid, req.id().to_string(), mrec.get_traits().to_owned()).await { - Ok(mid) => mid, - Err(err) => { - log::error!("Unable to record a minion {}: {err}", req.id()); - return; - } - }; - - match m.evtipc.add_event(&sid, EventMinion::new(mid), pl).await { - Ok(_) => { - log::debug!("Event added for {} in {:#?}", req.id(), sid); - } - Err(err) => { - log::error!("Unable to add event: {err}"); - } - }; - }); - } - - RequestType::ModelEvent => { - let c_master = Arc::clone(&master); - tokio::spawn(async move { - let master = c_master.lock().await; - let mrec = master.mreg.lock().await.get(req.id()).unwrap_or_default().unwrap_or_default(); - - let pl = match serde_json::from_str::>(req.payload().to_string().as_str()) { - Ok(pl) => pl, - Err(err) => { - log::error!("An event message with the bogus payload: {err}"); - return; - } - }; - let sid = match master - .evtipc - .get_session(&util::dataconv::as_str(pl.get(&ProtoKey::CycleId.to_string()).cloned())) - .await - { - Ok(sid) => sid, - Err(err) => { - log::debug!("Unable to acquire session for this iteration: {err}"); - return; - } - }; - - if master.cfg().telemetry_enabled() { - let mut otel = OtelLogger::new(&pl); - otel.set_map(true); // Use mapper (only) - match master.evtipc.get_events(sid.sid(), req.id()).await { - Ok(events) => match master.mreg.lock().await.get(req.id()) { - Ok(Some(mrec)) => { - otel.feed(events, mrec); - } - Ok(None) => { - log::error!("Unable to get minion record for {}", req.id()); - } - Err(err) => { - log::error!("Error retrieving minion record for {}: {}", req.id(), err); - } - }, - Err(err) => { - log::error!("Error retrieving events for minion {}: {}", req.id(), err); - } - } - otel.log(&mrec, DataExportType::Model); - } - }); - } - - RequestType::SensorsSyncRequest => { - let c_master = Arc::clone(&master); - let c_bcast = bcast.clone(); - tokio::spawn(async move { - let mut guard = c_master.lock().await; - _ = c_bcast.send(guard.msg_sensors_files().sendable().unwrap()); - }); - } - - _ => { - log::error!("Minion sends unknown request type"); - } - } + Self::spawn_incoming_request(Arc::clone(&master), bcast.clone(), req, minion_addr.clone()); } else { log::error!("Unable to parse minion message"); } @@ -669,10 +812,21 @@ impl SysMaster { } pub async fn on_traits(&mut self, mid: String, payload: String) { - let traits = serde_json::from_str::>(&payload).unwrap_or_default(); - if !traits.is_empty() { + let traits_payload = match TraitsTransportPayload::from_json_str(&payload) { + Ok(data) => data, + Err(err) => { + log::error!("Unable to parse traits payload for {}: {err}", mid); + return; + } + }; + if !traits_payload.traits.is_empty() { let mut mreg = self.mreg.lock().await; - if let Err(err) = mreg.refresh(&mid, traits) { + if let Err(err) = mreg.refresh( + &mid, + traits_payload.traits.into_iter().collect(), + traits_payload.static_keys.into_iter().collect(), + traits_payload.fn_keys.into_iter().collect(), + ) { log::error!("Unable to sync traits: {err}"); } else { let m = mreg.get(&mid).unwrap_or_default().unwrap_or_default(); @@ -685,460 +839,112 @@ impl SysMaster { } } - pub async fn on_fifo_commands(&mut self, msg: &MasterMessage) { - if msg.target().scheme().eq(&format!("cmd://{}", CLUSTER_REMOVE_MINION)) && !msg.target().id().is_empty() { - log::info!("Removing minion {}", msg.target().id()); - if let Err(err) = self.mreg.lock().await.remove(msg.target().id()) { - log::error!("Unable to remove minion {}: {err}", msg.target().id()); - } - if let Err(err) = self.mkr().remove_mn_key(msg.target().id()) { - log::error!("Unable to unregister minion: {err}"); - } - } else if msg.target().scheme().eq(&format!("cmd://{}", CLUSTER_ONLINE_MINIONS)) { - // XXX: This is just a logdumper for now, because there is no proper response channel yet. - // Most likely we need to dump FIFO mechanism in a whole and replace with something else. - log::info!("Listing online minions"); - let mreg = self.mreg.lock().await; - let mut session = self.session.lock().await; - match mreg.get_registered_ids() { - Ok(ids) => { - let mut msg: Vec = vec![]; - for (idx, mid) in ids.iter().enumerate() { - let alive = session.alive(mid); - let traits = match mreg.get(mid) { - Ok(Some(mrec)) => mrec.get_traits().to_owned(), - _ => HashMap::new(), - }; - let mut h = traits.get("system.hostname.fqdn").and_then(|v| v.as_str()).unwrap_or("unknown"); - if h.is_empty() { - h = traits.get("system.hostname").and_then(|v| v.as_str()).unwrap_or("unknown"); - } - let ip = traits.get("system.hostname.ip").and_then(|v| v.as_str()).unwrap_or("unknown"); - - msg.push(format!( - "{}. {} {} - {} ({})", - idx + 1, - if alive { " " } else { "!" }, - if alive { mid.cyan() } else { mid.white() }, - if alive { h.bright_green() } else { h.yellow() }, - if alive { ip.bright_green() } else { ip.yellow() }, - )); - } - log::info!("Status of all registered minions:\n{}", msg.join("\n")); - } - Err(err) => { - log::error!("Unable to get online minions: {err}"); - } - } - } + /// Extract the preferred host labels for one minion record. + /// + /// The returned tuple is `(fqdn, hostname)`. Either value may be an empty + /// string if the corresponding trait is missing. Consumers decide how to + /// fall back when rendering. + fn preferred_host(minion: &crate::registry::rec::MinionRecord) -> (String, String) { + let traits = minion.get_traits(); + let fqdn = traits.get("system.hostname.fqdn").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + let hostname = traits.get("system.hostname").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + (fqdn, hostname) } - /// Build the formatted online-minion summary returned by the console `--online` command. - async fn online_minions_summary(&mut self) -> Result { - let mreg = self.mreg.lock().await; - let mut session = self.session.lock().await; - let ids = mreg.get_registered_ids()?; - let mut rows: Vec<(String, String, String, String, String, String)> = vec![]; - - for mid in &ids { - let alive = session.alive(mid); - let traits = match mreg.get(mid) { - Ok(Some(mrec)) => mrec.get_traits().to_owned(), - _ => HashMap::new(), - }; - let mut h = traits.get("system.hostname.fqdn").and_then(|v| v.as_str()).unwrap_or("unknown"); - if h.is_empty() { - h = traits.get("system.hostname").and_then(|v| v.as_str()).unwrap_or("unknown"); - } - let ip = traits.get("system.hostname.ip").and_then(|v| v.as_str()).unwrap_or("unknown"); - let mid_short = if mid.chars().count() > 8 { - format!("{}...{}", &mid[..4], &mid[mid.len() - 4..]) - } else { - mid.to_string() - }; - rows.push(( - h.to_string(), - if alive { h.bright_green().to_string() } else { h.red().to_string() }, - ip.to_string(), - if alive { ip.bright_blue().to_string() } else { ip.blue().to_string() }, - mid_short.clone(), - if alive { mid_short.bright_green().to_string() } else { mid_short.green().to_string() }, - )); - } - - let host_width = rows.iter().map(|r| r.0.chars().count()).max().unwrap_or(4).max("HOST".chars().count()); - let ip_width = rows.iter().map(|r| r.2.chars().count()).max().unwrap_or(2).max("IP".chars().count()); - let id_width = rows.iter().map(|r| r.4.chars().count()).max().unwrap_or(2).max("ID".chars().count()); - - let mut out = vec![ - format!( - "{} {} {}", - pad_visible(&"HOST".bright_yellow().to_string(), host_width), - pad_visible(&"IP".bright_yellow().to_string(), ip_width), - pad_visible(&"ID".bright_yellow().to_string(), id_width), - ), - format!("{} {} {}", "─".repeat(host_width), "─".repeat(ip_width), "─".repeat(id_width)), - ]; - - for (_, host, _, ip, _, mid) in rows { - out.push(format!( - "{} {} {}", - pad_visible(&host, host_width), - pad_visible(&ip, ip_width), - pad_visible(&mid, id_width), - )); - } - - Ok(out.join("\n")) + /// Create a transport rotator bound to one minion using the currently known + /// master and minion RSA fingerprints. + fn master_rotator(&mut self, minion_id: &str) -> Result { + let master_fp = self.mkr().get_master_key_fingerprint()?; + let minion_fp = self.mkr().get_mn_key_fingerprint(minion_id)?; + let store = TransportStore::for_master_minion(&self.cfg, minion_id)?; + RsaTransportRotator::new(RotationActor::Master, store, minion_id, &master_fp, &minion_fp, SECURE_PROTOCOL_VERSION) } - /// Resolve target minions for console profile operations from id, traits query, or hostname query. - async fn selected_minions(&mut self, query: &str, traits: &str, mid: &str) -> Result, SysinspectError> { - let mut records = if !mid.is_empty() { - self.mreg.lock().await.get(mid)?.into_iter().collect::>() - } else if !traits.trim().is_empty() { - let traits = get_context(traits) - .ok_or_else(|| SysinspectError::InvalidQuery("Traits selector must be in key:value format".to_string()))? - .into_iter() - .collect::>(); - self.mreg - .lock() - .await - .get_by_traits(traits)? - } else { - self.mreg.lock().await.get_by_query(if query.trim().is_empty() { "*" } else { query })? - }; - records.sort_by(|a, b| a.id().cmp(b.id())); - Ok(records) + /// Build the serialized rotate-command context that will be sent to a + /// minion. + /// + /// This signs a fresh rotation intent with the master's private RSA key and + /// embeds the operator-facing request parameters alongside that intent. + fn stage_rotation_context(&mut self, minion_id: &str, request: &RotationConsoleRequest) -> Result { + let rotator = self.master_rotator(minion_id)?; + let plan = rotator.plan(request.reason()); + let signed = rotator.sign_plan(&plan, &self.mkr().master_private_key()?)?; + serde_json::to_string(&RotationCommandPayload { + op: "rotate".to_string(), + reason: request.reason().to_string(), + grace_seconds: request.grace_seconds(), + reconnect: request.reconnect(), + reregister: request.reregister(), + intent: signed, + }) + .map_err(|err| SysinspectError::SerializationError(format!("Failed to encode rotate payload: {err}"))) } - /// Execute one profile console request and return its console response plus any outbound master messages to broadcast. - async fn profile_console_response( - &mut self, request: &ProfileConsoleRequest, query: &str, traits: &str, mid: &str, - ) -> Result<(ConsoleResponse, Vec), SysinspectError> { - fn require_profile_name(request: &ProfileConsoleRequest) -> Result<(), SysinspectError> { - if !request.name().trim().is_empty() { - return Ok(()); - } + /// Persist or clear the pending serialized rotation context for one minion. + /// + /// A pending context is stored when the minion is offline so the exact same + /// operator request can be replayed on the next reconnect. + fn persist_pending_rotation_context(&mut self, minion_id: &str, context: Option) -> Result<(), SysinspectError> { + let store = TransportStore::for_master_minion(&self.cfg, minion_id)?; + let mut state = store.load()?.ok_or_else(|| SysinspectError::ProtoError(format!("No managed transport state exists for {minion_id}")))?; + state.set_pending_rotation_context(context); + store.save(&state) + } - Err(SysinspectError::InvalidQuery("Profile name cannot be empty".to_string())) + /// Build a concrete outbound rotation message for an online minion. + /// + /// The function stages and persists the serialized context first so the + /// request can still be recovered if later parts of the flow fail. + /// + /// Returns `Ok(None)` when the target minion is not registered. + async fn build_rotation_message( + &mut self, minion_id: &str, request: &RotationConsoleRequest, reason_suffix: Option<&str>, + ) -> Result, SysinspectError> { + if !self.mkr().is_registered(minion_id) { + return Ok(None); } - let repo = SysInspectModPak::new(self.cfg.get_mod_repo_root())?; - - match request.op() { - "new" => Ok(( - { - require_profile_name(request)?; - repo.new_profile(request.name())?; - ConsoleResponse { ok: true, message: format!("Created profile {}", request.name().bright_yellow()) } - }, - vec![], - )), - "delete" => Ok(( - { - require_profile_name(request)?; - repo.delete_profile(request.name())?; - ConsoleResponse { ok: true, message: format!("Deleted profile {}", request.name().bright_yellow()) } - }, - vec![], - )), - "list" => Ok(( - ConsoleResponse { - ok: true, - message: if request.name().is_empty() { - repo.list_profiles(None)?.join("\n") - } else { - repo.list_profile_matches(Some(request.name()), request.library())?.join("\n") - }, - }, - vec![], - )), - "show" => Ok(( - { - require_profile_name(request)?; - ConsoleResponse { ok: true, message: repo.show_profile(request.name())? } - }, - vec![], - )), - "add" => Ok(( - { - require_profile_name(request)?; - repo.add_profile_matches(request.name(), request.matches().to_vec(), request.library())?; - ConsoleResponse { ok: true, message: format!("Updated profile {}", request.name().bright_yellow()) } - }, - vec![], - )), - "remove" => Ok(( - { - require_profile_name(request)?; - repo.remove_profile_matches(request.name(), request.matches().to_vec(), request.library())?; - ConsoleResponse { ok: true, message: format!("Updated profile {}", request.name().bright_yellow()) } - }, - vec![], - )), - "tag" | "untag" => { - let known_profiles = repo.list_profiles(None)?; - let missing = request.profiles().iter().filter(|name| !known_profiles.contains(name)).cloned().collect::>(); - if !missing.is_empty() { - return Ok(( - ConsoleResponse { - ok: false, - message: format!("Unknown profile{}: {}", if missing.len() == 1 { "" } else { "s" }, missing.join(", ").bright_yellow()), - }, - vec![], - )); - } - - let mut msgs = Vec::new(); - for minion in self.selected_minions(query, traits, mid).await? { - let mut profiles = match minion.get_traits().get("minion.profile") { - Some(serde_json::Value::String(name)) if !name.trim().is_empty() => vec![name.to_string()], - Some(serde_json::Value::Array(names)) => names.iter().filter_map(|name| name.as_str().map(str::to_string)).collect::>(), - _ => vec![], - }; - profiles.retain(|profile| profile != "default"); - if request.op() == "tag" { - for profile in request.profiles() { - if !profiles.contains(profile) { - profiles.push(profile.to_string()); - } - } - } else { - profiles.retain(|profile| !request.profiles().contains(profile)); - } - if let Some(msg) = self - .msg_query_data( - &format!("{SCHEME_COMMAND}{CLUSTER_TRAITS_UPDATE}"), - "", - "", - minion.id(), - &if profiles.is_empty() { - json!({"op": "unset", "traits": {"minion.profile": null}}) - } else { - json!({"op": "set", "traits": {"minion.profile": profiles}}) - } - .to_string(), - ) - .await - { - msgs.push(msg); - } - } - - Ok(( - ConsoleResponse { - ok: true, - message: format!( - "{} {} on {} minion{}", - if request.op() == "tag" { "Applied profiles" } else { "Removed profiles" }, - request.profiles().join(", ").bright_yellow(), - msgs.len(), - if msgs.len() == 1 { "" } else { "s" } - ), - }, - msgs, - )) - } - _ => Ok(( - ConsoleResponse { ok: false, message: format!("Unsupported profile operation {}", request.op().bright_yellow()) }, - vec![], - )), + let mut requested = request.clone(); + if let Some(suffix) = reason_suffix { + requested.reason = Some(format!("{}:{suffix}", request.reason())); + } + let context = self.stage_rotation_context(minion_id, &requested)?; + self.persist_pending_rotation_context(minion_id, Some(context.clone()))?; + let msg = self.msg_query_data(&format!("{SCHEME_COMMAND}{CLUSTER_ROTATE}"), "", "", minion_id, &context).await; + if msg.is_none() { + self.persist_pending_rotation_context(minion_id, None)?; } + Ok(msg) } - /// Start the encrypted TCP console listener used by `sysinspect` to talk to the master. - pub async fn do_console(master: Arc>) { - log::trace!("Init local console channel"); - tokio::spawn({ - let master = Arc::clone(&master); - async move { - let (cfg, bcast) = { - let guard = master.lock().await; - (guard.cfg(), guard.broadcast().clone()) - }; - let master_prk = match load_master_private_key(&cfg) { - Ok(prk) => prk, - Err(err) => { - log::error!("Failed to load console private key: {err}"); - return; - } - }; - let listener = match TcpListener::bind(cfg.console_listen_addr()).await { - Ok(listener) => listener, - Err(err) => { - log::error!("Failed to bind console listener: {err}"); - return; - } - }; - loop { - match listener.accept().await { - Ok((stream, peer)) => { - let master = Arc::clone(&master); - let cfg = cfg.clone(); - let bcast = bcast.clone(); - let master_prk = master_prk.clone(); - tokio::spawn(async move { - let (read_half, mut write_half) = stream.into_split(); - let reader = TokioBufReader::new(read_half); - let mut frame = Vec::new(); - let mut reader = reader.take((MAX_CONSOLE_FRAME_SIZE + 1) as u64); - let reply = match time::timeout(CONSOLE_READ_TIMEOUT, reader.read_until(b'\n', &mut frame)).await { - Err(_) => serde_json::to_string(&ConsoleResponse { - ok: false, - message: format!("Console request timed out after {} seconds", CONSOLE_READ_TIMEOUT.as_secs()), - }) - .ok(), - Ok(Ok(0)) => { - serde_json::to_string(&ConsoleResponse { ok: false, message: "Empty console request".to_string() }).ok() - } - Ok(Ok(_)) if frame.len() > MAX_CONSOLE_FRAME_SIZE || !frame.ends_with(b"\n") => { - serde_json::to_string(&ConsoleResponse { - ok: false, - message: format!("Console request exceeds {} bytes", MAX_CONSOLE_FRAME_SIZE), - }) - .ok() - } - Ok(Ok(_)) => match String::from_utf8(frame).map(|line| line.trim().to_string()) { - Ok(line) => match serde_json::from_str::(&line) { - Ok(envelope) => { - if !authorised_console_client(&cfg, &envelope.bootstrap.client_pubkey).unwrap_or(false) { - serde_json::to_string(&ConsoleResponse { - ok: false, - message: "Console client key is not authorised".to_string(), - }) - .ok() - } else { - match envelope.bootstrap.session_key(&master_prk) { - Ok((key, _client_pkey)) => { - let response = match envelope.sealed.open::(&key) { - Ok(query) => { - if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_ONLINE_MINIONS}")) { - match master.lock().await.online_minions_summary().await { - Ok(summary) => ConsoleResponse { ok: true, message: summary }, - Err(err) => ConsoleResponse { - ok: false, - message: format!("Unable to get online minions: {err}"), - }, - } - } else if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_PROFILE}")) { - let (response, msgs) = match ProfileConsoleRequest::from_context(&query.context) { - Ok(request) => { - let mut guard = master.lock().await; - match guard.profile_console_response(&request, &query.query, &query.traits, &query.mid).await { - Ok(data) => data, - Err(err) => (ConsoleResponse { ok: false, message: err.to_string() }, vec![]), - } - } - Err(err) => ( - ConsoleResponse { - ok: false, - message: format!("Failed to parse profile request: {err}"), - }, - vec![], - ), - }; - for msg in msgs { - SysMaster::bcast_master_msg( - &bcast, - cfg.telemetry_enabled(), - Arc::clone(&master), - Some(msg.clone()), - ) - .await; - let guard = master.lock().await; - let ids = guard.mreg.lock().await.get_targeted_minions(msg.target(), false).await; - guard.taskreg.lock().await.register(msg.cycle(), ids); - } - response - } else { - let msg = { - let mut guard = master.lock().await; - guard.msg_query_data(&query.model, &query.query, &query.traits, &query.mid, &query.context).await - }; - if let Some(msg) = msg { - SysMaster::bcast_master_msg( - &bcast, - cfg.telemetry_enabled(), - Arc::clone(&master), - Some(msg.clone()), - ) - .await; - let guard = master.lock().await; - let ids = guard.mreg.lock().await.get_targeted_minions(msg.target(), false).await; - guard.taskreg.lock().await.register(msg.cycle(), ids); - ConsoleResponse { ok: true, message: format!("Accepted console command from {peer}") } - } else { - ConsoleResponse { - ok: false, - message: "No message constructed for the console query".to_string(), - } - } - } - } - Err(err) => ConsoleResponse { - ok: false, - message: format!("Failed to open console query: {err}"), - }, - }; - match ConsoleSealed::seal(&response, &key).and_then(|sealed| { - serde_json::to_string(&sealed) - .map_err(|e| SysinspectError::SerializationError(e.to_string())) - }) { - Ok(reply) => Some(reply), - Err(err) => { - log::error!("Failed to seal console response: {err}"); - serde_json::to_string(&ConsoleResponse { - ok: false, - message: format!("Failed to seal console response: {err}"), - }) - .ok() - } - } - } - Err(err) => serde_json::to_string(&ConsoleResponse { - ok: false, - message: format!("Console bootstrap failed: {err}"), - }) - .ok(), - } - } - } - Err(err) => { - serde_json::to_string(&ConsoleResponse { - ok: false, - message: format!("Failed to parse console request: {err}"), - }) - .ok() - } - }, - Err(err) => { - serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Console request is not valid UTF-8: {err}") }).ok() - } - }, - Ok(Err(err)) => serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Failed to read console request: {err}") }).ok(), - }; - - if let Some(reply) = reply - && let Err(err) = write_half.write_all(format!("{reply}\n").as_bytes()).await - { - log::error!("Failed to write console response: {err}"); - } - }); - } - Err(err) => { - log::error!("Console listener accept error: {err}"); - sleep(Duration::from_secs(1)).await; - } - } - } - } - }); + async fn pending_rotation_message_for(&mut self, minion_id: &str) -> Result, SysinspectError> { + let store = TransportStore::for_master_minion(&self.cfg, minion_id)?; + let state = match store.load()? { + Some(state) => state, + None => return Ok(None), + }; + if !matches!(state.rotation, libsysinspect::transport::TransportRotationStatus::Pending) + || state.pending_rotation_context.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) + { + return Ok(None); + } + + self.msg_query_data( + &format!("{SCHEME_COMMAND}{CLUSTER_ROTATE}"), + "", + "", + minion_id, + state.pending_rotation_context.as_deref().unwrap_or_default(), + ) + .await + .ok_or_else(|| SysinspectError::ProtoError(format!("Unable to construct deferred rotation message for {minion_id}"))) + .map(Some) } /// Broadcast a message to all minions + /// Broadcast a logical master message so each connected peer can encode it with its own transport state. pub async fn bcast_master_msg( - bcast: &broadcast::Sender>, use_telemetry: bool, master: Arc>, msg: Option, + bcast: &broadcast::Sender, use_telemetry: bool, master: Arc>, msg: Option, ) { if msg.is_none() { log::error!("No message to broadcast"); @@ -1146,15 +952,6 @@ impl SysMaster { } let msg = msg.unwrap(); - { - let c_master = Arc::clone(&master); - let c_msg = msg.clone(); - tokio::spawn(async move { - let mut guard = c_master.lock().await; - guard.on_fifo_commands(&c_msg).await; - }); - } - if use_telemetry { let c_master = Arc::clone(&master); let c_msg = msg.clone(); @@ -1165,8 +962,8 @@ impl SysMaster { log::debug!("Telemetry enabled, fired a task"); } - let _ = bcast.send(msg.sendable().unwrap()); log::debug!("Message broadcasted: {}", msg.target().scheme()); + let _ = bcast.send(msg); } pub async fn do_heartbeat(master: Arc>) { @@ -1179,11 +976,153 @@ impl SysMaster { let mut t = MinionTarget::default(); t.add_hostname("*"); p.set_target(t); - let _ = bcast.send(p.sendable().unwrap()); + let _ = bcast.send(p); } }); } + /// Encode one outbound frame for a connected peer, skipping broadcasts until the peer is allowed to receive them. + async fn encode_outgoing_frame(&mut self, peer_addr: &str, frame: OutgoingFrame) -> Result>, SysinspectError> { + match frame { + OutgoingFrame::Direct(msg) => Ok(Some(msg)), + OutgoingFrame::Broadcast(msg) => { + if !self.peer_transport.can_receive_broadcast(peer_addr) { + return Ok(None); + } + self.peer_transport.encode_message(peer_addr, &msg).map(Some) + } + } + } + + /// Write direct replies and broadcast frames to one connected peer until the socket closes. + async fn write_peer_frames( + master: Arc>, mut writer: OwnedWriteHalf, peer_addr: String, mut bcast_sub: broadcast::Receiver, + mut direct_rx: mpsc::Receiver>, cancel_writer: tokio::sync::watch::Sender, + ) { + log::info!("Minion {peer_addr} connected. Ready to send messages."); + + loop { + let frame = match tokio::select! { + biased; + Some(msg) = direct_rx.recv() => Some(OutgoingFrame::Direct(msg)), + Ok(msg) = bcast_sub.recv() => Some(OutgoingFrame::Broadcast(Box::new(msg))), + else => return, + } { + Some(frame) => frame, + None => return, + }; + + let encoded = { + let mut guard = master.lock().await; + match guard.encode_outgoing_frame(&peer_addr, frame).await { + Ok(Some(msg)) => msg, + Ok(None) => continue, + Err(err) => { + log::error!("Failed to encode outbound message for {peer_addr}: {err}"); + let _ = cancel_writer.send(true); + return; + } + } + }; + + log::trace!("Sending message to minion at {} length of {}", peer_addr, encoded.len()); + let mut guard = master.lock().await; + if writer.write_all(&(encoded.len() as u32).to_be_bytes()).await.is_err() + || writer.write_all(&encoded).await.is_err() + || writer.flush().await.is_err() + { + if let Err(err) = cancel_writer.send(true) { + log::debug!("Error sending cancel notification: {err}"); + } + break; + } + + if guard.to_drop.contains(&peer_addr) { + guard.to_drop.remove(&peer_addr); + log::info!("Dropping minion: {}", &peer_addr); + if let Err(err) = writer.shutdown().await { + log::error!("Error shutting down outgoing: {err}"); + } + if let Err(err) = cancel_writer.send(true) { + log::debug!("Error sending cancel notification: {err}"); + } + return; + } + } + } + + /// Read framed peer traffic, decode it through the peer transport object, and forward logical messages inward. + async fn read_peer_frames( + master: Arc>, reader: OwnedReadHalf, peer_addr: String, client_tx: mpsc::Sender<(Vec, String)>, + direct_tx: mpsc::Sender>, cancel_tx: tokio::sync::watch::Sender, cancel_rx: tokio::sync::watch::Receiver, + ) { + let mut reader = TokioBufReader::new(reader); + loop { + if *cancel_rx.borrow() { + log::info!("Process terminated"); + return; + } + + let mut len_buf = [0u8; 4]; + if reader.read_exact(&mut len_buf).await.is_err() { + let _ = client_tx.send((Vec::new(), peer_addr.clone())).await; + return; + } + + let msg_len = u32::from_be_bytes(len_buf) as usize; + let mut msg = vec![0u8; msg_len]; + if reader.read_exact(&mut msg).await.is_err() { + let _ = client_tx.send((Vec::new(), peer_addr.clone())).await; + return; + } + + let decoded = { + let mut guard = master.lock().await; + guard.decode_peer_frame(&peer_addr, &msg) + }; + match decoded { + Ok(IncomingFrame::Forward(msg)) => { + if client_tx.send((msg, peer_addr.clone())).await.is_err() { + break; + } + } + Ok(IncomingFrame::Reply(msg)) => { + let _ = direct_tx.send(msg).await; + } + Err(err) => { + log::error!("Failed to decode frame from {peer_addr}: {err}"); + let _ = cancel_tx.send(true); + let _ = client_tx.send((Vec::new(), peer_addr.clone())).await; + return; + } + } + } + } + + /// Spawn the paired reader and writer tasks for one accepted minion socket. + async fn handle_peer_connection( + master: Arc>, tx: mpsc::Sender<(Vec, String)>, bcast: &broadcast::Sender, socket: tokio::net::TcpStream, + ) { + let bcast_sub = bcast.subscribe(); + let client_tx = tx.clone(); + let peer_addr = socket.peer_addr().unwrap().to_string(); + let writer_peer_addr = peer_addr.clone(); + let (reader, writer) = socket.into_split(); + let c_master_writer = Arc::clone(&master); + let c_master_reader = Arc::clone(&master); + let (direct_tx, direct_rx) = mpsc::channel::>(8); + let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); + let cancel_writer = cancel_tx.clone(); + + tokio::spawn(async move { + Self::write_peer_frames(c_master_writer, writer, writer_peer_addr, bcast_sub, direct_rx, cancel_writer).await; + }); + + tokio::spawn(async move { + Self::read_peer_frames(c_master_reader, reader, peer_addr, client_tx, direct_tx, cancel_tx, cancel_rx).await; + }); + } + pub async fn do_outgoing(master: Arc>, tx: mpsc::Sender<(Vec, String)>) -> Result<(), SysinspectError> { log::trace!("Init outgoing channel"); let listener = master.lock().await.listener().await?; @@ -1194,76 +1133,7 @@ impl SysMaster { tokio::select! { // Accept a new connection Ok((socket, _)) = listener.accept() => { - let mut bcast_sub = bcast.subscribe(); - let client_tx = tx.clone(); - let peer_addr = socket.peer_addr().unwrap(); - let (reader, writer) = socket.into_split(); - let c_master = Arc::clone(&master); - let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); - - // Task to send messages to the client - tokio::spawn(async move { - let mut writer = writer; - log::info!("Minion {peer_addr} connected. Ready to send messages."); - - loop { - if let Ok(msg) = bcast_sub.recv().await { - log::trace!("Sending message to minion at {} length of {}", peer_addr, msg.len()); - let mut guard = c_master.lock().await; - if writer.write_all(&(msg.len() as u32).to_be_bytes()).await.is_err() - || writer.write_all(&msg).await.is_err() - || writer.flush().await.is_err() - { - if let Err(err) = cancel_tx.send(true) { - log::debug!("Error sending cancel notification: {err}"); - } - break; - } - - if guard.to_drop.contains(&peer_addr.to_string()) { - guard.to_drop.remove(&peer_addr.to_string()); - log::info!("Dropping minion: {}", &peer_addr.to_string()); - if let Err(err) = writer.shutdown().await { - log::error!("Error shutting down outgoing: {err}"); - } - if let Err(err) = cancel_tx.send(true) { - log::debug!("Error sending cancel notification: {err}"); - } - - return; - } - } - } - }); - - // Task to read messages from the client - tokio::spawn(async move { - let mut reader = TokioBufReader::new(reader); - loop { - if *cancel_rx.borrow() { - log::info!("Process terminated"); - return; - } - - let mut len_buf = [0u8; 4]; - if reader.read_exact(&mut len_buf).await.is_err() { - let _ = client_tx.send((Vec::new(), peer_addr.to_string())).await; - return; - } - - let msg_len = u32::from_be_bytes(len_buf) as usize; - let mut msg = vec![0u8; msg_len]; - if reader.read_exact(&mut msg).await.is_err() { - let _ = client_tx.send((Vec::new(), peer_addr.to_string())).await; - return; - } - - if client_tx.send((msg, peer_addr.to_string())).await.is_err() { - break; - } - - } - }); + Self::handle_peer_connection(Arc::clone(&master), tx.clone(), &bcast, socket).await; } } } diff --git a/sysmaster/src/master_ut.rs b/sysmaster/src/master_ut.rs new file mode 100644 index 00000000..46a3ce8a --- /dev/null +++ b/sysmaster/src/master_ut.rs @@ -0,0 +1,207 @@ +use crate::master::SysMaster; +use chrono::Utc; +use libsysinspect::{ + rsa::keys::{get_fingerprint, keygen}, + transport::{ + TransportKeyExchangeModel, TransportPeerState, TransportProvisioningMode, TransportRotationStatus, secure_bootstrap::SecureBootstrapSession, + }, +}; +use libsysproto::secure::{SECURE_PROTOCOL_VERSION, SecureBootstrapHello, SecureFrame, SecureSessionBinding}; +use rsa::RsaPublicKey; +use std::{collections::HashMap, time::Instant}; + +fn fresh_timestamp() -> i64 { + Utc::now().timestamp() +} + +fn state(master_pbk: &RsaPublicKey, minion_pbk: &RsaPublicKey) -> TransportPeerState { + TransportPeerState { + minion_id: "mid-1".to_string(), + master_rsa_fingerprint: get_fingerprint(master_pbk).unwrap(), + minion_rsa_fingerprint: get_fingerprint(minion_pbk).unwrap(), + protocol_version: SECURE_PROTOCOL_VERSION, + key_exchange: TransportKeyExchangeModel::EphemeralSessionKeys, + provisioning: TransportProvisioningMode::Automatic, + approved_at: Some(Utc::now()), + active_key_id: Some("kid-1".to_string()), + last_key_id: Some("kid-1".to_string()), + last_handshake_at: None, + rotation: TransportRotationStatus::Idle, + pending_rotation_context: None, + updated_at: Utc::now(), + keys: vec![], + } +} + +#[test] +fn unsupported_peer_bounces_secure_bootstrap_hello() { + let bounced = SysMaster::secure_peer_diag_with_state( + &mut HashMap::::new(), + "127.0.0.1:4200", + &serde_json::to_vec(&SecureFrame::BootstrapHello(SecureBootstrapHello { + binding: SecureSessionBinding::bootstrap_opening( + "mid-1".to_string(), + "minion-fp".to_string(), + "master-fp".to_string(), + "conn-1".to_string(), + "nonce-1".to_string(), + fresh_timestamp(), + ), + session_key_cipher: "cipher".to_string(), + binding_signature: "sig".to_string(), + key_id: Some("kid-1".to_string()), + })) + .unwrap(), + ) + .unwrap(); + + assert!(matches!(serde_json::from_slice::(&bounced).unwrap(), SecureFrame::BootstrapDiagnostic(_))); +} + +#[test] +fn unsupported_peer_ignores_legacy_plaintext_messages() { + let mut failures = HashMap::::new(); + + assert!(SysMaster::secure_peer_diag_with_state(&mut failures, "127.0.0.1:4200", br#"{"id":"mid-1","r":"ehlo","d":{},"c":1,"sid":""}"#).is_none()); + assert!(SysMaster::secure_peer_diag_with_state(&mut failures, "127.0.0.1:4200", br#"{"kind":"bootstrap_hello","broken":}"#).is_some()); + assert!(SECURE_PROTOCOL_VERSION > 0); +} + +#[test] +fn plaintext_registration_request_remains_allowed() { + assert!(SysMaster::plaintext_peer_diag(br#"{"id":"mid-1","r":"add","d":"pem","c":0,"sid":""}"#).is_none()); +} + +#[test] +fn plaintext_ehlo_is_rejected_when_secure_transport_is_enabled() { + let bounced = SysMaster::plaintext_peer_diag(br#"{"id":"mid-1","r":"ehlo","d":{},"c":1,"sid":"sid-1"}"#).unwrap(); + + assert!(matches!( + serde_json::from_slice::(&bounced).unwrap(), + SecureFrame::BootstrapDiagnostic(frame) + if frame.message.contains("secure bootstrap is required") + )); +} + +#[test] +fn broadcasts_are_blocked_for_prebootstrap_peers() { + assert!(!SysMaster::peer_can_receive_broadcast_state(false, false)); + assert!(SysMaster::peer_can_receive_broadcast_state(false, true)); + assert!(SysMaster::peer_can_receive_broadcast_state(true, false)); +} + +#[test] +fn malformed_secure_bootstrap_attempts_are_rate_limited() { + let mut failures = HashMap::::new(); + + let _ = SysMaster::secure_peer_diag_with_state(&mut failures, "127.0.0.1:4200", br#"{"kind":"bootstrap_hello","broken":}"#); + let _ = SysMaster::secure_peer_diag_with_state(&mut failures, "127.0.0.1:4200", br#"{"kind":"bootstrap_hello","broken":}"#); + let bounced = SysMaster::secure_peer_diag_with_state(&mut failures, "127.0.0.1:4200", br#"{"kind":"bootstrap_hello","broken":}"#).unwrap(); + + assert!(matches!( + serde_json::from_slice::(&bounced).unwrap(), + SecureFrame::BootstrapDiagnostic(frame) if frame.message.contains("Repeated malformed") + )); +} + +#[test] +fn malformed_secure_bootstrap_rate_limit_is_keyed_by_ip_not_port() { + let mut failures = HashMap::::new(); + + let _ = SysMaster::secure_peer_diag_with_state(&mut failures, "127.0.0.1:4200", br#"{"kind":"bootstrap_hello","broken":}"#); + let _ = SysMaster::secure_peer_diag_with_state(&mut failures, "127.0.0.1:4201", br#"{"kind":"bootstrap_hello","broken":}"#); + let bounced = SysMaster::secure_peer_diag_with_state(&mut failures, "127.0.0.1:4202", br#"{"kind":"bootstrap_hello","broken":}"#).unwrap(); + + assert!(matches!( + serde_json::from_slice::(&bounced).unwrap(), + SecureFrame::BootstrapDiagnostic(frame) if frame.message.contains("Repeated malformed") + )); +} + +#[test] +fn replay_cache_rejects_duplicate_bootstrap_openings() { + let mut cache = HashMap::::new(); + let binding = SecureSessionBinding::bootstrap_opening( + "mid-1".to_string(), + "minion-fp".to_string(), + "master-fp".to_string(), + "conn-1".to_string(), + "nonce-1".to_string(), + fresh_timestamp(), + ); + + assert!(SysMaster::bootstrap_precheck_with_state_for_test(&mut cache, &binding, Instant::now(),).is_none()); + SysMaster::record_bootstrap_replay_with_state_for_test(&mut cache, &binding, Instant::now()); + assert!(SysMaster::bootstrap_precheck_with_state_for_test(&mut cache, &binding, Instant::now(),).is_some()); +} + +#[test] +fn replay_cache_rejects_stale_bootstrap_openings_before_auth() { + let mut cache = HashMap::::new(); + let binding = SecureSessionBinding::bootstrap_opening( + "mid-1".to_string(), + "minion-fp".to_string(), + "master-fp".to_string(), + "conn-1".to_string(), + "nonce-1".to_string(), + fresh_timestamp() - 301, + ); + + let rejection = SysMaster::bootstrap_precheck_with_state_for_test(&mut cache, &binding, Instant::now()).unwrap(); + assert!(rejection.contains("timestamp drift")); + assert!(cache.is_empty()); +} + +#[test] +fn replay_cache_key_binds_minion_connection_and_nonce() { + let key = SysMaster::replay_cache_key_for_test(&SecureSessionBinding::bootstrap_opening( + "mid-1".to_string(), + "minion-fp".to_string(), + "master-fp".to_string(), + "conn-1".to_string(), + "nonce-1".to_string(), + fresh_timestamp(), + )); + + assert_eq!(key, "mid-1:conn-1:nonce-1"); + assert_eq!(SysMaster::peer_rate_limit_key_for_test("127.0.0.1:4200"), "127.0.0.1"); +} + +#[test] +fn invalid_hello_does_not_poison_replay_cache_then_valid_retry_is_accepted() { + let (master_prk, master_pbk) = keygen(2048).unwrap(); + let (minion_prk, minion_pbk) = keygen(2048).unwrap(); + let state = state(&master_pbk, &minion_pbk); + let (_, opening) = SecureBootstrapSession::open(&state, &minion_prk, &master_pbk).unwrap(); + let hello = match opening { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }; + + let mut tampered = hello.clone(); + tampered.binding_signature = "corrupted-signature".to_string(); + + let mut cache = HashMap::::new(); + let replay_key = SysMaster::replay_cache_key_for_test(&hello.binding); + + assert!(SysMaster::accept_bootstrap_auth_then_replay_for_test(&mut cache, &state, &tampered, &master_prk, &minion_pbk, Instant::now(),).is_err()); + assert!(!cache.contains_key(&replay_key)); + + assert!(matches!( + SysMaster::accept_bootstrap_auth_then_replay_for_test(&mut cache, &state, &hello, &master_prk, &minion_pbk, Instant::now(),).unwrap(), + SecureFrame::BootstrapAck(_) + )); + + assert!(matches!( + SysMaster::accept_bootstrap_auth_then_replay_for_test( + &mut cache, + &state, + &hello, + &master_prk, + &minion_pbk, + Instant::now(), + ) + .unwrap(), + SecureFrame::BootstrapDiagnostic(frame) if frame.message.contains("replay") + )); +} diff --git a/sysmaster/src/registry/mkb.rs b/sysmaster/src/registry/mkb.rs index 8dcc4a6c..ea007f14 100644 --- a/sysmaster/src/registry/mkb.rs +++ b/sysmaster/src/registry/mkb.rs @@ -1,9 +1,11 @@ use ::rsa::{RsaPrivateKey, RsaPublicKey}; use libcommon::SysinspectError; use libsysinspect::{ - cfg::mmconf::{CFG_MASTER_KEY_PRI, CFG_MASTER_KEY_PUB}, + cfg::mmconf::{CFG_MASTER_KEY_PRI, CFG_MASTER_KEY_PUB, CFG_TRANSPORT_ROOT, CFG_TRANSPORT_STATE}, rsa, + transport::{TransportStore, transport_minion_root}, }; +use libsysproto::secure::SECURE_PROTOCOL_VERSION; use std::{collections::HashMap, fs, io, path::PathBuf}; /// Registered minion base. @@ -39,7 +41,7 @@ impl MinionsKeyRegistry { self.ms_pbk_pem = Some(fs::read_to_string(pbk_pth)?); (self.ms_prk, self.ms_pbk) = rsa::keys::from_pem(Some(&prk_pem), self.ms_pbk_pem.as_deref())?; - if self.ms_pbk.is_none() || self.ms_pbk.is_none() { + if self.ms_prk.is_none() || self.ms_pbk.is_none() { return Err(SysinspectError::MasterGeneralError("Unable to initialise RSA keys".to_string())); } @@ -58,6 +60,8 @@ impl MinionsKeyRegistry { fs::write(prk_pth, prk_pem.unwrap().as_bytes())?; fs::write(pbk_pth, pbk_pem.clone().unwrap().as_bytes())?; + self.ms_prk = Some(prk); + self.ms_pbk = Some(pbk); self.ms_pbk_pem = pbk_pem; log::debug!("RSA keys saved to the disk"); @@ -71,11 +75,16 @@ impl MinionsKeyRegistry { fs::create_dir_all(&self.root)?; } else { for e in fs::read_dir(&self.root)?.flatten() { - self.keys.insert(e.file_name().to_str().and_then(|e| e.split('.').next()).unwrap_or_default().to_string(), None); + if let Some(mid) = e.file_name().to_str().and_then(|e| e.split('.').next()) + && !mid.is_empty() + { + self.keys.insert(mid.to_string(), None); + } } } - self.init_keys() + self.init_keys()?; + self.backfill_transport_state() } /// Returns a method if a minion Id is known to the key registry. @@ -88,6 +97,18 @@ impl MinionsKeyRegistry { &self.ms_pbk_pem } + /// Return the loaded master RSA private key used for secure bootstrap acceptance. + pub fn master_private_key(&self) -> Result { + self.ms_prk.clone().ok_or_else(|| SysinspectError::MasterGeneralError("Master RSA private key is not loaded".to_string())) + } + + pub fn get_master_key_fingerprint(&self) -> Result { + rsa::keys::get_fingerprint( + self.ms_pbk.as_ref().ok_or_else(|| SysinspectError::MasterGeneralError("Master RSA public key is not loaded".to_string()))?, + ) + .map_err(|err| SysinspectError::RSAError(err.to_string())) + } + /// Add minion key pub fn add_mn_key(&mut self, mid: &str, addr: &str, pbk_pem: &str) -> Result<(), SysinspectError> { let k_pth = self.root.join(format!("{mid}.rsa.pub")); @@ -96,11 +117,24 @@ impl MinionsKeyRegistry { let (_, pbk) = rsa::keys::from_pem(None, Some(pbk_pem))?; if let Some(pbk) = pbk { + self.ensure_transport_state(mid, &pbk)?; self.keys.insert(mid.to_string(), Some(pbk)); } Ok(()) } + pub fn get_mn_key_fingerprint(&mut self, mid: &str) -> Result { + rsa::keys::get_fingerprint( + &self.get_mn_key(mid).ok_or_else(|| SysinspectError::MasterGeneralError(format!("RSA public key for minion {mid} is not loaded")))?, + ) + .map_err(|err| SysinspectError::RSAError(err.to_string())) + } + + /// Return the loaded minion RSA public key used for secure bootstrap verification. + pub fn minion_public_key(&mut self, mid: &str) -> Result { + self.get_mn_key(mid).ok_or_else(|| SysinspectError::MasterGeneralError(format!("RSA public key for minion {mid} is not loaded"))) + } + /// Lazy-load minion key. By start all keys are only containing minion Ids. /// If a key is requested, it is loaded from the disk on demand. fn get_mn_key(&mut self, mid: &str) -> Option { @@ -138,10 +172,84 @@ impl MinionsKeyRegistry { return Err(SysinspectError::IoErr(io::Error::new(io::ErrorKind::NotFound, format!("No RSA public key found for {mid}")))); } + // Keep registration cleanup symmetric by removing managed transport metadata too. + let transport_root = transport_minion_root(&self.transport_root()?, mid)?; + if transport_root.exists() { + fs::remove_dir_all(transport_root)?; + } + + Ok(()) + } + + fn transport_root(&self) -> Result { + Ok(self + .root + .parent() + .ok_or_else(|| SysinspectError::ConfigError(format!("Registry root {} has no parent for transport metadata", self.root.display())))? + .join(CFG_TRANSPORT_ROOT)) + } + + fn backfill_transport_state(&mut self) -> Result<(), SysinspectError> { + for mid in self.keys.keys().cloned().collect::>() { + if let Some(pbk) = self.get_mn_key(&mid) { + self.ensure_transport_state(&mid, &pbk)?; + } + } + Ok(()) + } + + fn ensure_transport_state(&self, mid: &str, pbk: &RsaPublicKey) -> Result<(), SysinspectError> { + let store = TransportStore::new(transport_minion_root(&self.transport_root()?, mid)?.join(CFG_TRANSPORT_STATE))?; + let _ = store.ensure_automatic_peer( + mid, + &self.get_master_key_fingerprint()?, + &rsa::keys::get_fingerprint(pbk).map_err(|err| SysinspectError::RSAError(err.to_string()))?, + SECURE_PROTOCOL_VERSION, + )?; Ok(()) } +} - pub fn encrypt_with_mn_key(&self) {} +#[cfg(test)] +mod tests { + use super::MinionsKeyRegistry; + use libsysinspect::{ + rsa::keys::{keygen, to_pem}, + transport::TransportStore, + }; + use libsysproto::secure::SECURE_PROTOCOL_VERSION; + + #[test] + fn registration_creates_transport_state_for_registered_minion() { + let root = tempfile::tempdir().unwrap(); + let mut registry = MinionsKeyRegistry::new(root.path().join("minion-keys")).unwrap(); + let (_, minion_pbk) = keygen(2048).unwrap(); + let (_, minion_pem) = to_pem(None, Some(&minion_pbk)).unwrap(); + + registry.add_mn_key("mid-1", "127.0.0.1:4200", &minion_pem.unwrap()).unwrap(); + + let store = TransportStore::new(root.path().join("transport/minions/mid-1/state.json")).unwrap(); + let state = store.load().unwrap().unwrap(); + assert_eq!(state.minion_id, "mid-1"); + assert_eq!(state.protocol_version, SECURE_PROTOCOL_VERSION); + assert_eq!(state.master_rsa_fingerprint, registry.get_master_key_fingerprint().unwrap()); + assert_eq!(state.minion_rsa_fingerprint, registry.get_mn_key_fingerprint("mid-1").unwrap()); + } - pub fn encrypt_with_mst_key(&self) {} + #[test] + fn startup_backfills_transport_state_for_existing_registered_minion() { + let root = tempfile::tempdir().unwrap(); + let (_, minion_pbk) = keygen(2048).unwrap(); + let (_, minion_pem) = to_pem(None, Some(&minion_pbk)).unwrap(); + std::fs::create_dir_all(root.path().join("minion-keys")).unwrap(); + std::fs::write(root.path().join("minion-keys/mid-1.rsa.pub"), minion_pem.unwrap()).unwrap(); + + let mut registry = MinionsKeyRegistry::new(root.path().join("minion-keys")).unwrap(); + + let store = TransportStore::new(root.path().join("transport/minions/mid-1/state.json")).unwrap(); + let state = store.load().unwrap().unwrap(); + assert_eq!(state.minion_id, "mid-1"); + assert_eq!(state.master_rsa_fingerprint, registry.get_master_key_fingerprint().unwrap()); + assert_eq!(state.minion_rsa_fingerprint, registry.get_mn_key_fingerprint("mid-1").unwrap()); + } } diff --git a/sysmaster/src/registry/mreg.rs b/sysmaster/src/registry/mreg.rs index f3c3645f..6e8f3a37 100644 --- a/sysmaster/src/registry/mreg.rs +++ b/sysmaster/src/registry/mreg.rs @@ -6,7 +6,12 @@ use libcommon::SysinspectError; use libsysproto::MinionTarget; use serde_json::{Value, json}; use sled::{Db, Tree}; -use std::{collections::HashMap, fs, path::PathBuf, sync::Arc}; +use std::{ + collections::{BTreeSet, HashMap}, + fs, + path::PathBuf, + sync::Arc, +}; const DB_MINIONS: &str = "minions"; @@ -38,7 +43,9 @@ impl MinionRegistry { } /// Add or update traits - pub fn refresh(&mut self, mid: &str, traits: HashMap) -> Result<(), SysinspectError> { + pub fn refresh( + &mut self, mid: &str, traits: HashMap, static_keys: BTreeSet, fn_keys: BTreeSet, + ) -> Result<(), SysinspectError> { let minions = self.get_tree(DB_MINIONS)?; match minions.contains_key(mid) { Ok(exists) => { @@ -55,7 +62,7 @@ impl MinionRegistry { Err(err) => return Err(SysinspectError::MasterGeneralError(format!("Unable to access the database: {err}"))), }; - self.add(mid, MinionRecord::new(mid.to_string(), traits))?; + self.add(mid, MinionRecord::new(mid.to_string(), traits, static_keys, fn_keys))?; Ok(()) } @@ -262,3 +269,39 @@ impl MinionRegistry { vec![] } } + +#[cfg(test)] +mod tests { + use super::MinionRegistry; + use serde_json::json; + use std::collections::{BTreeSet, HashMap}; + + fn registry_with_one_minion() -> MinionRegistry { + let tmp = tempfile::tempdir().unwrap(); + let mut registry = MinionRegistry::new(tmp.path().to_path_buf()).unwrap(); + let mut traits = HashMap::new(); + traits.insert("system.hostname".to_string(), json!("alien")); + traits.insert("system.hostname.fqdn".to_string(), json!("alien.lab")); + traits.insert("system.hostname.ip".to_string(), json!("192.168.2.186")); + registry.refresh("30006546535e428aba0a0caa6712e225", traits, BTreeSet::new(), BTreeSet::new()).unwrap(); + registry + } + + #[test] + fn get_by_hostname_or_ip_matches_plain_hostname() { + let mut registry = registry_with_one_minion(); + let records = registry.get_by_hostname_or_ip("alien").unwrap(); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].id(), "30006546535e428aba0a0caa6712e225"); + } + + #[test] + fn get_by_query_matches_plain_hostname() { + let registry = registry_with_one_minion(); + let records = registry.get_by_query("alien").unwrap(); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].id(), "30006546535e428aba0a0caa6712e225"); + } +} diff --git a/sysmaster/src/registry/rec.rs b/sysmaster/src/registry/rec.rs index 0bbc7e79..bfb1ac8a 100644 --- a/sysmaster/src/registry/rec.rs +++ b/sysmaster/src/registry/rec.rs @@ -1,16 +1,20 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct MinionRecord { id: String, traits: HashMap, + #[serde(default)] + static_keys: BTreeSet, + #[serde(default)] + fn_keys: BTreeSet, } impl MinionRecord { - pub fn new(id: String, traits: HashMap) -> Self { - MinionRecord { id, traits } + pub fn new(id: String, traits: HashMap, static_keys: BTreeSet, fn_keys: BTreeSet) -> Self { + MinionRecord { id, traits, static_keys, fn_keys } } /// Check if the record matches the value @@ -51,4 +55,12 @@ impl MinionRecord { pub fn get_traits(&self) -> &HashMap { &self.traits } + + pub fn is_function_trait(&self, key: &str) -> bool { + self.fn_keys.contains(key) + } + + pub fn is_yaml_trait(&self, key: &str) -> bool { + self.static_keys.contains(key) + } } diff --git a/sysmaster/src/transport.rs b/sysmaster/src/transport.rs new file mode 100644 index 00000000..dfeedbf5 --- /dev/null +++ b/sysmaster/src/transport.rs @@ -0,0 +1,317 @@ +use crate::{master::RotationCommandPayload, registry::mkb::MinionsKeyRegistry}; +use chrono::{Duration as ChronoDuration, Utc}; +use libcommon::SysinspectError; +use libsysinspect::{ + cfg::mmconf::MasterConfig, + rsa::rotation::{RotationActor, RsaTransportRotator}, + transport::{ + TransportPeerState, TransportStore, + secure_bootstrap::{SecureBootstrapDiagnostics, SecureBootstrapSession}, + secure_channel::{SecureChannel, SecurePeerRole}, + }, +}; +use libsysproto::{ + MasterMessage, MinionMessage, ProtoConversion, + rqtypes::RequestType, + secure::{SecureBootstrapHello, SecureFrame, SecureSessionBinding}, +}; +use rsa::RsaPublicKey; +use std::{ + collections::{HashMap, HashSet}, + time::{Duration as StdDuration, Instant}, +}; + +const BOOTSTRAP_MALFORMED_WINDOW: StdDuration = StdDuration::from_secs(30); +const BOOTSTRAP_REPLAY_WINDOW: StdDuration = StdDuration::from_secs(300); + +/// One active peer session bound to a minion identifier and channel state. +#[derive(Debug)] +pub(crate) struct PeerConnection { + minion_id: String, + channel: SecureChannel, +} + +/// Decoded inbound peer frames after bootstrap and transport checks. +#[derive(Debug)] +pub(crate) enum IncomingFrame { + Forward(Vec), + Reply(Vec), +} + +/// Outbound peer frames, either one-off replies or broadcast messages. +#[derive(Debug)] +pub(crate) enum OutgoingFrame { + Broadcast(Box), + Direct(Vec), +} + +/// Stateful master-side transport protocol manager for bootstrap, replay, and peer channel tracking. +#[derive(Debug, Default)] +pub(crate) struct PeerTransport { + peers: HashMap, + plaintext_peers: HashSet, + bootstrap_failures: HashMap, + bootstrap_replay_cache: HashMap, +} + +impl PeerTransport { + /// Create empty peer transport state for a fresh master instance. + pub(crate) fn new() -> Self { + Self::default() + } + + /// Mark a peer as allowed to receive the one-shot plaintext registration reply path. + pub(crate) fn allow_plaintext(&mut self, peer_addr: &str) { + self.plaintext_peers.insert(peer_addr.to_string()); + } + + /// Remove all transport state associated with one peer connection. + pub(crate) fn remove_peer(&mut self, peer_addr: &str) { + self.peers.remove(peer_addr); + self.plaintext_peers.remove(peer_addr); + } + + /// Return whether this peer may receive a broadcast frame right now. + pub(crate) fn can_receive_broadcast(&self, peer_addr: &str) -> bool { + Self::can_receive_broadcast_state(self.peers.contains_key(peer_addr), self.plaintext_peers.contains(peer_addr)) + } + + /// Return whether this connection may receive plaintext broadcast traffic before bootstrap completes. + pub(crate) fn can_receive_broadcast_state(has_session: bool, plaintext_allowed: bool) -> bool { + has_session || plaintext_allowed + } + + /// Build a plaintext bootstrap diagnostic when a peer speaks framed transport to a plaintext-only path. + pub(crate) fn bootstrap_diag(&mut self, peer_addr: &str, data: &[u8]) -> Option> { + Self::bootstrap_diag_with_state(&mut self.bootstrap_failures, peer_addr, data) + } + + /// Build a plaintext bootstrap diagnostic from shared malformed-attempt state. + pub(crate) fn bootstrap_diag_with_state(failures: &mut HashMap, peer_addr: &str, data: &[u8]) -> Option> { + let peer_key = Self::rate_limit_key(peer_addr); + match serde_json::from_slice::(data) { + Ok(SecureFrame::BootstrapHello(_) | SecureFrame::BootstrapAck(_) | SecureFrame::Data(_)) => { + failures.remove(&peer_key); + serde_json::to_vec(&SecureBootstrapDiagnostics::unsupported_version("Secure transport is not enabled on this master yet")).ok() + } + Err(_) if std::str::from_utf8(data).ok().map(|text| text.contains("\"kind\"")).unwrap_or(false) => { + serde_json::to_vec(&Self::malformed_diag(failures, &peer_key)).ok() + } + _ => None, + } + } + + /// Return a plaintext diagnostic when a minion sends normal protocol traffic before bootstrap. + pub(crate) fn plaintext_diag(data: &[u8]) -> Option> { + match serde_json::from_slice::(data) { + Ok(req) if matches!(req.req_type(), RequestType::Add) => None, + Ok(_) => serde_json::to_vec(&SecureBootstrapDiagnostics::unsupported_version( + "Plaintext minion traffic is not allowed; secure bootstrap is required", + )) + .ok(), + Err(_) => None, + } + } + + /// Encode one outbound message for a peer, sealing it when a session already exists. + pub(crate) fn encode_message(&mut self, peer_addr: &str, msg: &MasterMessage) -> Result, SysinspectError> { + if let Some(peer) = self.peers.get_mut(peer_addr) { + return peer.channel.seal(msg); + } + msg.sendable() + } + + /// Decode one inbound raw frame, handling bootstrap establishment and steady-state decryption. + pub(crate) fn decode_frame( + &mut self, peer_addr: &str, raw: &[u8], cfg: &MasterConfig, mkr: &mut MinionsKeyRegistry, + ) -> Result { + if self.peers.contains_key(peer_addr) { + let decoded = self + .peers + .get_mut(peer_addr) + .ok_or_else(|| SysinspectError::ProtoError(format!("Peer transport state for {peer_addr} disappeared")))? + .channel + .open_bytes(raw); + if decoded.is_err() { + log::warn!("Session for peer {} became invalid; dropping channel state", peer_addr); + self.peers.remove(peer_addr); + } + return decoded.map(IncomingFrame::Forward); + } + if let Ok(SecureFrame::BootstrapHello(hello)) = serde_json::from_slice::(raw) { + return self.accept_bootstrap(peer_addr, &hello, cfg, mkr).map(IncomingFrame::Reply); + } + if let Some(diag) = self.bootstrap_diag(peer_addr, raw) { + return Ok(IncomingFrame::Reply(diag)); + } + if let Some(diag) = Self::plaintext_diag(raw) { + return Ok(IncomingFrame::Reply(diag)); + } + Ok(IncomingFrame::Forward(raw.to_vec())) + } + + /// Accept a bootstrap hello from a registered minion and store the resulting session for that peer. + pub(crate) fn accept_bootstrap( + &mut self, peer_addr: &str, hello: &SecureBootstrapHello, cfg: &MasterConfig, mkr: &mut MinionsKeyRegistry, + ) -> Result, SysinspectError> { + let now = Instant::now(); + if self.peers.iter().any(|(addr, peer)| addr != peer_addr && peer.minion_id == hello.binding.minion_id) { + log::warn!("Rejecting duplicate bootstrap for minion {} from {}", hello.binding.minion_id, peer_addr); + return serde_json::to_vec(&SecureBootstrapDiagnostics::duplicate_session(format!( + "Secure session for {} already exists", + hello.binding.minion_id + ))) + .map_err(SysinspectError::from); + } + if let Some(message) = Self::bootstrap_precheck(&mut self.bootstrap_replay_cache, &hello.binding, now) { + log::warn!("Rejecting bootstrap for minion {} from {}: {}", hello.binding.minion_id, peer_addr, message); + return serde_json::to_vec(&SecureBootstrapDiagnostics::replay_rejected(message)).map_err(SysinspectError::from); + } + let mut state = TransportStore::for_master_minion(cfg, &hello.binding.minion_id)? + .load()? + .ok_or_else(|| SysinspectError::ProtoError(format!("No managed transport state exists for {}", hello.binding.minion_id)))?; + let master_prk = mkr.master_private_key()?; + let minion_pbk = mkr.minion_public_key(&hello.binding.minion_id)?; + let (bootstrap, ack) = SecureBootstrapSession::accept( + &state, + hello, + &master_prk, + &minion_pbk, + None, + state.active_key_id.clone().or_else(|| state.last_key_id.clone()), + None, + )?; + Self::record_bootstrap_replay(&mut self.bootstrap_replay_cache, &hello.binding, now); + + Self::promote_bootstrap_key(cfg, mkr, &mut state, hello, bootstrap.key_id())?; + TransportStore::for_master_minion(cfg, &hello.binding.minion_id)?.save(&state)?; + log::info!( + "Session established for minion {} from {} using key {} and protocol v{}", + hello.binding.minion_id, + peer_addr, + bootstrap.key_id(), + hello.binding.protocol_version + ); + self.peers.insert( + peer_addr.to_string(), + PeerConnection { minion_id: hello.binding.minion_id.clone(), channel: SecureChannel::new(SecurePeerRole::Master, &bootstrap)? }, + ); + serde_json::to_vec(&ack).map_err(SysinspectError::from) + } + + /// Return the replay-cache key for one bootstrap opening attempt. + pub(crate) fn replay_cache_key(binding: &SecureSessionBinding) -> String { + format!("{}:{}:{}", binding.minion_id, binding.connection_id, binding.client_nonce) + } + + /// Normalize peer address for rate-limiting so reconnects from new source ports cannot evade limits. + pub(crate) fn rate_limit_key(peer_addr: &str) -> String { + peer_addr.parse::().map(|addr| addr.ip().to_string()).unwrap_or_else(|_| peer_addr.to_string()) + } + + /// Reject stale or already-seen bootstrap openings before any expensive cryptographic work. + pub(crate) fn bootstrap_precheck(cache: &mut HashMap, binding: &SecureSessionBinding, now: Instant) -> Option { + Self::prune_bootstrap_replay_cache(cache, now); + let current_time = chrono::Utc::now().timestamp(); + let drift = (current_time - binding.timestamp).abs(); + if drift > BOOTSTRAP_REPLAY_WINDOW.as_secs() as i64 { + return Some(format!("Secure bootstrap timestamp drift {}s exceeds the allowed {}s window", drift, BOOTSTRAP_REPLAY_WINDOW.as_secs())); + } + let key = Self::replay_cache_key(binding); + if cache.contains_key(&key) { + return Some(format!("Secure bootstrap replay rejected for {}", binding.minion_id)); + } + None + } + + /// Record one authenticated bootstrap opening so later duplicates are rejected before RSA decryption. + pub(crate) fn record_bootstrap_replay(cache: &mut HashMap, binding: &SecureSessionBinding, now: Instant) { + Self::prune_bootstrap_replay_cache(cache, now); + let key = Self::replay_cache_key(binding); + cache.insert(key, now); + } + + fn prune_bootstrap_replay_cache(cache: &mut HashMap, now: Instant) { + cache.retain(|_, seen_at| now.duration_since(*seen_at) <= BOOTSTRAP_REPLAY_WINDOW); + } + + /// Apply staged rotation context when the bootstrap key matches the expected promoted key. + fn promote_bootstrap_key( + cfg: &MasterConfig, mkr: &mut MinionsKeyRegistry, state: &mut TransportPeerState, hello: &SecureBootstrapHello, bootstrap_key_id: &str, + ) -> Result<(), SysinspectError> { + if let Some(context) = state.pending_rotation_context.clone() + && let Ok(payload) = serde_json::from_str::(&context) + && payload.intent.intent().next_key_id() == bootstrap_key_id + { + let overlap = ChronoDuration::seconds(payload.grace_seconds as i64); + let mut rotator = Self::master_rotator(cfg, mkr, &hello.binding.minion_id)?; + let master_pbk = RsaPublicKey::from(&mkr.master_private_key()?); + let _ = rotator.execute_signed_intent_with_overlap(&payload.intent, &master_pbk, overlap)?; + let _ = rotator.retire_elapsed_keys(Utc::now(), overlap)?; + *state = rotator.state().clone(); + state.set_pending_rotation_context(None); + return Ok(()); + } + state.upsert_key(bootstrap_key_id, libsysinspect::transport::TransportKeyStatus::Active); + Ok(()) + } + + /// Build the reusable master-side transport rotator for one minion. + fn master_rotator(cfg: &MasterConfig, mkr: &mut MinionsKeyRegistry, minion_id: &str) -> Result { + RsaTransportRotator::new( + RotationActor::Master, + TransportStore::for_master_minion(cfg, minion_id)?, + minion_id, + &libsysinspect::rsa::keys::get_fingerprint(&RsaPublicKey::from(&mkr.master_private_key()?)) + .map_err(|err| SysinspectError::RSAError(err.to_string()))?, + &mkr.get_mn_key_fingerprint(minion_id)?, + libsysproto::secure::SECURE_PROTOCOL_VERSION, + ) + } + + /// Rate-limit repeated malformed bootstrap attempts from the same peer before transport is enabled. + fn malformed_diag(failures: &mut HashMap, peer_key: &str) -> SecureFrame { + let now = Instant::now(); + let count = match failures.get_mut(peer_key) { + Some((seen_at, count)) if now.duration_since(*seen_at) <= BOOTSTRAP_MALFORMED_WINDOW => { + *count += 1; + *seen_at = now; + *count + } + Some(entry) => { + *entry = (now, 1); + 1 + } + None => { + failures.insert(peer_key.to_string(), (now, 1)); + 1 + } + }; + + if count >= 3 { + return SecureBootstrapDiagnostics::rate_limited("Repeated malformed secure bootstrap frames"); + } + SecureBootstrapDiagnostics::malformed("Malformed secure bootstrap frame") + } + + #[cfg(test)] + pub(crate) fn accept_bootstrap_auth_then_replay_for_test( + cache: &mut HashMap, state: &TransportPeerState, hello: &SecureBootstrapHello, master_prk: &rsa::RsaPrivateKey, + minion_pbk: &rsa::RsaPublicKey, now: Instant, + ) -> Result { + if let Some(message) = Self::bootstrap_precheck(cache, &hello.binding, now) { + return Ok(SecureBootstrapDiagnostics::replay_rejected(message)); + } + let (_, ack) = SecureBootstrapSession::accept( + state, + hello, + master_prk, + minion_pbk, + None, + state.active_key_id.clone().or_else(|| state.last_key_id.clone()), + None, + )?; + Self::record_bootstrap_replay(cache, &hello.binding, now); + Ok(ack) + } +} diff --git a/sysminion/Cargo.toml b/sysminion/Cargo.toml index dd8a5f7e..cdd44984 100644 --- a/sysminion/Cargo.toml +++ b/sysminion/Cargo.toml @@ -4,6 +4,7 @@ version = "0.4.0" edition = "2024" [dependencies] +chrono = "0.4.43" clap = { version = "4.5.54", features = ["unstable-styles"] } colored = "3.1.1" ed25519-dalek = { version = "2.2.0", features = [ diff --git a/sysminion/src/main.rs b/sysminion/src/main.rs index e7c68562..ec233b51 100644 --- a/sysminion/src/main.rs +++ b/sysminion/src/main.rs @@ -12,6 +12,12 @@ mod filedata_ut; #[cfg(test)] mod minion_ut; +#[cfg(test)] +mod proto_ut; + +#[cfg(test)] +mod rsa_ut; + use clap::{ArgMatches, Command}; use clidef::cli; use daemonize::Daemonize; diff --git a/sysminion/src/minion.rs b/sysminion/src/minion.rs index 484eb288..5f28ccc3 100644 --- a/sysminion/src/minion.rs +++ b/sysminion/src/minion.rs @@ -8,6 +8,7 @@ use crate::{ ptcounter::PTCounter, rsa::MinionRSAKeyManager, }; +use chrono::{Duration as ChronoDuration, Utc}; use clap::ArgMatches; use colored::Colorize; use indexmap::IndexMap; @@ -20,7 +21,7 @@ use libsetup::get_ssh_client_ip; use libsysinspect::{ cfg::{ get_minion_config, - mmconf::{DEFAULT_PORT, MinionConfig, SysInspectConfig}, + mmconf::{CFG_MASTER_KEY_PUB, DEFAULT_PORT, MinionConfig, SysInspectConfig}, }, context, inspector::SysInspectRunner, @@ -33,8 +34,16 @@ use libsysinspect::{ evtproc::EventProcessor, fmt::{formatter::StringFormatter, kvfmt::KeyValueFormatter}, }, - rsa, + rsa::{ + self, + rotation::{RotationActor, RsaTransportRotator, SignedRotationIntent}, + }, traits::{self, TraitUpdateRequest, effective_profiles, ensure_master_traits_file, systraits::SystemTraits}, + transport::{ + TransportStore, + secure_bootstrap::SecureBootstrapSession, + secure_channel::{SECURE_MAX_FRAME_SIZE, SecureChannel, SecurePeerRole}, + }, util::{self, dataconv}, }; use libsysproto::{ @@ -46,8 +55,10 @@ use libsysproto::{ commands::{CLUSTER_REBOOT, CLUSTER_REMOVE_MINION, CLUSTER_ROTATE, CLUSTER_SHUTDOWN, CLUSTER_SYNC, CLUSTER_TRAITS_UPDATE}, }, rqtypes::{ProtoValue, RequestType}, + secure::{SecureDiagnosticCode, SecureFrame}, }; use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; use serde_json::json; use serde_yaml::Value as YamlValue; use std::{ @@ -69,6 +80,16 @@ use uuid::Uuid; /// Session Id of the minion pub static MINION_SID: Lazy = Lazy::new(|| Uuid::new_v4().to_string()); + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RotationCommandPayload { + op: String, + reason: String, + grace_seconds: u64, + reconnect: bool, + reregister: bool, + intent: SignedRotationIntent, +} #[derive(Debug)] pub struct SysMinion { cfg: MinionConfig, @@ -86,6 +107,7 @@ pub struct SysMinion { pt_counter: Mutex, dpq: Arc, connected: AtomicBool, + secure: Mutex>, minion_id: String, @@ -136,6 +158,7 @@ impl SysMinion { pt_counter: Mutex::new(PTCounter::new()), dpq, connected: AtomicBool::new(false), + secure: Mutex::new(None), minion_id: dataconv::as_str(traits::get_minion_traits(None).get(traits::SYS_ID)), sensors_task: Mutex::new(None), sensors_pump: Mutex::new(None), @@ -189,6 +212,9 @@ impl SysMinion { log::debug!("Creating directory for the synced profiles at {}", self.cfg.profiles_dir().as_os_str().to_str().unwrap_or_default()); fs::create_dir_all(self.cfg.profiles_dir())?; } + if let Err(err) = self.kman.ensure_transport_state(self.get_minion_id()) { + log::warn!("Unable to refresh local transport state from RSA identity: {err}"); + } let mut out: Vec = vec![]; for t in traits::get_minion_traits(Some(&self.cfg)).trait_keys() { @@ -272,27 +298,123 @@ impl SysMinion { /// Talk-back to the master pub(crate) async fn request(&self, msg: Vec) { - fn trigger_reconnect(context: &str, err: &std::io::Error) { - log::warn!("{context}: {err}; triggering reconnect"); + let payload = match self.secure.lock().await.as_mut().map(|secure| secure.seal_bytes(&msg)).transpose() { + Ok(Some(msg)) => msg, + Ok(None) => msg, + Err(err) => { + log::error!("Failed to encode secure payload to master: {err}"); + let _ = CONNECTION_TX.send(()); + return; + } + }; + + if let Err(err) = self.write_frame(&payload).await { + log::warn!("Failed to send message to master: {err}; triggering reconnect"); let _ = CONNECTION_TX.send(()); + } else { + log::trace!("To master: {}", String::from_utf8_lossy(&payload)); } + } + /// Write one length-prefixed transport frame to the master connection. + async fn write_frame(&self, frame: &[u8]) -> Result<(), SysinspectError> { let mut stm = self.wstm.lock().await; + stm.write_all(&(frame.len() as u32).to_be_bytes()).await?; + stm.write_all(frame).await?; + stm.flush().await?; + Ok(()) + } - if let Err(e) = stm.write_all(&(msg.len() as u32).to_be_bytes()).await { - trigger_reconnect("Failed to send message length to master", &e); - return; + /// Read one length-prefixed transport frame from the master connection. + async fn read_frame(&self) -> Result, SysinspectError> { + let mut stm = self.rstm.lock().await; + let mut len = [0u8; 4]; + stm.read_exact(&mut len).await?; + let frame_len = u32::from_be_bytes(len) as usize; + if frame_len > SECURE_MAX_FRAME_SIZE { + return Err(SysinspectError::ProtoError(format!("Transport frame exceeds maximum size of {SECURE_MAX_FRAME_SIZE} bytes"))); } + let mut frame = vec![0u8; frame_len]; + stm.read_exact(&mut frame).await?; + Ok(frame) + } - if let Err(e) = stm.write_all(&msg).await { - trigger_reconnect("Failed to send message to master", &e); - return; + /// Mark the specified transport key, or the current managed key, as broken after a failed secure bootstrap. + fn mark_broken_transport(&self, store: &TransportStore, state: &mut libsysinspect::transport::TransportPeerState, key_id: Option<&str>) { + let changed = if let Some(key_id) = key_id { + state.upsert_key(key_id, libsysinspect::transport::TransportKeyStatus::Broken); + true + } else { + state.mark_current_key_broken() + }; + if changed && let Err(err) = store.save(state) { + log::warn!("Unable to persist broken transport state: {err}"); } + } - if let Err(e) = stm.flush().await { - trigger_reconnect("Failed to flush writer to master", &e); - } else { - log::trace!("To master: {}", String::from_utf8_lossy(&msg)); + /// Bootstrap a secure session with the master before any normal protocol traffic is allowed. + pub(crate) async fn bootstrap_secure(&self) -> Result<(), SysinspectError> { + let store = TransportStore::for_minion(&self.cfg)?; + let master_pbk = match self.kman.master_public_key()? { + Some(key) => key, + None => { + return Err(SysinspectError::ConfigError(format!( + "Trusted master RSA key is missing at {}; secure bootstrap cannot continue", + self.cfg.root_dir().join(CFG_MASTER_KEY_PUB).display() + ))); + } + }; + let mut state = match store.load()? { + Some(state) => state, + None => { + return Err(SysinspectError::ConfigError(format!( + "Managed transport state is missing at {}; secure bootstrap cannot continue", + store.state_path().display() + ))); + } + }; + let (opening, hello) = match SecureBootstrapSession::open(&state, &self.kman.private_key()?, &master_pbk) { + Ok(opening) => opening, + Err(err) => { + self.mark_broken_transport(&store, &mut state, None); + return Err(err); + } + }; + let opening_key_id = opening.key_id().to_string(); + self.write_frame( + &serde_json::to_vec(&hello) + .map_err(|err| SysinspectError::SerializationError(format!("Failed to encode secure bootstrap hello: {err}")))?, + ) + .await?; + match serde_json::from_slice::(&self.read_frame().await?) + .map_err(|err| SysinspectError::DeserializationError(format!("Failed to decode secure bootstrap reply: {err}")))? + { + SecureFrame::BootstrapAck(ack) => { + let session = match opening.verify_ack(&state, &ack, &master_pbk) { + Ok(session) => session, + Err(err) => { + self.mark_broken_transport(&store, &mut state, Some(&opening_key_id)); + return Err(err); + } + }; + state.upsert_key(session.key_id(), libsysinspect::transport::TransportKeyStatus::Active); + store.save(&state)?; + *self.secure.lock().await = Some(SecureChannel::new(SecurePeerRole::Minion, &session)?); + log::info!( + "Secure session established with master using key {} and session {}", + session.key_id(), + session.session_id().unwrap_or_default() + ); + Ok(()) + } + SecureFrame::BootstrapDiagnostic(diag) => { + self.mark_broken_transport(&store, &mut state, Some(&opening_key_id)); + Err(SysinspectError::ProtoError(format!("Master rejected secure bootstrap with {:?}: {}", diag.code, diag.message))) + } + _ => { + self.mark_broken_transport(&store, &mut state, Some(&opening_key_id)); + Err(SysinspectError::ProtoError("Master replied with a non-bootstrap frame during secure bootstrap".to_string())) + } } } @@ -443,30 +565,40 @@ impl SysMinion { } pub async fn do_proto(self: Arc) -> Result<(), SysinspectError> { - let rstm = Arc::clone(&self.rstm); let this = self.clone(); let handle = tokio::spawn(async move { loop { - let mut buff = [0u8; 4]; - if let Err(e) = rstm.lock().await.read_exact(&mut buff).await { - log::warn!("Proto read failed (len): {e}; triggering reconnect"); - let _ = CONNECTION_TX.send(()); - break; - } - - let msg_len = u32::from_be_bytes(buff) as usize; - let mut msg = vec![0u8; msg_len]; + let msg = match this.read_frame().await { + Ok(msg) => msg, + Err(err) => { + log::warn!("Proto read failed: {err}; triggering reconnect"); + let _ = CONNECTION_TX.send(()); + break; + } + }; - if let Err(e) = rstm.lock().await.read_exact(&mut msg).await { - log::warn!("Proto read failed (msg): {e}; triggering reconnect"); - let _ = CONNECTION_TX.send(()); - break; - } + let msg = match this.secure.lock().await.as_mut().map(|secure| secure.open_bytes(&msg)).transpose() { + Ok(Some(msg)) => msg, + Ok(None) => msg, + Err(err) => { + log::error!("Failed to decode secure frame from master: {err}"); + let _ = CONNECTION_TX.send(()); + break; + } + }; - let msg = match proto::msg::payload_to_msg(msg) { + let msg = match proto::msg::payload_to_msg(msg.clone()) { Ok(msg) => msg, Err(err) => { + if let Ok(diag) = proto::msg::payload_to_diag(&msg) { + log::warn!("Master rejected secure bootstrap: {}", diag.message); + if matches!(diag.code, SecureDiagnosticCode::UnsupportedVersion) { + let _ = CONNECTION_TX.send(()); + break; + } + continue; + } log::error!("Error getting network payload as message: {err}"); continue; } @@ -503,8 +635,8 @@ impl SysMinion { continue; } - if let Err(e) = this.as_ptr().dpq.add(WorkItem::MasterCommand(msg.to_owned())) { - log::error!("Failed to enqueue master command: {e}"); + if let Err(err) = this.as_ptr().dpq.add(WorkItem::MasterCommand(msg.to_owned())) { + log::error!("Failed to enqueue master command: {err}"); } else { log::info!("Scheduled master command: {}", msg.target().scheme()); } @@ -541,8 +673,8 @@ impl SysMinion { let pbk_pem = dataconv::as_str(Some(msg.payload()).cloned()); let (_, pbk) = match rsa::keys::from_pem(None, Some(&pbk_pem)) { Ok(val) => val, - Err(e) => { - log::error!("Failed to parse PEM: {e}"); + Err(err) => { + log::error!("Failed to parse PEM: {err}"); let _ = CONNECTION_TX.send(()); break; } @@ -559,8 +691,8 @@ impl SysMinion { let fpt = match rsa::keys::get_fingerprint(&pbk) { Ok(fp) => fp, - Err(e) => { - log::error!("Failed to get fingerprint: {e}"); + Err(err) => { + log::error!("Failed to get fingerprint: {err}"); let _ = CONNECTION_TX.send(()); break; } @@ -601,7 +733,7 @@ impl SysMinion { this.request(proto::msg::get_pong(ProtoValue::PingTypeDiscovery, None)).await; } - Err(e) => log::warn!("Invalid ping payload `{}`: {}", p, e), + Err(err) => log::warn!("Invalid ping payload `{}`: {}", p, err), } } @@ -613,13 +745,13 @@ impl SysMinion { RequestType::SensorsSyncResponse => { log::info!("Received sensors sync response from master"); - let sensors = SensorsFiledata::from_payload(msg.payload().clone(), this.cfg.sensors_dir()).unwrap_or_else(|e| { - log::error!("Failed to parse sensors payload: {e}"); + let sensors = SensorsFiledata::from_payload(msg.payload().clone(), this.cfg.sensors_dir()).unwrap_or_else(|err| { + log::error!("Failed to parse sensors payload: {err}"); SensorsFiledata::default() }); - if let Err(e) = this.as_ptr().do_sensors(sensors).await { - log::error!("Failed to start sensors: {e}"); + if let Err(err) = this.as_ptr().do_sensors(sensors).await { + log::error!("Failed to start sensors: {err}"); } } @@ -634,7 +766,7 @@ impl SysMinion { pub async fn send_traits(self: Arc) -> Result<(), SysinspectError> { let fresh_traits = SystemTraits::new(self.cfg.clone(), false); - let mut r = MinionMessage::new(self.get_minion_id().to_string(), RequestType::Traits, fresh_traits.to_json_value()?); + let mut r = MinionMessage::new(self.get_minion_id().to_string(), RequestType::Traits, fresh_traits.to_transport_value()?); r.set_sid(MINION_SID.to_string()); self.request(r.sendable().map_err(|e| { log::error!("Error preparing traits message: {e}"); @@ -647,11 +779,7 @@ impl SysMinion { /// Send ehlo pub async fn send_ehlo(self: Arc) -> Result<(), SysinspectError> { let fresh_traits = SystemTraits::new(self.cfg.clone(), false); - let mut r = MinionMessage::new( - dataconv::as_str(fresh_traits.get(traits::SYS_ID)), - RequestType::Ehlo, - fresh_traits.to_json_value()?, - ); + let mut r = MinionMessage::new(dataconv::as_str(fresh_traits.get(traits::SYS_ID)), RequestType::Ehlo, fresh_traits.to_json_value()?); r.set_sid(MINION_SID.to_string()); log::info!("Ehlo on {}", self.cfg.master()); @@ -706,6 +834,53 @@ impl SysMinion { } } + async fn apply_rotation_command(self: Arc, context: &str) -> Result<(), SysinspectError> { + let payload: RotationCommandPayload = + serde_json::from_str(context).map_err(|err| SysinspectError::DeserializationError(format!("Failed to parse rotate payload: {err}")))?; + if payload.op != "rotate" { + return Err(SysinspectError::InvalidQuery(format!("Unsupported rotate operation {}", payload.op))); + } + + let master_pbk = self.kman.master_public_key()?.ok_or_else(|| { + SysinspectError::ConfigError(format!( + "Trusted master RSA key is missing at {}; rotate cannot proceed", + self.cfg.root_dir().join(CFG_MASTER_KEY_PUB).display() + )) + })?; + let master_fp = rsa::keys::get_fingerprint(&master_pbk).map_err(|err| SysinspectError::RSAError(err.to_string()))?; + let minion_fp = self.kman.get_pubkey_fingerprint()?; + let mut rotator = RsaTransportRotator::new( + RotationActor::Minion, + TransportStore::for_minion(&self.cfg)?, + self.get_minion_id(), + &master_fp, + &minion_fp, + libsysproto::secure::SECURE_PROTOCOL_VERSION, + )?; + + let overlap = ChronoDuration::seconds(payload.grace_seconds as i64); + let _ = rotator.retire_elapsed_keys(Utc::now(), overlap)?; + let ticket = rotator.execute_signed_intent_with_overlap(&payload.intent, &master_pbk, overlap)?; + let _ = rotator.retire_elapsed_keys(Utc::now(), overlap)?; + + if payload.reregister { + let _ = self.kman.ensure_transport_state(self.get_minion_id())?; + } + + log::info!( + "Applied transport rotation {} for {} at {}", + ticket.result().active_key_id().bright_yellow(), + self.get_minion_id().bright_green(), + ticket.result().rotated_at().to_rfc3339().bright_blue() + ); + + if payload.reconnect { + self.send_bye().await; + } + + Ok(()) + } + /// Download a file from master async fn download_file(self: Arc, fname: &str) -> Result, SysinspectError> { async fn fetch_file(url: &str, filename: &str) -> Result { @@ -800,7 +975,10 @@ impl SysMinion { } dirty = true; } - Err(_) => todo!(), + Err(err) => { + log::error!("Unable to auto-update {uri_file}: {err}"); + continue; + } } } if dirty { @@ -848,9 +1026,14 @@ impl SysMinion { CLUSTER_REBOOT => { log::warn!("Command \"reboot\" is not implemented yet"); } - CLUSTER_ROTATE => { - log::warn!("Command \"rotate\" is not implemented yet"); - } + CLUSTER_ROTATE => match self.clone().apply_rotation_command(context).await { + Ok(_) => { + if !context.is_empty() { + let _ = CONNECTION_TX.send(()); + } + } + Err(err) => log::error!("Failed to apply rotate command: {err}"), + }, CLUSTER_REMOVE_MINION => { log::info!("{} from the master", "Unregistering".bright_red().bold()); self.as_ptr().send_bye().await; @@ -868,38 +1051,38 @@ impl SysMinion { } let _ = self.as_ptr().send_sensors_sync().await; } - CLUSTER_TRAITS_UPDATE => { - match TraitUpdateRequest::from_context(context) { - Ok(update) => match update.apply(&self.cfg) { - Ok(_) => { - let summary = if update.op() == "reset" { - "all master-managed traits".bright_yellow().to_string() - } else if update.op() == "unset" { - update.traits().keys().map(|key| key.yellow().to_string()).collect::>().join(", ") - } else { - update - .traits() - .iter() - .map(|(key, value)| format!("{}: {}", key.yellow(), dataconv::to_string(Some(value.clone())).unwrap_or_default().bright_yellow())) - .collect::>() - .join(", ") - }; - let label = match update.op() { - "set" => "Set traits", - "unset" => "Unset traits", - "reset" => "Reset traits", - _ => "Updated traits", - }; - log::info!("{}: {}", label, summary); - if let Err(err) = self.as_ptr().send_traits().await { - log::error!("Failed to sync traits with master: {err}"); - } + CLUSTER_TRAITS_UPDATE => match TraitUpdateRequest::from_context(context) { + Ok(update) => match update.apply(&self.cfg) { + Ok(_) => { + let summary = if update.op() == "reset" { + "all master-managed traits".bright_yellow().to_string() + } else if update.op() == "unset" { + update.traits().keys().map(|key| key.yellow().to_string()).collect::>().join(", ") + } else { + update + .traits() + .iter() + .map(|(key, value)| { + format!("{}: {}", key.yellow(), dataconv::to_string(Some(value.clone())).unwrap_or_default().bright_yellow()) + }) + .collect::>() + .join(", ") + }; + let label = match update.op() { + "set" => "Set traits", + "unset" => "Unset traits", + "reset" => "Reset traits", + _ => "Updated traits", + }; + log::info!("{}: {}", label, summary); + if let Err(err) = self.as_ptr().send_traits().await { + log::error!("Failed to sync traits with master: {err}"); } - Err(err) => log::error!("Failed to apply traits update: {err}"), - }, - Err(err) => log::error!("Failed to parse traits update payload: {err}"), - } - } + } + Err(err) => log::error!("Failed to apply traits update: {err}"), + }, + Err(err) => log::error!("Failed to parse traits update payload: {err}"), + }, _ => { log::warn!("Unknown command: {cmd}"); } @@ -989,6 +1172,12 @@ impl SysMinion { pub fn set_ping_timeout(&mut self, d: Duration) { self.ping_timeout = d; } + + /// Install a secure steady-state channel for tests that exercise encrypted transport writes. + #[cfg(test)] + pub async fn set_secure_channel(&self, channel: SecureChannel) { + *self.secure.lock().await = Some(channel); + } } /// Constructs and starts an actual minion @@ -1020,13 +1209,16 @@ pub(crate) async fn _minion_instance(cfg: MinionConfig, fingerprint: Option TransportPeerState { + let mut state = TransportPeerState::new( + "mid-1".to_string(), + libsysinspect::rsa::keys::get_fingerprint(master_pbk).unwrap(), + libsysinspect::rsa::keys::get_fingerprint(minion_pbk).unwrap(), + SECURE_PROTOCOL_VERSION, + ); + state.key_exchange = TransportKeyExchangeModel::EphemeralSessionKeys; + state.provisioning = TransportProvisioningMode::Automatic; + state.rotation = TransportRotationStatus::Idle; + state.active_key_id = Some("kid-1".to_string()); + state.last_key_id = Some("kid-1".to_string()); + state + } + + fn secure_channels(root: &std::path::Path) -> (SecureChannel, SecureChannel) { + let keyman = MinionRSAKeyManager::new(root.to_path_buf()).unwrap(); + let (master_prk, master_pbk) = libsysinspect::rsa::keys::keygen(2048).unwrap(); + let (_, minion_pbk) = libsysinspect::rsa::keys::from_pem(None, Some(&keyman.get_pubkey_pem())).unwrap(); + let state = secure_state(&master_pbk, &minion_pbk.unwrap()); + let (opening, hello) = SecureBootstrapSession::open(&state, &keyman.private_key().unwrap(), &master_pbk).unwrap(); + let accepted = SecureBootstrapSession::accept( + &state, + match &hello { + SecureFrame::BootstrapHello(hello) => hello, + _ => panic!("expected bootstrap hello"), + }, + &master_prk, + &libsysinspect::rsa::keys::from_pem(None, Some(&keyman.get_pubkey_pem())).unwrap().1.unwrap(), + Some("sid-1".to_string()), + Some("kid-1".to_string()), + None, + ) + .unwrap(); + + ( + SecureChannel::new(SecurePeerRole::Master, &accepted.0).unwrap(), + SecureChannel::new( + SecurePeerRole::Minion, + &opening + .verify_ack( + &state, + match &accepted.1 { + SecureFrame::BootstrapAck(ack) => ack, + _ => panic!("expected bootstrap ack"), + }, + &master_pbk, + ) + .unwrap(), + ) + .unwrap(), + ) + } + static TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); fn mk_cfg(master: String, _fileserver: String, root: &std::path::Path) -> MinionConfig { @@ -193,6 +258,112 @@ mod tests { assert_eq!(msg, payload); } + #[tokio::test] + async fn request_seals_payload_when_secure_channel_is_active() { + let _guard = TEST_LOCK.lock().await; + use tokio::io::AsyncReadExt; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut lenb = [0u8; 4]; + sock.read_exact(&mut lenb).await.unwrap(); + let mut msg = vec![0u8; u32::from_be_bytes(lenb) as usize]; + sock.read_exact(&mut msg).await.unwrap(); + msg + }); + + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = MinionConfig::default(); + cfg.set_master_ip(&addr.ip().to_string()); + cfg.set_master_port(addr.port().into()); + cfg.set_root_dir(tmp.path().to_str().unwrap()); + + let dpq = Arc::new(DiskPersistentQueue::open(tmp.path().join("pending-tasks")).unwrap()); + let minion = SysMinion::new(cfg, None, dpq).await.unwrap(); + let (mut master_channel, minion_channel) = secure_channels(tmp.path()); + minion.set_secure_channel(minion_channel).await; + + minion.request(br#"{"r":"ping"}"#.to_vec()).await; + + assert_eq!(master_channel.open_bytes(&server.await.unwrap()).unwrap(), br#"{"r":"ping"}"#.to_vec()); + } + + #[tokio::test] + async fn bootstrap_secure_fails_closed_without_trusted_master_key() { + let _guard = TEST_LOCK.lock().await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + let (_sock, _) = listener.accept().await.unwrap(); + tokio::time::sleep(Duration::from_secs(1)).await; + }); + + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = MinionConfig::default(); + cfg.set_master_ip(&addr.ip().to_string()); + cfg.set_master_port(addr.port().into()); + cfg.set_root_dir(tmp.path().to_str().unwrap()); + + let dpq = Arc::new(DiskPersistentQueue::open(tmp.path().join("pending-tasks")).unwrap()); + let minion = SysMinion::new(cfg, None, dpq).await.unwrap(); + + let err = minion.bootstrap_secure().await.unwrap_err().to_string(); + assert!(err.contains("Trusted master RSA key is missing"), "unexpected error: {err}"); + } + + #[tokio::test] + async fn bootstrap_secure_marks_transport_state_broken_on_diagnostic_reply() { + let _guard = TEST_LOCK.lock().await; + use libsysproto::secure::{SecureBootstrapDiagnostic, SecureDiagnosticCode, SecureFailureSemantics, SecureFrame}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut lenb = [0u8; 4]; + sock.read_exact(&mut lenb).await.unwrap(); + let mut hello = vec![0u8; u32::from_be_bytes(lenb) as usize]; + sock.read_exact(&mut hello).await.unwrap(); + assert!(!hello.is_empty()); + + let reply = serde_json::to_vec(&SecureFrame::BootstrapDiagnostic(SecureBootstrapDiagnostic { + code: SecureDiagnosticCode::BootstrapRejected, + message: "stale trust".to_string(), + failure: SecureFailureSemantics::diagnostic(false, true), + })) + .unwrap(); + sock.write_all(&(reply.len() as u32).to_be_bytes()).await.unwrap(); + sock.write_all(&reply).await.unwrap(); + sock.flush().await.unwrap(); + }); + + let tmp = tempfile::tempdir().unwrap(); + let (_, master_pbk) = keygen(2048).unwrap(); + key_to_file(&Public(master_pbk), tmp.path().to_str().unwrap(), CFG_MASTER_KEY_PUB).unwrap(); + + let mut cfg = MinionConfig::default(); + cfg.set_master_ip(&addr.ip().to_string()); + cfg.set_master_port(addr.port().into()); + cfg.set_root_dir(tmp.path().to_str().unwrap()); + + let dpq = Arc::new(DiskPersistentQueue::open(tmp.path().join("pending-tasks")).unwrap()); + let minion = SysMinion::new(cfg.clone(), None, dpq).await.unwrap(); + let store = TransportStore::for_minion(&cfg).unwrap(); + let err = minion.bootstrap_secure().await.unwrap_err().to_string(); + assert!(err.contains("Master rejected secure bootstrap"), "unexpected error: {err}"); + + let state = store.load().unwrap().unwrap(); + assert!(state.active_key_id.is_none()); + assert!(state.last_key_id.is_some()); + assert_eq!(state.keys.iter().filter(|record| record.status == TransportKeyStatus::Broken).count(), 1); + } + #[tokio::test] async fn send_ehlo_includes_sid_and_is_jsonish() { let _guard = TEST_LOCK.lock().await; diff --git a/sysminion/src/proto.rs b/sysminion/src/proto.rs index 1e6f8484..731b884c 100644 --- a/sysminion/src/proto.rs +++ b/sysminion/src/proto.rs @@ -5,6 +5,7 @@ pub mod msg { use libsysproto::{ MasterMessage, MinionMessage, ProtoConversion, rqtypes::{ProtoKey, ProtoValue, RequestType}, + secure::{SecureBootstrapDiagnostic, SecureFrame}, }; use once_cell::sync::Lazy; use serde_json::{Value, json, to_value}; @@ -65,4 +66,13 @@ pub mod msg { Ok(msg) } + + /// Parse a plaintext secure bootstrap diagnostic sent before a secure Master/Minion session exists. + pub fn payload_to_diag(data: &[u8]) -> Result { + match serde_json::from_slice::(data) { + Ok(SecureFrame::BootstrapDiagnostic(diag)) => Ok(diag), + Ok(_) => Err(SysinspectError::ProtoError("received a secure transport frame that is not a bootstrap diagnostic".to_string())), + Err(err) => Err(SysinspectError::ProtoError(format!("broken JSON from secure bootstrap diagnostic: {err}"))), + } + } } diff --git a/sysminion/src/proto_ut.rs b/sysminion/src/proto_ut.rs new file mode 100644 index 00000000..e621cf23 --- /dev/null +++ b/sysminion/src/proto_ut.rs @@ -0,0 +1,23 @@ +use crate::proto::msg::payload_to_diag; +use libsysproto::secure::{SecureBootstrapDiagnostic, SecureDiagnosticCode, SecureFailureSemantics, SecureFrame}; + +#[test] +fn payload_to_diag_reads_bootstrap_diagnostic_frames() { + let diag = payload_to_diag( + &serde_json::to_vec(&SecureFrame::BootstrapDiagnostic(SecureBootstrapDiagnostic { + code: SecureDiagnosticCode::UnsupportedVersion, + message: "old peer".to_string(), + failure: SecureFailureSemantics::diagnostic(true, false), + })) + .unwrap(), + ) + .unwrap(); + + assert!(matches!(diag.code, SecureDiagnosticCode::UnsupportedVersion)); + assert_eq!(diag.message, "old peer"); +} + +#[test] +fn payload_to_diag_rejects_non_diagnostic_frames() { + assert!(payload_to_diag(br#"{"id":"mid-1","r":"ehlo","d":{},"c":1,"sid":""}"#).is_err()); +} diff --git a/sysminion/src/rsa.rs b/sysminion/src/rsa.rs index 45a267d2..96aeefea 100644 --- a/sysminion/src/rsa.rs +++ b/sysminion/src/rsa.rs @@ -3,7 +3,11 @@ RSA keys manager */ use libcommon::SysinspectError; -use libsysinspect::cfg::mmconf::{CFG_MINION_RSA_PRV, CFG_MINION_RSA_PUB}; +use libsysinspect::{ + cfg::mmconf::{CFG_MASTER_KEY_PUB, CFG_MINION_RSA_PRV, CFG_MINION_RSA_PUB, CFG_TRANSPORT_MASTER, CFG_TRANSPORT_ROOT, CFG_TRANSPORT_STATE}, + transport::TransportStore, +}; +use libsysproto::secure::SECURE_PROTOCOL_VERSION; use rsa::{RsaPrivateKey, RsaPublicKey}; use std::{fs, path::PathBuf}; @@ -53,6 +57,8 @@ impl MinionRSAKeyManager { } self.mn_pbk_pem = pbk_pem.to_owned().unwrap(); + self.mn_prk = Some(prk); + self.mn_pbk = Some(pbk); log::info!("Writing public keys to {:?}", pbk_pth.parent()); @@ -74,4 +80,45 @@ impl MinionRSAKeyManager { pub fn get_pubkey_pem(&self) -> String { self.mn_pbk_pem.to_owned() } + + pub fn get_pubkey_fingerprint(&self) -> Result { + libsysinspect::rsa::keys::get_fingerprint( + self.mn_pbk.as_ref().ok_or_else(|| SysinspectError::RSAError("Minion public key is not loaded".to_string()))?, + ) + .map_err(|err| SysinspectError::RSAError(err.to_string())) + } + + /// Return the trusted master RSA public key when it is already present on disk. + pub fn master_public_key(&self) -> Result, SysinspectError> { + let master_pem_path = self.root.join(CFG_MASTER_KEY_PUB); + if !master_pem_path.exists() { + return Ok(None); + } + Ok(libsysinspect::rsa::keys::from_pem(None, Some(&fs::read_to_string(master_pem_path)?))?.1) + } + + /// Return the loaded minion RSA private key used for secure bootstrap creation. + pub fn private_key(&self) -> Result { + self.mn_prk.clone().ok_or_else(|| SysinspectError::RSAError("Minion private key is not loaded".to_string())) + } + + fn transport_state_store(&self) -> Result { + TransportStore::new(self.root.join(CFG_TRANSPORT_ROOT).join(CFG_TRANSPORT_MASTER).join(CFG_TRANSPORT_STATE)) + } + + pub fn ensure_transport_state(&self, minion_id: &str) -> Result { + let master_pem_path = self.root.join(CFG_MASTER_KEY_PUB); + if !master_pem_path.exists() { + return Ok(false); + } + + let (_, master_pbk) = libsysinspect::rsa::keys::from_pem(None, Some(&fs::read_to_string(master_pem_path)?))?; + let master_fingerprint = libsysinspect::rsa::keys::get_fingerprint( + &master_pbk.ok_or_else(|| SysinspectError::RSAError("Master public key is not loaded".to_string()))?, + ) + .map_err(|err| SysinspectError::RSAError(err.to_string()))?; + let store = self.transport_state_store()?; + let _ = store.ensure_automatic_peer(minion_id, &master_fingerprint, &self.get_pubkey_fingerprint()?, SECURE_PROTOCOL_VERSION)?; + Ok(true) + } } diff --git a/sysminion/src/rsa_ut.rs b/sysminion/src/rsa_ut.rs new file mode 100644 index 00000000..d3797ccb --- /dev/null +++ b/sysminion/src/rsa_ut.rs @@ -0,0 +1,26 @@ +use crate::rsa::MinionRSAKeyManager; +use libsysinspect::{ + cfg::mmconf::CFG_MASTER_KEY_PUB, + rsa::keys::{RsaKey::Public, key_to_file, keygen}, +}; + +#[test] +fn ensure_transport_state_is_noop_without_master_key() { + let root = tempfile::tempdir().unwrap(); + let keyman = MinionRSAKeyManager::new(root.path().to_path_buf()).unwrap(); + + assert!(!keyman.ensure_transport_state("mid-1").unwrap()); + assert!(!root.path().join("transport/master/state.json").exists()); +} + +#[test] +fn ensure_transport_state_writes_managed_state_when_master_key_exists() { + let root = tempfile::tempdir().unwrap(); + let keyman = MinionRSAKeyManager::new(root.path().to_path_buf()).unwrap(); + let (_, master_pbk) = keygen(2048).unwrap(); + + key_to_file(&Public(master_pbk), root.path().to_str().unwrap(), CFG_MASTER_KEY_PUB).unwrap(); + + assert!(keyman.ensure_transport_state("mid-1").unwrap()); + assert!(root.path().join("transport/master/state.json").exists()); +}