diff --git a/Cargo.lock b/Cargo.lock index 4cdde33a..4f625e03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "ahash" version = "0.7.8" @@ -99,6 +110,17 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "approx" version = "0.5.1" @@ -114,6 +136,126 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -286,6 +428,28 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borsh" version = "1.6.1" @@ -416,6 +580,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.64" @@ -454,6 +627,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -542,6 +725,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.16.3" @@ -643,6 +835,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crossterm" version = "0.29.0" @@ -788,6 +986,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -875,6 +1074,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "env_filter" version = "1.0.1" @@ -923,6 +1149,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fancy-regex" version = "0.11.0" @@ -1040,6 +1287,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1100,6 +1357,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1267,12 +1537,36 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "http" version = "1.4.2" @@ -1576,6 +1870,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "instability" version = "0.3.12" @@ -1764,6 +2068,27 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "keyring" +version = "4.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ee8d4dae108d4177d0a0ce241f98acc1ef28e20837bdef43cff4d160cf70fe" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "lab" version = "0.11.0" @@ -2098,6 +2423,39 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2115,6 +2473,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2195,6 +2583,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "ostool" version = "0.24.1" @@ -2209,10 +2607,12 @@ dependencies = [ "crossterm", "env_logger", "fitimage", + "fs4", "futures", "httpboot-protocol", "indicatif", "jkconfig", + "keyring", "log", "lzma-rs", "network-interface", @@ -2300,6 +2700,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2430,12 +2836,37 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2861,6 +3292,7 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -3149,6 +3581,25 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2 0.10.9", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3247,6 +3698,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -3981,9 +4443,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4082,6 +4556,17 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unescaper" version = "0.1.8" @@ -4569,6 +5054,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -4905,6 +5403,78 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.52" @@ -4990,3 +5560,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow", +] diff --git a/README.en.md b/README.en.md index cc1365a3..4def5406 100644 --- a/README.en.md +++ b/README.en.md @@ -307,8 +307,8 @@ baud_rate = "${env:BAUD_RATE:-115200}" ```toml [board] -server_ip = "localhost" -port = 2999 +server = "http://localhost:2999" +auth_mode = "disabled" ``` Use the TUI editor to update it: @@ -317,7 +317,30 @@ Use the TUI editor to update it: ostool board config ``` -Project-local `.board.toml` `server` / `port` fields still apply to `ostool board run`, with precedence lower than CLI flags and higher than the global config. +`server` should be a complete URL including `http://` or `https://`; the optional `port` overrides the URL port. For legacy LAN configurations, a bare IPv4 or IPv6 address is interpreted as `http://`. The base release's persisted `server_ip` / `port` pair is also migrated to `server` / `port` when read; the next configuration save writes only the new format. Bare host names are not supported. Project-local `.board.toml` `server` / `port` fields still apply to `ostool board run`, with precedence lower than CLI flags and higher than the global config. + +### Public board authentication + +Direct LAN connections to `ostool-server` keep the anonymous HTTP configuration above. A public authentication gateway uses a complete HTTPS URL: + +```toml +[board] +server = "https://203.0.113.10:8443" +auth_mode = "required" +``` + +Sign in through browser device authorization, or import a personal access token (PAT) created in the web UI from standard input: + +```bash +ostool login --server https://203.0.113.10:8443 +printf '%s' "$OSTOOL_PAT" | ostool login --with-token --server https://203.0.113.10:8443 +ostool auth status --server https://203.0.113.10:8443 +ostool logout --server https://203.0.113.10:8443 +``` + +OAuth sign-in automatically refreshes short-lived access tokens. A PAT is sent directly as a Bearer token and is never refreshed. Credentials are stored in the system credential store when available, with a warned user-level credential-file fallback. Automation can use `OSTOOL_BOARD_ACCESS_TOKEN`; it is neither stored nor refreshed. + +Public authentication requires HTTPS. The client verifies certificates only through the system trust store; deployments using a private CA must install its root certificate on client systems. Do not use HTTP, bypass certificate verification, or put tokens in configuration files. ## 🛠️ Subproject Details diff --git a/README.md b/README.md index 553dcc50..2aa7dba1 100644 --- a/README.md +++ b/README.md @@ -304,8 +304,8 @@ baud_rate = "${env:BAUD_RATE:-115200}" ```toml [board] -server_ip = "localhost" -port = 2999 +server = "http://localhost:2999" +auth_mode = "disabled" ``` 可以通过下面的命令打开 TUI 编辑器修改: @@ -314,7 +314,30 @@ port = 2999 ostool board config ``` -项目级 `.board.toml` 中的 `server` / `port` 仍可用于 `ostool board run`,其优先级低于命令行参数,高于全局配置。 +`server` 应使用包含 `http://` 或 `https://` 的完整 URL;可选的 `port` 会覆盖 URL 中的端口。为兼容旧的局域网配置,裸 IPv4 或 IPv6 地址会自动补为 `http://`。基线版本写出的 `server_ip` / `port` 也会在读取时迁移为 `server` / `port`,下一次保存配置时只写新格式;无 scheme 的主机名不支持。项目级 `.board.toml` 中的 `server` / `port` 仍可用于 `ostool board run`,其优先级低于命令行参数,高于全局配置。 + +### 公网开发板认证 + +局域网直接连接 `ostool-server` 时保留上述匿名 HTTP 配置。公网认证网关使用完整 HTTPS 地址: + +```toml +[board] +server = "https://203.0.113.10:8443" +auth_mode = "required" +``` + +登录使用浏览器设备授权流程,或从标准输入导入在 Web 管理台创建的个人访问令牌(PAT): + +```bash +ostool login --server https://203.0.113.10:8443 +printf '%s' "$OSTOOL_PAT" | ostool login --with-token --server https://203.0.113.10:8443 +ostool auth status --server https://203.0.113.10:8443 +ostool logout --server https://203.0.113.10:8443 +``` + +OAuth 登录会自动刷新短期 access token;PAT 直接用于 Bearer 认证,不会刷新。凭据优先保存到系统 credential store;不可用时会警告并退回用户级凭据文件。自动化场景可设置 `OSTOOL_BOARD_ACCESS_TOKEN`,该 token 不保存也不刷新。 + +公网认证必须使用 HTTPS。客户端仅使用系统信任库验证证书;部署组织私有 CA 时,需由运维将其根证书安装到客户端系统。不要使用 HTTP、跳过证书验证或把 token 放进配置文件。 ## 🛠️ 子项目详解 diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 00000000..c1ce7256 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,523 @@ +# `ostool` 实际调用的服务 API + +本文根据当前 `ostool` 客户端实现整理认证网关和开发板服务的调用接口;不包含 `ostool-server` 管理后台的 `/api/v1/admin/...` 接口。 + +服务地址来自全局或项目配置中的 `board.server`(完整 URL),可被命令行 `--server` 覆盖;可选的 `board.port` 或 `--port` 用于覆盖 URL 中的端口。为兼容旧的局域网配置,`board.server` 为裸 IPv4 或 IPv6 地址时客户端自动补为 `http://`。基线版本写出的 `board.server_ip` / `board.port` 也会在读取时迁移为 `board.server` / `board.port`,下一次保存配置时只写新格式;无 scheme 的主机名不支持。认证网关和 board API 使用同一个 Base URL。 + +- `auth_mode = "required"` 时,`board.server` 必须使用 HTTPS,所有请求携带下文描述的 Bearer Token; +- `auth_mode = "disabled"`(默认)时通常使用 HTTP,不会发送认证 Header,适合局域网直连 `ostool-server`。 + +## 通用认证规则 + +当 `auth_mode = "required"` 时,所有下列 board HTTP 请求和串口 WebSocket 握手均携带: + +```http +Authorization: Bearer +``` + +Access Token 选择规则: + +1. `OSTOOL_BOARD_ACCESS_TOKEN` 非空时优先使用;该值不保存也不刷新; +2. 否则读取当前 endpoint 保存的一条本地凭据:PAT 直接使用,OAuth Access Token 剩余有效期超过 60 秒时直接使用; +3. OAuth Access Token 剩余有效期不超过 60 秒时,使用保存的 Refresh Token 刷新后再发送请求。 + +HTTP 客户端不跟随重定向。认证模式下,绝对 WebSocket URL 必须与 Base URL 使用相同的 scheme、host 和有效端口。 + +## 命令索引 + +表中未写全的会话路径均以 `/api/v1/sessions/{session_id}` 为前缀。 + +| 命令 | 基本功能 | `auth_mode = "required"` 时的行为 | 涉及 API | +| --- | --- | --- | --- | +| `ostool login [--server URL] [--port PORT]` | 发起浏览器 Device Authorization 登录。 | 保存 OAuth 凭据;未指定 `--server` 时使用全局 board 配置。 | `POST /oauth/device/code`;`POST /oauth/token`(Device Code 轮询) | +| `ostool login --with-token [--server URL] [--port PORT]` | 从标准输入导入 PAT。 | 保存 PAT;不刷新该 Token。 | 无网络 API | +| `ostool auth status [--server URL] [--port PORT]` | 显示当前 endpoint 的认证状态。 | 显示凭据类型、已知过期时间和 scope,不显示 Token。 | 无网络 API | +| `ostool logout [--server URL] [--port PORT]` | 退出登录。 | OAuth 凭据会尝试远端撤销;随后删除本地凭据。PAT 仅删除本地副本。 | `POST /oauth/revoke`(仅 OAuth) | +| `ostool board ls [--server URL] [--port PORT]` | 查询按类型聚合的可用开发板信息。 | 调用时携带 Bearer Token。 | `GET /api/v1/board-types` | +| `ostool board connect --board-type TYPE [--server URL] [--port PORT]` | 请求服务端从指定类型中自动分配一块开发板并打开串口终端。 | REST 和 WebSocket 请求均携带 Bearer Token。 | `POST /api/v1/sessions`;`POST /api/v1/sessions/{session_id}/heartbeat`;WebSocket `/api/v1/sessions/{session_id}/serial/ws`;`DELETE /api/v1/sessions/{session_id}` | +| `ostool board run [--server URL] [--port PORT]` | 构建后请求服务端按 `.board.toml` 的 `board_type` 自动分配开发板并启动。 | REST 和 WebSocket 请求均携带 Bearer Token。 | 始终:`POST /api/v1/sessions`、`POST /api/v1/sessions/{session_id}/heartbeat`、`DELETE /api/v1/sessions/{session_id}`。U-Boot:`GET /boot-profile`、`GET /serial`、`GET /tftp`、`GET /dtb`、`GET /dtb/download`、`PUT /files`、WebSocket `/serial/ws`。HTTP Boot:`GET /boot-profile`、`GET /serial`、`PUT /http-boot/kernel`、WebSocket `/serial/ws`。 | + +## OAuth Device Authorization API + +所有 OAuth 请求使用 `application/x-www-form-urlencoded`。 + +### 基本原理 + +该流程是 OAuth 2.0 Device Authorization Grant,适用于 CLI、SSH 和无图形界面环境。它将浏览器中的用户登录与 CLI 获取 Token 分离,使 `ostool` 不接触用户密码,也不需要接收浏览器回调。 + +```mermaid +sequenceDiagram + participant CLI as ostool CLI + participant Auth as 认证服务 + participant User as 用户浏览器 + + CLI->>Auth: POST /oauth/device/code + Auth-->>CLI: device_code、user_code、验证地址、interval + CLI->>User: 输出验证地址和用户码 + User->>Auth: 登录并确认授权 + loop 授权完成前,按 interval 轮询 + CLI->>Auth: POST /oauth/token(device_code) + Auth-->>CLI: authorization_pending + end + CLI->>Auth: POST /oauth/token(device_code) + Auth-->>CLI: access_token、refresh_token +``` + +第一步只创建短期授权请求:`device_code` 供 CLI 轮询使用,不应展示给用户;`user_code` 用于浏览器授权。用户完成登录和授权前,`/oauth/token` 返回 `authorization_pending`,客户端按服务端给出的轮询间隔继续等待。登录成功后,后续续期仅使用 Refresh Token 调用 `/oauth/token`,不再重新执行设备授权流程。 + +### 获取设备登录码 + +```http +POST /oauth/device/code +Content-Type: application/x-www-form-urlencoded + +client_id=ostool-cli&scope=board%3Aoperate+offline_access +``` + +响应至少应包含: + +```json +{ + "device_code": "opaque-device-code", + "user_code": "ABCD-EFGH", + "verification_uri": "https://board.example.com/activate", + "verification_uri_complete": "https://board.example.com/activate?user_code=ABCD-EFGH", + "expires_in": 600, + "interval": 5 +} +``` + +`verification_uri_complete` 可选;未返回 `interval` 时,客户端默认每 5 秒轮询。 + +### 使用设备登录码换取 Token + +```http +POST /oauth/token +Content-Type: application/x-www-form-urlencoded + +grant_type=urn:ietf:params:oauth:grant-type:device_code +&device_code= +&client_id=ostool-cli +``` + +客户端在 `expires_in` 期间按 `interval` 轮询。`authorization_pending` 必须作为非 2xx OAuth 错误响应返回,客户端会继续轮询;`slow_down` 同样必须作为非 2xx OAuth 错误响应返回,客户端会将轮询间隔增加 5 秒。成功的 2xx 响应必须包含下文的完整 Token 字段。 + +### 刷新 Access Token + +```http +POST /oauth/token +Content-Type: application/x-www-form-urlencoded + +grant_type=refresh_token +&refresh_token= +&client_id=ostool-cli +``` + +当 OAuth Access Token 剩余有效期不超过 60 秒时调用。多个进程共享 Refresh Token 时,客户端会先获得本地跨进程锁,避免并发刷新。 + +### Token 响应 + +上述两个 `/oauth/token` 请求均要求返回: + +```json +{ + "access_token": "...", + "refresh_token": "...", + "token_type": "Bearer", + "expires_in": 900 +} +``` + +`token_type` 必须为 `Bearer`,`expires_in` 必须为正数,`refresh_token` 必须非空。`scope` 为可选字段。 +服务端应在刷新时返回轮换后的 Refresh Token。 + +### 撤销 OAuth 会话 + +```http +POST /oauth/revoke +Content-Type: application/x-www-form-urlencoded + +token= +&token_type_hint=refresh_token +&client_id=ostool-cli +``` + +执行 `ostool logout` 时调用。客户端将 HTTP 2xx 和 404 都视为成功;其他错误只会输出警告,随后仍删除本地凭据。 + +### OAuth 错误响应 + +认证客户端尝试解析: + +```json +{ + "error": "invalid_grant", + "error_description": "optional detail" +} +``` + +刷新时若错误包含 `invalid_grant` 或 `invalid_token`,客户端删除本地 OAuth 凭据并要求重新登录。 + +## Board REST API + +本节覆盖 `ostool-server` 的全部公开、非管理 REST 接口。`ostool` 当前命令直接调用其中的大部分接口;电源控制、HTTP Boot 普通文件上传及会话查询/文件管理端点虽未由当前命令路径调用,仍是 `BoardServerClient` 或公开 board 服务契约的一部分。 + +### 查询开发板类型 + +```http +GET /api/v1/board-types +``` + +用于 `ostool board ls`。客户端只接受 `ostool-server` 的已聚合开发板类型列表: + +```json +[ + { + "board_type": "rk3568", + "tags": [], + "total": 2, + "available": 1 + } +] +``` + +每个 `board_type` 仅有一个聚合条目:`total` 是该类型未禁用开发板的数量,`available` 是其中租约状态为 idle 的数量,`tags` 是该类型所有未禁用开发板标签的去重并集。该接口不返回 `board_id`;创建会话时由服务端自动选择实际开发板,并在成功响应中返回 `board_id`。 + +### 创建会话 + +```http +POST /api/v1/sessions +Content-Type: application/json + +{ + "board_type": "rk3568", + "required_tags": [], + "client_name": "ostool" +} +``` + +用于 `ostool board connect` 和 `ostool board run`。`board_type` 必填,`required_tags` 由当前 CLI 固定发送空数组,`client_name` 固定为 `ostool`。服务端在满足类型和标签条件的空闲开发板中自动分配,不支持通过当前 `ostool` 指定 `board_id`。 + +成功时返回 `201 Created`: + +```json +{ + "session_id": "...", + "board_id": "rk3568-01", + "lease_expires_at": "2026-07-20T02:00:00Z", + "serial_available": true, + "boot_mode": "uboot", + "ws_url": "/api/v1/sessions/.../serial/ws" +} +``` + +指定类型不存在时返回 `404`;类型存在但没有符合条件的空闲开发板时返回 `409`。客户端会对后一种情况每秒重试一次,直到分配成功或收到其他错误。 + +### 查询会话详情 + +```http +GET /api/v1/sessions/{session_id} +``` + +请求没有请求体,成功返回 `200 OK`: + +```json +{ + "session": { + "id": "...", + "board_id": "rk3568-01", + "client_name": "ostool", + "created_at": "2026-07-20T02:00:00Z", + "last_heartbeat_at": "2026-07-20T02:00:01Z", + "expires_at": "2026-07-20T02:01:01Z", + "serial_connected": false, + "state": "active" + }, + "board": { + "id": "rk3568-01", + "board_type": "rk3568", + "tags": ["lab"], + "serial": null, + "power_management": { + "kind": "custom", + "power_on_cmd": "power-on", + "power_off_cmd": "power-off" + }, + "boot": { + "kind": "uboot", + "use_tftp": true, + "dtb_name": null, + "kernel_load_addr": null, + "fit_load_addr": null, + "bootm_addr": null, + "network_mode": "dhcp", + "board_ip": null, + "server_ip": null, + "netmask": null, + "gatewayip": null + }, + "notes": null, + "disabled": false + }, + "serial_available": false, + "serial_connected": false, + "files": [] +} +``` + +`session.state` 为会话生命周期状态;`board.serial`、`board.notes` 可为 `null`。`files` 的元素使用下方“上传会话文件”中的文件响应格式。 + +### 会话保活与释放 + +```http +POST /api/v1/sessions/{session_id}/heartbeat +DELETE /api/v1/sessions/{session_id} +``` + +两个请求均没有请求体。心跳成功返回 `200 OK`: + +```json +{ + "session_id": "...", + "lease_expires_at": "2026-07-20T02:00:00Z" +} +``` + +删除成功时服务端返回 `202 Accepted` 且没有响应体;删除时返回 `404`,客户端也将其视为已释放。 + +### 获取启动配置 + +```http +GET /api/v1/sessions/{session_id}/boot-profile +``` + +请求没有请求体,成功返回 `200 OK`。`boot.kind` 决定 `boot` 对象的具体字段: + +```json +{ + "boot": { + "kind": "uboot", + "use_tftp": true, + "dtb_name": "board.dtb", + "kernel_load_addr": "0x80200000", + "fit_load_addr": "0x82200000", + "bootm_addr": "0x82200000", + "network_mode": "dhcp", + "board_ip": null, + "server_ip": null, + "netmask": null, + "gatewayip": null + }, + "server_ip": "192.168.1.2", + "netmask": "255.255.255.0", + "interface": "eth0" +} +``` + +`boot.kind` 可为 `uboot`、`pxe` 或 `httpboot`(客户端也接受别名 `uefi_http`)。`pxe` 的对象仅含可选 `notes`;`httpboot` 的对象含可选 `boot_arch`(`x86_64`、`aarch64`、`loongarch64`、`riscv64` 或 `other`)和 `mac`。顶层 `server_ip`、`netmask`、`interface` 均可为 `null`。 + +### 获取串口状态 + +```http +GET /api/v1/sessions/{session_id}/serial +``` + +请求没有请求体,成功返回 `200 OK`: + +```json +{ + "available": true, + "connected": false, + "port": "/dev/ttyUSB0", + "baud_rate": 115200, + "ws_url": "/api/v1/sessions/.../serial/ws" +} +``` + +没有串口时,`available` 和 `connected` 为 `false`,`port`、`baud_rate`、`ws_url` 均为 `null`。 + +### 获取 TFTP 状态 + +```http +GET /api/v1/sessions/{session_id}/tftp +``` + +请求没有请求体,成功返回 `200 OK`: + +```json +{ + "available": true, + "provider": "internal", + "server_ip": "192.168.1.2", + "netmask": "255.255.255.0", + "writable": true, + "files": [ + { + "filename": "Image", + "relative_path": "boot/Image", + "tftp_url": "tftp://192.168.1.2/boot/Image", + "size": 1048576, + "uploaded_at": "2026-07-20T02:00:00Z" + } + ] +} +``` + +`server_ip`、`netmask` 和每个文件的 `tftp_url` 可为 `null`。`available` 表示 TFTP 已启用、健康、可写且能解析服务端 IP。 + +### 获取和下载预置 DTB + +```http +GET /api/v1/sessions/{session_id}/dtb +GET /api/v1/sessions/{session_id}/dtb/download +``` + +第一个请求没有请求体,成功返回 `200 OK` 的 DTB 元数据: + +```json +{ + "dtb_name": "board.dtb", + "relative_path": "boot/dtb/board.dtb", + "session_file_path": "boot/dtb/board.dtb", + "tftp_url": "tftp://192.168.1.2/boot/dtb/board.dtb" +} +``` + +没有预置 DTB 时上述四个字段均为 `null`。下载接口没有请求体,成功返回 `200 OK`、`Content-Type: application/octet-stream` 和 DTB 原始字节;未配置预置 DTB 或文件不存在时返回 `404`。 + +### 开关机 + +```http +POST /api/v1/sessions/{session_id}/board/power-on +POST /api/v1/sessions/{session_id}/board/power-off +``` + +这两个 `BoardServerClient` 方法没有请求体,成功返回 `200 OK`: + +```json +{ + "ok": true, + "message": "..." +} +``` + +`message` 由具体电源管理实现返回。 + +### 上传会话文件 + +```http +PUT /api/v1/sessions/{session_id}/files +X-File-Path: + + +``` + +`X-File-Path` 必填,必须是相对路径;请求体是文件原始字节。成功返回 `201 Created`: + +```json +{ + "filename": "Image", + "relative_path": "boot/Image", + "tftp_url": "tftp://192.168.1.2/boot/Image", + "size": 1048576, + "uploaded_at": "2026-07-20T02:00:00Z" +} +``` + +`tftp_url` 可为 `null`。 + +### 列出、查询和删除会话文件 + +```http +GET /api/v1/sessions/{session_id}/files +GET /api/v1/sessions/{session_id}/files/{path} +DELETE /api/v1/sessions/{session_id}/files/{path} +``` + +三个请求均没有请求体。前两个请求成功返回 `200 OK`:列表接口返回文件对象数组,单文件接口返回一个文件对象,格式与上传会话文件的成功响应相同。删除成功返回 `204 No Content`。`path` 必须是相对路径。 + +历史上传路径 `PUT /api/v1/sessions/{session_id}/files/{path}` 被明确拒绝并返回 `404`;上传必须使用前述 `PUT /files` 加 `X-File-Path` Header 的形式。 + +### 上传 HTTP Boot 文件 + +```http +PUT /api/v1/sessions/{session_id}/http-boot/files +X-File-Path: + + +``` + +`X-File-Path` 必填,请求体是文件原始字节。成功返回 `201 Created`: + +```json +{ + "filename": "kernel.elf", + "relative_path": "kernel.elf", + "http_url": "https://board.example.com/boot/sessions/.../kernel.elf", + "size": 1048576, + "uploaded_at": "2026-07-20T02:00:00Z" +} +``` + +### 下载 HTTP Boot 文件 + +```http +GET /boot/sessions/{session_id}/{path} +Range: bytes=- # 可选 +``` + +该接口供目标机 HTTP Boot 下载已上传文件,请求没有消息体。无 Range 时成功返回 `200 OK` 和文件原始字节;带合法单段 Range 时返回 `206 Partial Content`,并包含 `Content-Range`、`Content-Length`、`Accept-Ranges: bytes`。响应的 `Content-Type` 根据文件名推断。`path` 必须是相对路径。 + +### 上传 HTTP Boot 内核 + +```http +PUT /api/v1/sessions/{session_id}/http-boot/kernel +X-HttpBoot-Remote-Name: # 可选,默认 kernel.elf +X-HttpBoot-Arch: # 必填:x86_64、aarch64、loongarch64、riscv64 或 other +X-HttpBoot-Image-Format: # 可选,当前仅支持 elf64 +X-HttpBoot-Entry-Symbol: # 可选 + + +``` + +请求体是内核原始字节。成功返回 `201 Created`: + +```json +{ + "boot_id": "...", + "kernel_url": "https://board.example.com/boot/sessions/.../kernel.elf", + "kernel_size": 1048576, + "kernel_sha256": "..." +} +``` + +`kernel_sha256` 可为 `null`。当前 `ostool board run` 的 HTTP Boot 流程固定发送 `remote_name=kernel.elf`、`image_format=elf64` 和 `entry_symbol=httpboot_entry`。 + +## 串口 WebSocket API + +会话创建或串口状态响应中的 `ws_url` 用于建立串口连接。相对地址相对于 Base URL 解析;HTTP/HTTPS Base URL 会分别转换为 `ws`/`wss`。 + +```http +GET /api/v1/sessions/{session_id}/serial/ws +Upgrade: websocket +Authorization: Bearer # 仅 required 模式 +``` + +WebSocket 二进制帧承载串口字节流;客户端关闭时会发送: + +```json +{"type":"close"} +``` + +## Board API 错误响应 + +非成功的 board REST 响应优先按以下格式解析: + +```json +{ + "code": "not_found", + "message": "board type `rk3568` not found", + "details": null +} +``` + +`details` 是可选 JSON 值,当前服务端通常返回 `null`;客户端只使用 `code` 和 `message`。任一 board API 返回 `401 Unauthorized` 时,客户端删除当前 endpoint 的本地凭据;不会自动刷新并重试该业务请求。 diff --git a/ostool/Cargo.toml b/ostool/Cargo.toml index f779d599..8c2de57d 100644 --- a/ostool/Cargo.toml +++ b/ostool/Cargo.toml @@ -41,7 +41,7 @@ log = { workspace = true } network-interface = "2" object = "0.39" ratatui = { workspace = true } -reqwest = { version = "0.13", features = ["json"] } +reqwest = { version = "0.13", features = ["json", "form"] } schemars = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } @@ -59,6 +59,8 @@ tar = "0.4" thiserror = { workspace = true } ureq = "3.0" chrono = { version = "0.4.44", features = ["serde"] } +fs4 = "1.1" +keyring = "4.1" tokio-tungstenite = "0.28" tokio-serial = "5.4" diff --git a/ostool/src/auth/client.rs b/ostool/src/auth/client.rs new file mode 100644 index 00000000..8b13639e --- /dev/null +++ b/ostool/src/auth/client.rs @@ -0,0 +1,213 @@ +use std::time::Duration; + +use anyhow::{Context as _, bail}; +use chrono::{Duration as ChronoDuration, Utc}; +use reqwest::{Client, StatusCode, redirect::Policy}; +use serde::Deserialize; + +use crate::board::global_config::BoardEndpoint; + +use super::credential_store::CredentialRecord; + +const CLIENT_ID: &str = "ostool-cli"; + +#[derive(Debug, Deserialize)] +pub struct DeviceCodeResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: Option, + pub expires_in: u64, + #[serde(default = "default_interval")] + pub interval: u64, +} + +fn default_interval() -> u64 { + 5 +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: Option, + token_type: String, + expires_in: i64, + scope: Option, +} + +#[derive(Debug, Deserialize)] +struct OAuthErrorResponse { + error: String, + error_description: Option, +} + +#[derive(Clone)] +pub struct AuthClient { + client: Client, + endpoint: BoardEndpoint, +} + +impl AuthClient { + pub fn new(endpoint: BoardEndpoint) -> anyhow::Result { + Ok(Self { + client: Client::builder() + .no_proxy() + // Authentication responses must never be redirected to a host that + // did not receive the original Device Authorization request. + .redirect(Policy::none()) + .build() + .context("failed to build authentication HTTP client")?, + endpoint, + }) + } + + pub async fn request_device_code(&self) -> anyhow::Result { + let response = self + .client + .post(self.endpoint.base_url.join("oauth/device/code")?) + .form(&[ + ("client_id", CLIENT_ID), + ("scope", "board:operate offline_access"), + ]) + .send() + .await + .context("failed to request device code")?; + decode_json(response).await + } + + pub async fn exchange_device_code( + &self, + device_code: &str, + ) -> anyhow::Result { + let response = self + .client + .post(self.endpoint.base_url.join("oauth/token")?) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("device_code", device_code), + ("client_id", CLIENT_ID), + ]) + .send() + .await + .context("failed to exchange device code")?; + token_record(response).await + } + + pub async fn refresh(&self, refresh_token: &str) -> anyhow::Result { + let response = self + .client + .post(self.endpoint.base_url.join("oauth/token")?) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", CLIENT_ID), + ]) + .send() + .await + .context("failed to refresh access token")?; + token_record(response).await + } + + pub async fn revoke(&self, refresh_token: &str) -> anyhow::Result<()> { + let response = self + .client + .post(self.endpoint.base_url.join("oauth/revoke")?) + .form(&[ + ("token", refresh_token), + ("token_type_hint", "refresh_token"), + ("client_id", CLIENT_ID), + ]) + .send() + .await + .context("failed to revoke OAuth session")?; + if response.status().is_success() || response.status() == StatusCode::NOT_FOUND { + Ok(()) + } else { + Err(oauth_error(response).await) + } + } +} + +pub async fn complete_device_login(client: &AuthClient) -> anyhow::Result { + let device = client.request_device_code().await?; + println!("Open this URL to sign in:"); + println!( + " {}", + device + .verification_uri_complete + .as_deref() + .unwrap_or(&device.verification_uri) + ); + println!("Code: {}", device.user_code); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(device.expires_in); + let mut interval = Duration::from_secs(device.interval.max(1)); + loop { + if tokio::time::Instant::now() >= deadline { + bail!("device login expired before authorization completed"); + } + // Device Authorization requires polling at the server-provided rate; a + // slow_down response below increases this delay instead of busy-looping. + tokio::time::sleep(interval).await; + match client.exchange_device_code(&device.device_code).await { + Ok(record) => return Ok(record), + Err(error) if error.to_string().contains("authorization_pending") => continue, + Err(error) if error.to_string().contains("slow_down") => { + interval += Duration::from_secs(5); + } + Err(error) => return Err(error), + } + } +} + +async fn token_record(response: reqwest::Response) -> anyhow::Result { + let token: TokenResponse = decode_json(response).await?; + if !token.token_type.eq_ignore_ascii_case("bearer") { + bail!( + "authentication server returned unsupported token type `{}`", + token.token_type + ); + } + if token.expires_in <= 0 { + bail!("authentication server returned a non-positive token lifetime"); + } + let refresh_token = token.refresh_token.filter(|value| !value.is_empty()); + if refresh_token.is_none() { + bail!("authentication server did not return a refresh token"); + } + Ok(CredentialRecord::OAuthRefresh { + refresh_token: refresh_token.expect("required refresh token checked"), + access_token: token.access_token, + access_expires_at: Utc::now() + ChronoDuration::seconds(token.expires_in), + scope: token.scope, + }) +} + +async fn decode_json( + response: reqwest::Response, +) -> anyhow::Result { + if response.status().is_success() { + response + .json() + .await + .context("failed to decode authentication response") + } else { + Err(oauth_error(response).await) + } +} + +async fn oauth_error(response: reqwest::Response) -> anyhow::Error { + let status = response.status(); + match response.json::().await { + Ok(error) => anyhow::anyhow!( + "authentication request failed with {status}: {}{}", + error.error, + error + .error_description + .as_deref() + .map(|description| format!(" ({description})")) + .unwrap_or_default() + ), + Err(_) => anyhow::anyhow!("authentication request failed with {status}"), + } +} diff --git a/ostool/src/auth/credential_store.rs b/ostool/src/auth/credential_store.rs new file mode 100644 index 00000000..e6e26b86 --- /dev/null +++ b/ostool/src/auth/credential_store.rs @@ -0,0 +1,127 @@ +use std::{collections::BTreeMap, env, fs, path::PathBuf}; + +use anyhow::Context as _; +use chrono::{DateTime, Utc}; +use keyring::Entry; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const KEYRING_SERVICE: &str = "ostool"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CredentialRecord { + OAuthRefresh { + refresh_token: String, + access_token: String, + access_expires_at: DateTime, + #[serde(default)] + scope: Option, + }, + PersonalAccessToken { + token: String, + #[serde(default)] + expires_at: Option>, + #[serde(default)] + scope: Option, + }, +} + +#[derive(Debug, Clone)] +pub struct CredentialStore { + server_key: String, +} + +impl CredentialStore { + pub fn new(canonical_server_url: &str) -> Self { + let digest = Sha256::digest(canonical_server_url.as_bytes()); + let digest = digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + Self { + // URL-derived keys isolate credentials for different gateways without + // exposing the gateway address through the operating-system keyring UI. + server_key: format!("board-server:{digest}"), + } + } + + pub fn load(&self) -> anyhow::Result> { + if let Ok(entry) = Entry::new(KEYRING_SERVICE, &self.server_key) + && let Ok(value) = entry.get_password() + { + return serde_json::from_str(&value) + .map(Some) + .context("failed to decode keyring credential"); + } + let path = credential_file_path()?; + let records = read_file_records(&path)?; + Ok(records.get(&self.server_key).cloned()) + } + + pub fn save(&self, record: &CredentialRecord) -> anyhow::Result<()> { + let value = serde_json::to_string(record).context("failed to encode credential")?; + if let Ok(entry) = Entry::new(KEYRING_SERVICE, &self.server_key) + && entry.set_password(&value).is_ok() + { + return Ok(()); + } + + eprintln!( + "warning: the system credential store is unavailable; storing ostool credentials in a user-only file" + ); + let path = credential_file_path()?; + let mut records = read_file_records(&path)?; + records.insert(self.server_key.clone(), record.clone()); + write_file_records(&path, &records) + } + + pub fn delete(&self) -> anyhow::Result<()> { + if let Ok(entry) = Entry::new(KEYRING_SERVICE, &self.server_key) { + let _ = entry.delete_credential(); + } + + let path = credential_file_path()?; + let mut records = read_file_records(&path)?; + if records.remove(&self.server_key).is_some() { + write_file_records(&path, &records)?; + } + Ok(()) + } +} + +fn credential_file_path() -> anyhow::Result { + let config_home = env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) + .context("HOME and XDG_CONFIG_HOME are not set")?; + Ok(config_home.join("ostool").join("hosts.json")) +} + +fn read_file_records(path: &PathBuf) -> anyhow::Result> { + match fs::read_to_string(path) { + Ok(content) => serde_json::from_str(&content) + .with_context(|| format!("failed to parse credential fallback {}", path.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()), + Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())), + } +} + +fn write_file_records( + path: &PathBuf, + records: &BTreeMap, +) -> anyhow::Result<()> { + let parent = path.parent().expect("credential path has parent"); + // This is an explicit fallback for hosts without a usable system credential store. + // Keep the file private even though the OS cannot provide keyring protection here. + fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; + fs::write(path, serde_json::to_vec_pretty(records)?) + .with_context(|| format!("failed to write {}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + .with_context(|| format!("failed to protect {}", path.display()))?; + } + Ok(()) +} diff --git a/ostool/src/auth/mod.rs b/ostool/src/auth/mod.rs new file mode 100644 index 00000000..0a0789ac --- /dev/null +++ b/ostool/src/auth/mod.rs @@ -0,0 +1,11 @@ +//! Authentication, credential storage, and token lifecycle management. +//! +//! This module communicates with the authentication gateway independently from +//! the board-service client. Board operations may consume an access token, but +//! they do not own login, refresh, revoke, or credential persistence. + +mod client; +mod credential_store; + +/// Access-token acquisition, refresh, logout, and local authentication state. +pub mod token_manager; diff --git a/ostool/src/auth/token_manager.rs b/ostool/src/auth/token_manager.rs new file mode 100644 index 00000000..7ba889a5 --- /dev/null +++ b/ostool/src/auth/token_manager.rs @@ -0,0 +1,247 @@ +use std::{env, fs, path::PathBuf, sync::Arc}; + +use anyhow::{Context as _, bail}; +use chrono::{Duration, Utc}; +use tokio::sync::Mutex; + +use crate::board::global_config::{AuthMode, BoardEndpoint}; + +use super::{ + client::{AuthClient, complete_device_login}, + credential_store::{CredentialRecord, CredentialStore}, +}; + +#[derive(Clone)] +pub struct TokenManager { + endpoint: BoardEndpoint, + store: Arc, + auth: AuthClient, + cached: Arc>>, +} + +#[derive(Debug, Clone)] +pub struct AuthStatus { + pub kind: Option<&'static str>, + pub expires_at: Option>, + pub scope: Option, +} + +impl TokenManager { + pub fn new(endpoint: BoardEndpoint) -> anyhow::Result { + let canonical = endpoint.base_url.as_str().to_string(); + Ok(Self { + store: Arc::new(CredentialStore::new(&canonical)), + auth: AuthClient::new(endpoint.clone())?, + endpoint, + cached: Arc::new(Mutex::new(None)), + }) + } + + pub async fn authorization_token(&self) -> anyhow::Result> { + if self.endpoint.auth_mode == AuthMode::Disabled { + return Ok(None); + } + // Automation credentials are intentionally process-local: do not write + // them to the credential store or try to refresh an unknown token. + if let Some(token) = env::var_os("OSTOOL_BOARD_ACCESS_TOKEN") { + let token = token.to_string_lossy().trim().to_string(); + if !token.is_empty() { + return Ok(Some(token)); + } + } + + let mut cached = self.cached.lock().await; + if cached.is_none() { + *cached = self.store.load()?; + } + let Some(record) = cached.clone() else { + bail!("authentication is required; run `ostool login` or `ostool login --with-token`"); + }; + + match record { + CredentialRecord::PersonalAccessToken { + token, expires_at, .. + } => { + if expires_at.is_some_and(|expiry| expiry <= Utc::now()) { + self.store.delete()?; + *cached = None; + bail!( + "personal access token has expired; create a new token in the web UI and import it with `ostool login --with-token`" + ); + } + Ok(Some(token)) + } + CredentialRecord::OAuthRefresh { + access_token, + access_expires_at, + refresh_token: _, + .. + } if access_expires_at > Utc::now() + Duration::seconds(60) => Ok(Some(access_token)), + CredentialRecord::OAuthRefresh { refresh_token, .. } => { + // Refresh-token rotation makes concurrent refreshes unsafe. Lock, + // then reload storage so another process can satisfy this request. + let _refresh_lock = RefreshLock::acquire(&self.endpoint.base_url).await?; + if let Some(CredentialRecord::OAuthRefresh { + access_token, + access_expires_at, + .. + }) = self.store.load()? + && access_expires_at > Utc::now() + Duration::seconds(60) + { + *cached = self.store.load()?; + return Ok(Some(access_token)); + } + let refreshed = self.auth.refresh(&refresh_token).await; + match refreshed { + Ok(record) => { + let access_token = match &record { + CredentialRecord::OAuthRefresh { access_token, .. } => { + access_token.clone() + } + _ => unreachable!(), + }; + self.store.save(&record)?; + *cached = Some(record); + Ok(Some(access_token)) + } + Err(error) + if error.to_string().contains("invalid_grant") + || error.to_string().contains("invalid_token") => + { + self.store.delete()?; + *cached = None; + Err(error.context("login has expired; run `ostool login` again")) + } + Err(error) => Err(error), + } + } + } + } + + pub async fn login_device(&self) -> anyhow::Result<()> { + if self.endpoint.auth_mode == AuthMode::Disabled { + bail!("the configured board server has authentication disabled"); + } + let record = complete_device_login(&self.auth).await?; + self.store.save(&record)?; + *self.cached.lock().await = Some(record); + Ok(()) + } + + pub async fn import_personal_access_token(&self, token: String) -> anyhow::Result<()> { + if self.endpoint.auth_mode == AuthMode::Disabled { + bail!("the configured board server has authentication disabled"); + } + let token = token.trim().to_string(); + if token.is_empty() { + bail!("personal access token input is empty"); + } + let record = CredentialRecord::PersonalAccessToken { + token, + expires_at: None, + scope: None, + }; + self.store.save(&record)?; + *self.cached.lock().await = Some(record); + Ok(()) + } + + pub async fn status(&self) -> anyhow::Result { + if self.endpoint.auth_mode == AuthMode::Disabled { + return Ok(AuthStatus { + kind: None, + expires_at: None, + scope: None, + }); + } + if env::var_os("OSTOOL_BOARD_ACCESS_TOKEN").is_some() { + return Ok(AuthStatus { + kind: Some("environment access token"), + expires_at: None, + scope: None, + }); + } + let record = self.store.load()?; + let (kind, expires_at, scope) = match record { + Some(CredentialRecord::OAuthRefresh { + access_expires_at, + scope, + .. + }) => (Some("OAuth"), Some(access_expires_at), scope), + Some(CredentialRecord::PersonalAccessToken { + expires_at, scope, .. + }) => (Some("personal access token"), expires_at, scope), + None => (None, None, None), + }; + Ok(AuthStatus { + kind, + expires_at, + scope, + }) + } + + pub async fn logout(&self) -> anyhow::Result<()> { + let record = self.store.load()?; + if let Some(CredentialRecord::OAuthRefresh { refresh_token, .. }) = record + && let Err(error) = self.auth.revoke(&refresh_token).await + { + eprintln!("warning: failed to revoke OAuth session remotely: {error:#}"); + } + self.store.delete()?; + *self.cached.lock().await = None; + Ok(()) + } + + pub async fn invalidate_after_unauthorized(&self) -> anyhow::Result<()> { + self.store.delete()?; + *self.cached.lock().await = None; + Ok(()) + } +} + +struct RefreshLock(fs::File); + +impl RefreshLock { + async fn acquire(base_url: &reqwest::Url) -> anyhow::Result { + use sha2::{Digest as _, Sha256}; + + let cache_home = env::var_os("XDG_CACHE_HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".cache"))) + .context("HOME and XDG_CACHE_HOME are not set")?; + let digest = Sha256::digest(base_url.as_str().as_bytes()); + let digest = digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let path = cache_home + .join("ostool") + .join("auth-locks") + .join(format!("{digest}.lock")); + + tokio::task::spawn_blocking(move || { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let file = fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + .with_context(|| format!("failed to open {}", path.display()))?; + fs4::FileExt::lock(&file) + .with_context(|| format!("failed to lock {}", path.display()))?; + Ok::<_, anyhow::Error>(Self(file)) + }) + .await + .context("refresh lock task failed")? + } +} + +impl Drop for RefreshLock { + fn drop(&mut self) { + let _ = fs4::FileExt::unlock(&self.0); + } +} diff --git a/ostool/src/board/client.rs b/ostool/src/board/client.rs index d0e57c6d..8fdaa695 100644 --- a/ostool/src/board/client.rs +++ b/ostool/src/board/client.rs @@ -3,15 +3,31 @@ use std::fmt; use anyhow::Context as _; use chrono::{DateTime, Utc}; use httpboot_protocol::KernelPublishResponse; -use reqwest::{StatusCode, Url}; +use reqwest::{Method, StatusCode, Url}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use thiserror::Error; -#[derive(Debug, Clone)] +use crate::{ + auth::token_manager::TokenManager, + board::global_config::{AuthMode, BoardEndpoint}, +}; + +#[derive(Clone)] pub struct BoardServerClient { client: reqwest::Client, base_url: Url, ws_base_url: Url, + endpoint: BoardEndpoint, + token_manager: TokenManager, +} + +impl fmt::Debug for BoardServerClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BoardServerClient") + .field("base_url", &self.base_url) + .field("auth_mode", &self.endpoint.auth_mode) + .finish_non_exhaustive() + } } #[derive(Debug, Clone, Deserialize)] @@ -198,20 +214,31 @@ impl BoardServerClientError { impl BoardServerClient { pub fn new(server: &str, port: u16) -> anyhow::Result { + Self::new_with_endpoint(BoardEndpoint::new( + &format!("http://{server}"), + Some(port), + AuthMode::Disabled, + )?) + } + + pub fn new_with_endpoint(endpoint: BoardEndpoint) -> anyhow::Result { Ok(Self { client: reqwest::Client::builder() .no_proxy() + .redirect(reqwest::redirect::Policy::none()) .build() .context("failed to build HTTP client")?, - base_url: build_base_url("http", server, port)?, - ws_base_url: build_base_url("ws", server, port)?, + base_url: endpoint.base_url.clone(), + ws_base_url: endpoint.websocket_base_url()?, + token_manager: TokenManager::new(endpoint.clone())?, + endpoint, }) } pub async fn list_board_types(&self) -> Result, BoardServerClientError> { let response = self - .client - .get(self.endpoint("/api/v1/board-types")) + .request(Method::GET, self.endpoint("/api/v1/board-types")) + .await? .send() .await .map_err(Self::request_error)?; @@ -223,8 +250,8 @@ impl BoardServerClient { board_type: &str, ) -> Result { let response = self - .client - .post(self.endpoint("/api/v1/sessions")) + .request(Method::POST, self.endpoint("/api/v1/sessions")) + .await? .json(&CreateSessionRequest { board_type: board_type.to_string(), required_tags: vec![], @@ -241,8 +268,11 @@ impl BoardServerClient { session_id: &str, ) -> Result { let response = self - .client - .post(self.endpoint(&format!("/api/v1/sessions/{session_id}/heartbeat"))) + .request( + Method::POST, + self.endpoint(&format!("/api/v1/sessions/{session_id}/heartbeat")), + ) + .await? .send() .await .map_err(Self::request_error)?; @@ -251,8 +281,11 @@ impl BoardServerClient { pub async fn delete_session(&self, session_id: &str) -> Result<(), BoardServerClientError> { let response = self - .client - .delete(self.endpoint(&format!("/api/v1/sessions/{session_id}"))) + .request( + Method::DELETE, + self.endpoint(&format!("/api/v1/sessions/{session_id}")), + ) + .await? .send() .await .map_err(Self::request_error)?; @@ -267,8 +300,11 @@ impl BoardServerClient { session_id: &str, ) -> Result { let response = self - .client - .get(self.endpoint(&format!("/api/v1/sessions/{session_id}/boot-profile"))) + .request( + Method::GET, + self.endpoint(&format!("/api/v1/sessions/{session_id}/boot-profile")), + ) + .await? .send() .await .map_err(Self::request_error)?; @@ -280,8 +316,11 @@ impl BoardServerClient { session_id: &str, ) -> Result { let response = self - .client - .get(self.endpoint(&format!("/api/v1/sessions/{session_id}/serial"))) + .request( + Method::GET, + self.endpoint(&format!("/api/v1/sessions/{session_id}/serial")), + ) + .await? .send() .await .map_err(Self::request_error)?; @@ -293,8 +332,11 @@ impl BoardServerClient { session_id: &str, ) -> Result { let response = self - .client - .get(self.endpoint(&format!("/api/v1/sessions/{session_id}/tftp"))) + .request( + Method::GET, + self.endpoint(&format!("/api/v1/sessions/{session_id}/tftp")), + ) + .await? .send() .await .map_err(Self::request_error)?; @@ -306,8 +348,11 @@ impl BoardServerClient { session_id: &str, ) -> Result { let response = self - .client - .get(self.endpoint(&format!("/api/v1/sessions/{session_id}/dtb"))) + .request( + Method::GET, + self.endpoint(&format!("/api/v1/sessions/{session_id}/dtb")), + ) + .await? .send() .await .map_err(Self::request_error)?; @@ -319,8 +364,11 @@ impl BoardServerClient { session_id: &str, ) -> Result, BoardServerClientError> { let response = self - .client - .get(self.endpoint(&format!("/api/v1/sessions/{session_id}/dtb/download"))) + .request( + Method::GET, + self.endpoint(&format!("/api/v1/sessions/{session_id}/dtb/download")), + ) + .await? .send() .await .map_err(Self::request_error)?; @@ -329,8 +377,11 @@ impl BoardServerClient { pub async fn power_on_board(&self, session_id: &str) -> Result<(), BoardServerClientError> { let response = self - .client - .post(self.endpoint(&format!("/api/v1/sessions/{session_id}/board/power-on"))) + .request( + Method::POST, + self.endpoint(&format!("/api/v1/sessions/{session_id}/board/power-on")), + ) + .await? .send() .await .map_err(Self::request_error)?; @@ -339,8 +390,11 @@ impl BoardServerClient { pub async fn power_off_board(&self, session_id: &str) -> Result<(), BoardServerClientError> { let response = self - .client - .post(self.endpoint(&format!("/api/v1/sessions/{session_id}/board/power-off"))) + .request( + Method::POST, + self.endpoint(&format!("/api/v1/sessions/{session_id}/board/power-off")), + ) + .await? .send() .await .map_err(Self::request_error)?; @@ -354,8 +408,11 @@ impl BoardServerClient { bytes: Vec, ) -> Result { let response = self - .client - .put(self.endpoint(&format!("/api/v1/sessions/{session_id}/files"))) + .request( + Method::PUT, + self.endpoint(&format!("/api/v1/sessions/{session_id}/files")), + ) + .await? .header("X-File-Path", relative_path) .body(bytes) .send() @@ -371,8 +428,11 @@ impl BoardServerClient { bytes: Vec, ) -> Result { let response = self - .client - .put(self.endpoint(&format!("/api/v1/sessions/{session_id}/http-boot/files"))) + .request( + Method::PUT, + self.endpoint(&format!("/api/v1/sessions/{session_id}/http-boot/files")), + ) + .await? .header("X-File-Path", relative_path) .body(bytes) .send() @@ -387,8 +447,11 @@ impl BoardServerClient { upload: HttpBootKernelUpload, ) -> Result { let mut request = self - .client - .put(self.endpoint(&format!("/api/v1/sessions/{session_id}/http-boot/kernel"))) + .request( + Method::PUT, + self.endpoint(&format!("/api/v1/sessions/{session_id}/http-boot/kernel")), + ) + .await? .header("X-HttpBoot-Remote-Name", upload.remote_name) .header("X-HttpBoot-Arch", upload.arch) .header("X-HttpBoot-Image-Format", upload.image_format); @@ -405,7 +468,20 @@ impl BoardServerClient { pub fn resolve_ws_url(&self, ws_url: &str) -> anyhow::Result { if ws_url.starts_with("ws://") || ws_url.starts_with("wss://") { - return Url::parse(ws_url).with_context(|| format!("invalid websocket URL `{ws_url}`")); + let url = + Url::parse(ws_url).with_context(|| format!("invalid websocket URL `{ws_url}`"))?; + // A server-provided absolute URL must not redirect a Bearer token + // to another origin. Relative URLs are resolved against ws_base_url below. + if self.endpoint.auth_mode == AuthMode::Required + && (url.scheme() != self.ws_base_url.scheme() + || url.host() != self.ws_base_url.host() + || url.port_or_known_default() != self.ws_base_url.port_or_known_default()) + { + anyhow::bail!( + "refusing to send authentication credentials to cross-origin websocket URL `{url}`" + ); + } + return Ok(url); } self.ws_base_url @@ -419,6 +495,28 @@ impl BoardServerClient { .expect("static API path should be valid") } + pub async fn websocket_authorization(&self) -> anyhow::Result> { + self.token_manager.authorization_token().await + } + + async fn request( + &self, + method: Method, + url: Url, + ) -> Result { + let request = self.client.request(method, url); + match self.token_manager.authorization_token().await { + // TokenManager returns None only for the explicitly anonymous mode. + Ok(Some(token)) => Ok(request.bearer_auth(token)), + Ok(None) => Ok(request), + Err(error) => Err(BoardServerClientError { + status: StatusCode::UNAUTHORIZED, + code: Some("authentication_required".to_string()), + message: error.to_string(), + }), + } + } + async fn decode_json( &self, response: reqwest::Response, @@ -426,6 +524,8 @@ impl BoardServerClient { if response.status().is_success() { response.json::().await.map_err(Self::request_error) } else { + self.clear_invalid_credential_if_unauthorized(&response) + .await; Err(Self::api_error(response).await) } } @@ -437,6 +537,8 @@ impl BoardServerClient { if response.status().is_success() { Ok(()) } else { + self.clear_invalid_credential_if_unauthorized(&response) + .await; Err(Self::api_error(response).await) } } @@ -452,10 +554,22 @@ impl BoardServerClient { .map(|bytes| bytes.to_vec()) .map_err(Self::request_error) } else { + self.clear_invalid_credential_if_unauthorized(&response) + .await; Err(Self::api_error(response).await) } } + async fn clear_invalid_credential_if_unauthorized(&self, response: &reqwest::Response) { + // Do not keep a credential the gateway has explicitly rejected. The next + // command must authenticate again instead of silently falling back to LAN mode. + if response.status() == StatusCode::UNAUTHORIZED + && let Err(error) = self.token_manager.invalidate_after_unauthorized().await + { + log::warn!("failed to remove rejected board credentials: {error:#}"); + } + } + async fn api_error(response: reqwest::Response) -> BoardServerClientError { let status = response.status(); let body = response.text().await.unwrap_or_default(); @@ -471,16 +585,6 @@ impl BoardServerClient { } } -fn build_base_url(scheme: &str, server: &str, port: u16) -> anyhow::Result { - let mut url = Url::parse(&format!("{scheme}://localhost")) - .with_context(|| format!("failed to create {scheme} URL"))?; - url.set_host(Some(server)) - .map_err(|_| anyhow::anyhow!("invalid server host `{server}`"))?; - url.set_port(Some(port)) - .map_err(|_| anyhow::anyhow!("invalid port `{port}`"))?; - Ok(url) -} - fn parse_error_body(status: StatusCode, body: &str) -> BoardServerClientError { match serde_json::from_str::(body) { Ok(error) => BoardServerClientError { @@ -512,6 +616,7 @@ mod tests { use reqwest::StatusCode; use super::{BoardServerClient, BootConfig, parse_error_body}; + use crate::board::global_config::{AuthMode, BoardEndpoint}; #[test] fn resolve_relative_ws_url_uses_server_defaults() { @@ -537,6 +642,19 @@ mod tests { ); } + #[test] + fn authenticated_client_rejects_cross_origin_websocket_url() { + let client = BoardServerClient::new_with_endpoint( + BoardEndpoint::new("https://203.0.113.10:8443", None, AuthMode::Required).unwrap(), + ) + .unwrap(); + assert!( + client + .resolve_ws_url("wss://203.0.113.11:8443/api/v1/sessions/demo/serial/ws") + .is_err() + ); + } + #[test] fn parse_error_body_prefers_structured_api_errors() { let error = parse_error_body( diff --git a/ostool/src/board/config.rs b/ostool/src/board/config.rs index 0b9f79dc..0fd4d8e1 100644 --- a/ostool/src/board/config.rs +++ b/ostool/src/board/config.rs @@ -5,12 +5,13 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::{ - board::global_config::BoardGlobalConfig, + board::global_config::{AuthMode, BoardEndpoint, BoardGlobalConfig}, project::variables::{self, VariableScope}, run::shell_init::normalize_shell_init_config, }; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct BoardRunConfig { pub board_type: String, pub dtb_file: Option, @@ -26,6 +27,8 @@ pub struct BoardRunConfig { pub shell_prefix: Option, pub shell_init_cmd: Option, pub timeout: Option, + pub auth_mode: Option, + /// Complete board service URL. `port` optionally overrides its port. pub server: Option, pub port: Option, } @@ -63,18 +66,18 @@ impl BoardRunConfig { Ok(config) } - pub(crate) fn resolve_server( + pub(crate) fn resolve_endpoint( &self, cli_server: Option<&str>, cli_port: Option, global_config: &BoardGlobalConfig, - ) -> (String, u16) { + ) -> anyhow::Result { + let auth_mode = self.auth_mode.unwrap_or(global_config.auth_mode); let server = cli_server - .map(str::to_string) - .or_else(|| self.server.clone()) - .unwrap_or_else(|| global_config.server_ip.clone()); - let port = cli_port.or(self.port).unwrap_or(global_config.port); - (server, port) + .or(self.server.as_deref()) + .unwrap_or(&global_config.server); + let port = cli_port.or(self.port).or(global_config.port); + BoardEndpoint::new(server, port, auth_mode) } pub(crate) fn apply_overrides( @@ -254,7 +257,7 @@ uboot_cmd = [" run bootcmd "] shell_prefix = " login: " shell_init_cmd = " root " timeout = 15 -server = "10.0.0.2" +server = "http://10.0.0.2" port = 9000 "#, ) @@ -272,15 +275,16 @@ port = 9000 assert_eq!(config.shell_init_cmd.as_deref(), Some("root")); assert_eq!(config.timeout, Some(15)); assert_eq!( - config.resolve_server( - Some("127.0.0.1"), - None, - &BoardGlobalConfig { - server_ip: "localhost".into(), - port: 2999, - } - ), - ("127.0.0.1".to_string(), 9000) + config + .resolve_endpoint( + Some("http://127.0.0.1"), + None, + &BoardGlobalConfig::default(), + ) + .unwrap() + .base_url + .as_str(), + "http://127.0.0.1:9000/" ); } @@ -298,7 +302,7 @@ port = 9000 let mut config: BoardRunConfig = toml::from_str( r#" board_type = "orangepi5plus" -server = "10.0.0.2" +server = "http://10.0.0.2" port = 9000 "#, ) @@ -317,13 +321,13 @@ port = 9000 .apply_overrides( &invocation.variable_scope().unwrap(), Some(" rk3568 "), - Some(" 127.0.0.1 "), + Some(" http://127.0.0.1 "), Some(7000), ) .unwrap(); assert_eq!(config.board_type, "rk3568"); - assert_eq!(config.server.as_deref(), Some("127.0.0.1")); + assert_eq!(config.server.as_deref(), Some("http://127.0.0.1")); assert_eq!(config.port, Some(7000)); } diff --git a/ostool/src/board/config_tui.rs b/ostool/src/board/config_tui.rs index a4df74e2..31093344 100644 --- a/ostool/src/board/config_tui.rs +++ b/ostool/src/board/config_tui.rs @@ -19,7 +19,7 @@ use ratatui::{ widgets::{Block, Borders, Paragraph, Wrap}, }; -use crate::board::global_config::{BoardGlobalConfig, LoadedBoardGlobalConfig}; +use crate::board::global_config::{AuthMode, BoardGlobalConfig, LoadedBoardGlobalConfig}; const EVENT_POLL_INTERVAL: Duration = Duration::from_millis(50); const FORM_CONTENT_HEIGHT: u16 = 14; @@ -30,7 +30,7 @@ const FORM_MIN_WIDTH: u16 = 64; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ActiveField { - ServerIp, + Server, Port, } @@ -125,7 +125,8 @@ impl InputField { #[derive(Debug, Clone)] struct BoardConfigApp { path: PathBuf, - server_ip: InputField, + auth_mode: AuthMode, + server: InputField, port: InputField, active: ActiveField, error: Option, @@ -135,9 +136,10 @@ impl BoardConfigApp { fn new(path: PathBuf, config: BoardGlobalConfig) -> Self { Self { path, - server_ip: InputField::new(config.server_ip), - port: InputField::new(config.port.to_string()), - active: ActiveField::ServerIp, + auth_mode: config.auth_mode, + server: InputField::new(config.server), + port: InputField::new(config.port.map(|port| port.to_string()).unwrap_or_default()), + active: ActiveField::Server, error: None, } } @@ -223,7 +225,7 @@ impl BoardConfigApp { fn insert_char(&mut self, ch: char) { match self.active { - ActiveField::ServerIp => self.server_ip.insert_char(ch), + ActiveField::Server => self.server.insert_char(ch), ActiveField::Port if ch.is_ascii_digit() => self.port.insert_char(ch), ActiveField::Port => {} } @@ -242,28 +244,27 @@ impl BoardConfigApp { } fn validate(&self) -> anyhow::Result { - let server_ip = self.server_ip.value.trim().to_string(); - if server_ip.is_empty() { - bail!("server_ip must not be empty"); + let server = self.server.value.trim().to_string(); + if server.is_empty() { + bail!("server must not be empty"); } - let port: u16 = self - .port - .value - .trim() - .parse() - .context("port must be a valid integer")?; - if port == 0 { - bail!("port must be in 1..=65535"); - } + let port = match self.port.value.trim() { + "" => None, + value => Some(value.parse().context("port must be a valid integer")?), + }; - Ok(BoardGlobalConfig { server_ip, port }) + Ok(BoardGlobalConfig { + auth_mode: self.auth_mode, + server, + port, + }) } fn focus_next(&mut self) { self.active = match self.active { - ActiveField::ServerIp => ActiveField::Port, - ActiveField::Port => ActiveField::ServerIp, + ActiveField::Server => ActiveField::Port, + ActiveField::Port => ActiveField::Server, }; } @@ -273,7 +274,7 @@ impl BoardConfigApp { fn active_field_mut(&mut self) -> &mut InputField { match self.active { - ActiveField::ServerIp => &mut self.server_ip, + ActiveField::Server => &mut self.server, ActiveField::Port => &mut self.port, } } @@ -319,9 +320,9 @@ impl BoardConfigApp { let server_cursor = draw_input( frame, chunks[1], - "server_ip", - &self.server_ip, - self.active == ActiveField::ServerIp, + "server", + &self.server, + self.active == ActiveField::Server, ); let port_cursor = draw_input( frame, @@ -360,7 +361,7 @@ impl BoardConfigApp { frame.render_widget(footer, chunks[5]); let (cursor_area, cursor_offset) = match self.active { - ActiveField::ServerIp => (chunks[1], server_cursor), + ActiveField::Server => (chunks[1], server_cursor), ActiveField::Port => (chunks[2], port_cursor), }; frame.set_cursor_position(Position::new( @@ -483,14 +484,15 @@ mod tests { let app = BoardConfigApp::new( PathBuf::from("/tmp/config.toml"), BoardGlobalConfig { - server_ip: "10.0.0.2".into(), - port: 9000, + server: "http://10.0.0.2".into(), + port: Some(9000), + ..BoardGlobalConfig::default() }, ); - assert_eq!(app.server_ip.value, "10.0.0.2"); + assert_eq!(app.server.value, "http://10.0.0.2"); assert_eq!(app.port.value, "9000"); - assert_eq!(app.active, ActiveField::ServerIp); + assert_eq!(app.active, ActiveField::Server); } #[test] @@ -504,12 +506,12 @@ mod tests { } #[test] - fn handle_key_edits_server_ip() { + fn handle_key_edits_server() { let mut app = BoardConfigApp::new( PathBuf::from("/tmp/config.toml"), BoardGlobalConfig::default(), ); - app.server_ip = InputField::new(""); + app.server = InputField::new(""); let outcome = app .handle_key_event(KeyEvent { @@ -521,7 +523,7 @@ mod tests { .unwrap(); assert!(outcome.is_none()); - assert_eq!(app.server_ip.value, "a"); + assert_eq!(app.server.value, "a"); } #[test] @@ -542,25 +544,25 @@ mod tests { let temp = tempdir().unwrap(); let path = temp.path().join(".ostool/config.toml"); let mut app = BoardConfigApp::new(path.clone(), BoardGlobalConfig::default()); - app.server_ip = InputField::new("10.0.0.2"); + app.server = InputField::new("http://10.0.0.2"); app.port = InputField::new("9000"); app.save().unwrap(); let content = std::fs::read_to_string(path).unwrap(); - assert!(content.contains("server_ip = \"10.0.0.2\"")); + assert!(content.contains("server = \"http://10.0.0.2\"")); assert!(content.contains("port = 9000")); } #[test] - fn save_rejects_empty_server_ip() { + fn save_rejects_empty_server() { let temp = tempdir().unwrap(); let path = temp.path().join(".ostool/config.toml"); let mut app = BoardConfigApp::new(path.clone(), BoardGlobalConfig::default()); - app.server_ip = InputField::new(" "); + app.server = InputField::new(" "); let err = app.save().unwrap_err(); - assert!(err.to_string().contains("server_ip")); + assert!(err.to_string().contains("server")); assert!(!path.exists()); } diff --git a/ostool/src/board/global_config.rs b/ostool/src/board/global_config.rs index ff074abe..5a785f34 100644 --- a/ostool/src/board/global_config.rs +++ b/ostool/src/board/global_config.rs @@ -1,13 +1,88 @@ use std::{ env, fs, + net::IpAddr, path::{Path, PathBuf}, }; use anyhow::{Context, bail}; -use serde::{Deserialize, Serialize}; +use reqwest::Url; +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize, de::Error as _}; -pub const DEFAULT_BOARD_SERVER_IP: &str = "localhost"; -pub const DEFAULT_BOARD_SERVER_PORT: u16 = 2999; +// Keep the URL-based configuration aligned with ostool-server's established +// default listen port so a newly generated config works without user edits. +pub const DEFAULT_BOARD_SERVER: &str = "http://localhost:2999"; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum AuthMode { + #[default] + Disabled, + Required, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BoardEndpoint { + pub base_url: Url, + pub auth_mode: AuthMode, +} + +impl BoardEndpoint { + pub fn new(server: &str, port: Option, auth_mode: AuthMode) -> anyhow::Result { + let server = server.trim(); + // Before `board.server` became a complete URL, LAN configurations used a + // bare IP address. Keep that specific legacy form working by treating it + // as HTTP; host names and all new configurations must include a scheme. + let server_url = match server.parse::() { + Ok(IpAddr::V4(_)) => format!("http://{server}"), + Ok(IpAddr::V6(_)) => format!("http://[{server}]"), + Err(_) => server.to_string(), + }; + let mut base_url = Url::parse(&server_url) + .with_context(|| format!("invalid board server URL `{server}`"))?; + if !matches!(base_url.scheme(), "http" | "https") { + bail!("board server URL must use http or https"); + } + if base_url.host().is_none() { + bail!("board server URL must include a host"); + } + if auth_mode == AuthMode::Required && base_url.scheme() != "https" { + bail!("authenticated board server URL must use https"); + } + if let Some(port) = port { + if port == 0 { + bail!("board server port must be in 1..=65535"); + } + base_url + .set_port(Some(port)) + .map_err(|_| anyhow::anyhow!("invalid board server port `{port}`"))?; + } + // Url::join treats a base without a trailing slash as a file path and + // would discard its final path component when resolving API endpoints. + if !base_url.path().ends_with('/') { + let path = format!("{}/", base_url.path()); + base_url.set_path(&path); + } + Ok(Self { + base_url, + auth_mode, + }) + } + + pub fn websocket_base_url(&self) -> anyhow::Result { + let mut url = self.base_url.clone(); + match url.scheme() { + "http" => url + .set_scheme("ws") + .map_err(|_| anyhow::anyhow!("failed to build websocket URL"))?, + "https" => url + .set_scheme("wss") + .map_err(|_| anyhow::anyhow!("failed to build websocket URL"))?, + _ => unreachable!("BoardEndpoint validates schemes"), + } + Ok(url) + } +} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct BoardGlobalConfigFile { @@ -15,19 +90,71 @@ pub struct BoardGlobalConfigFile { pub board: BoardGlobalConfig, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct BoardGlobalConfig { - #[serde(default = "default_server_ip")] - pub server_ip: String, - #[serde(default = "default_server_port")] - pub port: u16, + /// Complete board service URL, including its scheme and optional base path. + #[serde(default = "default_server")] + pub server: String, + /// Optional port override for `server`. + #[serde(default)] + pub port: Option, + #[serde(default)] + pub auth_mode: AuthMode, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BoardGlobalConfigInput { + #[serde(default)] + server: Option, + // The base release persisted this field. It exists only in the input + // representation so upgraded installations can be read and rewritten in + // the URL-based format; new configuration is always serialized as `server`. + #[serde(default)] + server_ip: Option, + #[serde(default)] + port: Option, + #[serde(default)] + auth_mode: AuthMode, +} + +impl<'de> Deserialize<'de> for BoardGlobalConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let input = BoardGlobalConfigInput::deserialize(deserializer)?; + let server = match (input.server, input.server_ip) { + (Some(server), None) => server, + (None, Some(server_ip)) => { + let server_ip = server_ip.trim(); + match server_ip.parse::() { + Ok(IpAddr::V6(_)) => format!("http://[{server_ip}]"), + Ok(IpAddr::V4(_)) | Err(_) => format!("http://{server_ip}"), + } + } + (None, None) => default_server(), + (Some(_), Some(_)) => { + return Err(D::Error::custom( + "`board.server` and legacy `board.server_ip` cannot be used together", + )); + } + }; + + Ok(Self { + server, + port: input.port, + auth_mode: input.auth_mode, + }) + } } impl Default for BoardGlobalConfig { fn default() -> Self { Self { - server_ip: DEFAULT_BOARD_SERVER_IP.to_string(), - port: DEFAULT_BOARD_SERVER_PORT, + server: default_server(), + port: None, + auth_mode: AuthMode::Disabled, } } } @@ -79,39 +206,37 @@ impl LoadedBoardGlobalConfig { ) } - pub fn resolve_server(&self, cli_server: Option<&str>, cli_port: Option) -> (String, u16) { - self.board.resolve_server(cli_server, cli_port) + pub fn resolve_endpoint( + &self, + cli_server: Option<&str>, + cli_port: Option, + ) -> anyhow::Result { + self.board.resolve_endpoint(cli_server, cli_port) } } impl BoardGlobalConfig { - pub fn resolve_server(&self, cli_server: Option<&str>, cli_port: Option) -> (String, u16) { - let server_ip = cli_server - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| self.server_ip.clone()); - let port = cli_port.unwrap_or(self.port); - (server_ip, port) - } - pub fn validate(&self, path: &Path) -> anyhow::Result<()> { - if self.server_ip.trim().is_empty() { - bail!("`board.server_ip` must not be empty in {}", path.display()); - } - if self.port == 0 { - bail!("`board.port` must be in 1..=65535 in {}", path.display()); + if self.server.trim().is_empty() { + bail!("`board.server` must not be empty in {}", path.display()); } + BoardEndpoint::new(&self.server, self.port, self.auth_mode)?; Ok(()) } -} -fn default_server_ip() -> String { - DEFAULT_BOARD_SERVER_IP.to_string() + pub fn resolve_endpoint( + &self, + cli_server: Option<&str>, + cli_port: Option, + ) -> anyhow::Result { + let server = cli_server.unwrap_or(&self.server); + let port = cli_port.or(self.port); + BoardEndpoint::new(server, port, self.auth_mode) + } } -const fn default_server_port() -> u16 { - DEFAULT_BOARD_SERVER_PORT +fn default_server() -> String { + DEFAULT_BOARD_SERVER.to_string() } fn default_config_path() -> anyhow::Result { @@ -134,54 +259,150 @@ fn write_config_file(path: &Path, file: &BoardGlobalConfigFile) -> anyhow::Resul mod tests { use tempfile::tempdir; - use super::{BoardGlobalConfig, LoadedBoardGlobalConfig}; + use super::{AuthMode, BoardGlobalConfig, LoadedBoardGlobalConfig}; #[test] - fn load_or_create_creates_default_config_when_missing() { + fn load_or_create_creates_url_based_default_config_when_missing() { let temp = tempdir().unwrap(); let path = temp.path().join(".ostool/config.toml"); let loaded = LoadedBoardGlobalConfig::load_or_create_at(&path).unwrap(); assert!(loaded.created); - assert_eq!(loaded.board.server_ip, "localhost"); - assert_eq!(loaded.board.port, 2999); + assert_eq!(loaded.board.server, "http://localhost:2999"); + assert_eq!(loaded.board.port, None); let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("[board]")); - assert!(content.contains("server_ip = \"localhost\"")); - assert!(content.contains("port = 2999")); + assert!(content.contains("server = \"http://localhost:2999\"")); + assert!(!content.contains("server_ip")); } #[test] - fn resolve_server_prefers_cli_over_global_defaults() { + fn default_config_resolves_to_the_board_server_default_port() { + assert_eq!( + BoardGlobalConfig::default() + .resolve_endpoint(None, None) + .unwrap() + .base_url + .as_str(), + "http://localhost:2999/" + ); + } + + #[test] + fn resolve_endpoint_uses_url_port_or_scheme_default() { + let config = BoardGlobalConfig { + server: "https://board.example.com:9443/base".into(), + port: None, + auth_mode: AuthMode::Required, + }; + + assert_eq!( + config + .resolve_endpoint(None, None) + .unwrap() + .base_url + .as_str(), + "https://board.example.com:9443/base/" + ); + assert_eq!( + config + .resolve_endpoint(None, Some(8443)) + .unwrap() + .base_url + .as_str(), + "https://board.example.com:8443/base/" + ); + } + + #[test] + fn resolve_endpoint_accepts_legacy_bare_ip_as_http() { let config = BoardGlobalConfig { - server_ip: "10.0.0.2".into(), - port: 8000, + server: "192.0.2.10".into(), + port: Some(9000), + auth_mode: AuthMode::Disabled, }; assert_eq!( - config.resolve_server(Some("192.168.1.2"), Some(9000)), - ("192.168.1.2".to_string(), 9000) + config + .resolve_endpoint(None, None) + .unwrap() + .base_url + .as_str(), + "http://192.0.2.10:9000/" ); + } + + #[test] + fn endpoint_wraps_legacy_bare_ipv6_address() { assert_eq!( - config.resolve_server(None, None), - ("10.0.0.2".to_string(), 8000) + super::BoardEndpoint::new("2001:db8::10", Some(9000), AuthMode::Disabled) + .unwrap() + .base_url + .as_str(), + "http://[2001:db8::10]:9000/" ); } #[test] - fn save_persists_updated_values() { + fn required_authentication_requires_https() { + let config = BoardGlobalConfig { + server: "http://203.0.113.10:8443".into(), + port: None, + auth_mode: AuthMode::Required, + }; + assert!(config.resolve_endpoint(None, None).is_err()); + } + + #[test] + fn save_persists_url_and_optional_port() { let temp = tempdir().unwrap(); let path = temp.path().join(".ostool/config.toml"); let mut loaded = LoadedBoardGlobalConfig::load_or_create_at(&path).unwrap(); - loaded.board.server_ip = "10.0.0.2".into(); - loaded.board.port = 9000; + loaded.board = BoardGlobalConfig { + server: "http://10.0.0.2".into(), + port: Some(9000), + auth_mode: AuthMode::Disabled, + }; loaded.save().unwrap(); let reloaded = LoadedBoardGlobalConfig::load_or_create_at(&path).unwrap(); assert!(!reloaded.created); - assert_eq!(reloaded.board.server_ip, "10.0.0.2"); - assert_eq!(reloaded.board.port, 9000); + assert_eq!(reloaded.board.server, "http://10.0.0.2"); + assert_eq!(reloaded.board.port, Some(9000)); + } + + #[test] + fn load_migrates_base_server_ip_config_and_save_writes_url_format() { + let temp = tempdir().unwrap(); + let path = temp.path().join(".ostool/config.toml"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + &path, + r#" + [board] + server_ip = "localhost" + port = 2999 + "#, + ) + .unwrap(); + + let loaded = LoadedBoardGlobalConfig::load_or_create_at(&path).unwrap(); + assert!(!loaded.created); + assert_eq!(loaded.board.server, "http://localhost"); + assert_eq!(loaded.board.port, Some(2999)); + assert_eq!( + loaded + .resolve_endpoint(None, None) + .unwrap() + .base_url + .as_str(), + "http://localhost:2999/" + ); + + loaded.save().unwrap(); + let content = std::fs::read_to_string(path).unwrap(); + assert!(content.contains("server = \"http://localhost\"")); + assert!(!content.contains("server_ip")); } } diff --git a/ostool/src/board/mod.rs b/ostool/src/board/mod.rs index 1d1bf584..21ac17a4 100644 --- a/ostool/src/board/mod.rs +++ b/ostool/src/board/mod.rs @@ -14,7 +14,7 @@ use crate::board::{ client::{BoardServerClient, BoardTypeSummary}, config::BoardRunConfig, config_tui::run_board_config_tui, - global_config::LoadedBoardGlobalConfig, + global_config::{BoardEndpoint, LoadedBoardGlobalConfig}, session::BoardSession, }; use crate::{ @@ -40,6 +40,18 @@ pub async fn fetch_board_types(server: &str, port: u16) -> anyhow::Result anyhow::Result> { + let client = BoardServerClient::new_with_endpoint(endpoint)?; + let mut boards = client + .list_board_types() + .await + .context("failed to list board types")?; + boards.sort_by(|a, b| a.board_type.cmp(&b.board_type)); + Ok(boards) +} + pub fn render_board_table(boards: &[BoardTypeSummary]) -> String { if boards.is_empty() { return "No board types found.".to_string(); @@ -102,6 +114,12 @@ pub async fn list_boards(server: &str, port: u16) -> anyhow::Result<()> { Ok(()) } +pub async fn list_boards_endpoint(endpoint: BoardEndpoint) -> anyhow::Result<()> { + let boards = fetch_board_types_endpoint(endpoint).await?; + println!("{}", render_board_table(&boards)); + Ok(()) +} + pub fn config() -> anyhow::Result<()> { run_board_config_tui() } @@ -126,8 +144,35 @@ pub async fn acquire_board_session( Ok((client, session)) } +pub async fn acquire_board_session_endpoint( + endpoint: BoardEndpoint, + board_type: &str, +) -> anyhow::Result<(BoardServerClient, BoardSession)> { + let client = BoardServerClient::new_with_endpoint(endpoint)?; + let session = BoardSession::acquire(client.clone(), board_type) + .await + .with_context(|| format!("failed to acquire board type `{board_type}`"))?; + Ok((client, session)) +} + pub async fn connect_board(server: &str, port: u16, board_type: &str) -> anyhow::Result<()> { let (client, session) = acquire_board_session(server, port, board_type).await?; + connect_allocated_board(client, session, board_type).await +} + +pub async fn connect_board_endpoint( + endpoint: BoardEndpoint, + board_type: &str, +) -> anyhow::Result<()> { + let (client, session) = acquire_board_session_endpoint(endpoint, board_type).await?; + connect_allocated_board(client, session, board_type).await +} + +async fn connect_allocated_board( + client: BoardServerClient, + session: BoardSession, + board_type: &str, +) -> anyhow::Result<()> { print_allocated_board_session(&session, board_type); let result = if session.info().serial_available { @@ -137,7 +182,7 @@ pub async fn connect_board(server: &str, port: u16, board_type: &str) -> anyhow: .as_deref() .ok_or_else(|| anyhow::anyhow!("server did not return a serial websocket URL"))?; let ws_url = client.resolve_ws_url(ws_path)?; - terminal::run_serial_terminal(ws_url).await + terminal::run_serial_terminal(ws_url, client.websocket_authorization().await?).await } else { let lease_expires_at = session.current_lease_expires_at().await; println!("Board has no serial configuration; keeping session alive until Ctrl+C."); @@ -285,8 +330,9 @@ pub async fn run_prepared_board( options.port, )?; - let (server, port) = board_config.resolve_server(None, None, &global_config.board); - let (client, session) = acquire_board_session(&server, port, &board_config.board_type).await?; + let endpoint = board_config.resolve_endpoint(None, None, &global_config.board)?; + let (client, session) = + acquire_board_session_endpoint(endpoint, &board_config.board_type).await?; print_allocated_board_session(&session, &board_config.board_type); let run_result = match session.info().boot_mode.as_str() { diff --git a/ostool/src/board/serial_stream.rs b/ostool/src/board/serial_stream.rs index 6703660b..b18daaf3 100644 --- a/ostool/src/board/serial_stream.rs +++ b/ostool/src/board/serial_stream.rs @@ -13,6 +13,10 @@ use tokio::{ time::timeout, }; use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::{ + client::IntoClientRequest as _, + http::{HeaderValue, header::AUTHORIZATION}, +}; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use crate::board::terminal::{ @@ -29,8 +33,10 @@ pub struct SerialStreamTasks { pub async fn connect_serial_stream( ws_url: reqwest::Url, + authorization: Option, ) -> anyhow::Result<(BoxedAsyncWrite, BoxedAsyncRead, SerialStreamTasks)> { - let (stream, _) = tokio_tungstenite::connect_async(ws_url.as_str()) + let request = websocket_request(&ws_url, authorization.as_deref())?; + let (stream, _) = tokio_tungstenite::connect_async(request) .await .with_context(|| format!("failed to connect serial websocket {ws_url}"))?; let (mut ws_sink, mut ws_stream) = stream.split(); @@ -127,6 +133,23 @@ pub async fn connect_serial_stream( )) } +pub(crate) fn websocket_request( + ws_url: &reqwest::Url, + authorization: Option<&str>, +) -> anyhow::Result> { + let mut request = ws_url + .as_str() + .into_client_request() + .context("failed to build serial websocket request")?; + if let Some(token) = authorization { + // The URL was origin-checked by BoardServerClient before this point. + let value = HeaderValue::from_str(&format!("Bearer {token}")) + .context("invalid websocket authorization header")?; + request.headers_mut().insert(AUTHORIZATION, value); + } + Ok(request) +} + async fn write_bridge_bytes(writer: &mut W, bytes: &[u8]) -> anyhow::Result where W: tokio::io::AsyncWrite + Unpin, @@ -222,7 +245,7 @@ mod tests { use tokio::{sync::Notify, task::JoinHandle}; - use super::{SerialStreamTasks, write_bridge_bytes}; + use super::{SerialStreamTasks, websocket_request, write_bridge_bytes}; #[tokio::test] async fn shutdown_waits_for_writer_before_reader() { @@ -292,4 +315,14 @@ mod tests { .unwrap() ); } + + #[test] + fn websocket_request_carries_bearer_token() { + let url = reqwest::Url::parse("wss://example.invalid/serial").unwrap(); + let request = websocket_request(&url, Some("token-value")).unwrap(); + assert_eq!( + request.headers().get("authorization").unwrap(), + "Bearer token-value" + ); + } } diff --git a/ostool/src/board/terminal.rs b/ostool/src/board/terminal.rs index 961868bb..b08c8bbc 100644 --- a/ostool/src/board/terminal.rs +++ b/ostool/src/board/terminal.rs @@ -51,8 +51,13 @@ pub(crate) fn classify_server_control_message( } } -pub async fn run_serial_terminal(ws_url: reqwest::Url) -> anyhow::Result<()> { - let (stream, _) = tokio_tungstenite::connect_async(ws_url.as_str()) +pub async fn run_serial_terminal( + ws_url: reqwest::Url, + authorization: Option, +) -> anyhow::Result<()> { + let request = + crate::board::serial_stream::websocket_request(&ws_url, authorization.as_deref())?; + let (stream, _) = tokio_tungstenite::connect_async(request) .await .with_context(|| format!("failed to connect serial websocket {ws_url}"))?; let (mut sink, mut stream) = stream.split(); diff --git a/ostool/src/lib.rs b/ostool/src/lib.rs index 59176e95..5ae8e424 100644 --- a/ostool/src/lib.rs +++ b/ostool/src/lib.rs @@ -35,6 +35,9 @@ mod artifact; mod boot; +/// Authentication gateway client, secure credential storage, and token lifecycle management. +pub mod auth; + /// Build system configuration and Cargo integration. /// /// Provides functionality for configuring and executing Cargo builds diff --git a/ostool/src/main.rs b/ostool/src/main.rs index 0d7a3e17..3902aef3 100644 --- a/ostool/src/main.rs +++ b/ostool/src/main.rs @@ -1,14 +1,15 @@ //! Main ostool CLI argument parsing and command dispatch. -use std::{path::PathBuf, process::ExitCode}; +use std::{io::Read as _, path::PathBuf, process::ExitCode}; -use anyhow::Result; +use anyhow::{Context as _, Result}; use clap::*; use colored::Colorize as _; use env_logger::Env; use log::info; use ostool::{ + auth::token_manager::TokenManager, board, build::{self, CargoQemuRunnerArgs, CargoRunnerKind, CargoUbootRunnerArgs}, invocation::{Invocation, InvocationOptions}, @@ -42,6 +43,12 @@ enum SubCommands { command: RunSubCommands, }, Board(BoardArgs), + /// Authenticate with an ostool board service. + Login(LoginArgs), + /// Remove stored authentication credentials for an ostool board service. + Logout(BoardServerArgs), + /// Inspect authentication state. + Auth(AuthArgs), Menuconfig { /// Menu configuration mode (qemu or uboot) #[arg(value_enum)] @@ -51,14 +58,34 @@ enum SubCommands { #[derive(Args, Debug, Default, Clone)] struct BoardServerArgs { - /// ostool-server host + /// Complete board service URL, for example https://board.example.com #[arg(long)] server: Option, - /// ostool-server port + /// Override the port in `--server` or the configured server URL. #[arg(long)] port: Option, } +#[derive(Args, Debug)] +struct LoginArgs { + #[command(flatten)] + server: BoardServerArgs, + /// Read a personal access token from standard input instead of using browser login. + #[arg(long)] + with_token: bool, +} + +#[derive(Args, Debug)] +struct AuthArgs { + #[command(subcommand)] + command: AuthSubCommands, +} + +#[derive(Subcommand, Debug)] +enum AuthSubCommands { + Status(BoardServerArgs), +} + #[derive(Args, Debug)] struct BoardArgs { #[command(subcommand)] @@ -189,18 +216,68 @@ async fn try_main() -> Result<()> { let Cli { manifest, command } = Cli::parse(); match command { + SubCommands::Login(args) => { + let global_config = board::load_board_global_config_with_notice()?; + let endpoint = resolve_auth_endpoint( + &global_config, + args.server.server.as_deref(), + args.server.port, + )?; + let tokens = TokenManager::new(endpoint)?; + if args.with_token { + let mut token = String::new(); + std::io::stdin() + .read_to_string(&mut token) + .context("failed to read personal access token from standard input")?; + tokens.import_personal_access_token(token).await?; + } else { + tokens.login_device().await?; + } + println!("Logged in successfully."); + } + SubCommands::Logout(args) => { + let global_config = board::load_board_global_config_with_notice()?; + let endpoint = + resolve_auth_endpoint(&global_config, args.server.as_deref(), args.port)?; + let tokens = TokenManager::new(endpoint)?; + tokens.logout().await?; + println!("Logged out."); + } + SubCommands::Auth(args) => match args.command { + AuthSubCommands::Status(args) => { + let global_config = board::load_board_global_config_with_notice()?; + let endpoint = + resolve_auth_endpoint(&global_config, args.server.as_deref(), args.port)?; + let tokens = TokenManager::new(endpoint.clone())?; + let status = tokens.status().await?; + println!("server: {}", endpoint.base_url); + println!("auth_mode: {:?}", endpoint.auth_mode); + match status.kind { + Some(kind) => { + println!("credential: {kind}"); + if let Some(expires_at) = status.expires_at { + println!("expires_at: {expires_at}"); + } + if let Some(scope) = status.scope { + println!("scope: {scope}"); + } + } + None => println!("credential: none"), + } + } + }, SubCommands::Board(args) => match args.command { BoardSubCommands::Ls(server) => { let global_config = board::load_board_global_config_with_notice()?; - let (server, port) = - global_config.resolve_server(server.server.as_deref(), server.port); - board::list_boards(&server, port).await?; + let endpoint = + global_config.resolve_endpoint(server.server.as_deref(), server.port)?; + board::list_boards_endpoint(endpoint).await?; } BoardSubCommands::Connect(args) => { let global_config = board::load_board_global_config_with_notice()?; - let (server, port) = - global_config.resolve_server(args.server.server.as_deref(), args.server.port); - board::connect_board(&server, port, &args.board_type).await?; + let endpoint = global_config + .resolve_endpoint(args.server.server.as_deref(), args.server.port)?; + board::connect_board_endpoint(endpoint, &args.board_type).await?; } BoardSubCommands::Run(args) => { let mut invocation = init_invocation(manifest.clone())?; @@ -500,6 +577,21 @@ fn report_error(err: &anyhow::Error) { println!("{}", format!("\nTrace:\n{err:?}").red()); } +fn resolve_auth_endpoint( + config: &board::global_config::LoadedBoardGlobalConfig, + explicit_server: Option<&str>, + explicit_port: Option, +) -> Result { + match explicit_server { + Some(server) => board::global_config::BoardEndpoint::new( + server, + explicit_port, + board::global_config::AuthMode::Required, + ), + None => config.resolve_endpoint(None, explicit_port), + } +} + #[cfg(test)] mod tests { use std::fs; @@ -508,9 +600,9 @@ mod tests { use ostool::invocation::{Invocation, InvocationOptions}; use super::{ - BoardArgs, BoardSubCommands, CargoSelectorArgs, Cli, RunSubCommands, SubCommands, - apply_cargo_selector, build, load_board_config, load_build_config, load_qemu_config, - load_uboot_config, + AuthArgs, AuthSubCommands, BoardArgs, BoardSubCommands, CargoSelectorArgs, Cli, LoginArgs, + RunSubCommands, SubCommands, apply_cargo_selector, build, load_board_config, + load_build_config, load_qemu_config, load_uboot_config, }; /// Verifies build parsing accepts manifest, config, package, and bin overrides. @@ -658,7 +750,13 @@ mod tests { #[test] fn parse_board_ls_with_server_args() { let cli = Cli::try_parse_from([ - "ostool", "board", "ls", "--server", "10.0.0.2", "--port", "9000", + "ostool", + "board", + "ls", + "--server", + "http://10.0.0.2", + "--port", + "9000", ]) .unwrap(); @@ -666,13 +764,54 @@ mod tests { SubCommands::Board(BoardArgs { command: BoardSubCommands::Ls(server), }) => { - assert_eq!(server.server.as_deref(), Some("10.0.0.2")); + assert_eq!(server.server.as_deref(), Some("http://10.0.0.2")); assert_eq!(server.port, Some(9000)); } other => panic!("unexpected command: {other:?}"), } } + #[test] + fn parse_root_device_login_with_server_and_port() { + let cli = Cli::try_parse_from([ + "ostool", + "login", + "--server", + "https://203.0.113.10", + "--port", + "8443", + ]) + .unwrap(); + match cli.command { + SubCommands::Login(args) => { + assert_eq!(args.server.server.as_deref(), Some("https://203.0.113.10")); + assert_eq!(args.server.port, Some(8443)); + assert!(!args.with_token); + } + other => panic!("unexpected command: {other:?}"), + } + } + + #[test] + fn parse_root_pat_login_and_auth_status() { + let login = Cli::try_parse_from(["ostool", "login", "--with-token"]).unwrap(); + assert!(matches!( + login.command, + SubCommands::Login(LoginArgs { + with_token: true, + .. + }) + )); + + let status = Cli::try_parse_from(["ostool", "auth", "status"]).unwrap(); + assert!(matches!( + status.command, + SubCommands::Auth(AuthArgs { + command: AuthSubCommands::Status(_) + }) + )); + } + #[test] fn parse_board_connect_with_short_board_type() { let cli = Cli::try_parse_from(["ostool", "board", "connect", "-b", "rk3568"]).unwrap(); @@ -698,7 +837,7 @@ mod tests { "--board-type", "rk3568", "--server", - "10.0.0.2", + "http://10.0.0.2", "--port", "9000", ]) @@ -709,7 +848,7 @@ mod tests { command: BoardSubCommands::Connect(args), }) => { assert_eq!(args.board_type, "rk3568"); - assert_eq!(args.server.server.as_deref(), Some("10.0.0.2")); + assert_eq!(args.server.server.as_deref(), Some("http://10.0.0.2")); assert_eq!(args.server.port, Some(9000)); } other => panic!("unexpected command: {other:?}"), @@ -755,7 +894,7 @@ mod tests { "--board-type", "rk3568", "--server", - "10.0.0.2", + "http://10.0.0.2", "--port", "9000", ]) @@ -777,7 +916,7 @@ mod tests { assert!(args.cargo_selector.bin.is_none()); assert!(args.cargo_selector.test.is_none()); assert_eq!(args.board_type.as_deref(), Some("rk3568")); - assert_eq!(args.server.server.as_deref(), Some("10.0.0.2")); + assert_eq!(args.server.server.as_deref(), Some("http://10.0.0.2")); assert_eq!(args.server.port, Some(9000)); } other => panic!("unexpected command: {other:?}"), diff --git a/ostool/src/run/httpboot_board.rs b/ostool/src/run/httpboot_board.rs index 1ccb7fe9..ffdd75b0 100644 --- a/ostool/src/run/httpboot_board.rs +++ b/ostool/src/run/httpboot_board.rs @@ -130,7 +130,8 @@ impl HttpBootBoardRunner { ); println!("Waiting for axloader on board serial..."); - let (serial_tx, serial_rx, tasks) = connect_serial_stream(ws_url).await?; + let (serial_tx, serial_rx, tasks) = + connect_serial_stream(ws_url, self.client.websocket_authorization().await?).await?; let mut serial_rx = serial_rx.compat(); let mut serial_tx = serial_tx.compat_write(); wait_for_loader_ready(&mut serial_rx, arch).await?; diff --git a/ostool/src/run/uboot.rs b/ostool/src/run/uboot.rs index 832be35d..747c7e19 100644 --- a/ostool/src/run/uboot.rs +++ b/ostool/src/run/uboot.rs @@ -951,7 +951,8 @@ impl RunnerBackend for RemoteBackend { .or(self.session.ws_url.as_deref()) .ok_or_else(|| anyhow!("server did not return a serial websocket URL"))?; let ws_url = self.client.resolve_ws_url(ws_url)?; - let (tx, rx, tasks) = connect_serial_stream(ws_url).await?; + let (tx, rx, tasks) = + connect_serial_stream(ws_url, self.client.websocket_authorization().await?).await?; self.console_tasks = Some(tasks); Ok(ConsoleTransport { tx, rx }) } @@ -1948,6 +1949,7 @@ timeout = 0 shell_prefix: Some("login:".into()), shell_init_cmd: Some("root".into()), timeout: Some(12), + auth_mode: None, server: None, port: None, });