diff --git a/.cargo/config.toml b/.cargo/config.toml index 29fd467..e5b2406 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,6 +1,15 @@ -[target.'cfg(target_os="linux")'] -# using mold breaks cargo cross on musl (the image is based on focal, which does not have a mold package) -#rustflags = ["-C", "link-arg=-fuse-ld=mold"] +[future-incompat-report] +frequency = 'always' + +#[target.'cfg(target_os="linux")'] +# Caution! Unconditionally specifying mold breaks cargo cross on musl. +# (the base docker image cross uses is based on focal, which does not have a mold package) +# So for now, we use defaults to not break CI. +# To speed up local builds, set up your own ~/.cargo/config.toml something like this: +# [target.'cfg(target_os="linux")'] +# linker = "clang-15" +# rustflags = ["-C", "link-arg=--ld-path=mold"] + [target.'cfg(target_os="windows")'] rustflags = ["-C", "link-arg=-fuse-ld=lld"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2dc0e2..cf6da85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,9 @@ on: workflow_dispatch: env: + BUILT_DEB_FILE: "invalid.deb" # updated by make-debian-package script CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 jobs: # Build the app on all supported platforms, at least for the time being @@ -39,7 +41,7 @@ jobs: toolchain: ${{ matrix.rust }} targets: ${{ matrix.target }} - name: Install tools - uses: taiki-e/install-action@v2.44.58 + uses: taiki-e/install-action@v2.46.20 with: tool: cross,cargo-deb #- name: Set minimal profile (Windows only) @@ -56,7 +58,7 @@ jobs: #- name: Build # run: cross build --release --locked --target ${{ matrix.target }} - - uses: taiki-e/upload-rust-binary-action@v1.22.1 + - uses: taiki-e/upload-rust-binary-action@v1.24.0 id: build with: bin: qcp @@ -64,6 +66,7 @@ jobs: target: ${{ matrix.target }} include: README.md,LICENSE,CHANGELOG.md leading-dir: true + locked: true tar: unix zip: windows dry_run: true @@ -98,10 +101,8 @@ jobs: - name: install packages run: scripts/install-ubuntu-packages # Checks begin here! - - run: cargo fmt --all -- --check + - run: cargo fmt --all --check - run: cargo test --locked - run: cargo clippy --locked --all-targets - # We care that the benchmarks build and run, not about their numeric output. - # To keep the CI a bit leaner, do this in the dev profile. - run: cargo build --locked --all-targets - run: cargo doc --no-deps --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37e2959..3c2b290 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,9 @@ on: workflow_dispatch: # for testing env: + BUILT_DEB_FILE: "invalid.deb" # updated by make-debian-package script CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 permissions: contents: write @@ -34,7 +36,7 @@ jobs: with: toolchain: ${{ matrix.rust }} targets: ${{ matrix.target }} - - uses: taiki-e/install-action@v2.44.58 + - uses: taiki-e/install-action@v2.46.20 with: tool: cross,cargo-deb - uses: Swatinem/rust-cache@v2 @@ -47,7 +49,7 @@ jobs: #- name: Build # run: cross build --release --locked --target ${{ matrix.target }} - - uses: taiki-e/upload-rust-binary-action@v1.22.1 + - uses: taiki-e/upload-rust-binary-action@v1.24.0 id: build with: bin: qcp @@ -55,6 +57,7 @@ jobs: target: ${{ matrix.target }} include: README.md,LICENSE,CHANGELOG.md leading-dir: true + locked: true tar: unix zip: windows dry_run: ${{ github.event_name != 'release' }} @@ -71,9 +74,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: qcp-deb-${{ matrix.target }} - path: target/**/debian/qcp*.deb + path: ${{ env.BUILT_DEB_FILE }} - name: Publish deb package to release - if: ${{ github.event_name == 'release' }} + if: ${{ matrix.build_deb }} && ${{ github.event_name == 'release' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release upload ${{ github.ref_name }} ${{ env.BUILT_DEB_FILE }} diff --git a/.vscode/launch.json b/.vscode/launch.json index 5915432..c0172a8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,15 +7,15 @@ { "type": "lldb", "request": "launch", - "name": "Debug executable 'qcpt'", + "name": "Debug executable 'qcp'", "cargo": { "args": [ "build", - "--bin=qcpt", - "--package=qcpt" + "--bin=qcp", + "--package=qcp" ], "filter": { - "name": "qcpt", + "name": "qcp", "kind": "bin" } }, @@ -25,16 +25,16 @@ { "type": "lldb", "request": "launch", - "name": "Debug unit tests in executable 'qcpt'", + "name": "Debug unit tests in executable 'qcp'", "cargo": { "args": [ "test", "--no-run", - "--bin=qcpt", - "--package=qcpt" + "--bin=qcp", + "--package=qcp" ], "filter": { - "name": "qcpt", + "name": "qcp", "kind": "bin" } }, diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9a6a5f7..8364bf0 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,7 +4,7 @@ { "type": "cargo", "command": "doc", - "args": ["--no-deps"], + "args": ["--no-deps", "--locked"], "problemMatcher": [ "$rustc" ], @@ -13,6 +13,19 @@ "isDefault": false }, "label": "rust: cargo doc" + }, + { + "type": "cargo", + "command": "doc", + "args": ["--no-deps", "--locked", "--document-private-items"], + "problemMatcher": [ + "$rustc" + ], + "group": { + "kind": "build", + "isDefault": false + }, + "label": "rust: cargo doc --document-private-items" } ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d31fd02 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing to QCP + +## 🐛 Bug reports & Feature requests + +Bug reports and feature requests are welcome, please open an [issue]. + +- It may be useful to check the [issues list] and the [discussions] first in case somebody else has already raised it. +- Please be aware that I mostly work on this project in my own time. + +## 🏗️ Pull request policy + +If you're thinking of contributing something non-trivial, it might be best to raise it in [discussions] first so you can get feedback early. This is particularly important for new features, to ensure they are aligned with the project goals and your approach is suitable. + +* Changes should normally be based on the `dev` branch. _(Exception: hotfixes may be branched against `main`.)_ +* PRs must pass the full set of CI checks (see below). No exceptions. +* Unit tests are encouraged, particularly those which fail before and pass after a fix. +* Refactoring for its own sake is OK if driven by a feature or bugfix. +* Clean commit histories are preferred, but don't be discouraged if you don't know how to do this. git can be a tricky tool. +* Commit messages should follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). + * Begin the commit message for a feature with `feat:`. If it's a bugfix, use `fix:`. + * For a full list of supported message tags, look at `commit_parsers` in [release-plz.toml](release-plz.toml). + * This policy is in force since release 0.1.0. Earlier commits are considered grandfathered in. +* Where there is an issue number, commit messages should reference it, e.g. (#12) +* Do not edit CHANGELOG.md, that will be done for you on release. + +## ☑️ CI checks applied + +| Check | How to run it yourself | Notes | +| ----- | ---------------------- | ----- | +| Code style | `cargo fmt --all --check` | For VS Code users, `editor.formatOnSave=true` is set | +| Everything must build | `cargo build --all-targets` | +| Unit tests pass | `cargo test` | +| Lints | `cargo clippy --all-targets` | This is a reasonably pedantic set of lints, which I make no apologies for | +| Docs build | `cargo doc --no-deps` | + + +[issue]: https://github.com/crazyscot/qcp/issues/new/choose +[issues list]: https://github.com/crazyscot/qcp/issues +[discussions]: https://github.com/crazyscot/qcp/discussions diff --git a/Cargo.lock b/Cargo.lock index 42fee71..0765e9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,11 +26,26 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" -version = "0.6.17" +version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23a1e53f0f5d86382dafe1cf314783b2044280f406e7e1506368220ad11b1338" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" dependencies = [ "anstyle", "anstyle-parse", @@ -43,9 +58,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8365de52b16c035ff4fcafe0092ba9390540e3e352870ac09933bebcaa2c8c56" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" [[package]] name = "anstyle-parse" @@ -77,9 +92,21 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.91" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" + +[[package]] +name = "arrayref" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c042108f3ed77fd83760a5fd79b53be043192bb3b9dba91d8c574c0ada7850c8" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" [[package]] name = "arrayvec" @@ -87,6 +114,21 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "assertables" +version = "9.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0082388b8564898f945b04215e800e800a164af15307d8dfe714b02cc69356e9" + +[[package]] +name = "atomic" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" +dependencies = [ + "bytemuck", +] + [[package]] name = "autocfg" version = "1.4.0" @@ -108,6 +150,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.22.1" @@ -120,6 +168,44 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +[[package]] +name = "blake2b_simd" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587" +dependencies = [ + "arrayref", + "arrayvec 0.5.2", + "constant_time_eq", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "bytecount" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ce89b21cab1437276d2650d57e971f9d548a2d9037cc231abdc0562b97498ce" + +[[package]] +name = "bytemuck" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef657dfab802224e671f5818e9a4935f9b1957ed18e58292690cc39e7a4092a3" + [[package]] name = "byteorder" version = "1.5.0" @@ -128,9 +214,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "capnp" @@ -143,28 +229,29 @@ dependencies = [ [[package]] name = "capnp-futures" -version = "0.20.0" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271140b6249c5755f29c4a971f1760d5e8d3708eceac7eddc1448348bc8c1a65" +checksum = "7b70b0d44372d42654e3efac38c1643c7b0f9d3a9e9b72b635f942ff3f17e891" dependencies = [ "capnp", - "futures", + "futures-channel", + "futures-util", ] [[package]] name = "capnpc" -version = "0.20.0" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d13cb6e2643fd1f9fb804ba938323636ef52e07d6e495e372d933ad0cb98207" +checksum = "1aa3d5f01e69ed11656d2c7c47bf34327ea9bfb5c85c7de787fcd7b6c5e45b61" dependencies = [ "capnp", ] [[package]] name = "cc" -version = "1.1.31" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2e7962b54006dcfcc61cb72735f4d89bb97061dd6a7ed882ec6b8ee53714c6f" +checksum = "c31a0499c1dc64f458ad13872de75c0eb7e3fdb0e67964610c914b034fc5956e" dependencies = [ "shlex", ] @@ -181,11 +268,23 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "windows-targets 0.52.6", +] + [[package]] name = "clap" -version = "4.5.20" +version = "4.5.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" +checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84" dependencies = [ "clap_builder", "clap_derive", @@ -193,9 +292,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.20" +version = "4.5.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" +checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838" dependencies = [ "anstream", "anstyle", @@ -210,17 +309,17 @@ version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", - "syn", + "syn 2.0.91", ] [[package]] name = "clap_lex" -version = "0.7.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "colorchoice" @@ -230,15 +329,52 @@ checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" [[package]] name = "console" -version = "0.15.8" +version = "0.15.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +checksum = "ea3c6ecd8059b57859df5c69830340ed3c41d30e3da0c1cbed90a96ac853041b" dependencies = [ "encode_unicode", - "lazy_static", "libc", + "once_cell", "unicode-width", - "windows-sys 0.52.0", + "windows-sys 0.59.0", +] + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", ] [[package]] @@ -250,6 +386,76 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive-deftly" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f9bc3564f74be6c35d49a7efee54380d7946ccc631323067f33fabb9246027" +dependencies = [ + "derive-deftly-macros", + "heck 0.5.0", +] + +[[package]] +name = "derive-deftly-macros" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b84d32b18d9a256d81e4fec2e4cfd0ab6dde5e5ff49be1713ae0adbd0060c2" +dependencies = [ + "heck 0.5.0", + "indexmap", + "itertools", + "proc-macro-crate", + "proc-macro2", + "quote", + "sha3", + "strum", + "syn 2.0.91", + "void", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fd78930633bd1c6e35c4b42b1df7b0cbc6bc191146e512bb3bedf243fcc3901" +dependencies = [ + "libc", + "redox_users 0.3.5", + "winapi", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + [[package]] name = "dns-lookup" version = "2.0.4" @@ -262,6 +468,21 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "document-features" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6969eaabd2421f8a2775cfd2471a2b634372b4a25d41e3bd647b79912850a0" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + [[package]] name = "embedded-io" version = "0.6.1" @@ -270,40 +491,61 @@ checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" [[package]] name = "encode_unicode" -version = "0.3.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", +] + +[[package]] +name = "expanduser" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14e0b79235da57db6b6c2beed9af6e5de867d63a973ae3e91910ddc33ba40bc0" +dependencies = [ + "dirs 1.0.5", + "lazy_static", + "pwd", ] [[package]] name = "fastrand" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] -name = "futures" -version = "0.3.31" +name = "figment" +version = "0.10.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", + "atomic", + "serde", + "uncased", + "version_check", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "futures-channel" version = "0.3.31" @@ -311,7 +553,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -344,10 +585,8 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ - "futures-channel", "futures-core", "futures-io", - "futures-sink", "futures-task", "memchr", "pin-project-lite", @@ -355,6 +594,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "0.5.0" @@ -365,6 +614,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.2.15" @@ -372,8 +632,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -382,17 +644,29 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + [[package]] name = "heck" -version = "0.5.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] -name = "hermit-abi" -version = "0.3.9" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "human-repr" @@ -406,27 +680,51 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "016b02deb8b0c415d8d56a6f0ab265e50c22df61194e37f9be75ed3a722de8a6" +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "indicatif" -version = "0.17.8" +version = "0.17.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "763a5a8f45087d6bcea4222e7b72c291a054edf80e4ef6efd2a4979878c7bea3" +checksum = "cbf675b85ed934d3c67b5c5469701eec7db22689d0a2139d856e0925fa28b281" dependencies = [ "console", - "instant", "number_prefix", "portable-atomic", "tokio", "unicode-width", -] - -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", + "web-time", ] [[package]] @@ -435,11 +733,20 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" -version = "1.0.11" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] name = "jemalloc-sys" @@ -461,6 +768,31 @@ dependencies = [ "libc", ] +[[package]] +name = "js-sys" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078e285eafdfb6c4b434e0d31e8cfcb5115b651496faca5749b88fafd4f23bfd" + +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -469,9 +801,19 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.161" +version = "0.2.169" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" + +[[package]] +name = "libredox" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags", + "libc", +] [[package]] name = "linux-raw-sys" @@ -479,6 +821,12 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "litrs" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" + [[package]] name = "log" version = "0.4.22" @@ -511,22 +859,21 @@ dependencies = [ [[package]] name = "miniz_oxide" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394" dependencies = [ "adler2", ] [[package]] name = "mio" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ - "hermit-abi", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] @@ -565,10 +912,19 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", "itoa", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "number_prefix" version = "0.4.0" @@ -577,9 +933,9 @@ checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" [[package]] name = "object" -version = "0.36.5" +version = "0.36.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" dependencies = [ "memchr", ] @@ -590,19 +946,36 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "overload" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "papergrid" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2b0f8def1f117e13c895f3eda65a7b5650688da29d6ad04635f61bc7b92eebd" +dependencies = [ + "bytecount", + "fnv", + "unicode-width", +] + [[package]] name = "pem" version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" dependencies = [ - "base64", + "base64 0.22.1", "serde", ] @@ -620,9 +993,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "portable-atomic" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc9c68a3f6da06753e9335d63e27f6b9754dd1920d941135b7ea8224f141adb2" +checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" [[package]] name = "powerfmt" @@ -639,52 +1012,112 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.91", +] + [[package]] name = "proc-macro2" -version = "1.0.89" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] +[[package]] +name = "pwd" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c71c0c79b9701efe4e1e4b563b2016dd4ee789eb99badcb09d61ac4b92e4a2" +dependencies = [ + "libc", + "thiserror 1.0.69", +] + [[package]] name = "qcp" version = "0.1.3" dependencies = [ + "anstream", "anstyle", "anyhow", + "assertables", "capnp", "capnp-futures", "capnpc", "clap", "console", + "derive-deftly", + "dirs 5.0.1", "dns-lookup", + "document-features", + "expanduser", "fastrand", + "figment", "futures-util", "gethostname", + "glob", + "heck 0.5.0", "human-repr", "humanize-rs", "indicatif", "jemallocator", + "json", + "lazy_static", "nix", "num-format", "quinn", + "rand", "rcgen", "rustls-pki-types", + "serde", + "serde_json", + "serde_test", "static_assertions", - "strum_macros", + "struct-field-names-as-array", + "strum", + "tabled", + "tempfile", "tokio", "tokio-util", "tracing", "tracing-subscriber", + "wildmatch", ] [[package]] name = "quinn" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7c5fdde3cdae7203427dc4f0a68fe0ed09833edc525a03456b153b79828684" +checksum = "62e96808277ec6f97351a2380e6c25114bc9e67037775464979f3037c92d05ef" dependencies = [ "bytes", "pin-project-lite", @@ -693,34 +1126,38 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.9", "tokio", "tracing", ] [[package]] name = "quinn-proto" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fadfaed2cd7f389d0161bb73eeb07b7b78f8691047a6f3e73caaeae55310a4a6" +checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d" dependencies = [ "bytes", + "getrandom 0.2.15", "rand", "ring", "rustc-hash", "rustls", + "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.9", "tinyvec", "tracing", + "web-time", ] [[package]] name = "quinn-udp" -version = "0.5.5" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fe68c2e9e1a1234e218683dbdf9f9dfcb094113c5ac2b938dfcb9bab4c4140b" +checksum = "1c40286217b4ba3a71d644d752e6a0b71f13f1b6a2c5311acfcbe0c2418ed904" dependencies = [ + "cfg_aliases", "libc", "once_cell", "socket2", @@ -730,9 +1167,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.37" +version = "1.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" dependencies = [ "proc-macro2", ] @@ -764,14 +1201,14 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.15", ] [[package]] name = "rcgen" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54077e1872c46788540de1ea3d7f4ccb1983d12f9aa909b234468676c1a36779" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" dependencies = [ "pem", "ring", @@ -780,6 +1217,34 @@ dependencies = [ "yasna", ] +[[package]] +name = "redox_syscall" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" + +[[package]] +name = "redox_users" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d" +dependencies = [ + "getrandom 0.1.16", + "redox_syscall", + "rust-argon2", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "regex" version = "1.11.1" @@ -788,7 +1253,7 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.8", + "regex-automata 0.4.9", "regex-syntax 0.8.5", ] @@ -803,9 +1268,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", @@ -832,13 +1297,25 @@ checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.15", "libc", "spin", "untrusted", "windows-sys 0.52.0", ] +[[package]] +name = "rust-argon2" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb" +dependencies = [ + "base64 0.13.1", + "blake2b_simd", + "constant_time_eq", + "crossbeam-utils", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -847,29 +1324,30 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustc-hash" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" +checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" [[package]] name = "rustix" -version = "0.38.37" +version = "0.38.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" +checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "rustls" -version = "0.23.18" +version = "0.23.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9cc1d47e243d655ace55ed38201c19ae02c148ae56412ab8750e8f0166ab7f" +checksum = "5065c3f250cbd332cd894be57c40fa52387247659b14a2d6041d121547903b1b" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -880,9 +1358,12 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" +checksum = "d2bf47e6ff922db3825eb750c4e2ff784c6ff8fb9e13046ef6a1d1c5401b0b37" +dependencies = [ + "web-time", +] [[package]] name = "rustls-webpki" @@ -901,24 +1382,61 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + [[package]] name = "serde" -version = "1.0.213" +version = "1.0.216" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ea7893ff5e2466df8d720bb615088341b295f849602c6956047f8f80f0e9bc1" +checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.213" +version = "1.0.216" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e85ad2009c50b58e87caa8cd6dac16bdf511bbfb7af6c33df902396aa480fa5" +checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.91", +] + +[[package]] +name = "serde_json" +version = "1.0.134" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_test" +version = "1.0.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f901ee573cab6b3060453d2d5f0bae4e6d628c23c0a962ff9b5f1d7c8d4f1ed" +dependencies = [ + "serde", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", ] [[package]] @@ -962,9 +1480,9 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "socket2" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" dependencies = [ "libc", "windows-sys 0.52.0", @@ -988,17 +1506,46 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "struct-field-names-as-array" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ba4bae771f9cc992c4f403636c54d2ef13acde6367583e99d06bb336674dd9" +dependencies = [ + "struct-field-names-as-array-derive", +] + +[[package]] +name = "struct-field-names-as-array-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2dbf8b57f3ce20e4bb171a11822b283bdfab6c4bb0fe64fa729f045f23a0938" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.91", +] + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + [[package]] name = "strum_macros" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.91", ] [[package]] @@ -1009,20 +1556,67 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.85" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "syn" +version = "2.0.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53cbcb5a243bd33b7858b1d7f4aca2153490815872d86d955d6ea29f743c035" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tabled" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6709222f3973137427ce50559cd564dc187a95b9cfe01613d2f4e93610e510a" +dependencies = [ + "papergrid", + "tabled_derive", +] + +[[package]] +name = "tabled_derive" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "931be476627d4c54070a1f3a9739ccbfec9b36b39815106a20cce2243bbcefe1" +dependencies = [ + "heck 0.4.1", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "tempfile" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "terminal_size" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f599bd7ca042cfdf8f4512b277c02ba102247820f9d9d4a9f521f496751a6ef" +checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9" dependencies = [ "rustix", "windows-sys 0.59.0", @@ -1030,22 +1624,42 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.65" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5" +checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.9", ] [[package]] name = "thiserror-impl" -version = "1.0.65" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.91", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.91", ] [[package]] @@ -1060,9 +1674,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.36" +version = "0.3.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" dependencies = [ "deranged", "num-conv", @@ -1079,9 +1693,9 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "tinyvec" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +checksum = "022db8904dfa342efe721985167e9fcd16c29b226db4397ed752a761cfce81e8" dependencies = [ "tinyvec_macros", ] @@ -1094,9 +1708,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.41.0" +version = "1.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145f3413504347a2be84393cc8a7d2fb4d863b375909ea59f2158261aa258bbb" +checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" dependencies = [ "backtrace", "bytes", @@ -1117,14 +1731,14 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.91", ] [[package]] name = "tokio-util" -version = "0.7.12" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" dependencies = [ "bytes", "futures-core", @@ -1134,11 +1748,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -1147,20 +1778,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.91", ] [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", "valuable", @@ -1179,10 +1810,11 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.18" +version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ + "chrono", "matchers", "nu-ansi-term", "once_cell", @@ -1195,17 +1827,32 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-ident" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" [[package]] name = "unicode-width" -version = "0.1.14" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "untrusted" @@ -1225,12 +1872,100 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasm-bindgen" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.91", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.91", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wildmatch" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ce1ab1f8c62655ebe1350f589c61e505cf94d385bc6a12899442d9081e71fd" + [[package]] name = "winapi" version = "0.3.9" @@ -1253,6 +1988,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -1401,6 +2145,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + [[package]] name = "yasna" version = "0.5.2" @@ -1428,7 +2181,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.91", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cec2857..ee97d35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "qcp" description = "Secure remote file copy utility which uses the QUIC protocol over UDP" -rust-version = "1.81.0" +rust-version = "1.81.0" # 1.81.0 was the current Rust version when this project started resolver = "2" version = "0.1.3" edition = "2021" @@ -12,33 +12,54 @@ homepage = "https://github.com/crazyscot/qcp/" keywords = [ "networking", "file-transfer", "quic" ] categories = [ "command-line-utilities" ] +[profile.dev] +split-debuginfo="unpacked" + [profile.release] lto = "thin" strip = "symbols" +[features] +default = ["rustls-log"] +## Enables rustls debug messages. You still have to request them using the environment variable, e.g. `RUST_LOG="rustls=debug"`. +rustls-log = ["quinn/rustls-log"] + [dependencies] -anstyle = "1.0.8" -anyhow = "1.0.89" -capnp = "0.20.1" -capnp-futures = "0.20.0" -clap = { version = "4.5.19", features = ["wrap_help", "derive", "cargo", "help", "string"] } +anstream = "0.6.18" +anstyle = "1.0.10" +anyhow = "1.0.94" +capnp = "0.20.3" +capnp-futures = "0.20.1" +clap = { version = "4.5.23", features = ["wrap_help", "derive", "cargo", "help", "string"] } console = "0.15.8" +derive-deftly = "0.14.2" +dirs = "5.0.1" dns-lookup = "2.0.4" +document-features = "0.2.10" +expanduser = "1.2.2" +figment = { version = "0.10.19" } futures-util = { version = "0.3.31", default-features = false } gethostname = "0.5.0" +glob = "0.3.1" +heck = "0.5.0" human-repr = "1.1.0" humanize-rs = "0.1.5" -indicatif = { version = "0.17.8", features = ["tokio"] } +indicatif = { version = "0.17.9", features = ["tokio"] } +lazy_static = "1.5.0" num-format = { version = "0.4.4" } -quinn = { version = "0.11.5", default-features = false, features = ["runtime-tokio", "rustls", "ring"] } +quinn = { version = "0.11.6", default-features = false, features = ["runtime-tokio", "rustls", "ring"] } rcgen = { version = "0.13.1" } -rustls-pki-types = "1.9.0" +rustls-pki-types = "1.10.0" +serde = { version = "1.0.216", features = ["derive"] } static_assertions = "1.1.0" -strum_macros = "0.26.4" -tokio = { version = "1.40.0", default-features = true, features = ["fs", "io-std", "macros", "process", "rt", "time", "sync"] } -tokio-util = { version = "0.7.12", features = ["compat"] } -tracing = "0.1.40" -tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +struct-field-names-as-array = "0.3.0" +strum = { version = "0.26.3", features = ["derive"]} +tabled = "0.17.0" +tokio = { version = "1.42.0", default-features = true, features = ["fs", "io-std", "macros", "process", "rt", "time", "sync"] } +tokio-util = { version = "0.7.13", features = ["compat"] } +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["env-filter", "chrono"] } +wildmatch = "2.4.0" [target.'cfg(unix)'.dependencies] nix = { version = "0.29.0", features = ["socket"] } @@ -47,11 +68,16 @@ nix = { version = "0.29.0", features = ["socket"] } jemallocator = "0.5.4" [build-dependencies] -capnpc = "0.20.0" +capnpc = "0.20.1" [dev-dependencies] -fastrand = "2.1.1" - +assertables = "9.5.0" +fastrand = "2.3.0" +json = "0.12.4" +rand = "0.8.5" +serde_json = "1.0.133" +serde_test = "1.0.177" +tempfile = "3.14.0" [lints.rust] dead_code = "warn" @@ -110,6 +136,10 @@ assets = [ [ "LICENSE", "usr/share/doc/qcp/", "644" ], # gzip -9n < CHANGELOG.md > misc/changelog.gz [ "misc/changelog.gz", "usr/share/doc/qcp/", "644" ], - [ "misc/20-qcp.conf", "etc/sysctl.d/", "644" ], + [ "misc/20-qcp.conf", "etc/sysctl.d/", "644" ], # this is automatically recognised as a conffile [ "misc/qcp.1", "usr/share/man/man1/", "644" ], + [ "misc/qcp_config.5", "usr/share/man/man5/", "644" ], + [ "misc/qcp.conf", "etc/", "644" ], # this is automatically recognised as a conffile ] +maintainer-scripts="debian" +depends = "$auto,debconf" diff --git a/MAINTENANCE.md b/MAINTENANCE.md index e9a84ae..0335d5a 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -1,12 +1,15 @@ ## Creating a release * Create PR: + * _(Optional)_ `release-plz update` to preview updates to the changelog and version * ```release-plz release-pr --git-token $GITHUB_QCP_TOKEN``` * _if this token has expired, you'll need to generate a fresh one; walk back through the release-plz setup steps_ +* Review changelog, edit if necessary. * Merge the PR (rebase strategy preferred) * Delete the PR branch * `git fetch && git merge --ff-only` * Finalise the release: * ```release-plz release --git-token $GITHUB_QCP_TOKEN``` + * Check the new (draft) Github release page; update notes as necessary, publish when ready (when artifacts have all uploaded). * Merge `dev` into `main`, or whatever suits the current branching strategy * Check the docs built, follow up on the release workflow, etc. diff --git a/README.md b/README.md index a4fb3c2..9e7df52 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ high-performance remote file copy utility for long-distance internet connections - 🔧 Drop-in replacement for `scp` - 🛡️ Similar security to `scp`, using existing, well-known mechanisms - 🚀 Better throughput on congested networks +- **(New in 0.2)** Configuration file support + +For a full list of changes, see the [changelog](CHANGELOG.md). #### Platform support status @@ -23,6 +26,9 @@ high-performance remote file copy utility for long-distance internet connections ## 🧰 Getting Started * You must have ssh access to the target machine. + - You must be able to exchange UDP packets with the target on a given port. + - If the local machine is behind connection-tracking NAT, things usually work just fine. This is the case for the vast majority of home and business network connections. + - You can tell qcp to use a particular port range if you need to. * Install the `qcp` binary on both machines. It needs to be in your `PATH` on the remote machine. * Run `qcp --help-buffers` and follow its instructions. @@ -70,9 +76,17 @@ $ qcp my-server:/tmp/testfile /tmp/ testfile ████████████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░ 1s @ 6.71 MB/s [10.49 MB] ``` -**The program uses the ssh binary on your system to connect to the target machine**. +Things you should know: + +* **qcp uses the ssh binary on your system to connect to the target machine**. ssh will check the remote host key and prompt you for a password or passphrase in the usual way. +* **qcp will read your ssh config file** to resolve any Hostname aliases you may have defined there. +The idea is, if you can `ssh` to a host, you should also be able to `qcp` to it. +However, some particularly complicated ssh config files may be too much for qcp to understand. +(In particular, `Match` directives are not currently supported.) +In that case, you can use `--ssh-config` to provide an alternative configuration (or set it in your qcp configuration file). + #### Tuning By default qcp is tuned for a 100Mbit connection, with 300ms round-trip time to the target server. @@ -87,6 +101,11 @@ qcp my-server:/tmp/testfile /tmp/ --rx 37M --tx 12M Performance tuning can be a tricky subject. See the [performance] documentation. +#### Persistent configuration + +The useful options -- those you might want to use regularly including `rx`, `tx` and `rtt` -- can be specified +in a configuration file. See [config] for details. + ## 📖 How qcp works The brief version: @@ -98,35 +117,56 @@ The brief version: The [protocol] documentation contains more detail and a discussion of its security properties. -## ⚖️ License +## 📘 Project Policies + +### ⚖️ License + +This project is released publicly under the [GNU Affero General Public License](LICENSE). + +Alternative license terms can be made available on request on a commercial basis (see below). + +### 🧑‍🏭 Bugs, Features & Contributions -The initial release is made under the [GNU Affero General Public License](LICENSE). +Bug reports and feature requests are welcome, please use the [issue] tracker. -## 🧑‍🏭 Contributing +- It may be useful to check the [issues list] and the [discussions] first in case somebody else has already raised it. +- Please be aware that I mostly work on this project in my own time. -Feel free to report bugs via the [bug tracker]. +🚧 If you're thinking of contributing code, please read [CONTRIBUTING.md](CONTRIBUTING.md). -I'd particularly welcome performance reports from BSD/OSX users as that's not a platform I use regularly. +#### Help wanted: MacOS/BSD -While suggestions and feature requests are welcome, please be aware that I mostly work on this project in my own time. +I'd particularly welcome performance reports from MacOS/BSD users as those are not platforms I use regularly. + +### 📑 Version number and compatibility + +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) 2.0.0. + +In its initial experimental phase, the major number will be kept at 0. +Breaking changes will be noted in the [changelog](CHANGELOG.md) and will trigger a minor version bump. + +The project will move to version 1.x when the protocol has stabilised. After 1.0, breaking changes will trigger a major version bump. ## 💸 Supporting the project If you find this software useful and would like to say thank you, please consider [buying me a coffee] or [ko-fi]. [Github sponsorship] is also available. -If you're a business and need a formal invoice for your accountant, my freelancing company can issue the paperwork. -For this, and any other commercial enquiries (alternative licensing, support, etc) please get in touch, to `qcp@crazyscot.com`. - Please also consider supporting the galaxy of projects this work builds upon. Most notably, [Quinn] is a pure-Rust implementation of the [QUIC] protocol, without which qcp simply wouldn't exist in its current form. -### 💡 Roadmap +If you're a business and need a formal invoice for your accountant, my freelancing company can issue the paperwork. +For this, and any other commercial enquiries please get in touch, to `qcp@crazyscot.com`. We would be pleased to discuss commercial terms for: + +* Alternative licensing +* Support +* Sponsoring feature development + +## 💡 Future Directions Some ideas for the future, in no particular order: -* A local config mechanism, so you don't have to type out the network parameters every time * Support for copying multiple files (e.g. shell globs or `scp -r`) -* Windows native support, at least for client mode +* Windows native support * Firewall/NAT traversal * Interactive file transfer (akin to `ftp`) * Smart file copy using the `rsync` protocol or similar (send only the sections you need to) @@ -135,12 +175,15 @@ Some ideas for the future, in no particular order: * Bind a daemon to a fixed port, for better firewall/NAT traversal properties but at the cost of having to implement user authentication. * _The same thing we do every night, Pinky. We try to take over the world!_ -[bug tracker]: https://github.com/crazyscot/qcp/issues +[issue]: https://github.com/crazyscot/qcp/issues/new/choose +[issues list]: https://github.com/crazyscot/qcp/issues +[discussions]: https://github.com/crazyscot/qcp/discussions [quic]: https://quicwg.github.io/ [Quinn]: https://opencollective.com/quinn-rs [rfc9000]: https://www.rfc-editor.org/rfc/rfc9000.html [buying me a coffee]: https://buymeacoffee.com/rossyounger [ko-fi]: https://ko-fi.com/rossyounger +[config]: https://docs.rs/qcp/latest/qcp/doc/config/index.html [protocol]: https://docs.rs/qcp/latest/qcp/protocol/index.html [performance]: https://docs.rs/qcp/latest/qcp/doc/performance/index.html [Github sponsorship]: https://github.com/sponsors/crazyscot?frequency=recurring&sponsor=crazyscot diff --git a/debian/config b/debian/config new file mode 100755 index 0000000..6266c26 --- /dev/null +++ b/debian/config @@ -0,0 +1,12 @@ +#!/bin/sh + +set -e + +if [ -e /usr/share/debconf/confmodule ]; then + . /usr/share/debconf/confmodule + db_version 2.0 + db_capb + db_settitle qcp/title +fi + +# We cannot meaningfully preconfigure as postinst checks the filesystem at runtime. diff --git a/debian/postinst b/debian/postinst new file mode 100755 index 0000000..a5b68a0 --- /dev/null +++ b/debian/postinst @@ -0,0 +1,74 @@ +#!/bin/sh + +set -e + +# By Debian policy, packages must not ask unnecessary questions. +# +# Therefore, we examine the persistent sysctl directories to see +# if there are any mentions of the sysctls we want to set. +# If not, we presume that the system has no special requirements +# (this is expected to be the general case). + +SYSCTL_FILE=20-qcp.conf +SYSCTL_PATH=/etc/sysctl.d/${SYSCTL_FILE} + +. /usr/share/debconf/confmodule +db_version 2.0 +db_capb +db_settitle qcp/title + +#when testing this script, this line resets the db: +#db_fset qcp/sysctl_clash seen false + +check_for_clashing_sysctls() { + for DIR in /etc/sysctl.d /usr/lib/sysctl.d; do + if grep -qcr -e net.core.rmem_max -e net.core.wmem_max --exclude "*${SYSCTL_FILE}*" ${DIR}; then + return 0 + fi + done + return 1 +} + +activate_our_sysctls() { + sysctl -w -p ${SYSCTL_PATH} +} + +disable_our_file() { + if [ -e ${SYSCTL_PATH} ]; then + mv -f ${SYSCTL_PATH} ${SYSCTL_PATH}.disabled + fi +} + +try_to_enable_our_file() { + if [ -e ${SYSCTL_PATH}.disabled ]; then + mv -f ${SYSCTL_PATH}.disabled ${SYSCTL_PATH} + fi +} + +alert_sysadmin() { + db_input high qcp/sysctl_clash || true + db_go || true + + db_get qcp/sysctl_clash || true + case "$RET" in + "install and activate now") + try_to_enable_our_file + activate_our_sysctls + ;; + "install but do NOT activate") + try_to_enable_our_file + # do nothing + ;; + "do not install") + # they don't want it, OK + disable_our_file + ;; + esac +} + +if check_for_clashing_sysctls; then + alert_sysadmin +else + # No clashes; proceed quietly. + activate_our_sysctls +fi diff --git a/debian/postrm b/debian/postrm new file mode 100755 index 0000000..d8d3019 --- /dev/null +++ b/debian/postrm @@ -0,0 +1,20 @@ +#!/bin/sh + +set -e + +if [ -e /usr/share/debconf/confmodule ]; then +. /usr/share/debconf/confmodule +db_version 2 + +case "$1" in +purge) + # Remove my changes to the db. + if [ -e /usr/share/debconf/confmodule ]; then + . /usr/share/debconf/confmodule + db_version 2 + db_purge + fi + ;; +esac + +fi \ No newline at end of file diff --git a/debian/templates b/debian/templates new file mode 100644 index 0000000..c6774c8 --- /dev/null +++ b/debian/templates @@ -0,0 +1,16 @@ +Template: qcp/title +Type: title +Description: qcp sysctl files + +Template: qcp/sysctl_clash +Type: select +Choices: install and activate now, install but do NOT activate, do not install +Default: install and activate now +Description: Install sysctl files for qcp? + qcp has detected a possible sysctl clash! + . + qcp would like to install sysctl files to configure the kernel for good performance. + . + The sysctls used by qcp are: net.core.rmem_max net.core.wmem_max + . + sysctl config files may be found in: /etc/sysctl.d /usr/lib/sysctl.d diff --git a/misc/qcp.1 b/misc/qcp.1 index 90cd1d2..8b087e9 100644 --- a/misc/qcp.1 +++ b/misc/qcp.1 @@ -1,59 +1,67 @@ .ie \n(.g .ds Aq \(aq .el .ds Aq ' -.TH qcp 1 "qcp v0.1" +.TH qcp 1 "qcp v0.2" .SH NAME -qcp \- Secure remote file copy utility which uses the QUIC protocol over UDP +qcp — Secure remote file copy utility which uses the QUIC protocol over UDP .SH SYNOPSIS \fBqcp\fR [\fB-46qsd\fR] [\fB--ssh\fR ssh-command] [\fB-S ssh-options\fR] [\fB-p local-port-range\fR] [\fB-P remote-port-range\fR] -[\fB--rx-bw bandwidth\fR] -[\fB--tx-bw bandwidth\fR] +[\fB--rx bandwidth\fR] +[\fB--tx bandwidth\fR] [\fB-r rtt\fR] [\fB-t timeout\fR] +[\fI\fR] <\fISOURCE\fR> <\fIDESTINATION\fR> .TP \fBqcp\fR [-h|--help|--help-buffers|-V|--version] .SH DESCRIPTION .TP -The QUIC Copier (\fIqcp\fR) is an experimental high-performance remote file copy utility for long-distance internet connections. -.TP -It is intended as a drop-in replacement for scp. +The QUIC Copier (\fIqcp\fR) is an experimental high-performance remote file copy utility for long-distance internet connections. It is intended as a drop-in replacement for scp. .TP qcp offers similar security to scp using existing, well-known mechanisms, and better throughput on congested networks. .SH LIMITATIONS .TP -.RS 14 .IP \(bu 2 -You must be able to ssh directly to the remote machine, and exchange UDP packets with it on a given port. (If the local machine is behind connection-tracking NAT, things work just fine. This is the case for the vast majority of home and business network connections.) +You must be able to ssh directly to the remote machine, and exchange UDP packets with it on a given port. +(If the local machine is behind connection-tracking NAT, things work just fine. +This is the case for the vast majority of home and business network connections.) .IP \(bu 2 -Network security systems can’t readily identify QUIC traffic as such. It’s opaque, and high bandwidth. Some security systems might flag it as a potential threat. -.RE +Network security systems can’t readily identify QUIC traffic as such. +It’s opaque, and high bandwidth. +Some security systems might flag it as a potential threat. .SH ARGUMENTS .TP <\fISOURCE\fR> -The source file. This may be a local filename, or remote specified as HOST:FILE or USER@HOST:FILE. +The source file. +This may be a local filename, or remote specified as HOST:FILE or USER@HOST:FILE. -Exactly one of source and destination must be remote. .TP <\fIDESTINATION\fR> -Destination. This may be a file or directory. It may be local or remote. +Destination. +This may be a file or directory. It may be local or remote. If remote, specify as HOST:DESTINATION or USER@HOST:DESTINATION; or simply HOST: or USER@HOST: to copy to your home directory there. -Exactly one of source and destination must be remote. - -.SH OPTIONS .TP -\fB\-q\fR, \fB\-\-quiet\fR -Quiet mode +Exactly one of \fIsource\fR and \fIdestination\fR must be remote. .TP -\fB\-s\fR, \fB\-\-statistics\fR -Outputs additional transfer statistics +qcp will read your ssh config file to resolve any host name aliases you may have defined. The idea is, if you can ssh directly to a given host, you should be able to qcp to it by the same name. However, some particularly complicated ssh config files may be too much for qcp to understand. (In particular, \fIMatch\fR directives are not currently supported.) In that case, you can use \fI--ssh-config\fR to provide an alternative configuration (or set it in your qcp configuration file). + +.SH CONFIGURATION +Many of qcp's configuration options may be set persistently via configuration files. +See \fBqcp_config\fR(5) for details. + +.SH OPTIONS + +\fINote: this man page is currently maintained by hand.\fR +In the event of discrepancies or confusion, the help text within the program (\fIqcp --help\fR) +is more likely to be correct. + .TP \fB\-h\fR, \fB\-\-help\fR Print help (see a summary with \*(Aq\-h\*(Aq) @@ -61,51 +69,105 @@ Print help (see a summary with \*(Aq\-h\*(Aq) \fB\-V\fR, \fB\-\-version\fR Print version -.SH Network tuning options +.SS Network tuning options .TP -\fB\-r\fR, \fB\-\-rtt\fR=\fIms\fR [default: 300] -The expected network Round Trip time to the target system, in milliseconds -.TP -\fB\-b\fR, \fB\-\-rx\-bw\fR=\fIbytes\fR [default: 12500k] +\fB\-b\fR, \fB\-\-rx\fR=\fIbytes\fR [default: 12500k] The maximum network bandwidth we expect receiving data FROM the remote system. -This may be specified directly as a number of bytes, or as an SI quantity e.g. "10M" or "256k". Note that this is described in BYTES, not bits; if (for example) you expect to fill a 1Gbit ethernet connection, 125M might be a suitable setting. +This may be specified directly as a number of bytes, or as an SI quantity e.g. "10M" or "256k". +Note that this is described in \fIBYTES\fR, not bits; if (for example) you expect to fill a 1Gbit ethernet connection, 125M might be a suitable setting. .TP -\fB\-B\fR, \fB\-\-tx\-bw\fR=\fIbytes\fR -The maximum network bandwidth we expect sending data TO the remote system, -if it is different from the bandwidth FROM the system. (For example, when you are connected via an asymmetric last\-mile DSL or fibre profile.) [default: same as \-\-rx\-bw] +\fB\-B\fR, \fB\-\-tx\fR=\fIbytes\fR +The maximum network bandwidth (in bytes) we expect sending data TO the remote system, +if it is different from the bandwidth FROM the system. +(For example, when you are connected via an asymmetric last\-mile DSL or fibre profile.) +[default: same as \-\-rx\-bw] +.TP +\fB\-r\fR, \fB\-\-rtt\fR=\fIms\fR [default: 300] +The expected network Round Trip time to the target system, in milliseconds .TP \fB\-\-help\-buffers\fR Outputs additional information about kernel UDP buffer sizes and platform\-specific tips -.SH Connection options +.SS Advanced network tuning .TP -\fB\-4\fR, \fB\-\-ipv4\fR +\fB\-\-congestion\fR=\fIalg\fR [default: cubic] +Specifies the congestion control algorithm to use +.br + +.br +\fIPossible values:\fR +.RS 8 +.IP \(bu 2 +cubic: The congestion algorithm TCP uses. This is good for most cases +.IP \(bu 2 +bbr: (Use with caution!) An experimental algorithm created by Google, which increases goodput in some situations (particularly long and fat connections where the intervening buffers are shallow). +However this comes at the cost of having more data in\-flight, and much greater packet retransmission. + +See +.UR https://blog.apnic.net/2020/01/10/when\-to\-use\-and\-not\-use\-bbr/ +.UE +for more discussion. +.RE +.TP +\fB\-\-initial\-congestion\-window\fR=\fIbytes\fR +(Network wizards only!) The initial value for the sending congestion control window. + +Setting this value too high reduces performance! + +If not specified, this setting is determined by the selected congestion control algorithm. + +.SS Connection options +.TP +\fB\-4\fR Forces IPv4 connection [default: autodetect] + .TP -\fB\-6\fR, \fB\-\-ipv6\fR +\fB\-6\fR Forces IPv6 connection [default: autodetect] +.TP +\fB\-\-address\-family\fR=\fIoption\fR [default: any] + +\fIPossible values:\fR +.RS 8 +.IP \(bu 2 +inet: IPv4 +.IP \(bu 2 +inet6: IPv6 +.IP \(bu 2 +any: Unspecified. qcp will use whatever seems suitable given the target address or the result of DNS lookup +.RE .TP -\fB\-\-ssh\fR=\fISSH\fR [default: ssh] -Specifies the ssh client program to use +\fB\-p\fR, \fB\-\-port\fR=\fIM\-N\fR +Uses the given UDP port or range on the local endpoint. + +This can be useful when there is a firewall between the endpoints. +.TP +\fB\-P\fR, \fB\-\-remote\-port\fR=\fIM\-N\fR +Uses the given UDP port or range on the remote endpoint. +This can be useful when there is a firewall between the endpoints. + .TP \fB\-S\fR=\fIssh\-option\fR Provides an additional option or argument to pass to the ssh client. -Note that you must repeat `\-S` for each. For example, to pass `\-i /dev/null` to ssh, specify: `\-S \-i \-S /dev/null` +Note that you must repeat \fI\-S\fR for each. For example, to pass \fI\-i /dev/null\fR to ssh, specify: \fI\-S \-i \-S /dev/null\fR .TP -\fB\-P\fR, \fB\-\-remote\-port\fR=\fIM\-N\fR -Uses the given UDP port or range on the remote endpoint. +\fB\-\-ssh\fR=\fISSH\fR [default: ssh] +Specifies the ssh client program to use -This can be useful when there is a firewall between the endpoints. .TP -\fB\-p\fR, \fB\-\-port\fR=\fIM\-N\fR -Uses the given UDP port or range on the local endpoint. +\fB\-\-ssh\-config\fR=\fIFILE\fR +Alternative ssh config file(s) + +By default, qcp reads your user and system ssh config files to look for Hostname aliases. +In some cases the logic in qcp may not read them successfully; this is an escape hatch, allowing you to specify one or more alternative files to read instead (which may be empty, nonexistent or \fI/dev/null\fR). + +This option is really intended to be used in a qcp configuration file. On the command line, you can repeat \fI\-\-ssh\-config file\fR as many times as needed. -This can be useful when there is a firewall between the endpoints. .TP \fB\-t\fR, \fB\-\-timeout\fR=\fIsec\fR [default: 5] Connection timeout for the QUIC endpoints. @@ -113,85 +175,99 @@ Connection timeout for the QUIC endpoints. This needs to be long enough for your network connection, but short enough to provide a timely indication that UDP may be blocked. -.SH Debug options +.SS Output options + .TP -\fB\-\-remote\-debug\fR -Enables detailed debug output from the remote endpoint +\fB\-l\fR, \fB\-\-log\-file\fR +Logs to a file. By default the log receives everything printed to stderr. To override this behaviour, set the environment variable \fIRUST_LOG_FILE_DETAIL\fR (same semantics as \fIRUST_LOG\fR). + .TP \fB\-\-profile\fR Prints timing profile data after completion -.TP -\fB\-d\fR, \fB\-\-debug\fR -Enable detailed debug output -This has the same effect as setting `RUST_LOG=qcp=debug` in the environment. If present, `RUST_LOG` overrides this option. .TP -\fB\-l\fR, \fB\-\-log\-file\fR=\fIFILE\fR -Log to a file - -By default the log receives everything printed to stderr. To override this behaviour, set the environment variable `RUST_LOG_FILE_DETAIL` (same semantics as `RUST_LOG`). - +\fB\-s\fR, \fB\-\-statistics\fR +Outputs additional transfer statistics -.SH Advanced network options .TP -\fB\-\-congestion\fR=\fIalg\fR [default: cubic] -Specifies the congestion control algorithm to use -.br +\fB\-T\fR, \fB\-\-time\-format\fR +Specifies the time format to use when printing messages to the console or to file [default: local] -.br \fIPossible values:\fR -.RS 14 +.RS 8 .IP \(bu 2 -cubic: The congestion algorithm TCP uses. This is good for most cases +local: Local time (as best as we can figure it out), as "year-month-day HH:MM:SS" .IP \(bu 2 -bbr: (Use with caution!) An experimental algorithm created by Google, which increases goodput in some situations (particularly long and fat connections where the intervening buffers are shallow). However this comes at the cost of having more data in\-flight, and much greater packet retransmission. See `https://blog.apnic.net/2020/01/10/when\-to\-use\-and\-not\-use\-bbr/` for more discussion +utc: UTC time, as "year-month-day HH:MM:SS" +.IP \(bu 2 +rfc3339: UTC time, in the format described in RFC3339 .RE + .TP -\fB\-\-initial\-congestion\-window\fR=\fIbytes\fR -(Network wizards only!) The initial value for the sending congestion control window. +\fB\-q\fR, \fB\-\-quiet\fR +Quiet mode -Setting this value too high reduces performance! +.SS Configuration options -If not specified, this setting is determined by the selected congestion control algorithm. +.TP +\fB\-\-config\-files\fR +Outputs the paths to configuration file(s), then exits + +.TP +\fB\-\-show\-config\fR +Outputs the configuration, then exits. + +If a remote \fISOURCE\fR or \fIDESTINATION\fR argument is given, outputs the configuration +we would use for operations to that host. + +If not, outputs only global settings from configuration, which may be overridden in +\fIHost\fR blocks in configuration files. + +.SS Debug options +.TP +\fB\-d\fR, \fB\-\-debug\fR +Enable detailed debug output + +This has the same effect as setting \fIRUST_LOG=qcp=debug\fR in the environment. If present, \fIRUST_LOG\fR overrides this option. + +.TP +\fB\-\-remote\-debug\fR +Enables detailed debug output from the remote endpoint .SH EXIT STATUS The qcp utility exits 0 on success, and >0 if an error occurs. .SH PROTOCOL -qcp is a \fIhybrid\fR protocol. We use \fIssh\fR to establish a control channel and exchange ephemeral TLS certificates, -then a \fIQUIC\fR connection to transport data. +qcp is a \fIhybrid\fR protocol. +We use \fIssh\fR to establish a control channel and exchange ephemeral TLS certificates, then a \fIQUIC\fR connection to transport data. Detailed protocol documentation can be found at -.nh -https://docs\.rs/qcp/latest/qcp/protocol/ -.hy -. +.UR https://docs\.rs/qcp/latest/qcp/protocol/ +.UE . .SH PERFORMANCE TUNING See -.nh -https://docs.rs/qcp/latest/qcp/doc/performance/index.html -.hy +.UR https://docs.rs/qcp/latest/qcp/doc/performance/index.html +.UE .SH TROUBLESHOOTING See -.nh -https://docs.rs/qcp/latest/qcp/doc/troubleshooting/index.html -.hy +.UR https://docs.rs/qcp/latest/qcp/doc/troubleshooting/index.html +.UE .SH SEE ALSO .sp -\fBssh(1)\fP, \fI\%RFC 4254\fP, \fI\%RFC 9000\fP, \fI\%RFC 9001\fP. +\fBssh\fR(1), \fBqcp_config\fR(5), \fI\%RFC 4254\fP, \fI\%RFC 9000\fP, \fI\%RFC 9001\fP. .SH AUTHOR Ross Younger .SH BUGS Please report via the issue tracker: -.nh +.UR https://github\.com/crazyscot/qcp/issues -.hy +.UE .SH CAVEATS diff --git a/misc/qcp.conf b/misc/qcp.conf new file mode 100644 index 0000000..366d8ef --- /dev/null +++ b/misc/qcp.conf @@ -0,0 +1,29 @@ +# This is the system-wide configuration file for QCP. +# The values set here can be overridden in per-user configuration files, or on the command line. +# +# This file is a very similar format to ssh_config. +# You can use Host and Include directives in the same way. +# See qcp_config(5) for more information. +# +# For explanations of the options you can set, see qcp(1) or `qcp --help`. +# + +Host * +# Rx 12500000 +# Tx 0 +# Rtt 300 + +# AddressFamily any + +# Port 0 +# RemotePort 0 + +# Congestion cubic +# InitialCongestionWindow 0 + +# Ssh ssh +# SshConfig +# SshOptions + +# TimeFormat local +# Timeout 5 diff --git a/misc/qcp_config.5 b/misc/qcp_config.5 new file mode 100644 index 0000000..9354336 --- /dev/null +++ b/misc/qcp_config.5 @@ -0,0 +1,182 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH qcp_config 5 "December 2024" +.SH NAME +qcp_config — Configuration options for qcp +.SH DESCRIPTION +\fBqcp\fR(1) obtains run-time configuration from the following sources, in order: + +.RS 0 +.IP +1. Command-line options +.IP +2. The user's configuration file (typically \fI~/.qcp.conf\fR) +.IP +3. The system-wide configuration file (typically \fI/etc/qcp.conf\fR) +.IP +4. Hard-wired defaults +.RE + +Each option may appear in multiple places, but only the first match is used. + +\fBNote:\fR Configuration file locations are platform-dependent. To see what applies on the current platform, run \fIqcp --config-files\fR. + +.SH FILE FORMAT + +qcp uses the same basic format as OpenSSH configuration files. + +Each line contains a keyword and one or more arguments. +Keywords are single words and are case-insensitive. + +Arguments are separated from keywords, and each other, by whitespace. +(It is also possible to write \fIKey=Value\fR or \fIKey = Value\fR.) + +Arguments may be surrounded by double quotes ("); this allows you to set an argument containing spaces. +If a backslash, double or single quote forms part of an argument it must be backslash-escaped i.e. \\" or \\\\ + +Comments are supported; # introduces a comment (up to the end of the line). + +Empty lines are ignored. + +qcp supports \fIHost\fR directives with wildcard and negative matching, as well as \fIInclude\fR directives. This allows you to tune your configuration for a range of network hosts. + +.SH EXAMPLE + Host old-faithful + # This is an old server with a very limited CPU which we do not want to overstress + rx 125k + tx 0 + + Host *.internal.corp + # This is a nearby data centre which we have a dedicated 1Gbit connection to. + # We don't need to use qcp, but it's convenient to use one tool in our scripts. + rx 125M + tx 0 + rtt 10 + + # For all other hosts, try to maximise our VDSL + Host * + rx 5M # we have 40Mbit download + tx 1000000 # we have 8Mbit upload; we could also have written this as "1M" + rtt 150 # most servers we care about are an ocean away + congestion bbr # this works well for us + +.SH CONFIGURATION DIRECTIVES + +.TP +\fBHost\fR \fIpattern [pattern ...]\fR +Introduces a \fIhost block\fR. +All following options - up to the next Host - only apply to hosts matching any of the patterns given. + +Pattern matching uses '*' and '?' as wildcards in the usual way. + +A single asterisk '*' matches all hosts; this is used to provide defaults. + +A pattern beginning with '!' is a \fInegative\fR match; it matches all remote hosts \fIexcept\fR those matching the rest of the pattern. + +Pattern matching is applied directly to the remote host given on the QCP command line, before DNS or alias resolution. +If you connect to hosts by IP address, a pattern of \fI10.11.12.*\fR works in the obvious way. + +.TP +\fBInclude\fR \fIfile [file ...]\fR + +Include the specified file(s) in the configuration at the current point. Glob wildcards ('*' and '?') are supported in filenames. + +User configuration files may refer to pathnames relative to '~' (the user's home directory). + +Filenames with relative paths are assumed to be in \fI~/.ssh/\fR if read from a user configuration file, or \fI/etc/ssh/\fR if read from a system configuration file. + +An Include directive inside a Host block retains the Host context. +This may be useful to apply common directives to multiple hosts with minimal repetition. +Note that if an included file begins a new Host block, that will continue to apply on return to the including file. + +It is possible for included files to themselves include additional files; there is a brake that prevents infinite recursion. + +.SH CONFIGURATION OPTIONS + +The following options from the CLI are supported in configuration files: + +\fIrx, tx, rtt, congestion, initial_congestion_window, port, timeout, address_family, ssh, ssh_options, remote_port, time_format, ssh_config\fR + +Refer to \fBqcp\fR(1) for details. + +In configuration files, option keywords are case insensitive and ignore hyphens and underscores. +(On the command line, options must be specified in kebab-case.) +For example, these declarations are all equivalent: + RemotePort 12345 + remoteport 12345 + remote_port 12345 + Remote_Port 12345 + ReMoTePoRt 12345 + rEmOtE-pOrT 12345 + +.SH CONFIGURATION EXPLAINER + +As configurations can get quite complex, it may be useful to understand where a particular value came from. + +qcp will do this for you, with the \fI--show-config\fR option. +Provide a source and destination as if you were copying a file to/from a host to see the configuration that would apply. For example: + + $ qcp --show-config myserver:some-file /tmp/ + + .---------------------------.-------------.------------------------------. + | field | value | source | + .---------------------------.-------------.------------------------------. + | (Remote host) | myserver | | + | AddressFamily | inet | /home/xyz/.qcp.conf (line 9) | + | Congestion | cubic | default | + | InitialCongestionWindow | 0 | default | + | Port | 0 | default | + | RemotePort | 60500-61000 | /home/xyz/.qcp.conf (line 8) | + | Rtt | 300 | /etc/qcp.conf (line 4) | + | Rx | 38M | /etc/qcp.conf (line 2) | + | Ssh | ssh | default | + | SshConfig | [] | default | + | SshOption | [] | default | + | TimeFormat | local | default | + | Timeout | 5 | default | + | Tx | 12M | /etc/qcp.conf (line 3) | + .---------------------------.-------------.------------------------------. + +.SH TIPS AND TRAPS +1. Like OpenSSH, for each setting we use the value from the \fIfirst\fR Host block we find that matches the remote hostname. + +2. Each setting is evaluated independently. +In the example above, the \fIHost old-faithful\fR block sets rx but does not set rtt. +Any operations to old-faithful inherit \fIrtt 150\fR from the Host * block. + +3. The tx setting has a default value of 0, which means “use the active rx value”. +\fIIf you set tx in a Host * block, you probably want to set it explicitly everywhere you set rx.\fR + +If you have a complicated config file we suggest you structure it as follows: +.RS 0 +.IP +1. Any global settings that are intended to apply to all hosts +.IP +2. Host blocks; if you use wildcards, from most-specific to least-specific +.IP +3. A Host * block to provide default settings to apply where no more specific value has been given +.RE + +.SH FILES + +.TP +~/.qcp.conf +The user configuration file (on most platforms) + +.TP +/etc/qcp.conf +The system configuration file (on most platforms) + +.TP +~/.ssh/ssh_config +The user ssh configuration file + +.TP +/etc/ssh/ssh_config +The system ssh configuration file + +.SH SEE ALSO +\fBqcp\fR(1), \fBssh_config\fR(5) + +.SH AUTHOR +Ross Younger diff --git a/release-plz.toml b/release-plz.toml index 9e0eb3f..f01000c 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -1,6 +1,14 @@ [workspace] dependencies_update = false #allow_dirty = true +git_release_draft = true +git_release_body = """ +{{ changelog }} + +## Download notes +* Debian and Ubuntu users may find the .deb packages convenient. +* The Linux binary builds `qcp--unknown-linux-musl.tar.gz` are static musl binaries which should work on all distributions. +""" [changelog] header = """# Changelog @@ -50,12 +58,13 @@ body = """ """ commit_parsers = [ - { message = "^feat", group = "⛰️ Features" }, - { message = "^fix", group = "🐛 Bug Fixes" }, + { body = ".*security", group = "🛡️ Security" }, + { message = "^feat", group = "⛰️ Features" }, + { message = "^fix", group = "🐛 Bug Fixes" }, { message = "^doc", group = "📚 Documentation" }, { message = "^perf", group = "⚡ Performance" }, { message = "^refactor\\(clippy\\)", skip = true }, - { message = "^refactor", group = "🚜 Refactor" }, + { message = "^refactor", group = "🚜 Refactor" }, { message = "^style", group = "🎨 Styling" }, { message = "^test", group = "🧪 Testing" }, { message = "^chore\\(release\\):", skip = true }, @@ -63,11 +72,10 @@ commit_parsers = [ { message = "^chore\\(deps.*\\)", skip = true }, { message = "^chore\\(pr\\)", skip = true }, { message = "^chore\\(pull\\)", skip = true }, - { message = "^chore\\(npm\\).*yarn\\.lock", skip = true }, - { message = "^build", group = "🏗️ Build & CI" }, - { message = "^chore|^ci|^misc|^tidyup", group = "⚙️ Miscellaneous Tasks" }, - { body = ".*security", group = "🛡️ Security" }, - { message = "^revert", group = "◀️ Revert" }, + { message = "^chore\\(skip", skip = true }, + { message = "^build|^ci|^package", group = "🏗️ Build, packaging & CI" }, + { message = "^chore|^misc|^tidyup", group = "⚙️ Miscellaneous Tasks" }, + { message = "^revert", group = "◀️ Revert" }, ] link_parsers = [ diff --git a/src/cli/args.rs b/src/cli/args.rs index 8066d83..974608c 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -1,17 +1,25 @@ -// QCP top-level command-line arguments +//! Command-line argument definition and processing // (c) 2024 Ross Younger -use clap::{Args as _, FromArgMatches as _, Parser}; +use clap::{ArgAction::SetTrue, Args as _, FromArgMatches as _, Parser}; + +use crate::{config::Manager, util::AddressFamily}; /// Options that switch us into another mode i.e. which don't require source/destination arguments -pub(crate) const MODE_OPTIONS: &[&str] = &["server", "help_buffers"]; +pub(crate) const MODE_OPTIONS: &[&str] = &["server", "help_buffers", "config_files", "show_config"]; +/// CLI argument definition #[derive(Debug, Parser, Clone)] #[command( author, // we set short/long version strings explicitly, see custom_parse() about, - before_help = "e.g. qcp some/file my-server:some-directory/", + before_help = r"e.g. qcp some/file my-server:some-directory/ + + Exactly one of source and destination must be remote. + + qcp will read your ssh config file to resolve any host name aliases you may have defined. The idea is, if you can ssh directly to a given host, you should be able to qcp to it by the same name. However, some particularly complicated ssh config files may be too much for qcp to understand. (In particular, Match directives are not currently supported.) In that case, you can use --ssh-config to provide an alternative configuration (or set it in your qcp configuration file). + ", infer_long_args(true) )] #[command(help_template( @@ -23,57 +31,107 @@ pub(crate) const MODE_OPTIONS: &[&str] = &["server", "help_buffers"]; {all-args}{after-help} " ))] -#[command(styles=super::styles::get())] +#[command(styles=super::styles::CLAP_STYLES)] +#[allow(clippy::struct_excessive_bools)] pub(crate) struct CliArgs { // MODE SELECTION ====================================================================== - /// Operates in server mode. + /// Operate in server mode. /// /// This is what we run on the remote machine; it is not /// intended for interactive use. #[arg( long, help_heading("Modes"), hide = true, - conflicts_with_all(["help_buffers", "quiet", "statistics", "ipv4", "ipv6", "remote_debug", "profile", "source", "destination", "ssh", "ssh_opt", "remote_port"]) + conflicts_with_all([ + "help_buffers", "show_config", "config_files", + "quiet", "statistics", "remote_debug", "profile", + "ssh", "ssh_options", "remote_port", + "source", "destination", + ]) )] pub server: bool, + // CONFIGURABLE OPTIONS ================================================================ + #[command(flatten)] + /// The set of options which may be set in a config file or via command-line. + pub config: crate::config::Configuration_Optional, + + // MODE SELECTION, part 2 ============================================================== + // (These are down here to control clap's output ordering.) + /// Outputs the configuration, then exits. + /// + /// If a remote `SOURCE` or `DESTINATION` argument is given, outputs the configuration we would use + /// for operations to that host. + /// + /// If not, outputs only global settings from configuration, which may be overridden in + /// `Host` blocks in configuration files. + /// + #[arg(long, help_heading("Configuration"), display_order(0))] + pub show_config: bool, + /// Outputs the paths to configuration file(s), then exits + #[arg(long, help_heading("Configuration"), display_order(0))] + pub config_files: bool, + /// Outputs additional information about kernel UDP buffer sizes and platform-specific tips - #[arg(long, action, help_heading("Network tuning"), display_order(50))] + #[arg(long, action, help_heading("Network tuning"), display_order(100))] pub help_buffers: bool, - // CLIENT-ONLY OPTIONS ================================================================= - #[command(flatten)] - pub client: crate::client::Options, - - // NETWORK OPTIONS ===================================================================== + // CLIENT-SIDE NON-CONFIGURABLE OPTIONS ================================================ + // (including positional arguments!) #[command(flatten)] - pub bandwidth: crate::transport::BandwidthParams, + /// The set of options which may only be provided via command-line. + pub client_params: crate::client::Parameters, - #[command(flatten)] - pub quic: crate::transport::QuicParams, - // DEBUG OPTIONS ======================================================================= - /// Enable detailed debug output + /// Forces use of IPv4 /// - /// This has the same effect as setting `RUST_LOG=qcp=debug` in the environment. - /// If present, `RUST_LOG` overrides this option. - #[arg(short, long, action, help_heading("Debug"))] - pub debug: bool, - /// Log to a file + /// This is a convenience alias for `--address-family inet` + // this is actioned by our custom parser + #[arg( + short = '4', + help_heading("Connection"), + group("ip address"), + action(SetTrue), + display_order(0) + )] + pub ipv4_alias__: bool, + /// Forces use of IPv6 /// - /// By default the log receives everything printed to stderr. - /// To override this behaviour, set the environment variable `RUST_LOG_FILE_DETAIL` (same semantics as `RUST_LOG`). - #[arg(short('l'), long, action, help_heading("Debug"), value_name("FILE"))] - pub log_file: Option, - // - // ====================================================================================== - // - // N.B. ClientOptions has positional arguments! + /// This is a convenience alias for `--address-family inet6` + // this is actioned by our custom parser + #[arg( + short = '6', + help_heading("Connection"), + group("ip address"), + action(SetTrue), + display_order(0) + )] + pub ipv6_alias__: bool, } impl CliArgs { - /// Sets up and executes ou + /// Sets up and executes our parser pub(crate) fn custom_parse() -> Self { let cli = clap::Command::new(clap::crate_name!()); let cli = CliArgs::augment_args(cli).version(crate::version::short()); - CliArgs::from_arg_matches(&cli.get_matches_from(std::env::args_os())).unwrap() + let mut args = + CliArgs::from_arg_matches(&cli.get_matches_from(std::env::args_os())).unwrap(); + // Custom logic: '-4' and '-6' convenience aliases + if args.ipv4_alias__ { + args.config.address_family = Some(AddressFamily::Inet); + } else if args.ipv6_alias__ { + args.config.address_family = Some(AddressFamily::Inet6); + } + args + } +} + +impl TryFrom<&CliArgs> for Manager { + type Error = anyhow::Error; + + fn try_from(value: &CliArgs) -> Result { + let host = value.client_params.remote_host_lossy()?; + + let mut mgr = Manager::standard(host.as_deref()); + mgr.merge_provider(&value.config); + Ok(mgr) } } diff --git a/src/cli/cli_main.rs b/src/cli/cli_main.rs index eac3b90..f3dd4cb 100644 --- a/src/cli/cli_main.rs +++ b/src/cli/cli_main.rs @@ -4,16 +4,29 @@ use std::process::ExitCode; use super::args::CliArgs; - use crate::{ - client::{client_main, MAX_UPDATE_FPS}, + client::{client_main, Parameters as ClientParameters, MAX_UPDATE_FPS}, + config::{Configuration, Manager}, os, server::server_main, util::setup_tracing, }; + +use anstream::{eprintln, println}; use indicatif::{MultiProgress, ProgressDrawTarget}; use tracing::error_span; +/// Computes the trace level for a given set of [ClientParameters] +fn trace_level(args: &ClientParameters) -> &str { + if args.debug { + "debug" + } else if args.quiet { + "error" + } else { + "info" + } +} + /// Main CLI entrypoint /// /// Call this from `main`. It reads argv. @@ -25,36 +38,59 @@ pub async fn cli() -> anyhow::Result { let args = CliArgs::custom_parse(); if args.help_buffers { os::print_udp_buffer_size_help_message( - args.bandwidth.recv_buffer(), - args.bandwidth.send_buffer(), + Configuration::recv_buffer(), + Configuration::send_buffer(), ); return Ok(ExitCode::SUCCESS); } - let trace_level = if args.debug { - "debug" - } else if args.client.quiet { - "error" - } else { - "info" + + let progress = (!args.server).then(|| { + MultiProgress::with_draw_target(ProgressDrawTarget::stderr_with_hz(MAX_UPDATE_FPS)) + }); + + if args.config_files { + // do this before attempting to read config, in case it fails + println!("{:?}", Manager::config_files()); + return Ok(ExitCode::SUCCESS); + } + + // Now fold the arguments in with the CLI config (which may fail) + let config_manager = match Manager::try_from(&args) { + Ok(m) => m, + Err(err) => { + eprintln!("ERROR: {err}"); + return Ok(ExitCode::FAILURE); + } }; - let progress = if args.server { - None - } else { - Some(MultiProgress::with_draw_target( - ProgressDrawTarget::stderr_with_hz(MAX_UPDATE_FPS), - )) + + let config = match config_manager.get::() { + Ok(c) => c, + Err(err) => { + eprintln!("ERROR: Failed to parse configuration"); + err.into_iter().for_each(|e| eprintln!("{e}")); + return Ok(ExitCode::FAILURE); + } }; - setup_tracing(trace_level, progress.as_ref(), &args.log_file) - .inspect_err(|e| eprintln!("{e:?}"))?; - if args.server { + setup_tracing( + trace_level(&args.client_params), + progress.as_ref(), + &args.client_params.log_file, + config.time_format, + ) + .inspect_err(|e| eprintln!("{e:?}"))?; + + if args.show_config { + println!("{}", config_manager.to_display_adapter::()); + Ok(ExitCode::SUCCESS) + } else if args.server { let _span = error_span!("REMOTE").entered(); - server_main(args.bandwidth, args.quic) + server_main(&config) .await .map(|()| ExitCode::SUCCESS) .inspect_err(|e| tracing::error!("{e}")) } else { - client_main(args.client, args.bandwidth, args.quic, progress.unwrap()) + client_main(&config, progress.unwrap(), args.client_params) .await .inspect_err(|e| tracing::error!("{e}")) .or_else(|_| Ok(false)) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index fe5d677..647c8c1 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,7 +1,7 @@ -/// Command Line Interface for qcp -/// (c) 2024 Ross Younger +//! Command Line Interface for qcp +// (c) 2024 Ross Younger mod args; mod cli_main; -mod styles; +pub(crate) mod styles; pub(crate) use args::MODE_OPTIONS; pub use cli_main::cli; diff --git a/src/cli/styles.rs b/src/cli/styles.rs index dd8c5d1..162b505 100644 --- a/src/cli/styles.rs +++ b/src/cli/styles.rs @@ -1,39 +1,28 @@ -// Default styling for qcp's help output +// (c) 2024 Ross Younger +//! CLI output styling +//! +//! Users of this module probably ought to use anstream's `println!` / `eprintln!` macros, -#[must_use] -/// Styling factory -pub(crate) fn get() -> clap::builder::Styles { - clap::builder::Styles::styled() - .usage( - anstyle::Style::new() - .bold() - .underline() - .fg_color(Some(anstyle::Color::Ansi(anstyle::AnsiColor::Yellow))), - ) - .header( - anstyle::Style::new() - .bold() - .underline() - .fg_color(Some(anstyle::Color::Ansi(anstyle::AnsiColor::Yellow))), - ) - .literal(anstyle::Style::new().bold()) - .invalid( - anstyle::Style::new() - .bold() - .fg_color(Some(anstyle::Color::Ansi(anstyle::AnsiColor::Yellow))), - ) - .error( - anstyle::Style::new() - .bold() - .fg_color(Some(anstyle::Color::Ansi(anstyle::AnsiColor::Red))), - ) - .valid( - anstyle::Style::new() - .bold() - .underline() - .fg_color(Some(anstyle::Color::Ansi(anstyle::AnsiColor::Cyan))), - ) - .placeholder( - anstyle::Style::new().fg_color(Some(anstyle::Color::Ansi(anstyle::AnsiColor::Cyan))), - ) -} +#[allow(clippy::enum_glob_use)] +use anstyle::AnsiColor::*; +use anstyle::Color::Ansi; +use clap::builder::styling::Styles; + +pub(crate) const ERROR: anstyle::Style = anstyle::Style::new().bold().fg_color(Some(Ansi(Red))); +pub(crate) const WARNING: anstyle::Style = + anstyle::Style::new().bold().fg_color(Some(Ansi(Yellow))); +pub(crate) const INFO: anstyle::Style = anstyle::Style::new().fg_color(Some(Ansi(Cyan))); +// pub(crate) const DEBUG: anstyle::Style = anstyle::Style::new().fg_color(Some(Ansi(Blue))); + +pub(crate) const CALL_OUT: anstyle::Style = anstyle::Style::new() + .underline() + .fg_color(Some(Ansi(Yellow))); + +pub(crate) const CLAP_STYLES: Styles = Styles::styled() + .usage(CALL_OUT) + .header(CALL_OUT) + .literal(anstyle::Style::new().bold()) + .invalid(WARNING) + .error(ERROR) + .valid(INFO.bold().underline()) + .placeholder(INFO); diff --git a/src/client/args.rs b/src/client/args.rs deleted file mode 100644 index 6b14ccd..0000000 --- a/src/client/args.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Options specific to qcp client-mode -// (c) 2024 Ross Younger - -use clap::Parser; - -use crate::{protocol::control::ConnectionType, util::PortRange}; - -use super::job::FileSpec; - -/// Options specific to qcp client mode -#[derive(Debug, Parser, Clone)] -#[allow(clippy::struct_excessive_bools)] -pub struct Options { - /// Quiet mode - /// - /// Switches off progress display and statistics; reports only errors - #[arg(short, long, action, conflicts_with("debug"))] - pub quiet: bool, - - /// Outputs additional transfer statistics - #[arg(short = 's', long, alias("stats"), action, conflicts_with("quiet"))] - pub statistics: bool, - - /// Forces IPv4 connection [default: autodetect] - #[arg(short = '4', long, action, help_heading("Connection"))] - pub ipv4: bool, - /// Forces IPv6 connection [default: autodetect] - #[arg( - short = '6', - long, - action, - conflicts_with("ipv4"), - help_heading("Connection") - )] - pub ipv6: bool, - - /// Specifies the ssh client program to use - #[arg(long, default_value("ssh"), help_heading("Connection"))] - pub ssh: String, - - /// Provides an additional option or argument to pass to the ssh client. - /// - /// Note that you must repeat `-S` for each. - /// For example, to pass `-i /dev/null` to ssh, specify: `-S -i -S /dev/null` - #[arg( - short = 'S', - action, - value_name("ssh-option"), - allow_hyphen_values(true), - help_heading("Connection") - )] - pub ssh_opt: Vec, - - /// Uses the given UDP port or range on the remote endpoint. - /// - /// This can be useful when there is a firewall between the endpoints. - #[arg(short = 'P', long, value_name("M-N"), help_heading("Connection"))] - pub remote_port: Option, - - // CLIENT DEBUG ---------------------------- - /// Enables detailed debug output from the remote endpoint - #[arg(long, action, help_heading("Debug"))] - pub remote_debug: bool, - /// Prints timing profile data after completion - #[arg(long, action, help_heading("Debug"))] - pub profile: bool, - - // POSITIONAL ARGUMENTS ================================================================ - /// The source file. This may be a local filename, or remote specified as HOST:FILE or USER@HOST:FILE. - /// - /// Exactly one of source and destination must be remote. - #[arg( - conflicts_with_all(crate::cli::MODE_OPTIONS), - required = true, - value_name = "SOURCE" - )] - pub source: Option, - - /// Destination. This may be a file or directory. It may be local or remote. - /// - /// If remote, specify as HOST:DESTINATION or USER@HOST:DESTINATION; or simply HOST: or USER@HOST: to copy to your home directory there. - /// - /// Exactly one of source and destination must be remote. - #[arg( - conflicts_with_all(crate::cli::MODE_OPTIONS), - required = true, - value_name = "DESTINATION" - )] - pub destination: Option, -} - -impl Options { - pub(crate) fn address_family(&self) -> Option { - if self.ipv4 { - Some(ConnectionType::Ipv4) - } else if self.ipv6 { - Some(ConnectionType::Ipv6) - } else { - None - } - } - - pub(crate) fn remote_user_host(&self) -> anyhow::Result<&str> { - let src = self.source.as_ref().ok_or(anyhow::anyhow!( - "both source and destination must be specified" - ))?; - let dest = self.destination.as_ref().ok_or(anyhow::anyhow!( - "both source and destination must be specified" - ))?; - Ok(src - .host - .as_ref() - .unwrap_or_else(|| dest.host.as_ref().unwrap())) - } - - pub(crate) fn remote_host(&self) -> anyhow::Result<&str> { - let user_host = self.remote_user_host()?; - // It might be user@host, or it might be just the hostname or IP. - let (_, host) = user_host.split_once('@').unwrap_or(("", user_host)); - Ok(host) - } -} diff --git a/src/client/control.rs b/src/client/control.rs index 4081051..d900c1e 100644 --- a/src/client/control.rs +++ b/src/client/control.rs @@ -1,7 +1,7 @@ //! Control channel management for the qcp client // (c) 2024 Ross Younger -use std::{net::IpAddr, process::Stdio, time::Duration}; +use std::{process::Stdio, time::Duration}; use anyhow::{anyhow, Context as _, Result}; use indicatif::MultiProgress; @@ -12,12 +12,12 @@ use tokio::{ use tracing::{debug, trace, warn}; use crate::{ + config::Configuration, protocol::control::{ClientMessage, ClosedownReport, ConnectionType, ServerMessage, BANNER}, - transport::{BandwidthParams, QuicParams}, util::Credentials, }; -use super::args::Options; +use super::Parameters; /// Control channel abstraction #[derive(Debug)] @@ -37,14 +37,14 @@ impl Channel { /// Opens the control channel, checks the banner, sends the Client Message, reads the Server Message. pub async fn transact( credentials: &Credentials, - server_address: IpAddr, + remote_host: &str, + connection_type: ConnectionType, display: &MultiProgress, - client: &Options, - bandwidth: BandwidthParams, - quic: QuicParams, + config: &Configuration, + parameters: &Parameters, ) -> Result<(Channel, ServerMessage)> { trace!("opening control channel"); - let mut new1 = Self::launch(display, client, bandwidth, quic)?; + let mut new1 = Self::launch(display, config, parameters, remote_host, connection_type)?; new1.wait_for_banner().await?; let mut pipe = new1 @@ -52,7 +52,7 @@ impl Channel { .stdin .as_mut() .ok_or(anyhow!("could not access process stdin (can't happen?)"))?; - ClientMessage::write(&mut pipe, &credentials.certificate, server_address.into()) + ClientMessage::write(&mut pipe, &credentials.certificate, connection_type) .await .with_context(|| "writing client message")?; @@ -78,49 +78,52 @@ impl Channel { /// This is effectively a constructor. At present, it launches a subprocess. fn launch( display: &MultiProgress, - client: &Options, - bandwidth: BandwidthParams, - quic: QuicParams, + config: &Configuration, + parameters: &Parameters, + remote_host: &str, + connection_type: ConnectionType, ) -> Result { - let mut server = tokio::process::Command::new(&client.ssh); + let mut server = tokio::process::Command::new(&config.ssh); let _ = server.kill_on_drop(true); - let _ = match client.address_family() { - None => &mut server, - Some(ConnectionType::Ipv4) => server.arg("-4"), - Some(ConnectionType::Ipv6) => server.arg("-6"), + let _ = match connection_type { + ConnectionType::Ipv4 => server.arg("-4"), + ConnectionType::Ipv6 => server.arg("-6"), }; - let _ = server.args(&client.ssh_opt); + let _ = server.args(&config.ssh_options); let _ = server.args([ - client.remote_user_host()?, + remote_host, "qcp", "--server", // Remote receive bandwidth = our transmit bandwidth "-b", - &bandwidth.tx().to_string(), + &config.tx().to_string(), // Remote transmit bandwidth = our receive bandwidth "-B", - &bandwidth.rx().to_string(), + &config.rx().to_string(), "--rtt", - &bandwidth.rtt.to_string(), + &config.rtt.to_string(), "--congestion", - &bandwidth.congestion.to_string(), + &config.congestion.to_string(), "--timeout", - &quic.timeout.as_secs().to_string(), + &config.timeout.to_string(), ]); - if client.remote_debug { + if parameters.remote_debug { let _ = server.arg("--debug"); } - if let Some(w) = bandwidth.initial_congestion_window { - let _ = server.args(["--initial-congestion-window", &w.to_string()]); + match config.initial_congestion_window { + 0 => (), + w => { + let _ = server.args(["--initial-congestion-window", &w.to_string()]); + } } - if let Some(pr) = client.remote_port { - let _ = server.args(["--port", &pr.to_string()]); + if !config.remote_port.is_default() { + let _ = server.args(["--port", &config.remote_port.to_string()]); } let _ = server .stdin(Stdio::piped()) .stdout(Stdio::piped()) .kill_on_drop(true); - if !client.quiet { + if !parameters.quiet { let _ = server.stderr(Stdio::piped()); } // else inherit debug!("spawning command: {:?}", server); @@ -129,7 +132,7 @@ impl Channel { .context("Could not launch control connection to remote server")?; // Whatever the remote outputs, send it to our output in a way that doesn't mess things up. - if !client.quiet { + if !parameters.quiet { let stderr = process.stderr.take(); let Some(stderr) = stderr else { anyhow::bail!("could not get stderr of remote process"); diff --git a/src/client/job.rs b/src/client/job.rs index 089be1b..f2501f3 100644 --- a/src/client/job.rs +++ b/src/client/job.rs @@ -5,12 +5,12 @@ use std::str::FromStr; use crate::transport::ThroughputMode; -use super::args::Options; - /// A file source or destination specified by the user -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct FileSpec { - /// The remote host for the file. + /// The remote host for the file. This may be a hostname or an IP address. + /// It may also be a _hostname alias_ that matches a Host section in the user's ssh config file. + /// (In that case, the ssh config file must specify a HostName.) /// /// If not present, this is a local file. pub host: Option, @@ -54,7 +54,6 @@ impl FromStr for FileSpec { } /// Details of a file copy job. -/// (This is a helper struct for the contents of `CliArgs` .) #[derive(Debug, Clone)] pub struct CopyJobSpec { pub(crate) source: FileSpec, @@ -70,31 +69,21 @@ impl CopyJobSpec { ThroughputMode::Tx } } -} -impl TryFrom<&Options> for CopyJobSpec { - type Error = anyhow::Error; - - fn try_from(args: &Options) -> Result { - let source = args - .source - .as_ref() - .ok_or_else(|| anyhow::anyhow!("source and destination are required"))? - .clone(); - let destination = args - .destination + /// The `[user@]hostname` portion of whichever of the arguments contained a hostname. + fn remote_user_host(&self) -> &str { + self.source + .host .as_ref() - .ok_or_else(|| anyhow::anyhow!("source and destination are required"))? - .clone(); - - if !(source.host.is_none() ^ destination.host.is_none()) { - anyhow::bail!("One file argument must be remote"); - } + .unwrap_or_else(|| self.destination.host.as_ref().unwrap()) + } - Ok(Self { - source, - destination, - }) + /// The hostname portion of whichever of the arguments contained one. + pub(crate) fn remote_host(&self) -> &str { + let user_host = self.remote_user_host(); + // It might be user@host, or it might be just the hostname or IP. + let (_, host) = user_host.split_once('@').unwrap_or(("", user_host)); + host } } diff --git a/src/client/main_loop.rs b/src/client/main_loop.rs index ec3538a..e8cb3d0 100644 --- a/src/client/main_loop.rs +++ b/src/client/main_loop.rs @@ -1,14 +1,15 @@ -// qcp client event loop +//! Main client mode event loop // (c) 2024 Ross Younger -use crate::client::control::Channel; -use crate::client::progress::spinner_style; -use crate::protocol::session::Status; -use crate::protocol::session::{FileHeader, FileTrailer, Response}; -use crate::protocol::{RawStreamPair, StreamPair}; -use crate::transport::{BandwidthParams, QuicParams, ThroughputMode}; -use crate::util::{ - self, lookup_host_by_family, time::Stopwatch, time::StopwatchChain, Credentials, PortRange, +use crate::{ + client::{control::Channel, progress::spinner_style}, + config::Configuration, + protocol::{ + session::{FileHeader, FileTrailer, Response, Status}, + RawStreamPair, StreamPair, + }, + transport::ThroughputMode, + util::{self, lookup_host_by_family, time::Stopwatch, time::StopwatchChain, Credentials}, }; use anyhow::{Context, Result}; @@ -26,53 +27,64 @@ use tokio::time::Instant; use tokio::{self, io::AsyncReadExt, time::timeout, time::Duration}; use tracing::{debug, error, info, span, trace, trace_span, warn, Instrument as _, Level}; -use super::args::Options; use super::job::CopyJobSpec; +use super::Parameters as ClientParameters; +/// a shared definition string used in a couple of places const SHOW_TIME: &str = "file transfer"; /// Main client mode event loop // Caution: As we are using ProgressBar, anything to be printed to console should use progress.println() ! #[allow(clippy::module_name_repetitions)] pub async fn client_main( - options: Options, - bandwidth: BandwidthParams, - quic: QuicParams, + config: &Configuration, display: MultiProgress, + parameters: ClientParameters, ) -> anyhow::Result { // N.B. While we have a MultiProgress we do not set up any `ProgressBar` within it yet... // not until the control channel is in place, in case ssh wants to ask for a password or passphrase. let _guard = trace_span!("CLIENT").entered(); - let job_spec = CopyJobSpec::try_from(&options)?; let mut timers = StopwatchChain::new_running("setup"); + let spinner = if parameters.quiet { + ProgressBar::hidden() + } else { + display.add(ProgressBar::new_spinner().with_style(spinner_style()?)) + }; + spinner.enable_steady_tick(Duration::from_millis(150)); + // Prep -------------------------- + spinner.set_message("Preparing"); + let job_spec = crate::client::CopyJobSpec::try_from(¶meters)?; let credentials = Credentials::generate()?; - let server_address = lookup_host_by_family(options.remote_host()?, options.address_family())?; + let user_hostname = job_spec.remote_host(); + let remote_host = super::ssh::resolve_host_alias(user_hostname, &config.ssh_config) + .unwrap_or_else(|| user_hostname.into()); + + // If the user didn't specify the address family: we do the DNS lookup, figure it out and tell ssh to use that. + // (Otherwise if we resolved a v4 and ssh a v6 - as might happen with round-robin DNS - that could be surprising.) + let remote_address = lookup_host_by_family(&remote_host, config.address_family)?; // Control channel --------------- + spinner.set_message("Opening control channel"); + spinner.disable_steady_tick(); // otherwise the spinner messes with ssh passphrase prompting; as we're using tokio spinner.suspend() isn't helpful timers.next("control channel"); let (mut control, server_message) = Channel::transact( &credentials, - server_address, + &remote_host, + remote_address.into(), &display, - &options, - bandwidth, - quic, + config, + ¶meters, ) .await?; // Data channel ------------------ - let server_address_port = match server_address { + let server_address_port = match remote_address { std::net::IpAddr::V4(ip) => SocketAddrV4::new(ip, server_message.port).into(), std::net::IpAddr::V6(ip) => SocketAddrV6::new(ip, server_message.port, 0, 0).into(), }; - let spinner = if options.quiet { - ProgressBar::hidden() - } else { - display.add(ProgressBar::new_spinner().with_style(spinner_style()?)) - }; spinner.enable_steady_tick(Duration::from_millis(150)); spinner.set_message("Establishing data channel"); timers.next("data channel setup"); @@ -80,15 +92,14 @@ pub async fn client_main( &credentials, server_message.cert.into(), &server_address_port, - quic.port, - bandwidth, + config, job_spec.throughput_mode(), )?; debug!("Opening QUIC connection to {server_address_port:?}"); debug!("Local endpoint address is {:?}", endpoint.local_addr()?); let connection = timeout( - quic.timeout, + config.timeout_duration(), endpoint.connect(server_address_port, &server_message.name)?, ) .await @@ -102,8 +113,8 @@ pub async fn client_main( job_spec, display.clone(), spinner.clone(), - bandwidth, - options.quiet, + config, + parameters.quiet, ) .await; let total_bytes = match result { @@ -118,11 +129,11 @@ pub async fn client_main( let remote_stats = control.read_closedown_report().await?; let control_fut = control.close(); - let _ = timeout(quic.timeout, endpoint.wait_idle()) + let _ = timeout(config.timeout_duration(), endpoint.wait_idle()) .await .inspect_err(|_| warn!("QUIC shutdown timed out")); // otherwise ignore errors trace!("QUIC closed; waiting for control channel"); - let _ = timeout(quic.timeout, control_fut) + let _ = timeout(config.timeout_duration(), control_fut) .await .inspect_err(|_| warn!("control channel timed out")); // Ignore errors. If the control channel closedown times out, we expect its drop handler will do the Right Thing. @@ -130,19 +141,19 @@ pub async fn client_main( timers.stop(); // Post-transfer chatter ----------- - if !options.quiet { + if !parameters.quiet { let transport_time = timers.find(SHOW_TIME).and_then(Stopwatch::elapsed); crate::util::stats::process_statistics( &connection.stats(), total_bytes, transport_time, remote_stats, - bandwidth, - options.statistics, + config, + parameters.statistics, ); } - if options.profile { + if parameters.profile { info!("Elapsed time by phase:\n{timers}"); } display.clear()?; @@ -157,11 +168,12 @@ async fn manage_request( copy_spec: CopyJobSpec, display: MultiProgress, spinner: ProgressBar, - bandwidth: BandwidthParams, + config: &Configuration, quiet: bool, ) -> Result { let mut tasks = tokio::task::JoinSet::new(); let connection = connection.clone(); + let config = config.clone(); let _jh = tasks.spawn(async move { // This async block returns a Result let sp = connection.open_bi().map_err(|e| anyhow::anyhow!(e)).await?; @@ -169,12 +181,12 @@ async fn manage_request( // This async block reports on errors. if copy_spec.source.host.is_some() { // This is a Get - do_get(sp, ©_spec, display, spinner, bandwidth, quiet) + do_get(sp, ©_spec, display, spinner, &config, quiet) .instrument(trace_span!("GET", filename = copy_spec.source.filename)) .await } else { // This is a Put - do_put(sp, ©_spec, display, spinner, bandwidth, quiet) + do_put(sp, ©_spec, display, spinner, &config, quiet) .instrument(trace_span!("PUT", filename = copy_spec.source.filename)) .await } @@ -218,6 +230,7 @@ async fn manage_request( } } +/// Adds a progress bar to the stack (in `MultiProgress`) for the current job fn progress_bar_for( display: &MultiProgress, job: &CopyJobSpec, @@ -256,8 +269,7 @@ pub(crate) fn create_endpoint( credentials: &Credentials, server_cert: CertificateDer<'_>, server_addr: &SocketAddr, - port: Option, - bandwidth: BandwidthParams, + options: &Configuration, mode: ThroughputMode, ) -> Result { let _ = span!(Level::TRACE, "create_endpoint").entered(); @@ -271,16 +283,16 @@ pub(crate) fn create_endpoint( ); let mut config = quinn::ClientConfig::new(Arc::new(QuicClientConfig::try_from(tls_config)?)); - let _ = config.transport_config(crate::transport::create_config(bandwidth, mode)?); + let _ = config.transport_config(crate::transport::create_config(options, mode)?); - trace!("bind & configure socket, port={port:?}", port = port); - let mut socket = util::socket::bind_range_for_peer(server_addr, port)?; + trace!("bind & configure socket, port={:?}", options.port); + let mut socket = util::socket::bind_range_for_peer(server_addr, options.port)?; let wanted_send = match mode { - ThroughputMode::Both | ThroughputMode::Tx => Some(bandwidth.send_buffer().try_into()?), + ThroughputMode::Both | ThroughputMode::Tx => Some(Configuration::send_buffer().try_into()?), ThroughputMode::Rx => None, }; let wanted_recv = match mode { - ThroughputMode::Both | ThroughputMode::Rx => Some(bandwidth.recv_buffer().try_into()?), + ThroughputMode::Both | ThroughputMode::Rx => Some(Configuration::recv_buffer().try_into()?), ThroughputMode::Tx => None, }; @@ -296,12 +308,13 @@ pub(crate) fn create_endpoint( Ok(endpoint) } +/// Actions a GET command async fn do_get( sp: RawStreamPair, job: &CopyJobSpec, display: MultiProgress, spinner: ProgressBar, - bandwidth: BandwidthParams, + config: &Configuration, quiet: bool, ) -> Result { let filename = &job.source.filename; @@ -338,7 +351,7 @@ async fn do_get( .with_elapsed(Instant::now().duration_since(real_start)); let mut meter = - crate::client::meter::InstaMeterRunner::new(&progress_bar, spinner, bandwidth.rx()); + crate::client::meter::InstaMeterRunner::new(&progress_bar, spinner, config.rx()); meter.start().await; let inbound = progress_bar.wrap_async_read(stream.recv); @@ -361,12 +374,13 @@ async fn do_get( Ok(header.size) } +/// Actions a PUT command async fn do_put( sp: RawStreamPair, job: &CopyJobSpec, display: MultiProgress, spinner: ProgressBar, - bandwidth: BandwidthParams, + config: &Configuration, quiet: bool, ) -> Result { let mut stream: StreamPair = sp.into(); @@ -393,11 +407,11 @@ async fn do_put( let progress_bar = progress_bar_for(&display, job, steps, quiet)?; let mut outbound = progress_bar.wrap_async_write(stream.send); let mut meter = - crate::client::meter::InstaMeterRunner::new(&progress_bar, spinner, bandwidth.tx()); + crate::client::meter::InstaMeterRunner::new(&progress_bar, spinner, config.tx()); meter.start().await; trace!("sending command"); - let mut file = BufReader::with_capacity(bandwidth.send_buffer().try_into()?, file); + let mut file = BufReader::with_capacity(Configuration::send_buffer().try_into()?, file); outbound .write_all(&crate::protocol::session::Command::new_put(dest_filename).serialize()) diff --git a/src/client/meter.rs b/src/client/meter.rs index 2532c35..60092c2 100644 --- a/src/client/meter.rs +++ b/src/client/meter.rs @@ -1,4 +1,4 @@ -// Instant progress read-out +//! Instant progress read-out // (c) 2024 Ross Younger //! # Rationale diff --git a/src/client/mod.rs b/src/client/mod.rs index 577e3b9..9faf56c 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,7 +1,7 @@ //! client-side (_initiator_) main loop and supporting structures -mod args; -pub use args::Options; +mod options; +pub use options::Parameters; mod control; pub use control::Channel; @@ -13,6 +13,7 @@ pub use job::FileSpec; mod main_loop; mod meter; mod progress; +pub mod ssh; #[allow(clippy::module_name_repetitions)] pub use main_loop::client_main; diff --git a/src/client/options.rs b/src/client/options.rs new file mode 100644 index 0000000..2699c8a --- /dev/null +++ b/src/client/options.rs @@ -0,0 +1,127 @@ +//! Options specific to qcp client-mode +// (c) 2024 Ross Younger + +use super::{CopyJobSpec, FileSpec}; +use clap::Parser; + +#[derive(Debug, Parser, Clone, Default)] +#[allow(clippy::struct_excessive_bools)] +/// Client-side options which may be provided on the command line, but are not persistent configuration options. +pub struct Parameters { + /// Enable detailed debug output + /// + /// This has the same effect as setting `RUST_LOG=qcp=debug` in the environment. + /// If present, `RUST_LOG` overrides this option. + #[arg(short, long, action, help_heading("Debug"), display_order(0))] + pub debug: bool, + + /// Log to a file + /// + /// By default the log receives everything printed to stderr. + /// To override this behaviour, set the environment variable `RUST_LOG_FILE_DETAIL` (same semantics as `RUST_LOG`). + #[arg( + short('l'), + long, + action, + value_name("FILE"), + help_heading("Output"), + next_line_help(true), + display_order(0) + )] + pub log_file: Option, + + /// Quiet mode + /// + /// Switches off progress display and statistics; reports only errors + #[arg(short, long, action, conflicts_with("debug"), help_heading("Output"))] + pub quiet: bool, + + /// Show additional transfer statistics + #[arg( + short = 's', + long, + alias("stats"), + action, + conflicts_with("quiet"), + help_heading("Output"), + display_order(0) + )] + pub statistics: bool, + + /// Enables detailed debug output from the remote endpoint + /// (this may interfere with transfer speeds) + #[arg(long, action, help_heading("Debug"), display_order(0))] + pub remote_debug: bool, + + /// Output timing profile data after completion + #[arg(long, action, help_heading("Output"), display_order(0))] + pub profile: bool, + + // JOB SPECIFICAION ==================================================================== + // (POSITIONAL ARGUMENTS!) + /// The source file. This may be a local filename, or remote specified as HOST:FILE or USER@HOST:FILE. + #[arg( + required_unless_present_any(crate::cli::MODE_OPTIONS), + value_name = "SOURCE" + )] + pub source: Option, + + /// Destination. This may be a file or directory. It may be local or remote. + /// + /// If remote, specify as HOST:DESTINATION or USER@HOST:DESTINATION; or simply HOST: or USER@HOST: to copy to your home directory there. + #[arg( + required_unless_present_any(crate::cli::MODE_OPTIONS), + value_name = "DESTINATION" + )] + pub destination: Option, +} + +impl TryFrom<&Parameters> for CopyJobSpec { + type Error = anyhow::Error; + + fn try_from(args: &Parameters) -> Result { + let source = args + .source + .as_ref() + .ok_or_else(|| anyhow::anyhow!("source and destination are required"))? + .clone(); + let destination = args + .destination + .as_ref() + .ok_or_else(|| anyhow::anyhow!("source and destination are required"))? + .clone(); + + if !(source.host.is_none() ^ destination.host.is_none()) { + anyhow::bail!("One file argument must be remote"); + } + + Ok(Self { + source, + destination, + }) + } +} + +impl Parameters { + /// A best-effort attempt to extract a single remote host string from the parameters. + /// + /// # Output + /// If neither source nor dest are present, `Ok("")` + /// If at most one of source and dest contains a remote host, `Ok()` + /// + /// # Errors + /// If both source and dest contain a remote host, Err("Only one remote file argument is supported") + pub(crate) fn remote_host_lossy(&self) -> anyhow::Result> { + let src_host = self.source.as_ref().and_then(|fs| fs.host.as_ref()); + let dst_host = self.destination.as_ref().and_then(|fs| fs.host.as_ref()); + Ok(if let Some(src_host) = src_host { + if dst_host.is_some() { + anyhow::bail!("Only one remote file argument is supported"); + } + Some(src_host.to_string()) + } else { + // Destination without source would be an exotic situation, but do our best anyway: + dst_host.map(std::string::ToString::to_string) + }) + } +} diff --git a/src/client/progress.rs b/src/client/progress.rs index aa64aa3..56104e8 100644 --- a/src/client/progress.rs +++ b/src/client/progress.rs @@ -7,33 +7,43 @@ pub const MAX_UPDATE_FPS: u8 = 20; use console::Term; use indicatif::ProgressStyle; +/// A single-line style format for Indicatif which should cover most situations. +/// +/// ```text +/// 11111111111111111111111111111111111111111111111111111111111111111111111111111111 +/// filename [========================== ] 2m30s @ 123.4MB/s [70%/1.24GB] +/// fairly-long-filename [==================== ] 2m30s @ 123.4MB/s [70%/1.24GB] +/// extremely-long-filename-no-really-very-long [== ] 2m30s @ 123.4MB/s [70%/1.24GB] +/// 11111111111111111111111111111111111111111111111111111111111111111111111111111111 +/// const PROGRESS_STYLE_COMPACT: &str = "{msg:.dim} {wide_bar:.cyan} {eta} @ {decimal_bytes_per_sec} [{decimal_total_bytes:.dim}]"; -// 11111111111111111111111111111111111111111111111111111111111111111111111111111111 -// filename [========================== ] 2m30s @ 123.4MB/s [70%/1.24GB] -// fairly-long-filename [==================== ] 2m30s @ 123.4MB/s [70%/1.24GB] -// extremely-long-filename-no-really-very-long [== ] 2m30s @ 123.4MB/s [70%/1.24GB] -// 11111111111111111111111111111111111111111111111111111111111111111111111111111111 - -// We need about 35 characters for the data readout. -// A useful progress bar needs maybe 20 characters. -// This informs how much space we can allow for the filename. +/// Space to allow for the filename +/// +/// We need about 35 characters for the data readout. +/// A useful progress bar needs maybe 20 characters. +/// This informs how much space we can allow for the filename. const DATA_AND_PROGRESS: usize = 55; -// 11111111111111111111111111111111111111111111111111111111111111111111111111111111 -// extremely-long-filename-no-really-very-long [70%/1.24GB] -// [========================== ] 2m30s @ 123.4MB/s -// 11111111111111111111111111111111111111111111111111111111111111111111111111111111 - +/// A double-line style format for Indicatif for use when the filename is too long. +/// +/// ```text +/// 11111111111111111111111111111111111111111111111111111111111111111111111111111111 +/// extremely-long-filename-no-really-very-long [70%/1.24GB] +/// [========================== ] 2m30s @ 123.4MB/s +/// 11111111111111111111111111111111111111111111111111111111111111111111111111111111 +/// ``` const PROGRESS_STYLE_OVERLONG: &str = "{wide_msg:.dim} [{decimal_total_bytes:.dim}]\n{wide_bar:.cyan} {eta} @ {decimal_bytes_per_sec}"; +/// Determine the appropriate progress style to use fn use_long_style(terminal: &Term, msg_size: usize) -> bool { let term_width = terminal.size().1 as usize; // this returns a reasonable default if it can't detect msg_size + DATA_AND_PROGRESS > term_width } +/// Determine and retrieve the appropriate progress style to use pub(crate) fn progress_style_for(terminal: &Term, msg_size: usize) -> &str { if use_long_style(terminal, msg_size) { PROGRESS_STYLE_OVERLONG @@ -42,8 +52,10 @@ pub(crate) fn progress_style_for(terminal: &Term, msg_size: usize) -> &str { } } +/// Indicatif template for spinner lines pub(crate) const SPINNER_TEMPLATE: &str = "{spinner} {wide_msg} {prefix}"; +/// Indicatif template for spinner lines pub(crate) fn spinner_style() -> anyhow::Result { Ok(ProgressStyle::with_template(SPINNER_TEMPLATE)?) } diff --git a/src/client/ssh.rs b/src/client/ssh.rs new file mode 100644 index 0000000..04eab63 --- /dev/null +++ b/src/client/ssh.rs @@ -0,0 +1,174 @@ +//! Interaction with ssh configuration +// (c) 2024 Ross Younger + +use std::{path::PathBuf, str::FromStr}; + +use crate::config::ssh::Parser; +use anyhow::{Context, Result}; +use tracing::{debug, warn}; + +use crate::os::{AbstractPlatform as _, Platform}; + +/// Metadata representing a QCP config file +struct ConfigFile { + /// The file to read + path: PathBuf, + /// if set, this is a user file i.e. ~ expansion is allowed + user: bool, + /// if set, warns on various failures and attempts to keep going + warn_on_error: bool, +} + +impl ConfigFile { + fn for_path(path: PathBuf, user: bool) -> Self { + Self { + path, + user, + warn_on_error: false, + } + } + fn for_str(path: &str, user: bool, warn_on_error: bool) -> Result { + Ok(Self { + path: PathBuf::from_str(path)?, + user, + warn_on_error, + }) + } + + /// Attempts to resolve a hostname from a single OpenSSH-style config file + fn resolve_one(&self, host: &str) -> Option { + let path = &self.path; + if !std::fs::exists(path).is_ok_and(|b| b) { + // file could not be verified to exist. + // This is not intrinsically an error; the user or system file might legitimately not be there. + // But if this was a file explicitly specified by the user, assume they do care and let them know. + if self.warn_on_error { + warn!("ssh-config file {path:?} not found"); + } + return None; + } + let parser = match Parser::for_path(path, self.user) { + Ok(p) => p, + Err(e) => { + // file permissions issue? + warn!("failed to open {path:?}: {e}"); + return None; + } + }; + let data = match parser + .parse_file_for(Some(host)) + .with_context(|| format!("reading configuration file {path:?}")) + { + Ok(data) => data, + Err(e) => { + warn!("{e}"); + return None; + } + }; + if let Some(s) = data.get("hostname") { + let result = s.first_arg(); + debug!("Using hostname '{result}' for '{host}' (from {})", s.source); + Some(result) + } else { + None + } + } +} + +/// Attempts to resolve hostname aliasing from ssh config files. +/// +/// ## Arguments +/// * host: the host name alias to look up (matching a 'Host' block in ssh_config) +/// * config_files: The list of ssh config files to use, in priority order. +/// +/// If the list is empty, the user's and system's ssh config files will be used. +/// +/// ## Returns +/// Some(hostname) if any config file matched. +/// None if no config files matched. +/// +/// ## ssh_config features not currently supported +/// * Match patterns +/// * CanonicalizeHostname and friends +#[must_use] +pub fn resolve_host_alias(host: &str, config_files: &[String]) -> Option { + let files = if config_files.is_empty() { + let mut v = Vec::new(); + if let Ok(f) = Platform::user_ssh_config() { + v.push(ConfigFile::for_path(f, true)); + } + if let Ok(f) = ConfigFile::for_str(Platform::system_ssh_config(), false, false) { + v.push(f); + } + v + } else { + config_files + .iter() + .flat_map(|s| ConfigFile::for_str(s, true, true)) + .collect() + }; + for cfg in files { + let result = cfg.resolve_one(host); + if result.is_some() { + return result; + } + } + None +} + +#[cfg(test)] +mod test { + use std::path::Path; + + use super::ConfigFile; + use crate::util::make_test_tempfile; + + fn resolve_one(path: &Path, user: bool, host: &str) -> Option { + ConfigFile::for_path(path.to_path_buf(), user).resolve_one(host) + } + + #[test] + fn hosts_resolve() { + let (path, _dir) = make_test_tempfile( + r" + Host aaa + HostName zzz + Host bbb ccc.ddd + HostName yyy + ", + "test_ssh_config", + ); + assert!(resolve_one(&path, false, "nope").is_none()); + assert_eq!(resolve_one(&path, false, "aaa").unwrap(), "zzz"); + assert_eq!(resolve_one(&path, false, "bbb").unwrap(), "yyy"); + assert_eq!(resolve_one(&path, false, "ccc.ddd").unwrap(), "yyy"); + } + + #[test] + fn wildcards_match() { + let (path, _dir) = make_test_tempfile( + r" + Host *.bar + HostName baz + Host 10.11.*.13 + # this is a silly example but it shows that wildcards match by IP + HostName wibble + Host fr?d + hostname barney + ", + "test_ssh_config", + ); + assert_eq!(resolve_one(&path, false, "foo.bar").unwrap(), "baz"); + assert_eq!(resolve_one(&path, false, "qux.qix.bar").unwrap(), "baz"); + assert!(resolve_one(&path, false, "qux.qix").is_none()); + assert_eq!(resolve_one(&path, false, "10.11.12.13").unwrap(), "wibble"); + assert_eq!(resolve_one(&path, false, "10.11.0.13").unwrap(), "wibble"); + assert_eq!(resolve_one(&path, false, "10.11.256.13").unwrap(), "wibble"); // yes I know this isn't a real IP address + assert!(resolve_one(&path, false, "10.11.0.130").is_none()); + + assert_eq!(resolve_one(&path, false, "fred").unwrap(), "barney"); + assert_eq!(resolve_one(&path, false, "frid").unwrap(), "barney"); + assert!(resolve_one(&path, false, "freed").is_none()); + assert!(resolve_one(&path, false, "fredd").is_none()); + } +} diff --git a/src/config/manager.rs b/src/config/manager.rs new file mode 100644 index 0000000..2423466 --- /dev/null +++ b/src/config/manager.rs @@ -0,0 +1,560 @@ +//! Configuration file wrangling +// (c) 2024 Ross Younger + +use crate::os::{AbstractPlatform as _, Platform}; + +use super::{ssh::SshConfigError, Configuration}; + +use figment::{providers::Serialized, value::Value, Figment, Metadata, Provider}; +use heck::ToUpperCamelCase; +use serde::Deserialize; +use std::{ + collections::HashSet, + fmt::{Debug, Display}, + path::{Path, PathBuf}, +}; +use struct_field_names_as_array::FieldNamesAsSlice; +use tabled::{ + settings::{object::Rows, style::Style, Color}, + Table, Tabled, +}; + +use tracing::{debug, warn}; + +// SYSTEM DEFAULTS ////////////////////////////////////////////////////////////////////////////////////////////// + +/// A [`figment::Provider`](https://docs.rs/figment/latest/figment/trait.Provider.html) that holds +/// the set of system default options +#[derive(Default)] +struct SystemDefault {} + +impl SystemDefault { + const META_NAME: &str = "default"; +} + +impl Provider for SystemDefault { + fn metadata(&self) -> Metadata { + figment::Metadata::named(Self::META_NAME) + } + + fn data( + &self, + ) -> std::result::Result< + figment::value::Map, + figment::Error, + > { + Serialized::defaults(Configuration::default()).data() + } +} + +// CONFIG MANAGER ///////////////////////////////////////////////////////////////////////////////////////////// + +/// Processes and merges all possible configuration sources. +/// +/// Configuration file locations are platform-dependent. +/// To see what applies on the current platform, run `qcp --config-files`. +#[derive(Debug)] +pub struct Manager { + /// Configuration data + data: Figment, + /// The host argument this data was read for, if applicable + host: Option, +} + +impl Default for Manager { + /// Initialises this structure fully-empty (for new(), or testing) + fn default() -> Self { + Self { + data: Figment::default(), + host: None, + } + } +} + +impl Manager { + /// Initialises this structure, reading the set of config files appropriate to the platform + /// and the current user. + #[must_use] + pub fn standard(for_host: Option<&str>) -> Self { + let mut new1 = Self { + data: Figment::new(), + host: for_host.map(std::borrow::ToOwned::to_owned), + }; + new1.merge_provider(SystemDefault::default()); + // N.B. This may leave data in a fused-error state, if a config file isn't parseable. + new1.add_config(false, "system", Platform::system_config_path(), for_host); + new1.add_config(true, "user", Platform::user_config_path(), for_host); + new1 + } + + /// Accessor (only used in tests at the moment) + #[cfg(test)] + fn host(&self) -> Option { + self.host.clone() + } + + fn add_config( + &mut self, + is_user: bool, + what: &str, + path: Option, + for_host: Option<&str>, + ) { + let Some(path) = path else { + warn!("could not determine {what} configuration file path"); + return; + }; + if !path.exists() { + debug!("{what} configuration file {path:?} not present"); + return; + } + self.merge_ssh_config(path, for_host, is_user); + } + + /// Returns the list of configuration files we read. + /// + /// This is a function of platform and the current user id. + #[must_use] + pub fn config_files() -> Vec { + let inputs = vec![Platform::system_config_path(), Platform::user_config_path()]; + + inputs + .into_iter() + .filter_map(|p| Some(p?.into_os_string().to_string_lossy().to_string())) + .collect() + } + + /// Testing/internal constructor, does not read files from system + #[must_use] + #[cfg(test)] + pub(crate) fn without_files(host: Option<&str>) -> Self { + let data = Figment::new().merge(SystemDefault::default()); + let host = host.map(std::string::ToString::to_string); + Self { data, host } + } + + /// Merges in a data set, which is some sort of [figment::Provider](https://docs.rs/figment/latest/figment/trait.Provider.html). + /// + /// Within qcp, we use [crate::util::derive_deftly_template_Optionalify] to implement Provider for [Configuration]. + pub fn merge_provider(&mut self, provider: T) + where + T: Provider, + { + let f = std::mem::take(&mut self.data); + self.data = f.merge(provider); // in the error case, this leaves the provider in a fused state + } + + /// Merges in a data set from an ssh config file. + /// + /// The caller is expected to specify the destination host. + /// This simplifies parsing dramatically, as it means we can apply host wildcard matching immediately. + pub fn merge_ssh_config(&mut self, file: F, host: Option<&str>, is_user: bool) + where + F: AsRef, + { + let path = file.as_ref(); + let p = super::ssh::Parser::for_path(file.as_ref(), is_user) + .and_then(|p| p.parse_file_for(host)) + .map(|hc| self.merge_provider(hc.as_figment())); + if let Err(e) = p { + warn!("parsing {ff}: {e}", ff = path.to_string_lossy()); + } + } + + /// Attempts to extract a particular struct from the data. + /// + /// Within qcp, `T` is usually [Configuration], but it isn't intrinsically required to be. + /// (This is useful for unit testing.) + pub(crate) fn get<'de, T>(&self) -> anyhow::Result + where + T: Deserialize<'de>, + { + let profile = if let Some(host) = &self.host { + figment::Profile::new(host) + } else { + figment::Profile::Default + }; + + self.data + .clone() + .select(profile) + .extract_lossy::() + .map_err(SshConfigError::from) + } +} + +// PRETTY PRINT SUPPORT /////////////////////////////////////////////////////////////////////////////////////// + +/// Data type used when rendering the config table +#[derive(Tabled)] +struct PrettyConfig { + field: String, + value: String, + source: String, +} + +impl PrettyConfig { + fn render_source(meta: Option<&Metadata>) -> String { + if let Some(m) = meta { + m.source + .as_ref() + .map_or_else(|| m.name.to_string(), figment::Source::to_string) + } else { + String::new() + } + } + + fn render_value(value: &Value) -> String { + match value { + Value::String(_tag, s) => s.to_string(), + Value::Char(_tag, c) => c.to_string(), + Value::Bool(_tag, b) => b.to_string(), + Value::Num(_tag, num) => { + if let Some(i) = num.to_i128() { + i.to_string() + } else if let Some(u) = num.to_u128() { + u.to_string() + } else if let Some(ff) = num.to_f64() { + ff.to_string() + } else { + todo!("unhandled Num case"); + } + } + Value::Empty(_tag, _) => "".into(), + // we don't currently support dict types + Value::Dict(_tag, _dict) => todo!(), + Value::Array(_tag, vec) => { + format!( + "[{}]", + vec.iter() + .map(PrettyConfig::render_value) + .collect::>() + .join(",") + ) + } + } + } + + fn new>(field: F, value: &Value, meta: Option<&Metadata>) -> Self { + Self { + field: field.into(), + value: PrettyConfig::render_value(value), + source: PrettyConfig::render_source(meta), + } + } +} + +/// Pretty-printing type wrapper to Manager +#[derive(Debug)] +pub struct DisplayAdapter<'a> { + /// Data source + source: &'a Manager, + /// The fields we want to output. (If empty, outputs everything.) + fields: HashSet, +} + +impl Manager { + /// Creates a `DisplayAdapter` for this struct with the given options. + /// + /// # Returns + /// An ephemeral structure implementing `Display`. + #[must_use] + pub fn to_display_adapter<'de, T>(&self) -> DisplayAdapter<'_> + where + T: Deserialize<'de> + FieldNamesAsSlice, + { + let mut fields = HashSet::::new(); + fields.extend(T::FIELD_NAMES_AS_SLICE.iter().map(|s| String::from(*s))); + DisplayAdapter { + source: self, + fields, + } + } +} + +impl Display for DisplayAdapter<'_> { + /// Formats the contents of this structure which are relevant to a given output type. + /// + /// N.B. This function uses CLI styling. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut data = self.source.data.clone(); + + let mut output = Vec::::new(); + // First line of the table is special + let (host_string, host_colour) = if let Some(host) = &self.source.host { + let profile = figment::Profile::new(host); + data = data.select(profile); + (host.clone(), Color::FG_GREEN) + } else { + ("* (globals)".into(), Color::FG_CYAN) + }; + output.push(PrettyConfig { + field: "(Remote host)".into(), + value: host_string, + source: String::new(), + }); + + let mut keys = self.fields.iter().collect::>(); + keys.sort(); + + for field in keys { + if let Ok(value) = data.find_value(field) { + let meta = data.get_metadata(value.tag()); + output.push(PrettyConfig::new(field.to_upper_camel_case(), &value, meta)); + } + } + write!( + f, + "{}", + Table::new(output) + .modify(Rows::single(1), host_colour) + .with(Style::sharp()) + ) + } +} + +#[cfg(test)] +mod test { + use crate::config::ssh::SshConfigError; + use crate::config::{Configuration, Configuration_Optional, Manager}; + use crate::util::{make_test_tempfile, PortRange}; + use serde::Deserialize; + + #[test] + fn defaults() { + let mgr = Manager::without_files(None); + let result = mgr.get().unwrap(); + let expected = Configuration::default(); + assert_eq!(expected, result); + } + + #[test] + fn config_merge() { + // simulate a CLI + let entered = Configuration_Optional { + rx: Some(12345.into()), + ..Default::default() + }; + let expected = Configuration { + rx: 12345.into(), + ..Default::default() + }; + + let mut mgr = Manager::without_files(None); + mgr.merge_provider(entered); + let result = mgr.get().unwrap(); + assert_eq!(expected, result); + } + + #[test] + fn type_error() { + // This is a semi unit test; this has a secondary goal of outputting something sensible + + #[derive(Deserialize)] + struct Test { + magic: i32, + } + + let (path, _tempdir) = make_test_tempfile( + r" + rx true # invalid + rtt 3.14159 # also invalid + magic 42 + ", + "test.conf", + ); + let mut mgr = Manager::without_files(None); + mgr.merge_ssh_config(path, None, false); + // This file successfully merges into the config, but you can't extract the struct. + let err = mgr.get::().unwrap_err(); + println!("Error: {err}"); + + // But the config as a whole is not broken and other things can be extracted: + let other_struct = mgr.get::().unwrap(); + assert_eq!(other_struct.magic, 42); + } + + #[test] + fn field_parse_failure() { + #[derive(Debug, Deserialize)] + #[allow(dead_code)] + struct Test { + p: PortRange, + } + + let (path, _tempdir) = make_test_tempfile( + r" + p 234-123 + ", + "test.conf", + ); + let mut mgr = Manager::without_files(None); + mgr.merge_ssh_config(path, None, true); + let result = mgr.get::().unwrap_err(); + println!("{result}"); + assert!(result.to_string().contains("must be increasing")); + } + + #[test] + fn ssh_style() { + #[derive(Debug, Deserialize)] + struct Test { + ssh_options: Vec, + } + // Bear in mind: in an ssh style config file, the first match for a particular keyword wins. + let (path, _tempdir) = make_test_tempfile( + r" + host bar + ssh_options d e f + host * + ssh_options a b c + ", + "test.conf", + ); + let mut mgr = Manager::without_files(Some("foo")); + mgr.merge_ssh_config(&path, Some("foo"), false); + //println!("{}", mgr.to_display_adapter::(false)); + let result = mgr.get::().unwrap(); + assert_eq!(result.ssh_options, vec!["a", "b", "c"]); + + let mut mgr = Manager::without_files(Some("bar")); + mgr.merge_ssh_config(&path, Some("bar"), false); + let result = mgr.get::().unwrap(); + assert_eq!(result.ssh_options, vec!["d", "e", "f"]); + } + + #[test] + fn types() { + use crate::transport::CongestionControllerType; + + #[derive(Debug, Deserialize, PartialEq)] + struct Test { + vecs: Vec, + s: String, + i: u32, + b: bool, + en: CongestionControllerType, + pr: PortRange, + } + + let (path, _tempdir) = make_test_tempfile( + r" + vecs a b c + s foo + i 42 + b true + en bbr + pr 123-456 + ", + "test.conf", + ); + let mut mgr = Manager::without_files(Some("foo")); + mgr.merge_ssh_config(&path, Some("foo"), false); + // println!("{mgr}"); + let result = mgr.get::().unwrap(); + assert_eq!( + result, + Test { + vecs: vec!["a".into(), "b".into(), "c".into()], + s: "foo".into(), + i: 42, + b: true, + en: CongestionControllerType::Bbr, + pr: PortRange { + begin: 123, + end: 456 + } + } + ); + } + + #[test] + fn bools() { + #[derive(Debug, Deserialize)] + struct Test { + b: bool, + } + + let tempdir = tempfile::tempdir().unwrap(); + let path = tempdir.path().join("testfile"); + + for (s, expected) in [ + ("yes", true), + ("true", true), + ("1", true), + ("no", false), + ("false", false), + ("0", false), + ] { + std::fs::write( + &path, + format!( + r" + b {s} + " + ), + ) + .expect("Unable to write tempfile"); + // ... test it + let mut mgr = Manager::without_files(Some("foo")); + mgr.merge_ssh_config(&path, Some("foo"), false); + let result = mgr + .get::() + .inspect_err(|e| println!("ERROR: {e}")) + .unwrap(); + assert_eq!(result.b, expected); + } + } + + #[test] + fn invalid_data() { + use crate::transport::CongestionControllerType; + + #[derive(Debug, Deserialize, PartialEq)] + struct Test { + b: bool, + en: CongestionControllerType, + i: u32, + pr: PortRange, + } + + let (path, _tempdir) = make_test_tempfile( + r" + i wombat + b wombat + en wombat + pr wombat + ", + "test.conf", + ); + let mut mgr = Manager::default(); + mgr.merge_ssh_config(&path, Some("foo"), false); + //println!("{mgr:?}"); + let err = mgr.get::().map_err(SshConfigError::from).unwrap_err(); + println!("{err}"); + } + + #[test] + fn cli_beats_config_file() { + // simulate a CLI + let entered = Configuration_Optional { + rx: Some(12345.into()), + ..Default::default() + }; + let (path, _tempdir) = make_test_tempfile( + r" + Host foo + rx 66666 + ", + "test.conf", + ); + + let mut mgr = Manager::without_files(Some("foo")); + mgr.merge_ssh_config(&path, Some("foo"), false); + // The order of merging mirrors what happens in Manager::try_from(&CliArgs) + mgr.merge_provider(entered); + assert_eq!(mgr.host(), Some("foo".to_string())); + let result = mgr.get::().unwrap(); + assert_eq!(12345, *result.rx); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..22633c3 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,117 @@ +// (c) 2024 Ross Younger +//! # 📖 Configuration management +//! +//! qcp obtains run-time configuration from the following sources, in order: +//! 1. Command-line options +//! 2. The user's configuration file (typically `~/.qcp.conf`) +//! 3. The system-wide configuration file (typically `/etc/qcp.conf`) +//! 4. Hard-wired defaults +//! +//! Each option may appear in multiple places, but only the first match is used. +//! +//! **Note** Configuration file locations are platform-dependent. +//! To see what applies on the current platform, run `qcp --config-files`. +//! +//! ## File format +//! +//! qcp uses the same basic format as OpenSSH configuration files. +//! +//! Each line contains a keyword and one or more arguments. +//! Keywords are single words and are case-insensitive. +//! +//! Arguments are separated from keywords, and each other, by whitespace. +//! (It is also possible to write `Key=Value` or `Key = Value`.) +//! +//! Arguments may be surrounded by double quotes (`"`); this allows you to set an argument containing spaces. +//! If a backslash, double or single quote forms part of an argument it must be backslash-escaped i.e. `\"` or `\\`. +//! +//! Empty lines are ignored. +//! +//! **qcp supports Host and Include directives in way that is intended to be compatible with OpenSSH.** +//! This allows you to tune your configuration for a range of network hosts. +//! +//! #### Host +//! +//! `Host host [host2 host3...]` +//! +//! This directive introduces a _host block_. +//! All following options - up to the next `Host` - only apply to hosts matching any of the patterns given. +//! +//! * Pattern matching uses `*` and `?` as wildcards in the usual way. +//! * A single asterisk `*` matches all hosts; this is used to provide defaults. +//! * A pattern beginning with `!` is a _negative_ match; it matches all remote hosts _except_ those matching the rest of the pattern. +//! * Pattern matching is applied directly to the remote host given on the QCP command line, before DNS or alias resolution. +//! If you connect to hosts by IP address, a pattern of `10.11.12.*` works in the obvious way. +//! +//! #### Include +//! +//! `Include file [file2 file3...]` +//! +//! Include the specified file(s) in the configuration at the current point. +//! +//! * Glob wildcards ('*' and '?') are supported in filenames. +//! * User configuration files may refer to pathnames relative to '~' (the user's home directory). +//! * Filenames with relative paths are assumed to be in `~/.ssh/` if read from a user configuration file, or `/etc/ssh/` if read from a system configuration file. +//! * An Include directive inside a Host block retains the Host context. +//! This may be useful to apply common directives to multiple hosts with minimal repetition. +//! Note that if an included file begins a new Host block, that will continue to apply on return to the including file. +//! * It is possible for included files to themselves include additional files; there is a brake that prevents infinite recursion. +//! +//! ## Configurable options +//! +//! The set of supported fields is defined by [Configuration]. +//! +//! In configuration files, option keywords are case insensitive and ignore hyphens and underscores. +//! (On the command line, they must be specified in kebab-case.) +//! +//! * `qcp --show-config` outputs a list of supported fields, their current values, and where each value came from. +//! * For an explanation of each field, refer to `qcp --help` . +//! * `qcp --config-files` outputs the list of configuration files for the current user and platform. +//! +//! ## Example +//! +//! ```text +//! Host old-faithful +//! # This is an old server with a very limited CPU which we do not want to overstress +//! rx 125k +//! tx 0 +//! RemotePort 65400-65500 # allowed in firewall config +//! +//! Host *.internal.corp +//! # This is a nearby data centre which we have a dedicated 1Gbit connection to. +//! # We don't need to use qcp, but it's convenient to use one tool in our scripts. +//! rx 125M +//! tx 0 +//! rtt 10 +//! +//! # For all other hosts, try to maximise our VDSL +//! Host * +//! rx 5M # we have 40Mbit download +//! tx 1000000 # we have 8Mbit upload; we could also have written this as "1M" +//! rtt 150 # most servers we care about are an ocean away +//! congestion bbr # this works well for us +//! ``` +//! +//! ## Tips and traps +//! 1. Like OpenSSH, for each setting we use the value from the _first_ Host block we find that matches the remote hostname. +//! 1. Each setting is evaluated independently. +//! In the example above, the `Host old-faithful` block sets an `rx` but does not set `rtt`. +//! Any operations to `old-faithful` therefore inherit `rtt 150` from the `Host *` block. +//! 1. The `tx` setting has a default value of 0, which means "use the active rx value". If you set `tx` in a `Host *` block, you probably want to set it explicitly everywhere you set `rx`. +//! +//! If you have a complicated config file we recommend you structure it as follows: +//! * Any global settings that are intended to apply to all hosts +//! * `Host` blocks; if you use wildcards, from most-specific to least-specific +//! * A `Host *` block to provide default settings to apply where no more specific value has been given +//! + +mod structure; +pub use structure::Configuration; +pub(crate) use structure::Configuration_Optional; + +mod manager; +pub use manager::Manager; + +pub(crate) const BASE_CONFIG_FILENAME: &str = "qcp.conf"; + +pub(crate) mod ssh; diff --git a/src/config/ssh.rs b/src/config/ssh.rs new file mode 100644 index 0000000..d6013b7 --- /dev/null +++ b/src/config/ssh.rs @@ -0,0 +1,19 @@ +//! Config file parsing, openssh-style +// (c) 2024 Ross Younger + +mod errors; +pub(crate) use errors::SshConfigError; + +mod files; +mod includes; +mod lines; +mod matching; +mod values; + +pub(crate) use files::Parser; +pub(crate) use values::Setting; + +use includes::find_include_files; +use lines::{split_args, Line}; +use matching::evaluate_host_match; +use values::ValueProvider; diff --git a/src/config/ssh/errors.rs b/src/config/ssh/errors.rs new file mode 100644 index 0000000..4e365fc --- /dev/null +++ b/src/config/ssh/errors.rs @@ -0,0 +1,81 @@ +//! Error output helpers +// (c) 2024 Ross Younger + +use figment::error::{Kind, OneOf}; + +/// A newtype wrapper implementing `Display` for errors originating from this module +#[derive(Debug)] +pub(crate) struct SshConfigError(figment::Error); +impl From for SshConfigError { + fn from(value: figment::Error) -> Self { + Self(value) + } +} + +impl SshConfigError { + fn rewrite_expected_type(s: &str) -> String { + match s { + "a boolean" => format!( + "a boolean ({})", + OneOf(&["yes", "no", "true", "false", "1", "0"]) + ), + _ => s.to_owned(), + } + } + + fn fmt_kind(kind: &Kind, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match kind { + Kind::InvalidType(v, exp) => write!( + f, + "invalid type: found {v}, expected {exp}", + exp = Self::rewrite_expected_type(exp) + ), + Kind::UnknownVariant(v, exp) => { + write!(f, "unknown variant: found {v}, expected {}", OneOf(exp)) + } + _ => std::fmt::Display::fmt(&kind, f), + } + } +} + +impl std::fmt::Display for SshConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let e = &self.0; + Self::fmt_kind(&e.kind, f)?; + + if let (Some(profile), Some(md)) = (&e.profile, &e.metadata) { + if !e.path.is_empty() { + let key = md.interpolate(profile, &e.path); + write!(f, " for {key}")?; + } + } + + if let Some(md) = &e.metadata { + if let Some(source) = &md.source { + write!(f, " at {source}")?; + } else { + write!(f, " in {}", md.name)?; + } + } + Ok(()) + } +} + +/// An iterator over all errors in an [`SshConfigError`] +pub(crate) struct IntoIter(::IntoIter); +impl Iterator for IntoIter { + type Item = SshConfigError; + + fn next(&mut self) -> Option { + self.0.next().map(std::convert::Into::into) + } +} + +impl IntoIterator for SshConfigError { + type Item = SshConfigError; + type IntoIter = IntoIter; + + fn into_iter(self) -> Self::IntoIter { + IntoIter(self.0.into_iter()) + } +} diff --git a/src/config/ssh/files.rs b/src/config/ssh/files.rs new file mode 100644 index 0000000..a1ea0bc --- /dev/null +++ b/src/config/ssh/files.rs @@ -0,0 +1,510 @@ +//! File parsing internals +// (c) 2024 Ross Younger + +use std::{ + collections::BTreeMap, + fs::File, + io::{BufRead, BufReader, Read}, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use figment::Figment; +use lazy_static::lazy_static; +use struct_field_names_as_array::FieldNamesAsSlice as _; +use tracing::warn; + +use super::{evaluate_host_match, find_include_files, split_args, Line, Setting, ValueProvider}; + +/// The result of parsing an ssh-style configuration file, with a particular host in mind. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct HostConfiguration { + /// The host we were interested in. If None, this is "unspecified", i.e. we return data in `Host *` sections or in an unqualified section at the top of the file. + host: Option, + /// If present, this is the file we read + source: Option, + /// Output data. Field names have been canonicalised (see [`CanonicalIntermediate`]), + /// then mapped back to fields in [`super::super::Configuration`] if they match. + data: BTreeMap, +} + +/// Creates a reverse mapping of intermediate-canonical keywords to field names for a struct. +fn create_field_name_map(fields: &'_ [&'_ str]) -> BTreeMap { + BTreeMap::::from_iter( + fields + .iter() + .map(|s| (CanonicalIntermediate::from(*s), (*s).to_string())) + .collect::>(), + ) +} + +lazy_static! { + static ref CONFIGURATION_FIELDS_MAP: BTreeMap = + create_field_name_map(crate::config::Configuration::FIELD_NAMES_AS_SLICE); +} + +impl HostConfiguration { + fn new(host: Option<&str>, source: Option) -> Self { + Self { + host: host.map(std::borrow::ToOwned::to_owned), + source, + data: BTreeMap::default(), + } + } + pub(crate) fn get(&self, key: &str) -> Option<&Setting> { + self.data.get(key) + } + + pub(crate) fn as_figment(&self) -> Figment { + let mut figment = Figment::new(); + let profile = self + .host + .as_deref() + .map_or(figment::Profile::Default, figment::Profile::new); + for (k, v) in &self.data { + figment = figment.merge(ValueProvider::new(k, v, &profile)); + } + figment + } +} + +/////////////////////////////////////////////////////////////////////////////////////// + +/// A keyword in an _intermediate canonical format_. +/// This format is lowercase and contains no underscores or hyphens. +/// +/// To convert from this format to snake case requires a lookup. +/// See [`CanonicalIntermediate::to_configuration_field`]. +#[derive(PartialOrd, Ord, PartialEq, Eq, Debug, Clone)] +struct CanonicalIntermediate(String); + +impl CanonicalIntermediate { + /// Attempt to reverse-map the canonicalised field to one from Configuration. + /// If the field is not known, return it unchanged. + fn to_configuration_field(&self) -> String { + CONFIGURATION_FIELDS_MAP + .get(self) + .unwrap_or(&self.0) + .clone() + } +} + +impl From<&str> for CanonicalIntermediate { + /// Converts a keyword into the inner canonical form defined by this module. + fn from(input: &str) -> Self { + Self( + input + .chars() + .map(|ch| ch.to_ascii_lowercase()) + .filter(|ch| *ch != '_' && *ch != '-') + .collect(), + ) + } +} + +/////////////////////////////////////////////////////////////////////////////////////// + +/// The business end of reading a config file. +/// +/// # Note +/// You can only use this struct once. If for some reason you want to re-parse a file, +/// you must create a fresh `Parser` to do so. +pub(crate) struct Parser +where + R: Read, +{ + line_number: usize, + reader: BufReader, + source: String, + path: Option, + is_user: bool, +} + +impl Parser { + pub(crate) fn for_path

(path: P, is_user: bool) -> Result + where + P: AsRef, + { + let path = path.as_ref(); + let file = File::open(path)?; + let reader = BufReader::new(file); + Ok(Self::for_reader( + reader, + path.to_string_lossy().to_string(), + Some(path.to_path_buf()), + is_user, + )) + } +} + +impl<'a> Parser<&'a [u8]> { + fn for_str(s: &'a str, is_user: bool) -> Self { + Self::for_reader( + BufReader::new(s.as_bytes()), + "".into(), + None, + is_user, + ) + } +} + +impl Default for Parser<&[u8]> { + fn default() -> Self { + Parser::for_str("", false) + } +} + +impl Parser { + fn for_reader( + reader: BufReader, + source: String, + path: Option, + is_user: bool, + ) -> Self { + Self { + line_number: 0, + reader, + source, + path, + is_user, + } + } + + fn parse_line(&self, line: &str) -> Result { + let line = line.trim(); + let line_number = self.line_number; + // extract keyword, which may be delimited by whitespace (Key Value) OR equals (Key=Value) + let (keyword, rest) = { + let mut splitter = line.splitn(2, &[' ', '\t', '=']); + let keyword = match splitter.next() { + None | Some("") => return Ok(Line::Empty), + Some(kw) => CanonicalIntermediate::from(kw), + }; + (keyword, splitter.next().unwrap_or_default()) + }; + if keyword.0.starts_with('#') { + return Ok(Line::Empty); + } + let args = split_args(rest).with_context(|| format!("at line {line_number}"))?; + anyhow::ensure!(!args.is_empty(), "missing argument at line {line_number}"); + + Ok(match keyword.0.as_str() { + "host" => Line::Host { line_number, args }, + "match" => Line::Match { line_number, args }, + "include" => Line::Include { line_number, args }, + _ => Line::Generic { + line_number, + keyword: keyword.to_configuration_field(), + args, + }, + }) + } + + const INCLUDE_DEPTH_LIMIT: u8 = 16; + + fn parse_file_inner( + &mut self, + accepting: &mut bool, + depth: u8, + output: &mut HostConfiguration, + ) -> Result<()> { + let mut line = String::new(); + anyhow::ensure!( + depth < Self::INCLUDE_DEPTH_LIMIT, + "too many nested includes" + ); + + loop { + line.clear(); + self.line_number += 1; + let mut line = String::new(); + if 0 == self.reader.read_line(&mut line)? { + break; // EOF + } + match self.parse_line(&line)? { + Line::Empty => (), + Line::Host { args, .. } => { + *accepting = evaluate_host_match(output.host.as_deref(), &args); + } + Line::Match { .. } => { + warn!("match expressions in ssh_config files are not yet supported"); + } + Line::Include { args, .. } => { + for arg in args { + let files = find_include_files(&arg, self.is_user)?; + for f in files { + let mut subparser = + Parser::for_path(f, self.is_user).with_context(|| { + format!( + "Include directive at {} line {}", + self.source, self.line_number + ) + })?; + subparser.parse_file_inner(accepting, depth + 1, output)?; + } + } + } + Line::Generic { keyword, args, .. } => { + if *accepting { + // per ssh_config(5), the first matching entry for a given key wins. + let _ = output.data.entry(keyword).or_insert_with(|| Setting { + source: self.source.clone(), + line_number: self.line_number, + args, + }); + } + } + } + } + Ok(()) + } + + /// Interprets the source with a given hostname in mind. + /// This consumes the `Parser`. + pub(crate) fn parse_file_for(mut self, host: Option<&str>) -> Result { + let mut output = HostConfiguration::new(host, self.path.take()); + let mut accepting = true; + self.parse_file_inner(&mut accepting, 0, &mut output)?; + Ok(output) + } +} + +/////////////////////////////////////////////////////////////////////////////////////// + +#[cfg(test)] +mod test { + use anyhow::{anyhow, Context, Result}; + use assertables::{assert_contains, assert_contains_as_result, assert_eq_as_result}; + use struct_field_names_as_array::FieldNamesAsSlice; + + use super::Parser; + use super::{super::Line, CanonicalIntermediate}; + + use crate::{ + config::Configuration, + os::{AbstractPlatform, Platform}, + util::make_test_tempfile, + }; + + macro_rules! assert_1_arg { + ($left:expr, $right:expr) => { + assert_eq!(($left).unwrap().args.first().unwrap(), $right); + }; + } + + macro_rules! make_vec { + ($v:expr) => { + $v.into_iter().map(|s| s.into()).collect() + }; + } + + fn host_(args: Vec<&str>) -> Line { + Line::Host { + line_number: 0, + args: make_vec!(args), + } + } + fn match_(args: Vec<&str>) -> Line { + Line::Match { + line_number: 0, + args: make_vec!(args), + } + } + fn include_(args: Vec<&str>) -> Line { + Line::Include { + line_number: 0, + args: make_vec!(args), + } + } + fn generic_(kw: &str, args: Vec<&str>) -> Line { + Line::Generic { + line_number: 0, + keyword: kw.into(), + args: make_vec!(args), + } + } + + #[test] + fn line_parsing() -> Result<()> { + let p = Parser::default(); + for (input, expected) in [ + ("", Line::Empty), + (" # foo", Line::Empty), + ("Foo Bar", generic_("foo", vec!["Bar"])), + ("Foo Bar baz", generic_("foo", vec!["Bar", "baz"])), + ("Foo \"Bar baz\"", generic_("foo", vec!["Bar baz"])), + ("Foo=bar", generic_("foo", vec!["bar"])), + ("Host a b", host_(vec!["a", "b"])), + ("Match a b", match_(vec!["a", "b"])), + ("iNcluDe c d", include_(vec!["c", "d"])), + ( + "QUOTED \"abc def\" ghi", + generic_("quoted", vec!["abc def", "ghi"]), + ), + // Fields unknown to Configuration, are converted to CanonicalIntermediate: + ("kebab-case foo", generic_("kebabcase", vec!["foo"])), + ("snake_case foo", generic_("snakecase", vec!["foo"])), + ( + "RanDomcaPitaLiZATion foo", + generic_("randomcapitalization", vec!["foo"]), + ), + // Fields known to Configuration are resolved back to their names from the structure + ("AddressFamily foo", generic_("address_family", vec!["foo"])), + ] { + let msg = || format!("input \"{input}\" failed"); + assert_eq_as_result!(p.parse_line(input).with_context(msg)?, expected) + .map_err(|e| anyhow!(e)) + .with_context(msg)?; + } + for (input, expected_msg) in [ + ("aaa bbb \" ccc", "unterminated quote"), + ("aaa", "missing argument"), + ] { + let err = p.parse_line(input).unwrap_err(); + assert_contains_as_result!(err.root_cause().to_string(), expected_msg) + .map_err(|e| anyhow!(e)) + .with_context(|| format!("input \"{input}\" failed"))?; + } + Ok(()) + } + + #[test] + fn defaults_without_host_block() { + let output = Parser::for_str( + r" + Foo Bar + Baz Qux + # foop is a comment + ", + true, + ) + .parse_file_for(None) + .unwrap(); + //println!("{output:?}"); + assert_1_arg!(output.get("foo"), "Bar"); + assert_1_arg!(output.get("baz"), "Qux"); + assert_eq!(output.get("foop"), None); + } + + #[test] + fn host_block_simple() { + let output = Parser::for_str( + r" + Host Fred + Foo Bar + Host Barney + Foo Baz + ", + true, + ) + .parse_file_for(Some("Fred")) + .unwrap(); + assert_1_arg!(output.get("foo"), "Bar"); + } + + #[test] + fn earlier_match_wins() { + let output = Parser::for_str( + r" + Host Fred + Foo Bar + Host Barney + Foo Baz + Host Fred + Foo Qux + Host * + Foo Qix + ", + true, + ) + .parse_file_for(Some("Fred")) + .unwrap(); + assert_1_arg!(output.get("foo"), "Bar"); + } + + #[test] + fn later_default_works() { + let output = Parser::for_str( + r" + Host Fred + Foo Bar + Host Barney + Foo Baz + Host * + Qux Qix + ", + true, + ) + .parse_file_for(Some("Fred")) + .unwrap(); + assert_1_arg!(output.get("qux"), "Qix"); + } + + #[test] + fn read_real_file() { + let (path, _dir) = make_test_tempfile( + r" + hi there + ", + "test.conf", + ); + let output = Parser::for_path(path, true) + .unwrap() + .parse_file_for(None) + .unwrap(); + assert_1_arg!(output.get("hi"), "there"); + } + + #[test] + fn recursion_limit() { + let tempdir = tempfile::tempdir().unwrap(); + let path = tempdir.path().join("test-recursion"); + let contents = format!( + " + include {path:?} + " + ); + std::fs::write(&path, contents).unwrap(); + let err = Parser::for_path(path, true) + .unwrap() + .parse_file_for(None) + .unwrap_err(); + assert_contains!(err.to_string(), "too many nested includes"); + } + + #[test] + fn expand_globs() { + let tempdir = tempfile::tempdir().unwrap(); + let path1 = tempdir.path().join("test1"); + let path2 = tempdir.path().join("other2"); + let path3 = tempdir.path().join("other3"); + let glob = tempdir.path().join("oth*"); + std::fs::write(&path1, format!("include {glob:?}")).unwrap(); + std::fs::write(&path2, "hi there").unwrap(); + std::fs::write(&path3, "green cheese").unwrap(); + let output = Parser::for_path(path1, true) + .unwrap() + .parse_file_for(None) + .unwrap(); + assert_1_arg!(output.get("hi"), "there"); + assert_1_arg!(output.get("green"), "cheese"); + } + + #[test] + #[ignore] + fn dump_local_config() { + let path = Platform::user_ssh_config().unwrap(); + let parser = Parser::for_path(path, true).unwrap(); + let data = parser.parse_file_for(Some("lapis")).unwrap(); + println!("{data:#?}"); + } + + #[test] + fn config_fields_pairwise() { + for f in Configuration::FIELD_NAMES_AS_SLICE { + let intermed = CanonicalIntermediate::from(*f); + let result = intermed.to_configuration_field(); + assert_eq!(result, *f); + } + } +} diff --git a/src/config/ssh/includes.rs b/src/config/ssh/includes.rs new file mode 100644 index 0000000..c2627dd --- /dev/null +++ b/src/config/ssh/includes.rs @@ -0,0 +1,90 @@ +//! Include directive logic +// (c) 2024 Ross Younger + +use anyhow::{Context, Result}; +use glob::{glob_with, MatchOptions}; +use std::path::PathBuf; + +/// Wildcard matching and ~ expansion for Include directives +pub(super) fn find_include_files(arg: &str, is_user: bool) -> Result> { + let mut path = if arg.starts_with('~') { + anyhow::ensure!( + is_user, + "include paths may not start with ~ in a system configuration file" + ); + expanduser::expanduser(arg) + .with_context(|| format!("expanding include expression {arg}"))? + } else { + PathBuf::from(arg) + }; + if !path.is_absolute() { + if is_user { + let Some(home) = dirs::home_dir() else { + anyhow::bail!("could not determine home directory"); + }; + let mut buf = home; + buf.push(".ssh"); + buf.push(path); + path = buf; + } else { + let mut buf = PathBuf::from("/etc/ssh/"); + buf.push(path); + path = buf; + } + } + + let mut result = Vec::new(); + let options = MatchOptions { + case_sensitive: true, + require_literal_leading_dot: true, + require_literal_separator: true, + }; + for entry in (glob_with(path.to_string_lossy().as_ref(), options)?).flatten() { + if let Some(s) = entry.to_str() { + result.push(s.into()); + } + } + Ok(result) +} + +#[cfg(test)] +mod test { + use super::find_include_files; + + #[test] + #[ignore] // this test is dependent on the current user filespace + fn tilde_expansion_current_user() { + let a = find_include_files("~/*.conf", true).expect("~ should expand to home directory"); + assert!(!a.is_empty()); + let _ = find_include_files("~/*", false) + .expect_err("~ should not be allowed in system configurations"); + } + + #[test] + #[ignore] // obviously this won't run on CI. TODO: figure out a way to make it CIable. + fn tilde_expansion_arbitrary_user() { + let a = + find_include_files("~wry/*.conf", true).expect("~ should expand to a home directory"); + println!("{a:?}"); + assert!(!a.is_empty()); + let _ = find_include_files("~/*", false) + .expect_err("~ should not be allowed in system configurations"); + } + + #[test] + #[ignore] // TODO: Make this runnable on CI + fn relative_path_expansion() { + let a = find_include_files("config", true).unwrap(); + println!("{a:?}"); + assert!(!a.is_empty()); + + let a = find_include_files("sshd_config", false).unwrap(); + println!("{a:?}"); + assert!(!a.is_empty()); + + // but the user does not have an sshd_config: + let a = find_include_files("sshd_config", true).unwrap(); + println!("{a:?}"); + assert!(a.is_empty()); + } +} diff --git a/src/config/ssh/lines.rs b/src/config/ssh/lines.rs new file mode 100644 index 0000000..5bb4e3c --- /dev/null +++ b/src/config/ssh/lines.rs @@ -0,0 +1,118 @@ +//! Line parsing internals +// (c) 2024 Ross Younger + +use anyhow::Result; + +#[derive(Debug, PartialEq)] +/// A parsed line we read from an ssh config file +pub(super) enum Line { + Empty, + Host { + line_number: usize, + args: Vec, + }, + Match { + line_number: usize, + args: Vec, + }, + Include { + line_number: usize, + args: Vec, + }, + Generic { + line_number: usize, + keyword: String, /*canonicalised!*/ + args: Vec, + }, +} + +/////////////////////////////////////////////////////////////////////////////////////// + +/// Splits a string into a list of arguments. +/// Arguments are delimited by whitespace, subject to quoting (single or double quotes), and simple escapes (\\, \", \'). +pub(super) fn split_args(input: &str) -> Result> { + // We need to index over the characters of the input, but also need to be able to peek at the next token in case of escapes. + let mut i = 0; + let input: Vec = input.chars().collect(); + let mut output = Vec::::new(); + while i < input.len() { + // Strip any leading whitespace + if input[i] == ' ' || input[i] == '\t' { + i += 1; + continue; + } + if input[i] == '#' { + break; // it's a comment, we're done + } + + // We're at the start of a real token + let mut current_arg = String::new(); + let mut quote_state: char = '\0'; + + while i < input.len() { + let ch = input[i]; + match (ch, quote_state) { + ('\\', _) => { + // It might be an escape + let next = input.get(i + 1); + match next { + Some(nn @ ('\'' | '\"' | '\\')) => { + // It is an escape + current_arg.push(*nn); + i += 1; + } + Some(_) | None => current_arg.push(ch), // Ignore unrecognised escape + } + } + (' ' | '\t', '\0') => break, // end of token + (q @ ('\'' | '\"'), '\0') => quote_state = q, // start of quote + (q1, q2) if q1 == q2 => quote_state = '\0', // end of quote + (c, _) => current_arg.push(c), // nothing special + } + i += 1; + } + + // end of token + anyhow::ensure!(quote_state == '\0', "unterminated quote"); + output.push(current_arg); + i += 1; + } + Ok(output) +} + +/////////////////////////////////////////////////////////////////////////////////////// + +#[cfg(test)] +mod test { + use anyhow::{anyhow, Context, Result}; + use assertables::{assert_contains_as_result, assert_eq_as_result}; + + use crate::config::ssh::split_args; + #[test] + fn arg_splitting() -> Result<()> { + for (input, expected) in [ + ("", vec![]), + ("a", vec!["a"]), + (" a b ", vec!["a", "b"]), + (" a b # c d", vec!["a", "b"]), + (r#"a\ \' \"b"#, vec!["a\\", "'", "\"b"]), + (r#""a b" 'c d'"#, vec!["a b", "c d"]), + (r#""a \"b" '\'c d'"#, vec!["a \"b", "'c d"]), + ] { + let msg = || format!("input \"{input}\" failed"); + assert_eq_as_result!(split_args(input).with_context(msg)?, expected) + .map_err(|e| anyhow!(e)) + .with_context(msg)?; + } + for (input, expected_msg) in [ + ("aaa\"bbb", "unterminated quote"), + ("'", "unterminated quote"), + ] { + let err = split_args(input).unwrap_err(); + assert_contains_as_result!(err.to_string(), expected_msg) + .map_err(|e| anyhow!(e)) + .with_context(|| format!("input \"{input}\" failed"))?; + } + Ok(()) + } +} diff --git a/src/config/ssh/matching.rs b/src/config/ssh/matching.rs new file mode 100644 index 0000000..8b84770 --- /dev/null +++ b/src/config/ssh/matching.rs @@ -0,0 +1,77 @@ +//! Host matching +// (c) 2024 Ross Younger + +fn match_one_pattern(host: &str, pattern: &str) -> bool { + if let Some(negative_pattern) = pattern.strip_prefix('!') { + !wildmatch::WildMatch::new(negative_pattern).matches(host) + } else { + wildmatch::WildMatch::new(pattern).matches(host) + } +} + +pub(super) fn evaluate_host_match(host: Option<&str>, args: &[String]) -> bool { + if let Some(host) = host { + args.iter().any(|arg| match_one_pattern(host, arg)) + } else { + // host is None i.e. unspecified; match only on '*' + args.iter().any(|arg| arg == "*") + } +} + +/////////////////////////////////////////////////////////////////////////////////////// + +#[cfg(test)] +mod test { + use super::evaluate_host_match; + use anyhow::{anyhow, Context, Result}; + use assertables::assert_eq_as_result; + + /// helper macro: concise notation to create a Vec + /// + /// # Example + /// ``` + /// use vec_of_strings as sv; + /// assert_eq!(sv["a","b"], vec![String::from("a"), String::from("b")]); + /// ``` + macro_rules! vec_of_strings { + ($($x:expr),*) => (vec![$($x.to_string()),*]); + } + + use vec_of_strings as sv; + + #[test] + fn host_matching() -> Result<()> { + for (host, args, result) in [ + ("foo", sv!["foo"], true), + ("foo", sv![""], false), + ("foo", sv!["bar"], false), + ("foo", sv!["bar", "foo"], true), + ("foo", sv!["f?o"], true), + ("fooo", sv!["f?o"], false), + ("foo", sv!["f*"], true), + ("oof", sv!["*of"], true), + ("192.168.1.42", sv!["192.168.?.42"], true), + ("192.168.10.42", sv!["192.168.?.42"], false), + ("xyzy", sv!["!xyzzy"], true), + ("xyzy", sv!["!xyzy"], false), + ] { + assert_eq_as_result!(evaluate_host_match(Some(host), &args), result) + .map_err(|e| anyhow!(e)) + .with_context(|| format!("host {host}, args {args:?}"))?; + } + Ok(()) + } + #[test] + fn unspecified_host() -> Result<()> { + for (args, result) in [ + (sv!["foo", "bar", "baz"], false), + (sv!["*"], true), + (sv!["foo", "bar", "*", "baz"], true), // silly case but we ought to get it right + ] { + assert_eq_as_result!(evaluate_host_match(None, &args), result) + .map_err(|e| anyhow!(e)) + .with_context(|| format!("host , args {args:?}"))?; + } + Ok(()) + } +} diff --git a/src/config/ssh/values.rs b/src/config/ssh/values.rs new file mode 100644 index 0000000..3f581c1 --- /dev/null +++ b/src/config/ssh/values.rs @@ -0,0 +1,78 @@ +//! Individual configured values +// (c) 2024 Ross Younger + +use figment::{Metadata, Profile, Source}; + +#[derive(Debug, Clone, PartialEq)] +/// A setting we read from a config file +pub(crate) struct Setting { + /// where the value came from + pub source: String, + /// line number within the value + pub line_number: usize, + /// the setting data itself (not parsed; we assert nothing beyond the parser has applied the ssh quoting logic) + pub args: Vec, +} + +impl Setting { + pub(crate) fn first_arg(&self) -> String { + self.args.first().cloned().unwrap_or_else(String::new) + } +} + +/////////////////////////////////////////////////////////////////////////////////////// + +/// Wraps a Setting into something Figment can deal with +pub(super) struct ValueProvider<'a> { + key: &'a String, + value: &'a Setting, + profile: &'a Profile, +} + +impl<'a> ValueProvider<'a> { + pub(super) fn new(key: &'a String, value: &'a Setting, profile: &'a Profile) -> Self { + Self { + key, + value, + profile, + } + } +} + +impl figment::Provider for ValueProvider<'_> { + fn metadata(&self) -> figment::Metadata { + Metadata::from( + "configuration file", + Source::Custom(format!( + "{src} (line {line})", + src = self.value.source, + line = self.value.line_number + )), + ) + .interpolater(|profile, path| { + let key = path.to_vec(); + format!("key `{key}` of host `{profile}`", key = key.join(".")) + }) + } + + fn data( + &self, + ) -> std::result::Result< + figment::value::Map, + figment::Error, + > { + use figment::value::{Dict, Empty, Value}; + let mut dict = Dict::new(); + let value: Value = match self.value.args.len() { + 0 => Empty::Unit.into(), + 1 => self.value.args.first().unwrap().clone().into(), + _ => self.value.args.clone().into(), + }; + let _ = dict.insert(self.key.clone(), value); + Ok(self.profile.collect(dict)) + } + + fn profile(&self) -> Option { + Some(self.profile.clone()) + } +} diff --git a/src/config/structure.rs b/src/config/structure.rs new file mode 100644 index 0000000..45c0fa6 --- /dev/null +++ b/src/config/structure.rs @@ -0,0 +1,314 @@ +//! Configuration structure +// (c) 2024 Ross Younger + +use std::time::Duration; + +use clap::Parser; +use human_repr::{HumanCount as _, HumanDuration as _}; +use serde::{Deserialize, Serialize}; +use struct_field_names_as_array::FieldNamesAsSlice; + +use crate::{ + transport::CongestionControllerType, + util::{ + derive_deftly_template_Optionalify, humanu64::HumanU64, AddressFamily, PortRange, + TimeFormat, + }, +}; + +use derive_deftly::Deftly; + +/// The set of configurable options supported by qcp. +/// +/// **Note:** The implementation of `default()` for this struct returns qcp's hard-wired configuration defaults. +/// +/// This structure uses the [Optionalify](derive_deftly_template_Optionalify) deftly macro to automatically +/// define the `Configuration_Optional` struct, which is the same but has all members of type `Option`. +/// This is the magic that lets us use the same underlying struct for CLI and saved configuration files: +/// the CLI uses the `_Optional` version , with everything defaulting to `None`. +/// The result is that wherever the user does not provide a value, values read from lower priority sources +/// (configuration files and system defaults) obtain. +/// +// Maintainer note: None of the members of this struct should be Option. That leads to sunspots in the CLI and strange warts (Some(Some(foo))). +#[derive(Deftly)] +#[derive_deftly(Optionalify)] +#[deftly(visibility = "pub(crate)")] +#[derive(Debug, Clone, PartialEq, Eq, Parser, Deserialize, Serialize, FieldNamesAsSlice)] +pub struct Configuration { + // TRANSPORT PARAMETERS ============================================================================ + // System bandwidth, UDP ports, timeout. + /// The maximum network bandwidth we expect receiving data FROM the remote system. + /// [default: 12500k] + /// + /// This may be specified directly as a number of bytes, or as an SI quantity + /// like `10M` or `256k`. **Note that this is described in BYTES, not bits**; + /// if (for example) you expect to fill a 1Gbit ethernet connection, + /// 125M might be a suitable setting. + #[arg(short('b'), long, alias("rx-bw"), help_heading("Network tuning"), display_order(1), value_name="bytes", value_parser=clap::value_parser!(HumanU64))] + pub rx: HumanU64, + /// The maximum network bandwidth we expect sending data TO the remote system, + /// if it is different from the bandwidth FROM the system. + /// + /// (For example, when you are connected via an asymmetric last-mile DSL or fibre profile.) + /// + /// If not specified or 0, uses the value of `rx`. + #[arg(short('B'), long, alias("tx-bw"), help_heading("Network tuning"), display_order(1), value_name="bytes", value_parser=clap::value_parser!(HumanU64))] + pub tx: HumanU64, + + /// The expected network Round Trip time to the target system, in milliseconds. + /// [default: 300] + #[arg( + short('r'), + long, + help_heading("Network tuning"), + display_order(10), + value_name("ms") + )] + pub rtt: u16, + + /// Specifies the congestion control algorithm to use. + /// [default: cubic] + #[arg( + long, + action, + value_name = "alg", + help_heading("Advanced network tuning"), + display_order(0) + )] + #[clap(value_enum)] + pub congestion: CongestionControllerType, + + /// _(Network wizards only!)_ + /// The initial value for the sending congestion control window. + /// If unspecified, the active congestion control algorithm decides. + /// + /// _Setting this value too high reduces performance!_ + #[arg( + long, + help_heading("Advanced network tuning"), + value_name = "bytes", + display_order(0) + )] + pub initial_congestion_window: u64, + + /// Uses the given UDP port or range on the local endpoint. + /// This can be useful when there is a firewall between the endpoints. + /// + /// For example: `12345`, `20000-20100` + /// + /// If unspecified, uses any available UDP port. + #[arg( + short = 'p', + long, + value_name("M-N"), + help_heading("Connection"), + display_order(0) + )] + pub port: PortRange, + + /// Connection timeout for the QUIC endpoints [seconds; default 5] + /// + /// This needs to be long enough for your network connection, but short enough to provide + /// a timely indication that UDP may be blocked. + #[arg( + short, + long, + value_name("sec"), + help_heading("Connection"), + display_order(0) + )] + pub timeout: u16, + + // CLIENT OPTIONS ================================================================================== + /// Forces use of a particular IP version when connecting to the remote. [default: any] + /// + // (see also [CliArgs::ipv4_alias__] and [CliArgs::ipv6_alias__]) + #[arg( + long, + help_heading("Connection"), + group("ip address"), + display_order(0) + )] + pub address_family: AddressFamily, + + /// Specifies the ssh client program to use [default: `ssh`] + #[arg(long, help_heading("Connection"), display_order(0))] + pub ssh: String, + + /// Provides an additional option or argument to pass to the ssh client. [default: none] + /// + /// **On the command line** you must repeat `-S` for each argument. + /// For example, to pass `-i /dev/null` to ssh, specify: `-S -i -S /dev/null` + #[arg( + short = 'S', + action, + value_name("ssh-option"), + allow_hyphen_values(true), + help_heading("Connection"), + display_order(0) + )] + pub ssh_options: Vec, + + /// Uses the given UDP port or range on the remote endpoint. + /// This can be useful when there is a firewall between the endpoints. + /// + /// For example: `12345`, `20000-20100` + /// + /// If unspecified, uses any available UDP port. + #[arg( + short = 'P', + long, + value_name("M-N"), + help_heading("Connection"), + display_order(0) + )] + pub remote_port: PortRange, + + /// Specifies the time format to use when printing messages to the console or to file + /// [default: local] + #[arg( + short = 'T', + long, + value_name("FORMAT"), + help_heading("Output"), + next_line_help(true), + display_order(0) + )] + pub time_format: TimeFormat, + + /// Alternative ssh config file(s) + /// + /// By default, qcp reads your user and system ssh config files to look for Hostname aliases. + /// In some cases the logic in qcp may not read them successfully; this is an escape hatch, + /// allowing you to specify one or more alternative files to read instead (which may be empty, + /// nonexistent or /dev/null). + /// + /// This option is really intended to be used in a qcp configuration file. + /// On the command line, you can repeat `--ssh-config file` as many times as needed. + #[arg(long, value_name("FILE"), help_heading("Connection"), display_order(0))] + pub ssh_config: Vec, +} + +impl Configuration { + /// Computes the theoretical bandwidth-delay product for outbound data + #[must_use] + #[allow(clippy::cast_possible_truncation)] + pub fn bandwidth_delay_product_tx(&self) -> u64 { + self.tx() * u64::from(self.rtt) / 1000 + } + /// Computes the theoretical bandwidth-delay product for inbound data + #[must_use] + #[allow(clippy::cast_possible_truncation)] + pub fn bandwidth_delay_product_rx(&self) -> u64 { + self.rx() * u64::from(self.rtt) / 1000 + } + #[must_use] + /// Receive bandwidth (accessor) + pub fn rx(&self) -> u64 { + *self.rx + } + #[must_use] + /// Transmit bandwidth (accessor) + pub fn tx(&self) -> u64 { + match *self.tx { + 0 => self.rx(), + tx => tx, + } + } + /// RTT accessor as Duration + #[must_use] + pub fn rtt_duration(&self) -> Duration { + Duration::from_millis(u64::from(self.rtt)) + } + + /// UDP kernel sending buffer size to use + #[must_use] + pub fn send_buffer() -> u64 { + // UDP kernel buffers of 2MB have proven sufficient to get close to line speed on a 300Mbit downlink with 300ms RTT. + 2_097_152 + } + /// UDP kernel receive buffer size to use + #[must_use] + pub fn recv_buffer() -> u64 { + // UDP kernel buffers of 2MB have proven sufficient to get close to line speed on a 300Mbit downlink with 300ms RTT. + 2_097_152 + } + + /// QUIC receive window + #[must_use] + pub fn recv_window(&self) -> u64 { + // The theoretical in-flight limit appears to be sufficient + self.bandwidth_delay_product_rx() + } + + /// QUIC send window + #[must_use] + pub fn send_window(&self) -> u64 { + // There might be random added latency en route, so provide for a larger send window than theoretical. + 2 * self.bandwidth_delay_product_tx() + } + + /// Accessor for `timeout`, as a Duration + #[must_use] + pub fn timeout_duration(&self) -> Duration { + Duration::from_secs(self.timeout.into()) + } + + /// Formats the transport-related options for display + #[must_use] + pub fn format_transport_config(&self) -> String { + let iwind = match self.initial_congestion_window { + 0 => "".to_string(), + s => s.human_count_bytes().to_string(), + }; + let (tx, rx) = (self.tx(), self.rx()); + format!( + "rx {rx} ({rxbits}), tx {tx} ({txbits}), rtt {rtt}, congestion algorithm {congestion:?} with initial window {iwind}", + tx = tx.human_count_bytes(), + txbits = (tx * 8).human_count("bit"), + rx = rx.human_count_bytes(), + rxbits = (rx * 8).human_count("bit"), + rtt = self.rtt_duration().human_duration(), + congestion = self.congestion, + ) + } +} + +impl Default for Configuration { + /// **(Unusual!)** + /// Returns qcp's hard-wired configuration defaults. + fn default() -> Self { + Self { + // Transport + rx: 12_500_000.into(), + tx: 0.into(), + rtt: 300, + congestion: CongestionControllerType::Cubic, + initial_congestion_window: 0, + port: PortRange::default(), + timeout: 5, + + // Client + address_family: AddressFamily::Any, + ssh: "ssh".into(), + ssh_options: vec![], + remote_port: PortRange::default(), + time_format: TimeFormat::Local, + ssh_config: Vec::new(), + } + } +} + +#[cfg(test)] +mod test { + use super::Configuration; + + #[test] + fn flattened() { + let v = Configuration::default(); + let j = serde_json::to_string(&v).unwrap(); + let d = json::parse(&j).unwrap(); + assert!(!d.has_key("bw")); + assert!(d.has_key("rtt")); + } +} diff --git a/src/doc/troubleshooting.rs b/src/doc/troubleshooting.rs index 49f50af..21bb4ef 100644 --- a/src/doc/troubleshooting.rs +++ b/src/doc/troubleshooting.rs @@ -53,3 +53,30 @@ //! If this bothers you, you might want to look into setting up QoS on your router. //! //! There might be some mileage in having qcp try to limit its bandwidth use or tune it to be less aggressive in the face of congestion, but that's not implemented at the moment. +//! +//! ### It takes a long time to set up the control channel +//! +//! The control channel is an ordinary ssh connection, so you need to figure out how to make ssh faster. +//! This is not within qcp's control. +//! +//! * Often this is due to a DNS misconfiguration at the server side, causing it to stall until a DNS lookup times out. +//! * There are a number of guides online purporting to advise you how to speed up ssh connections; I can't vouch for them. +//! * You might also look into ssh connection multiplexing. +//! +//! ### qcp isn't using the network parameters you expected it to +//! * Parameters specified on the command line always override those in config files. +//! * Settings in the user config file take precedence over those in the system config file. +//! * For each setting, the first value found in a matching Host block wins. +//! * Add `--show-config` to your command line to see the settings qcp is using and where it got them from: +//! ```text +//! $ qcp myserver:some-file /tmp/ --show-config +//! ┌───────────────────────────┬────────────────┬──────────────────────────────┐ +//! │ field │ value │ source │ +//! ├───────────────────────────┼────────────────┼──────────────────────────────┤ +//! │ (Remote host) │ myserver │ │ +//! │ address_family │ inet │ /home/xyz/.qcp.conf (line 9) │ +//! │ congestion │ cubic │ /etc/qcp.conf (line 4) │ +//! │ initial_congestion_window │ 0 │ default │ +//! │ port │ 0 │ default │ +//! ... +//! ``` diff --git a/src/lib.rs b/src/lib.rs index eaf4adc..c9d5b2c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,7 +21,7 @@ //! I was inspired to write this when I needed to copy a load of multi-GB files from a server on the other side of the planet. //! //! #### Limitations -//! - You must be able to ssh directly to the remote machine, and exchange UDP packets with it on a given port. (If the local machine is behind connection-tracking NAT, things work just fine. This is the case for the vast majority of home and business network connections.) +//! - You must be able to ssh directly to the remote machine, and exchange UDP packets with it on a given port. (If the local machine is behind connection-tracking NAT, things work just fine. This is the case for the vast majority of home and business network connections. If need be, you can configure qcp to use a particular port range.) //! - Network security systems can't readily identify QUIC traffic as such. It's opaque, and high bandwidth. Some security systems might flag it as a potential threat. //! //! #### What qcp is not @@ -42,21 +42,44 @@ //! //! The [protocol] documentation contains more detail and a discussion of its security properties. //! +//! * **qcp uses the ssh binary on your system to connect to the target machine**. +//! ssh will check the remote host key and prompt you for a password or passphrase in the usual way. +//! * **qcp will read your ssh config file** to resolve any Hostname aliases you may have defined there. +//! The idea is, if you can `ssh` to a host, you should also be able to `qcp` to it. +//! However, some particularly complicated ssh config files may be too much for qcp to understand. +//! (In particular, `Match` directives are not currently supported.) +//! In that case, you can use `--ssh-config` to provide an alternative configuration (or set it in your qcp configuration file). +//! +//! ## Configuration +//! +//! On the command line, qcp has a comprehensive `--help` message. +//! +//! Many options can also be specified in a config file. See [config] for detalis. +//! //! ## 📈 Getting the best out of qcp //! //! See [performance](doc::performance) and [troubleshooting](doc::troubleshooting). //! +//! ## MSRV policy +//! +//! As this is an application crate, the MSRV is not guaranteed to remain stable. +//! The MSRV may be upgraded from time to time to take advantage of new language features. +//! //! [QUIC]: https://quicwg.github.io/ //! [ssh]: https://en.wikipedia.org/wiki/Secure_Shell //! [CDN]: https://en.wikipedia.org/wiki/Content_delivery_network //! [BitTorrent]: https://en.wikipedia.org/wiki/BitTorrent //! [rsync]: https://en.wikipedia.org/wiki/Rsync //! [mosh]: https://mosh.org/ +//! +//! ## Feature flags +#![doc = document_features::document_features!()] mod cli; pub use cli::cli; // needs to be re-exported for the binary crate pub mod client; +pub mod config; pub mod protocol; pub mod server; pub mod transport; @@ -67,3 +90,8 @@ pub mod doc; pub mod os; mod version; + +#[doc(hidden)] +pub use derive_deftly; +// Use the current version of derive_deftly here: +derive_deftly::template_export_semver_check!("0.14.0"); diff --git a/src/os/mod.rs b/src/os/mod.rs index 97b93ee..9e3f1a7 100644 --- a/src/os/mod.rs +++ b/src/os/mod.rs @@ -1,6 +1,8 @@ //! OS abstraction layer // (c) 2024 Ross Younger +use std::path::PathBuf; + use anyhow::Result; /// OS abstraction trait providing access to socket options @@ -28,10 +30,50 @@ pub trait SocketOptions { fn force_recvbuf(&mut self, size: usize) -> Result<()>; } +/// General platform abstraction trait. +/// The active implementation should be pulled into this crate +/// Implementations should be called `Platform`, e.g. [unix::Platform]. +/// +/// Usage: +/// ``` +/// use qcp::os::Platform; +/// use qcp::os::AbstractPlatform as _; +/// println!("{}", Platform::system_ssh_config()); +/// ``` +pub trait AbstractPlatform { + /// Path to the system ssh config file. + /// On most platforms this will be `/etc/ssh/ssh_config` + fn system_ssh_config() -> &'static str; + + /// Path to the user ssh config file. + /// On most platforms this will be `${HOME}/.ssh/config` + /// # Note + /// This is a _theoretical_ path construction; it does not guarantee that the path actually exists. + /// That is up to the caller to determine and reason about. + /// # Errors + /// If the current user's home directory could not be determined + fn user_ssh_config() -> Result; + + /// The directory to store user configuration files in. + /// + /// On Unix platforms this is the traditional home directory. + /// + /// If somehow we could not determine the directory to use, returns None (and may emit a warning). + fn user_config_dir() -> Option; + + /// The absolute path to the user configuration file, if one is defined on this platform. + /// + /// If somehow we could not determine the path to use, returns None (and may emit a warning). + fn user_config_path() -> Option; + + /// The absolute path to the system configuration file, if one is defined on this platform. + fn system_config_path() -> Option; +} + #[cfg(any(unix, doc))] mod unix; #[cfg(any(unix, doc))] -pub(crate) use unix::*; +pub use unix::*; static_assertions::assert_cfg!(unix, "This OS is not yet supported"); diff --git a/src/os/unix.rs b/src/os/unix.rs index 078dc05..3ccaa9c 100644 --- a/src/os/unix.rs +++ b/src/os/unix.rs @@ -1,11 +1,14 @@ -// OS abstraction layer for qcp - Unix implementation +//! OS concretions for Unix platforms // (c) 2024 Ross Younger +use crate::config::BASE_CONFIG_FILENAME; + use super::SocketOptions; use anyhow::Result; use nix::sys::socket::{self, sockopt}; -use std::net::UdpSocket; +use std::{net::UdpSocket, path::PathBuf}; +/// Is this platform BSDish? fn bsdish() -> bool { cfg!(any( target_os = "netbsd", @@ -87,3 +90,41 @@ can create a file /etc/sysctl.d/20-qcp.conf containing: } // TODO add other OS-specific notes here } + +/// Concretions for Unix platforms +#[derive(Debug, Clone, Copy)] +pub struct Platform {} + +impl super::AbstractPlatform for Platform { + fn system_ssh_config() -> &'static str { + "/etc/ssh/ssh_config" + } + + fn user_ssh_config() -> Result { + let Some(mut pb) = dirs::home_dir() else { + anyhow::bail!("could not determine home directory"); + }; + pb.push(".ssh"); + pb.push("config"); + Ok(pb) + } + + fn user_config_dir() -> Option { + dirs::home_dir() + } + + fn user_config_path() -> Option { + // ~/. for now + let mut d: PathBuf = Self::user_config_dir()?; + d.push(format!(".{BASE_CONFIG_FILENAME}")); + Some(d) + } + + fn system_config_path() -> Option { + // /etc/ for now + let mut p: PathBuf = PathBuf::new(); + p.push("/etc"); + p.push(BASE_CONFIG_FILENAME); + Some(p) + } +} diff --git a/src/protocol/control.rs b/src/protocol/control.rs index 920731d..a1fb3ad 100644 --- a/src/protocol/control.rs +++ b/src/protocol/control.rs @@ -56,7 +56,6 @@ impl ClientMessage { Ok(()) } /// Deserializer - pub async fn read(read: &mut R) -> Result where R: tokio::io::AsyncRead + Unpin, diff --git a/src/protocol/control_capnp.rs b/src/protocol/control_capnp.rs index 1d6b12d..5362796 100644 --- a/src/protocol/control_capnp.rs +++ b/src/protocol/control_capnp.rs @@ -10,6 +10,7 @@ clippy::missing_panics_doc, clippy::module_name_repetitions, clippy::must_use_candidate, + clippy::needless_lifetimes, clippy::semicolon_if_nothing_returned, clippy::uninlined_format_args, clippy::used_underscore_binding diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 509ae88..64db8f8 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -1,16 +1,16 @@ // (c) 2024 Ross Younger -//! Protocol defininitions +//! 📖 Protocol defininitions //! #![allow(clippy::doc_markdown)] //! # The QCP protocol //! `qcp` is a **hybrid protocol**. //! The binary contains the complete protocol implementation, -//! but not the ssh binary used to establish the control channel itself. +//! except for the ssh binary used to establish the control channel itself. //! //! The protocol flow looks like this: //! -//! 1. The user runs `qcp` from the a machine we will call the _initiator_ or _client_. +//! 1. The user runs `qcp` from the machine we will call the _initiator_ or _client_. //! * qcp uses ssh to connect to the _remote_ machine and start a `qcp --server` process there. //! * We call this link between the two processes the _control channel_. //! * The _remote_ machine is also known as the _server_, in keeping with other communication protocols. diff --git a/src/protocol/session.rs b/src/protocol/session.rs index 4d99c49..06a612a 100644 --- a/src/protocol/session.rs +++ b/src/protocol/session.rs @@ -47,7 +47,7 @@ use std::fmt::Display; use tokio_util::compat::TokioAsyncReadCompatExt as _; /// Command packet -#[derive(Debug, strum_macros::Display)] +#[derive(Debug, strum::Display)] #[allow(missing_docs)] pub enum Command { Get(GetArgs), @@ -223,7 +223,6 @@ pub struct FileTrailer {} impl FileTrailer { /// One-stop serializer - #[must_use] pub fn serialize_direct() -> Vec { let mut msg = ::capnp::message::Builder::new_default(); diff --git a/src/protocol/session_capnp.rs b/src/protocol/session_capnp.rs index f897e75..90a5f0f 100644 --- a/src/protocol/session_capnp.rs +++ b/src/protocol/session_capnp.rs @@ -11,6 +11,7 @@ clippy::missing_panics_doc, clippy::module_name_repetitions, clippy::must_use_candidate, + clippy::needless_lifetimes, clippy::semicolon_if_nothing_returned, clippy::uninlined_format_args, clippy::used_underscore_binding diff --git a/src/server.rs b/src/server.rs index 3418a4b..e3678bd 100644 --- a/src/server.rs +++ b/src/server.rs @@ -4,14 +4,12 @@ use std::path::PathBuf; use std::sync::Arc; +use crate::config::Configuration; use crate::protocol::control::{ClientMessage, ClosedownReport, ServerMessage}; use crate::protocol::session::{Command, FileHeader, FileTrailer, Response, Status}; use crate::protocol::{self, StreamPair}; -use crate::transport::BandwidthParams; -use crate::util::socket::bind_range_for_family; -use crate::util::Credentials; -use crate::util::PortRange; -use crate::{transport, util}; +use crate::transport::ThroughputMode; +use crate::util::{io, socket, Credentials}; use anyhow::Context as _; use quinn::crypto::rustls::QuicServerConfig; @@ -27,10 +25,7 @@ use tracing::{debug, error, info, trace, trace_span, warn, Instrument}; /// Server event loop #[allow(clippy::module_name_repetitions)] -pub async fn server_main( - bandwidth: crate::transport::BandwidthParams, - quic: crate::transport::QuicParams, -) -> anyhow::Result<()> { +pub async fn server_main(config: &Configuration) -> anyhow::Result<()> { let mut stdin = tokio::io::stdin(); let mut stdout = tokio::io::stdout(); // There are tricks you can use to get an unbuffered handle to stdout, but at a typing cost. @@ -53,11 +48,11 @@ pub async fn server_main( client_message.connection_type, ); - let bandwidth_info = format!("{bandwidth:?}"); - let file_buffer_size = usize::try_from(bandwidth.send_buffer())?; + let bandwidth_info = config.format_transport_config().to_string(); + let file_buffer_size = usize::try_from(Configuration::send_buffer())?; let credentials = Credentials::generate()?; - let (endpoint, warning) = create_endpoint(&credentials, client_message, bandwidth, quic.port)?; + let (endpoint, warning) = create_endpoint(&credentials, client_message, config)?; let local_addr = endpoint.local_addr()?; debug!("Local address is {local_addr}"); ServerMessage::write( @@ -79,7 +74,7 @@ pub async fn server_main( // but a timeout is useful to give the user a cue that UDP isn't getting there. trace!("waiting for QUIC"); let (stats_tx, mut stats_rx) = oneshot::channel(); - if let Some(conn) = timeout(quic.timeout, endpoint.accept()) + if let Some(conn) = timeout(config.timeout_duration(), endpoint.accept()) .await .with_context(|| "Timed out waiting for QUIC connection")? { @@ -113,8 +108,7 @@ pub async fn server_main( fn create_endpoint( credentials: &Credentials, client_message: ClientMessage, - bandwidth: BandwidthParams, - ports: Option, + transport: &Configuration, ) -> anyhow::Result<(quinn::Endpoint, Option)> { let client_cert: CertificateDer<'_> = client_message.cert.into(); @@ -127,24 +121,24 @@ fn create_endpoint( tls_config.max_early_data_size = u32::MAX; let qsc = QuicServerConfig::try_from(tls_config)?; - let mut config = quinn::ServerConfig::with_crypto(Arc::new(qsc)); - let _ = config.transport_config(crate::transport::create_config( - bandwidth, - transport::ThroughputMode::Both, + let mut server = quinn::ServerConfig::with_crypto(Arc::new(qsc)); + let _ = server.transport_config(crate::transport::create_config( + transport, + ThroughputMode::Both, )?); - let mut socket = bind_range_for_family(client_message.connection_type, ports)?; + let mut socket = socket::bind_range_for_family(client_message.connection_type, transport.port)?; // We don't know whether client will send or receive, so configure for both. - let wanted_send = Some(usize::try_from(bandwidth.send_buffer())?); - let wanted_recv = Some(usize::try_from(bandwidth.recv_buffer())?); - let warning = util::socket::set_udp_buffer_sizes(&mut socket, wanted_send, wanted_recv)? + let wanted_send = Some(usize::try_from(Configuration::send_buffer())?); + let wanted_recv = Some(usize::try_from(Configuration::recv_buffer())?); + let warning = socket::set_udp_buffer_sizes(&mut socket, wanted_send, wanted_recv)? .inspect(|s| warn!("{s}")); // SOMEDAY: allow user to specify max_udp_payload_size in endpoint config, to support jumbo frames let runtime = quinn::default_runtime().ok_or_else(|| anyhow::anyhow!("no async runtime found"))?; Ok(( - quinn::Endpoint::new(EndpointConfig::default(), Some(config), socket, runtime)?, + quinn::Endpoint::new(EndpointConfig::default(), Some(server), socket, runtime)?, warning, )) } @@ -212,7 +206,7 @@ async fn handle_get( trace!("begin"); let path = PathBuf::from(&filename); - let (file, meta) = match crate::util::io::open_file(&filename).await { + let (file, meta) = match io::open_file(&filename).await { Ok(res) => res, Err((status, message, _)) => { return send_response(&mut stream.send, status, message.as_deref()).await; @@ -271,7 +265,7 @@ async fn handle_put(mut stream: StreamPair, destination: String) -> anyhow::Resu } let append_filename = if path.is_dir() || path.is_file() { // Destination exists - if !crate::util::io::dest_is_writeable(&path).await { + if !io::dest_is_writeable(&path).await { return send_response( &mut stream.send, Status::IncorrectPermissions, @@ -290,7 +284,7 @@ async fn handle_put(mut stream: StreamPair, destination: String) -> anyhow::Resu path_test.push("."); } if path_test.is_dir() { - if !crate::util::io::dest_is_writeable(&path_test).await { + if !io::dest_is_writeable(&path_test).await { return send_response( &mut stream.send, Status::IncorrectPermissions, diff --git a/src/transport.rs b/src/transport.rs index e81c7d6..9593d39 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -1,40 +1,23 @@ //! QUIC transport configuration // (c) 2024 Ross Younger -use std::{fmt::Display, sync::Arc, time::Duration}; +use std::{str::FromStr, sync::Arc, time::Duration}; use anyhow::Result; -use clap::Parser; -use human_repr::{HumanCount, HumanDuration as _}; -use humanize_rs::bytes::Bytes; +use human_repr::HumanCount as _; use quinn::{ congestion::{BbrConfig, CubicConfig}, TransportConfig, }; +use serde::{de, Deserialize, Serialize}; +use strum::VariantNames; use tracing::debug; -use crate::util::{parse_duration, PortRange}; +use crate::config::Configuration; /// Keepalive interval for the QUIC connection pub const PROTOCOL_KEEPALIVE: Duration = Duration::from_secs(5); -/// Shared parameters used to set up the QUIC UDP connection -#[derive(Copy, Clone, Debug, Parser)] -pub struct QuicParams { - /// Uses the given UDP port or range on the local endpoint. - /// - /// This can be useful when there is a firewall between the endpoints. - #[arg(short = 'p', long, value_name("M-N"), help_heading("Connection"))] - pub port: Option, - - /// Connection timeout for the QUIC endpoints. - /// - /// This needs to be long enough for your network connection, but short enough to provide - /// a timely indication that UDP may be blocked. - #[arg(short, long, default_value("5"), value_name("sec"), value_parser=parse_duration, help_heading("Connection"))] - pub timeout: Duration, -} - /// Specifies whether to configure to maximise transmission throughput, receive throughput, or both. /// Specifying `Both` for a one-way data transfer will work, but wastes kernel memory. #[derive(Copy, Clone, Debug)] @@ -48,8 +31,19 @@ pub enum ThroughputMode { } /// Selects the congestion control algorithm to use -#[derive(Copy, Clone, Debug, strum_macros::Display, clap::ValueEnum)] -#[strum(serialize_all = "kebab_case")] +#[derive( + Copy, + Clone, + Debug, + PartialEq, + Eq, + strum::Display, + strum::EnumString, + strum::VariantNames, + clap::ValueEnum, + Serialize, +)] +#[strum(serialize_all = "lowercase")] // N.B. this applies to EnumString, not Display pub enum CongestionControllerType { /// The congestion algorithm TCP uses. This is good for most cases. Cubic, @@ -64,143 +58,21 @@ pub enum CongestionControllerType { Bbr, } -/// Parameters needed to set up transport configuration -#[derive(Copy, Clone, Debug, Parser)] -pub struct BandwidthParams { - /// The maximum network bandwidth we expect receiving data FROM the remote system. - /// - /// This may be specified directly as a number of bytes, or as an SI quantity - /// e.g. "10M" or "256k". Note that this is described in BYTES, not bits; - /// if (for example) you expect to fill a 1Gbit ethernet connection, - /// 125M might be a suitable setting. - #[arg(short('b'), long, help_heading("Network tuning"), display_order(10), default_value("12500k"), value_name="bytes", value_parser=clap::value_parser!(Bytes))] - pub rx_bw: Bytes, - - /// The maximum network bandwidth we expect sending data TO the remote system, - /// if it is different from the bandwidth FROM the system. - /// (For example, when you are connected via an asymmetric last-mile DSL or fibre profile.) - /// [default: use the value of --rx-bw] - #[arg(short('B'), long, help_heading("Network tuning"), display_order(10), value_name="bytes", value_parser=clap::value_parser!(Bytes))] - pub tx_bw: Option>, - - /// The expected network Round Trip time to the target system, in milliseconds. - #[arg( - short('r'), - long, - help_heading("Network tuning"), - display_order(1), - default_value("300"), - value_name("ms") - )] - pub rtt: u16, - - /// Specifies the congestion control algorithm to use. - #[arg( - long, - action, - value_name = "alg", - help_heading("Advanced network tuning") - )] - #[clap(value_enum, default_value_t=CongestionControllerType::Cubic)] - pub congestion: CongestionControllerType, - - /// (Network wizards only!) - /// The initial value for the sending congestion control window. - /// - /// Setting this value too high reduces performance! - /// - /// If not specified, this setting is determined by the selected - /// congestion control algorithm. - #[arg(long, help_heading("Advanced network tuning"), value_name = "bytes")] - pub initial_congestion_window: Option, -} - -impl Display for BandwidthParams { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let iwind = match self.initial_congestion_window { - None => "".to_string(), - Some(s) => s.human_count_bytes().to_string(), - }; - let (tx, rx) = (self.tx(), self.rx()); - write!( - f, - "rx {rx} ({rxbits}), tx {tx} ({txbits}), rtt {rtt}, congestion algorithm {congestion:?} with initial window {iwind}", - tx = tx.human_count_bytes(), - txbits = (tx * 8).human_count("bit"), - rx = rx.human_count_bytes(), - rxbits = (rx * 8).human_count("bit"), - rtt = self.rtt_duration().human_duration(), - congestion = self.congestion, - ) - } -} - -impl BandwidthParams { - /// Computes the theoretical bandwidth-delay product for outbound data - #[must_use] - #[allow(clippy::cast_possible_truncation)] - pub fn bandwidth_delay_product_tx(&self) -> u64 { - self.tx() * u64::from(self.rtt) / 1000 - } - /// Computes the theoretical bandwidth-delay product for inbound data - #[must_use] - #[allow(clippy::cast_possible_truncation)] - pub fn bandwidth_delay_product_rx(&self) -> u64 { - self.rx() * u64::from(self.rtt) / 1000 - } - #[must_use] - /// Receive bandwidth (accessor) - pub fn rx(&self) -> u64 { - self.rx_bw.size() - } - #[must_use] - /// Transmit bandwidth (accessor) - pub fn tx(&self) -> u64 { - if let Some(tx) = self.tx_bw { - tx.size() - } else { - self.rx() - } - } - /// RTT accessor as Duration - #[must_use] - pub fn rtt_duration(&self) -> Duration { - Duration::from_millis(u64::from(self.rtt)) - } - - /// UDP kernel sending buffer size to use - #[must_use] - pub fn send_buffer(&self) -> u64 { - // UDP kernel buffers of 2MB have proven sufficient to get close to line speed on a 300Mbit downlink with 300ms RTT. - 2_097_152 - } - /// UDP kernel receive buffer size to use - #[must_use] - pub fn recv_buffer(&self) -> u64 { - // UDP kernel buffers of 2MB have proven sufficient to get close to line speed on a 300Mbit downlink with 300ms RTT. - 2_097_152 - } - - /// QUIC receive window - #[must_use] - pub fn recv_window(&self) -> u64 { - // The theoretical in-flight limit appears to be sufficient - self.bandwidth_delay_product_rx() - } - - /// QUIC send window - #[must_use] - pub fn send_window(&self) -> u64 { - // There might be random added latency en route, so provide for a larger send window than theoretical. - 2 * self.bandwidth_delay_product_tx() +impl<'de> Deserialize<'de> for CongestionControllerType { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let lower = s.to_ascii_lowercase(); + // requires strum::EnumString && strum::VariantNames && #[strum(serialize_all = "lowercase")] + FromStr::from_str(&lower) + .map_err(|_| de::Error::unknown_variant(&s, CongestionControllerType::VARIANTS)) } } /// Creates a `quinn::TransportConfig` for the endpoint setup -pub fn create_config( - params: BandwidthParams, - mode: ThroughputMode, -) -> Result> { +pub fn create_config(params: &Configuration, mode: ThroughputMode) -> Result> { let mut config = TransportConfig::default(); let _ = config .max_concurrent_bidi_streams(1u8.into()) @@ -212,7 +84,7 @@ pub fn create_config( ThroughputMode::Tx | ThroughputMode::Both => { let _ = config .send_window(params.send_window()) - .datagram_send_buffer_size(params.send_buffer().try_into()?); + .datagram_send_buffer_size(Configuration::send_buffer().try_into()?); } ThroughputMode::Rx => (), } @@ -222,35 +94,39 @@ pub fn create_config( ThroughputMode::Rx | ThroughputMode::Both => { let _ = config .stream_receive_window(params.recv_window().try_into()?) - .datagram_receive_buffer_size(Some(params.recv_buffer() as usize)); + .datagram_receive_buffer_size(Some(Configuration::recv_buffer() as usize)); } ThroughputMode::Tx => (), } + let window = params.initial_congestion_window; match params.congestion { CongestionControllerType::Cubic => { let mut cubic = CubicConfig::default(); - if let Some(w) = params.initial_congestion_window { - let _ = cubic.initial_window(w); + if window != 0 { + let _ = cubic.initial_window(window); } let _ = config.congestion_controller_factory(Arc::new(cubic)); } CongestionControllerType::Bbr => { let mut bbr = BbrConfig::default(); - if let Some(w) = params.initial_congestion_window { - let _ = bbr.initial_window(w); + if window != 0 { + let _ = bbr.initial_window(window); } let _ = config.congestion_controller_factory(Arc::new(bbr)); } } - debug!("Network configuration: {params}"); + debug!( + "Network configuration: {}", + params.format_transport_config() + ); debug!( "Buffer configuration: send window {sw}, buffer {sb}; recv window {rw}, buffer {rb}", sw = params.send_window().human_count_bytes(), - sb = params.send_buffer().human_count_bytes(), + sb = Configuration::send_buffer().human_count_bytes(), rw = params.recv_window().human_count_bytes(), - rb = params.recv_buffer().human_count_bytes() + rb = Configuration::recv_buffer().human_count_bytes() ); Ok(config.into()) diff --git a/src/util/address_family.rs b/src/util/address_family.rs new file mode 100644 index 0000000..9dbfe35 --- /dev/null +++ b/src/util/address_family.rs @@ -0,0 +1,98 @@ +//! CLI helper - Address family +// (c) 2024 Ross Younger + +use std::str::FromStr; + +use figment::error::{Actual, OneOf}; +use serde::{de, Deserialize, Serialize}; + +/// Representation of an IP address family +/// +/// This is a local type with special parsing semantics and aliasing to take part in the config/CLI system. +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, Serialize)] +#[serde(rename_all = "kebab-case")] // to match clap::ValueEnum +pub enum AddressFamily { + /// IPv4 + #[value(alias("4"), alias("inet4"))] + Inet, + /// IPv6 + #[value(alias("6"))] + Inet6, + /// Unspecified. qcp will use whatever seems suitable given the target address or the result of DNS lookup. + Any, +} + +impl FromStr for AddressFamily { + type Err = figment::Error; + + fn from_str(s: &str) -> Result { + let lc = s.to_ascii_lowercase(); + match lc.as_str() { + "4" | "inet" | "inet4" => Ok(AddressFamily::Inet), + "6" | "inet6" => Ok(AddressFamily::Inet6), + "any" => Ok(AddressFamily::Any), + _ => Err(figment::error::Kind::InvalidType( + Actual::Str(s.into()), + OneOf(&["inet", "4", "inet6", "6"]).to_string(), + ) + .into()), + } + } +} + +impl<'de> Deserialize<'de> for AddressFamily { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + FromStr::from_str(&s).map_err(de::Error::custom) + } +} + +#[cfg(test)] +mod test { + use std::str::FromStr; + + use super::AddressFamily; + + #[test] + fn serialize() { + let a = AddressFamily::Inet; + let b = AddressFamily::Inet6; + let c = AddressFamily::Any; + + let aa = serde_json::to_string(&a); + let bb = serde_json::to_string(&b); + let cc = serde_json::to_string(&c); + assert_eq!(aa.unwrap(), "\"inet\""); + assert_eq!(bb.unwrap(), "\"inet6\""); + assert_eq!(cc.unwrap(), "\"any\""); + } + + #[test] + fn deser_str() { + use AddressFamily::*; + for (str, expected) in &[ + ("4", Inet), + ("inet", Inet), + ("inet4", Inet), + ("6", Inet6), + ("inet6", Inet6), + ("any", Any), + ] { + let raw = AddressFamily::from_str(str).expect(str); + let json = format!(r#""{str}""#); + let output = serde_json::from_str::(&json).expect(str); + assert_eq!(raw, *expected); + assert_eq!(output, *expected); + } + } + + #[test] + fn deser_invalid() { + for s in &["true", "5", r#""5""#, "-1", r#""42"#, r#""string"#] { + let _ = serde_json::from_str::(s).expect_err(s); + } + } +} diff --git a/src/util/dns.rs b/src/util/dns.rs index e78545d..c7faee4 100644 --- a/src/util/dns.rs +++ b/src/util/dns.rs @@ -1,28 +1,26 @@ -// DNS helpers +//! DNS helpers // (c) 2024 Ross Younger use std::net::IpAddr; -use crate::protocol::control::ConnectionType; use anyhow::Context as _; +use super::AddressFamily; + /// DNS lookup helper /// /// Results can be restricted to a given address family. /// Only the first matching result is returned. /// If there are no matching records of the required type, returns an error. -pub fn lookup_host_by_family( - host: &str, - desired: Option, -) -> anyhow::Result { +pub fn lookup_host_by_family(host: &str, desired: AddressFamily) -> anyhow::Result { let candidates = dns_lookup::lookup_host(host) .with_context(|| format!("host name lookup for {host} failed"))?; let mut it = candidates.iter(); let found = match desired { - None => it.next(), - Some(ConnectionType::Ipv4) => it.find(|addr| addr.is_ipv4()), - Some(ConnectionType::Ipv6) => it.find(|addr| addr.is_ipv6()), + AddressFamily::Any => it.next(), + AddressFamily::Inet => it.find(|addr| addr.is_ipv4()), + AddressFamily::Inet6 => it.find(|addr| addr.is_ipv6()), }; found .map(std::borrow::ToOwned::to_owned) diff --git a/src/util/humanu64.rs b/src/util/humanu64.rs new file mode 100644 index 0000000..ad0445c --- /dev/null +++ b/src/util/humanu64.rs @@ -0,0 +1,122 @@ +//! Serialization helper type - u64 parseable by humanize_rs +// (c) 2024 Ross Younger + +use std::{ops::Deref, str::FromStr}; + +use humanize_rs::bytes::Bytes; +use serde::{ + de::{self, Error as _}, + Serialize, +}; + +/// An integer field that may also be expressed using SI notation (k, M, G, etc). +/// For example, `1k` and `1000` are the same. +/// +/// (Nerdy description: This is a newtype wrapper to `u64` that adds a flexible deserializer via `humanize_rs::bytes::Bytes`.) + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(from = "String", into = "String")] +pub struct HumanU64(pub u64); + +impl HumanU64 { + /// standard constructor + #[must_use] + pub fn new(value: u64) -> Self { + Self(value) + } +} + +impl Deref for HumanU64 { + type Target = u64; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for u64 { + fn from(value: HumanU64) -> Self { + value.0 + } +} + +impl From for String { + fn from(value: HumanU64) -> Self { + format!("{}", *value) + } +} + +impl FromStr for HumanU64 { + type Err = figment::Error; + + fn from_str(s: &str) -> Result { + use figment::error::Error as FigmentError; + Ok(Self::new( + Bytes::from_str(s) + .map_err(|_| { + FigmentError::invalid_value( + de::Unexpected::Str(s), + &"an integer with optional units (examples: `100`, `10M`, `42k`)", + ) + })? + .size(), + )) + } +} + +impl From for HumanU64 { + fn from(value: u64) -> Self { + Self::new(value) + } +} + +impl<'de> serde::Deserialize<'de> for HumanU64 { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + FromStr::from_str(&s).map_err(de::Error::custom) + } +} + +#[cfg(test)] +mod test { + use std::str::FromStr as _; + + use serde_test::{assert_tokens, Token}; + + use super::HumanU64; + + fn test_deser_str(s: &str, n: u64) { + let foo: HumanU64 = serde_json::from_str(s).unwrap(); + assert_eq!(*foo, n); + } + + #[test] + fn deser_number_string() { + test_deser_str("\"12345\"", 12345); + } + + #[test] + fn deser_human() { + test_deser_str("\"100k\"", 100_000); + } + + #[test] + fn serde_test() { + let bw = HumanU64::new(42); + assert_tokens(&bw, &[Token::Str("42")]); + } + + #[test] + fn from_int() { + let result = HumanU64::new(12345); + assert_eq!(*result, 12345); + } + #[test] + fn from_str() { + let result = HumanU64::from_str("12345k").unwrap(); + assert_eq!(*result, 12_345_000); + } +} diff --git a/src/util/mod.rs b/src/util/mod.rs index f972f28..a4d9b53 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,19 +1,37 @@ //! General utility code that didn't fit anywhere else // (c) 2024 Ross Younger +mod address_family; +pub use address_family::AddressFamily; + mod dns; pub use dns::lookup_host_by_family; mod cert; pub use cert::Credentials; +pub mod humanu64; pub mod io; pub mod socket; pub mod stats; pub mod time; mod tracing; -pub use tracing::setup as setup_tracing; +pub use tracing::{setup as setup_tracing, TimeFormat}; + +mod port_range; +pub use port_range::PortRange; + +mod optionalify; +pub use optionalify::{derive_deftly_template_Optionalify, insert_if_some}; -mod cli; -pub use cli::{parse_duration, PortRange}; +#[cfg(test)] +pub(crate) fn make_test_tempfile( + data: &str, + filename: &str, +) -> (std::path::PathBuf, tempfile::TempDir) { + let tempdir = tempfile::tempdir().unwrap(); + let path = tempdir.path().join(filename); + std::fs::write(&path, data).expect("Unable to write tempfile"); + (path, tempdir) +} diff --git a/src/util/optionalify.rs b/src/util/optionalify.rs new file mode 100644 index 0000000..bf8d4a8 --- /dev/null +++ b/src/util/optionalify.rs @@ -0,0 +1,161 @@ +//! Macro to clone a structure for use with configuration data +// (c) 2024 Ross Younger + +#![allow(meta_variable_misuse)] // false positives in these macro definitions + +use derive_deftly::define_derive_deftly; +use figment::value::{Dict, Value}; + +/// Helper function for `figment::Provider` implementation +/// +/// If the given `arg` is not None, inserts it into `dict` with key `arg_name`. +pub fn insert_if_some( + dict: &mut Dict, + arg_name: &str, + arg: Option, +) -> Result<(), figment::Error> +where + T: serde::Serialize, +{ + if let Some(a) = arg { + let _ = dict.insert(arg_name.to_string(), Value::serialize(a)?); + } + Ok(()) +} + +define_derive_deftly! { + /// Clones a structure for use with CLI ([`clap`](https://docs.rs/clap/)) and options managers ([`figment`](https://docs.rs/figment/)). + /// + /// The expected use case is for configuration structs. + /// The idea is that you define your struct how you want, then automatically generate a variety of the struct + /// to make life easier. The variant: + /// * doesn't require the user to enter all parameters (everything is an `Option`) + /// * implements the [`figment::Provider`](https://docs.rs/figment/latest/figment/trait.Provider.html) helper trait + /// which makes it easy to extract only the parameters the user entered. + /// + /// Of course, you would not set `default_value` attributes, because you would normally register + /// the defaults with the configuration system at a different place (e.g. by implementing the [`Default`][std::default::Default] trait). + /// + /// The new struct: + /// * is named `{OriginalName}_Optional` + /// * has the same fields as the original, with all their attributes, but with their types wrapped + /// in [`std::option::Option`]. (Yes, even any that were already `Option<...>`.) + /// * contains exactly the same attributes as the original, plus `#[derive(Default)]` *(see note)*. + /// * has the same visibility as the original, though you can override this with `#[deftly(visibility = ...)]` + /// + /// **Note:** + /// If you already derived Default for the original struct, add the + /// attribute ```#[deftly(already_has_default)]```. + /// This tells the macro to *not* add ```#[derive(Default)]```, avoiding a compile error. + /// + ///

+ /// CAUTION: Attribute ordering is crucial. All attributes to be cloned to the new struct + /// must appear after deriving `Optionalify`. It might look something like this: + ///
+ /// + /// ``` + /// use derive_deftly::Deftly; + /// use qcp::derive_deftly_template_Optionalify; + /// #[derive(Deftly)] + /// #[derive_deftly(Optionalify)] + /// #[derive(Debug, Clone /*, WhateverElseYouNeed...*/)] + /// struct MyStruct { + /// /* ... */ + /// } + /// ``` + /// + /// As with other template macros created with [`derive-deftly`](https://docs.rs/derive-deftly/), if you need + /// to see what's going on you can use `#[derive_deftly(Optionalify[dbg])]` instead of `#[derive_deftly(Optionalify)]` + /// to see the expanded output at compile time. + export Optionalify for struct, expect items: + ${define OPTIONAL_TYPE ${paste $tdeftype _Optional}} + + /// Auto-derived struct variant + /// + #[allow(non_camel_case_types)] + ${tattrs} + ${if not(tmeta(already_has_default)){ + #[derive(Default)] + }} + ${if tmeta(visibility) { + ${tmeta(visibility) as token_stream} + } else { + ${tvis} + }} + struct $OPTIONAL_TYPE { + $( + ${fattrs} + ${fvis} $fname: Option<$ftype>, + // Yes, if $ftype is Option, the derived struct ends up with Option>. That's OK. + ) + } + + impl figment::Provider for $OPTIONAL_TYPE { + fn metadata(&self) -> figment::Metadata { + figment::Metadata::named("command-line").interpolater(|_profile, path| { + use heck::ToKebabCase; + let key = path.last().map_or("".to_string(), |s| s.to_kebab_case()); + format!("--{key}") + }) + } + + fn data(&self) -> Result, figment::Error> { + use $crate::util::insert_if_some; + use figment::{Profile, value::{Dict, Map}}; + let mut dict = Dict::new(); + + $( + insert_if_some(&mut dict, stringify!($fname), self.${fname}.clone())?; + ) + + let mut profile_map = Map::new(); + let _ = profile_map.insert(Profile::Global, dict); + + Ok(profile_map) + } + } +} + +#[allow(clippy::module_name_repetitions)] +pub use derive_deftly_template_Optionalify; + +#[cfg(test)] +mod test { + use super::derive_deftly_template_Optionalify; + use derive_deftly::Deftly; + use figment::{providers::Serialized, Figment}; + + #[derive(Deftly)] + #[derive_deftly(Optionalify)] + #[deftly(already_has_default)] + #[derive(PartialEq, Debug, Default, serde::Serialize, serde::Deserialize)] + struct Foo { + bar: i32, + baz: String, + wibble: Option, + q: Option, + } + + #[test] + fn optionality() { + let mut entered = Foo_Optional::default(); + assert!(entered.bar.is_none()); + entered.bar = Some(999); + entered.wibble = Some(Some("hi".to_string())); + entered.q = Some(Some(123)); + + //println!("simulated cli: {entered:?}"); + let f = Figment::new() + .merge(Serialized::defaults(Foo::default())) + .merge(entered); + let working: Foo = f.extract().expect("extract failed"); + + let expected = Foo { + bar: 999, + baz: String::new(), // default + wibble: Some("hi".into()), + q: Some(123), + }; + assert_eq!(expected, working); + } +} diff --git a/src/util/cli.rs b/src/util/port_range.rs similarity index 54% rename from src/util/cli.rs rename to src/util/port_range.rs index 0e3b5c2..3cac9d9 100644 --- a/src/util/cli.rs +++ b/src/util/port_range.rs @@ -1,15 +1,26 @@ -// CLI argument +//! CLI argument helper type - a range of UDP port numnbers. // (c) 2024 Ross Younger - +use serde::{ + de::{self, Error, Unexpected}, + Serialize, +}; use std::{fmt::Display, str::FromStr}; -/// Represents a number or a contiguous range of positive integers -#[derive(Debug, Clone, Copy)] +/// A range of UDP port numbers. +/// +/// Port 0 is allowed with the usual meaning ("any available port"), but 0 may not form part of a range. +/// +/// In a configuration file, a range may specified as an integer or as a pair of ports. For example: +/// ```text +/// remote_port 60000 # a single port +/// remote_port 60000-60010 # a range +/// ``` +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(from = "String", into = "String")] pub struct PortRange { /// First number in the range pub begin: u16, - /// Last number in the range. - /// The caller defines whether the range is inclusive or exclusive of `end`. + /// Last number in the range, inclusive. pub end: u16, } @@ -23,15 +34,30 @@ impl Display for PortRange { } } +impl From for String { + fn from(value: PortRange) -> Self { + value.to_string() + } +} + impl FromStr for PortRange { - type Err = anyhow::Error; + type Err = figment::Error; fn from_str(s: &str) -> Result { + use figment::error::Error as FigmentError; + static EXPECTED: &str = "a single port number [0..65535] or a range `a-b`"; if let Ok(n) = s.parse::() { // case 1: it's a number // port 0 is allowed here (with the usual "unspecified" semantics), the user may know what they're doing. return Ok(Self { begin: n, end: n }); } + if let Ok(n) = s.parse::() { + // out of range + return Err(FigmentError::invalid_value( + Unexpected::Unsigned(n), + &EXPECTED, + )); + } // case 2: it's a range if let Some((a, b)) = s.split_once('-') { let aa = a.parse(); @@ -39,21 +65,36 @@ impl FromStr for PortRange { if aa.is_ok() && bb.is_ok() { let aa = aa.unwrap_or_default(); let bb = bb.unwrap_or_default(); - anyhow::ensure!(aa != 0, "0 is not valid in a port range"); - anyhow::ensure!(aa <= bb, "invalid range"); + if aa > bb { + return Err(FigmentError::custom(format!( + "invalid port range `{s}` (must be increasing)" + ))); + } else if aa == 0 && bb != 0 { + return Err(FigmentError::custom(format!("invalid port range `{s}` (port 0 means \"any\" so cannot be part of a range)"))); + } return Ok(Self { begin: aa, end: bb }); } // else failed to parse } // else failed to parse - anyhow::bail!("failed to parse range"); + Err(FigmentError::invalid_value(Unexpected::Str(s), &EXPECTED)) } } -/// Parse helper for Duration fields specified in seconds -pub fn parse_duration(arg: &str) -> Result { - let seconds = arg.parse()?; - Ok(std::time::Duration::from_secs(seconds)) +impl PortRange { + pub(crate) fn is_default(self) -> bool { + self.begin == 0 && self.begin == self.end + } +} + +impl<'de> serde::Deserialize<'de> for PortRange { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + FromStr::from_str(&s).map_err(de::Error::custom) + } } #[cfg(test)] diff --git a/src/util/socket.rs b/src/util/socket.rs index 4c4631a..1552ceb 100644 --- a/src/util/socket.rs +++ b/src/util/socket.rs @@ -86,7 +86,7 @@ pub fn bind_unspecified_for(peer: &SocketAddr) -> anyhow::Result, + range: PortRange, ) -> anyhow::Result { let addr: IpAddr = match peer { SocketAddr::V4(_) => IpAddr::V4(Ipv4Addr::UNSPECIFIED), @@ -98,12 +98,8 @@ pub fn bind_range_for_peer( /// Creates and binds a UDP socket from a restricted range of local ports, for a given local address pub fn bind_range_for_address( addr: IpAddr, - range: Option, + range: PortRange, ) -> anyhow::Result { - let range = match range { - None => PortRange { begin: 0, end: 0 }, - Some(r) => r, - }; if range.begin == range.end { return Ok(UdpSocket::bind(SocketAddr::new(addr, range.begin))?); } @@ -119,7 +115,7 @@ pub fn bind_range_for_address( /// Creates and binds a UDP socket from a restricted range of local ports, for the unspecified address of the given address family pub fn bind_range_for_family( family: ConnectionType, - range: Option, + range: PortRange, ) -> anyhow::Result { let addr = match family { ConnectionType::Ipv4 => IpAddr::V4(Ipv4Addr::UNSPECIFIED), diff --git a/src/util/stats.rs b/src/util/stats.rs index 0e09e84..cc21cfc 100644 --- a/src/util/stats.rs +++ b/src/util/stats.rs @@ -7,7 +7,7 @@ use quinn::ConnectionStats; use std::{cmp, fmt::Display, time::Duration}; use tracing::{info, warn}; -use crate::{protocol::control::ClosedownReport, transport::BandwidthParams}; +use crate::{config::Configuration, protocol::control::ClosedownReport}; /// Human friendly output helper #[derive(Debug, Clone, Copy)] @@ -56,7 +56,7 @@ pub fn process_statistics( payload_bytes: u64, transport_time: Option, remote_stats: ClosedownReport, - bandwidth: BandwidthParams, + bandwidth: &Configuration, show_statistics: bool, ) { let locale = &num_format::Locale::en; diff --git a/src/util/tracing.rs b/src/util/tracing.rs index 403d5c5..1c4b624 100644 --- a/src/util/tracing.rs +++ b/src/util/tracing.rs @@ -7,13 +7,71 @@ use std::{ sync::{Arc, Mutex}, }; +use anstream::eprintln; use anyhow::Context; use indicatif::MultiProgress; -use tracing_subscriber::{fmt, prelude::*, EnvFilter, Layer}; +use serde::{de, Deserialize, Serialize}; +use strum::VariantNames as _; +use tracing_subscriber::{ + fmt::{ + time::{ChronoLocal, ChronoUtc}, + MakeWriter, + }, + prelude::*, + EnvFilter, +}; + +const FRIENDLY_FORMAT_LOCAL: &str = "%Y-%m-%d %H:%M:%SL"; +const FRIENDLY_FORMAT_UTC: &str = "%Y-%m-%d %H:%M:%SZ"; +/// Environment variable that controls what gets logged to stderr const STANDARD_ENV_VAR: &str = "RUST_LOG"; +/// Environment variable that controls what gets logged to file const LOG_FILE_DETAIL_ENV_VAR: &str = "RUST_LOG_FILE_DETAIL"; +/// Selects the format of time stamps in output messages +#[derive( + Copy, + Clone, + Debug, + Default, + Eq, + PartialEq, + strum::Display, + strum::EnumString, + strum::VariantNames, + clap::ValueEnum, + Serialize, +)] +#[strum(serialize_all = "lowercase")] +#[serde(rename_all = "kebab-case")] +pub enum TimeFormat { + /// Local time (as best as we can figure it out), as "year-month-day HH:MM:SS" + #[default] + Local, + /// UTC time, as "year-month-day HH:MM:SS" + Utc, + /// UTC time, in the format described in [RFC 3339](https://datatracker.ietf.org/doc/html/rfc3339). + /// + /// Examples: + /// `1997-11-12T09:55:06-06:00` + /// `2010-03-14T18:32:03Z` + Rfc3339, +} + +impl<'de> Deserialize<'de> for TimeFormat { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let lower = s.to_ascii_lowercase(); + // requires strum::EnumString && strum::VariantNames && #[strum(serialize_all = "lowercase")] + std::str::FromStr::from_str(&lower) + .map_err(|_| de::Error::unknown_variant(&s, TimeFormat::VARIANTS)) + } +} + /// Result type for `filter_for()` struct FilterResult { filter: EnvFilter, @@ -41,6 +99,46 @@ fn filter_for(trace_level: &str, key: &str) -> anyhow::Result { }) } +fn make_tracing_layer( + writer: W, + filter: F, + time_format: TimeFormat, + show_target: bool, + ansi: bool, +) -> Box + Send + Sync> +where + S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>, + W: for<'writer> MakeWriter<'writer> + 'static + Sync + Send, + F: tracing_subscriber::layer::Filter + 'static + Sync + Send, +{ + // The common bit + let layer = tracing_subscriber::fmt::layer::() + .compact() + .with_target(show_target) + .with_ansi(ansi); + + // Unfortunately, you have to add the timer before you can add the writer and filter, so + // there's a bit of duplication here: + match time_format { + TimeFormat::Local => layer + .with_timer(ChronoLocal::new(FRIENDLY_FORMAT_LOCAL.into())) + .with_writer(writer) + .with_filter(filter) + .boxed(), + TimeFormat::Utc => layer + .with_timer(ChronoUtc::new(FRIENDLY_FORMAT_UTC.into())) + .with_writer(writer) + .with_filter(filter) + .boxed(), + + TimeFormat::Rfc3339 => layer + .with_timer(ChronoLocal::rfc_3339()) + .with_writer(writer) + .with_filter(filter) + .boxed(), + } +} + /// Set up rust tracing, to console (via an optional `MultiProgress`) and optionally to file. /// /// By default we log only our events (qcp), at a given trace level. @@ -53,6 +151,7 @@ pub fn setup( trace_level: &str, display: Option<&MultiProgress>, filename: &Option, + time_format: TimeFormat, ) -> anyhow::Result<()> { let mut layers = Vec::new(); @@ -60,22 +159,25 @@ pub fn setup( let filter = filter_for(trace_level, STANDARD_ENV_VAR)?; // If we used the environment variable, show log targets; if we did not, we're only logging qcp, so do not show targets. - let format = fmt::layer().compact().with_target(filter.used_env); match display { None => { - let format = format - .with_writer(std::io::stderr) - .with_filter(filter.filter) - .boxed(); - layers.push(format); + layers.push(make_tracing_layer( + std::io::stderr, + filter.filter, + time_format, + filter.used_env, + true, + )); } Some(mp) => { - let format = format - .with_writer(ProgressWriter::wrap(mp)) - .with_filter(filter.filter) - .boxed(); - layers.push(format); + layers.push(make_tracing_layer( + ProgressWriter::wrap(mp), + filter.filter, + time_format, + filter.used_env, + true, + )); } }; @@ -91,15 +193,14 @@ pub fn setup( } else { filter_for(trace_level, STANDARD_ENV_VAR)? }; - let layer = tracing_subscriber::fmt::layer() - .with_writer(out_file) - // Same logic for if we used the environment variable. - .with_target(filter.used_env) - .compact() - .with_ansi(false) - .with_filter(filter.filter) - .boxed(); - layers.push(layer); + // Same logic for whether we used the environment variable. + layers.push(make_tracing_layer( + out_file, + filter.filter, + time_format, + filter.used_env, + false, + )); } //////// @@ -110,15 +211,11 @@ pub fn setup( } /// A wrapper type so tracing can output in a way that doesn't mess up `MultiProgress` -struct ProgressWriter { - display: MultiProgress, -} +struct ProgressWriter(MultiProgress); impl ProgressWriter { fn wrap(display: &MultiProgress) -> Mutex { - Mutex::new(Self { - display: display.clone(), - }) + Mutex::new(Self(display.clone())) } } @@ -126,10 +223,10 @@ impl Write for ProgressWriter { fn write(&mut self, buf: &[u8]) -> std::io::Result { let msg = std::str::from_utf8(buf) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; - if self.display.is_hidden() { + if self.0.is_hidden() { eprintln!("{msg}"); } else { - self.display.println(msg)?; + self.0.println(msg)?; } Ok(buf.len()) }