diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..219d29a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Dwarnings + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Check formatting + run: cargo fmt --all --check + + - name: Build (default features) + run: cargo build --release -p bascule-server + + - name: Build (all features) + run: cargo build --release -p bascule-server --features agent-id + + - name: Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Tests + run: cargo test --all + + - name: Binary size + run: ls -lh target/release/bascule + + - name: Substrate contamination check + run: | + count=$(grep -c "substrate\|chronicle\|gsap\|hfl\|metakernel" Cargo.lock || true) + if [ "$count" -gt 0 ]; then + echo "ERROR: Substrate dependencies found in Cargo.lock" + exit 1 + fi diff --git a/Cargo.lock b/Cargo.lock index f1040b9..02e7ecb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,6 +181,7 @@ dependencies = [ "russh", "russh-keys", "serde", + "tempfile", "thiserror 1.0.69", "tokio", "toml", @@ -201,6 +202,24 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "bascule-shell" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "dirs", + "hex", + "nix 0.29.0", + "serde", + "serde_json", + "sha2", + "toml", + "tracing", + "tracing-subscriber", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -576,6 +595,27 @@ dependencies = [ "subtle", ] +[[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", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -669,6 +709,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" + [[package]] name = "ff" version = "0.13.1" @@ -926,6 +972,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hex-literal" version = "0.4.1" @@ -1304,6 +1356,21 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1396,6 +1463,18 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1496,6 +1575,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "p256" version = "0.13.2" @@ -1693,7 +1778,7 @@ dependencies = [ "lazy_static", "libc", "log", - "nix", + "nix 0.25.1", "serial", "shared_library", "shell-words", @@ -1897,6 +1982,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -2150,6 +2246,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.37" @@ -2584,6 +2693,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "termios" version = "0.2.2" @@ -3313,6 +3435,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -3340,6 +3471,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -3373,6 +3519,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3385,6 +3537,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3397,6 +3555,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3421,6 +3585,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3433,6 +3603,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3445,6 +3621,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3457,6 +3639,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 4dbdb4c..7f09696 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/bascule-core", "crates/bascule-server", "crates/bascule-auth-agent-id"] +members = ["crates/bascule-core", "crates/bascule-server", "crates/bascule-auth-agent-id", "crates/bascule-shell"] resolver = "2" [workspace.package] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7391d38 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# Build stage +FROM rust:1-bookworm AS builder +WORKDIR /build +COPY . . +RUN cargo build --release -p bascule-server + +# Runtime stage +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + openssh-client \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /build/target/release/bascule /usr/local/bin/bascule +RUN chmod +x /usr/local/bin/bascule + +# Create non-root user +RUN useradd -r -s /usr/sbin/nologin bascule + +# Config, keys, and host key directories +RUN mkdir -p /etc/bascule/keys /var/lib/bascule \ + && chown -R bascule:bascule /etc/bascule /var/lib/bascule + +USER bascule +EXPOSE 2222 +ENTRYPOINT ["bascule"] +CMD ["--config", "/etc/bascule/config.toml"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81fbaf6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 170c042..7370d3b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Identity-aware SSH proxy for modern infrastructure. -**Bascule** is a lightweight SSH proxy that authenticates users via SSH keys, OIDC, or AI agent tokens, then connects them to a local shell, remote host, or ephemeral container. No agents. No control plane. One binary. +**Bascule** is a lightweight SSH proxy that authenticates users via SSH keys or AI agent tokens, then connects them to a local shell, remote host, or ephemeral container. No agents. No control plane. One binary. ## Quick Start @@ -39,8 +39,9 @@ memory_limit = "512m" - **Three backends** — local PTY, remote SSH proxy, ephemeral containers - **Identity-aware sessions** — every connection authenticated and attributed -- **SSH key authentication** — standard authorized_keys, no surprises -- **AI agent authentication** — native Microsoft Entra Agent ID support +- **SSH key authentication** — standard authorized_keys file +- **AI agent authentication** — Microsoft Entra Agent ID support (optional feature) +- **Session limiting** — configurable max concurrent sessions - **Right-sized images** — curated container images (minimal, k8s-ops, net-ops, dev) - **SessionHandler trait** — extend with custom policy, audit, or recording - **Structured logging** — JSON format for production observability @@ -55,6 +56,7 @@ memory_limit = "512m" | License | Apache 2.0 | AGPL | MPL | | Container sessions | Native | No | No | | AI Agent Identity | Native | No | No | +| Auth | SSH keys, Entra Agent ID | OIDC, SAML, GitHub | OIDC, LDAP | | Binary size | ~7MB | ~150MB | ~100MB | See [docs/comparison.md](docs/comparison.md) for the full comparison. @@ -82,7 +84,7 @@ impl SessionHandler for MyAuditHandler { } ``` -Projects like [Guildhouse](https://guildhouse.dev) use the SessionHandler trait to add authorization contexts, completion receipts, and merkle-anchored audit trails. +Projects like [Guildhouse](https://guildhouse.dev) use the SessionHandler trait to add custom authorization, audit logging, and session governance. ## Documentation @@ -94,6 +96,15 @@ Projects like [Guildhouse](https://guildhouse.dev) use the SessionHandler trait - [Comparison](docs/comparison.md) - [Container Images](images/README.md) +## Roadmap + +- [ ] OIDC authentication (Keycloak, Entra, Okta, Google) +- [ ] Certificate-based authentication +- [ ] OpenTelemetry OTLP trace export +- [ ] Prometheus metrics endpoint +- [ ] Session recording +- [ ] Web UI for session management + ## License Apache 2.0 diff --git a/crates/bascule-auth-agent-id/src/lib.rs b/crates/bascule-auth-agent-id/src/lib.rs index 148f09e..3462da0 100644 --- a/crates/bascule-auth-agent-id/src/lib.rs +++ b/crates/bascule-auth-agent-id/src/lib.rs @@ -118,10 +118,7 @@ impl EntraAgentIdProvider { let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256); validation.set_audience(&self.expected_audiences); - let tenant_issuer = format!( - "https://login.microsoftonline.com/{}/v2.0", - self.tenant_id - ); + let tenant_issuer = format!("https://login.microsoftonline.com/{}/v2.0", self.tenant_id); if self.allow_multi_tenant { let issuers = [ tenant_issuer, @@ -208,11 +205,7 @@ impl EntraAgentIdProvider { #[async_trait] impl AuthProvider for EntraAgentIdProvider { - async fn check_password( - &self, - user: &str, - password: &str, - ) -> bool { + async fn check_password(&self, user: &str, password: &str) -> bool { match self.validate_token(password).await { Ok(identity) => { tracing::info!( diff --git a/crates/bascule-core/Cargo.toml b/crates/bascule-core/Cargo.toml index 02d6237..1cdd469 100644 --- a/crates/bascule-core/Cargo.toml +++ b/crates/bascule-core/Cargo.toml @@ -23,3 +23,6 @@ chrono = { workspace = true } uuid = { workspace = true } rand = { workspace = true } portable-pty = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/bascule-core/src/auth.rs b/crates/bascule-core/src/auth.rs index f8e07bc..20f6571 100644 --- a/crates/bascule-core/src/auth.rs +++ b/crates/bascule-core/src/auth.rs @@ -1,5 +1,7 @@ //! Pluggable authentication providers. +use std::path::{Path, PathBuf}; + use async_trait::async_trait; use russh_keys::key::PublicKey; @@ -39,3 +41,228 @@ impl AuthProvider for AcceptAllKeys { true } } + +/// SSH authorized_keys file authentication provider. +/// +/// Reads OpenSSH-format public keys from either: +/// - `{keys_path}` (single file mode, when path points to a file) +/// - `{keys_path}/{username}/authorized_keys` + `{keys_path}/authorized_keys` (directory mode) +pub struct AuthorizedKeysProvider { + keys_path: PathBuf, +} + +impl AuthorizedKeysProvider { + pub fn new(keys_path: impl Into) -> Self { + Self { + keys_path: keys_path.into(), + } + } + + fn load_keys_for_user(&self, user: &str) -> Vec { + let mut keys = Vec::new(); + + if self.keys_path.is_file() { + // Single file mode: one authorized_keys file for all users + if let Ok(loaded) = Self::parse_authorized_keys_file(&self.keys_path) { + keys.extend(loaded); + } + } else if self.keys_path.is_dir() { + // Directory mode: per-user + shared files + let per_user = self.keys_path.join(user).join("authorized_keys"); + if let Ok(loaded) = Self::parse_authorized_keys_file(&per_user) { + keys.extend(loaded); + } + + let shared = self.keys_path.join("authorized_keys"); + if let Ok(loaded) = Self::parse_authorized_keys_file(&shared) { + keys.extend(loaded); + } + } + + keys + } + + fn parse_authorized_keys_file(path: &Path) -> anyhow::Result> { + let content = std::fs::read_to_string(path)?; + let mut keys = Vec::new(); + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + // OpenSSH format: key-type base64-data [comment] + // Extract the base64 key data (second field) + let base64_key = match line.split_whitespace().nth(1) { + Some(k) => k, + None => continue, + }; + match russh_keys::parse_public_key_base64(base64_key) { + Ok(key) => keys.push(key), + Err(e) => { + tracing::debug!(error = %e, "Skipping unparseable key line"); + } + } + } + + Ok(keys) + } +} + +#[async_trait] +impl AuthProvider for AuthorizedKeysProvider { + async fn check_public_key(&self, user: &str, key: &PublicKey) -> bool { + let authorized = self.load_keys_for_user(user); + if authorized.is_empty() { + tracing::debug!(user = %user, "No authorized keys found"); + return false; + } + authorized.iter().any(|k| k == key) + } + + fn principal_for_user(&self, user: &str) -> String { + user.to_string() + } +} + +/// Composite auth provider — tries multiple providers in order. +/// Useful for supporting both human SSH key auth and AI agent token auth. +pub struct CompositeAuthProvider { + providers: Vec>, +} + +impl CompositeAuthProvider { + pub fn new(providers: Vec>) -> Self { + Self { providers } + } +} + +#[async_trait] +impl AuthProvider for CompositeAuthProvider { + async fn check_public_key(&self, user: &str, key: &PublicKey) -> bool { + for provider in &self.providers { + if provider.check_public_key(user, key).await { + return true; + } + } + false + } + + async fn check_password(&self, user: &str, password: &str) -> bool { + for provider in &self.providers { + if provider.check_password(user, password).await { + return true; + } + } + false + } + + fn principal_for_user(&self, user: &str) -> String { + // Use first provider's principal mapping + self.providers + .first() + .map(|p| p.principal_for_user(user)) + .unwrap_or_else(|| user.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn test_parse_authorized_keys_empty_file() { + let file = NamedTempFile::new().unwrap(); + let keys = AuthorizedKeysProvider::parse_authorized_keys_file(file.path()).unwrap(); + assert!(keys.is_empty()); + } + + #[test] + fn test_parse_authorized_keys_comments_and_blanks() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "# this is a comment").unwrap(); + writeln!(file).unwrap(); + writeln!(file, " # another comment").unwrap(); + let keys = AuthorizedKeysProvider::parse_authorized_keys_file(file.path()).unwrap(); + assert!(keys.is_empty()); + } + + #[test] + fn test_parse_authorized_keys_valid_ed25519() { + let mut file = NamedTempFile::new().unwrap(); + // Generate a real key, write its public part + let keypair = russh_keys::key::KeyPair::generate_ed25519(); + let pubkey = keypair.clone_public_key().unwrap(); + let key_data = russh_keys::PublicKeyBase64::public_key_base64(&pubkey); + writeln!(file, "ssh-ed25519 {} test@bascule", key_data).unwrap(); + + let keys = AuthorizedKeysProvider::parse_authorized_keys_file(file.path()).unwrap(); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0], pubkey); + } + + #[test] + fn test_parse_authorized_keys_invalid_line_skipped() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "not-a-valid-key-line").unwrap(); + writeln!(file, "ssh-ed25519 notvalidbase64 comment").unwrap(); + let keys = AuthorizedKeysProvider::parse_authorized_keys_file(file.path()).unwrap(); + assert!(keys.is_empty()); + } + + #[tokio::test] + async fn test_authorized_keys_provider_file_mode() { + let mut file = NamedTempFile::new().unwrap(); + let keypair = russh_keys::key::KeyPair::generate_ed25519(); + let pubkey = keypair.clone_public_key().unwrap(); + let key_data = russh_keys::PublicKeyBase64::public_key_base64(&pubkey); + writeln!(file, "ssh-ed25519 {} test@bascule", key_data).unwrap(); + + let provider = AuthorizedKeysProvider::new(file.path()); + + // Should accept the matching key + assert!(provider.check_public_key("anyuser", &pubkey).await); + + // Should reject a different key + let other_keypair = russh_keys::key::KeyPair::generate_ed25519(); + let other_pubkey = other_keypair.clone_public_key().unwrap(); + assert!(!provider.check_public_key("anyuser", &other_pubkey).await); + } + + #[tokio::test] + async fn test_authorized_keys_provider_dir_mode() { + let dir = tempfile::tempdir().unwrap(); + + // Create per-user key file + let user_dir = dir.path().join("tking"); + std::fs::create_dir_all(&user_dir).unwrap(); + + let keypair = russh_keys::key::KeyPair::generate_ed25519(); + let pubkey = keypair.clone_public_key().unwrap(); + let key_data = russh_keys::PublicKeyBase64::public_key_base64(&pubkey); + std::fs::write( + user_dir.join("authorized_keys"), + format!("ssh-ed25519 {} tking@test\n", key_data), + ) + .unwrap(); + + let provider = AuthorizedKeysProvider::new(dir.path()); + + // tking should authenticate + assert!(provider.check_public_key("tking", &pubkey).await); + + // other users should not (no shared file, no per-user file) + assert!(!provider.check_public_key("other", &pubkey).await); + } + + #[tokio::test] + async fn test_accept_all_keys() { + let provider = AcceptAllKeys; + let keypair = russh_keys::key::KeyPair::generate_ed25519(); + let pubkey = keypair.clone_public_key().unwrap(); + assert!(provider.check_public_key("anyone", &pubkey).await); + assert!(provider.check_password("anyone", "anything").await); + } +} diff --git a/crates/bascule-core/src/config.rs b/crates/bascule-core/src/config.rs index a51da92..18c4a36 100644 --- a/crates/bascule-core/src/config.rs +++ b/crates/bascule-core/src/config.rs @@ -67,7 +67,7 @@ pub struct ProxyConfig { pub accept_target_host_key: bool, } -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Deserialize)] pub struct AuthConfig { /// Auth mode: "accept-all" (dev), "authorized-keys" #[serde(default = "default_auth_mode")] @@ -80,6 +80,16 @@ pub struct AuthConfig { pub agent_id: Option, } +impl Default for AuthConfig { + fn default() -> Self { + Self { + mode: default_auth_mode(), + authorized_keys_path: None, + agent_id: None, + } + } +} + #[derive(Debug, Deserialize)] pub struct AgentIdConfig { /// Entra tenant ID. @@ -200,9 +210,219 @@ pub struct MetricsConfig { pub port: u16, } -fn default_service_name() -> String { "bascule".to_string() } -fn default_metrics_port() -> u16 { 9090 } +impl ContainerConfig { + /// Validate config values to prevent CLI argument injection. + /// Call at startup before accepting any connections. + pub fn validate(&self) -> anyhow::Result<()> { + // Memory limit: digits + optional k/m/g suffix + if let Some(ref mem) = self.memory_limit { + if !mem + .chars() + .all(|c| c.is_ascii_digit() || "kmgbKMGB".contains(c)) + { + anyhow::bail!("Invalid memory_limit: '{}'. Expected format: 512m, 1g", mem); + } + } -fn default_runtime() -> String { "auto".to_string() } -fn default_pull_policy() -> String { "if-not-present".to_string() } -fn default_true() -> bool { true } + // CPU limit: valid float + if let Some(ref cpu) = self.cpu_limit { + if cpu.parse::().is_err() { + anyhow::bail!("Invalid cpu_limit: '{}'. Expected format: 1.0, 0.5", cpu); + } + } + + // Image: no shell metacharacters, no flags + if self.image.starts_with('-') + || self.image.contains(';') + || self.image.contains('|') + || self.image.contains('&') + || self.image.contains('$') + || self.image.contains('`') + { + anyhow::bail!( + "Invalid image name: '{}'. Contains shell metacharacters.", + self.image + ); + } + + // Network: alphanumeric + hyphens/underscores/colons only + if let Some(ref net) = self.network { + if !net + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == ':') + { + anyhow::bail!( + "Invalid network: '{}'. Alphanumeric, hyphens, colons only.", + net + ); + } + } + + // User: alphanumeric + common user chars + if let Some(ref user) = self.user { + if !user + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == ':') + { + anyhow::bail!( + "Invalid user: '{}'. Alphanumeric, hyphens, colons only.", + user + ); + } + } + + // Mount paths: no shell metacharacters + for mount in &self.mounts { + for path in [&mount.source, &mount.target] { + if path.contains(';') + || path.contains('|') + || path.contains('&') + || path.contains('$') + || path.contains('`') + { + anyhow::bail!( + "Invalid mount path: '{}'. Contains shell metacharacters.", + path + ); + } + } + } + + // Env keys: alphanumeric + underscores + for key in self.env.keys() { + if !key.chars().all(|c| c.is_alphanumeric() || c == '_') { + anyhow::bail!( + "Invalid env var name: '{}'. Alphanumeric + underscores only.", + key + ); + } + } + + Ok(()) + } +} + +fn default_service_name() -> String { + "bascule".to_string() +} +fn default_metrics_port() -> u16 { + 9090 +} + +fn default_runtime() -> String { + "auto".to_string() +} +fn default_pull_policy() -> String { + "if-not-present".to_string() +} +fn default_true() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_container_config() -> ContainerConfig { + ContainerConfig { + runtime: "docker".to_string(), + image: "bascule-shell:minimal".to_string(), + pull_policy: "if-not-present".to_string(), + mounts: vec![], + env: std::collections::HashMap::new(), + memory_limit: Some("512m".to_string()), + cpu_limit: Some("1.0".to_string()), + shell: None, + user: Some("operator".to_string()), + ephemeral: true, + hardened: true, + read_only_rootfs: false, + network: Some("none".to_string()), + } + } + + #[test] + fn test_valid_container_config() { + assert!(valid_container_config().validate().is_ok()); + } + + #[test] + fn test_invalid_memory_limit() { + let mut cfg = valid_container_config(); + cfg.memory_limit = Some("--privileged".to_string()); + assert!(cfg.validate().is_err()); + } + + #[test] + fn test_invalid_cpu_limit() { + let mut cfg = valid_container_config(); + cfg.cpu_limit = Some("--privileged".to_string()); + assert!(cfg.validate().is_err()); + } + + #[test] + fn test_invalid_image_flag() { + let mut cfg = valid_container_config(); + cfg.image = "--privileged".to_string(); + assert!(cfg.validate().is_err()); + } + + #[test] + fn test_invalid_image_injection() { + let mut cfg = valid_container_config(); + cfg.image = "ubuntu; rm -rf /".to_string(); + assert!(cfg.validate().is_err()); + } + + #[test] + fn test_invalid_network() { + let mut cfg = valid_container_config(); + cfg.network = Some("host; malicious".to_string()); + assert!(cfg.validate().is_err()); + } + + #[test] + fn test_invalid_env_key() { + let mut cfg = valid_container_config(); + cfg.env.insert("VALID_KEY".to_string(), "ok".to_string()); + assert!(cfg.validate().is_ok()); + cfg.env.insert("BAD;KEY".to_string(), "bad".to_string()); + assert!(cfg.validate().is_err()); + } + + #[test] + fn test_invalid_mount_path() { + let mut cfg = valid_container_config(); + cfg.mounts.push(MountConfig { + source: "/host/path".to_string(), + target: "/container; rm -rf /".to_string(), + readonly: true, + }); + assert!(cfg.validate().is_err()); + } + + #[test] + fn test_config_from_toml() { + let toml = r#" +listen_addr = "127.0.0.1:2222" +[auth] +mode = "authorized-keys" +authorized_keys_path = "/etc/bascule/authorized_keys" +"#; + let config = BasculeConfig::from_toml(toml).unwrap(); + assert_eq!(config.listen_addr, "127.0.0.1:2222"); + assert_eq!(config.auth.mode, "authorized-keys"); + assert_eq!( + config.auth.authorized_keys_path.unwrap(), + "/etc/bascule/authorized_keys" + ); + } + + #[test] + fn test_config_defaults() { + let config = BasculeConfig::default(); + assert_eq!(config.listen_addr, "0.0.0.0:2222"); + assert_eq!(config.auth.mode, "accept-all"); + assert_eq!(config.max_sessions, 0); + } +} diff --git a/crates/bascule-core/src/container.rs b/crates/bascule-core/src/container.rs index e8671ce..1512d3e 100644 --- a/crates/bascule-core/src/container.rs +++ b/crates/bascule-core/src/container.rs @@ -4,7 +4,7 @@ //! The container is destroyed when the session ends. //! //! ```text -//! SSH client ←→ bascule (auth + hooks) ←→ container (docker/podman/nerdctl) +//! SSH client <-> bascule (auth + hooks) <-> container (docker/podman/nerdctl) //! ``` //! //! Uses CLI execution for maximum portability — no libdocker dependency. @@ -12,7 +12,6 @@ use std::collections::HashMap; use std::process::Stdio; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::process::{Child, Command}; use crate::config::ContainerConfig; @@ -56,7 +55,7 @@ impl ContainerRuntime { } /// Parse from config string. - pub fn from_str(s: &str) -> Option { + pub fn from_config(s: &str) -> Option { match s { "docker" => Some(Self::Docker), "podman" => Some(Self::Podman), @@ -80,8 +79,9 @@ impl ContainerSession { session_id: &str, config: &ContainerConfig, extra_env: &HashMap, + command: Option<&str>, ) -> anyhow::Result { - let runtime = ContainerRuntime::from_str(&config.runtime) + let runtime = ContainerRuntime::from_config(&config.runtime) .ok_or_else(|| anyhow::anyhow!("No container runtime available"))?; // Pull image if needed @@ -135,10 +135,14 @@ impl ContainerSession { // Security hardening if config.hardened { args.extend([ - "--security-opt".into(), "no-new-privileges".into(), - "--cap-drop".into(), "ALL".into(), - "--cap-add".into(), "SETUID".into(), - "--cap-add".into(), "SETGID".into(), + "--security-opt".into(), + "no-new-privileges".into(), + "--cap-drop".into(), + "ALL".into(), + "--cap-add".into(), + "SETUID".into(), + "--cap-add".into(), + "SETGID".into(), ]); } @@ -146,8 +150,10 @@ impl ContainerSession { if config.read_only_rootfs { args.extend([ "--read-only".into(), - "--tmpfs".into(), "/tmp:rw,noexec,nosuid,size=64m".into(), - "--tmpfs".into(), "/run:rw,noexec,nosuid,size=16m".into(), + "--tmpfs".into(), + "/tmp:rw,noexec,nosuid,size=64m".into(), + "--tmpfs".into(), + "/run:rw,noexec,nosuid,size=16m".into(), ]); } @@ -159,10 +165,15 @@ impl ContainerSession { // Image args.push(config.image.clone()); - // Shell command override - if let Some(ref shell) = config.shell { + // Command: exec request or configured shell or image default + if let Some(cmd) = command { + // Exec mode: run this specific command in the container + args.extend(["/bin/sh".into(), "-c".into(), cmd.to_string()]); + } else if let Some(ref shell) = config.shell { + // Interactive mode: use configured shell args.push(shell.clone()); } + // else: use image's ENTRYPOINT/CMD tracing::info!( runtime = runtime.binary(), diff --git a/crates/bascule-core/src/handler.rs b/crates/bascule-core/src/handler.rs index 5661d91..f18ec1c 100644 --- a/crates/bascule-core/src/handler.rs +++ b/crates/bascule-core/src/handler.rs @@ -15,7 +15,7 @@ use russh::server::{Auth, Handler, Msg, Session}; use russh::{Channel, ChannelId, CryptoVec}; use russh_keys::key; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, OwnedSemaphorePermit}; use crate::auth::AuthProvider; use crate::config::BasculeConfig; @@ -42,6 +42,9 @@ pub struct BasculeHandler { pty_cols: u16, pty_rows: u16, peer_addr: String, + /// Semaphore permit — held for the lifetime of this connection. + /// None means max sessions reached; auth will be rejected. + _session_permit: Option, } impl BasculeHandler { @@ -50,6 +53,7 @@ impl BasculeHandler { session_handler: Arc, config: Arc, peer_addr: String, + session_permit: Option, ) -> Self { Self { auth, @@ -60,6 +64,7 @@ impl BasculeHandler { pty_cols: 80, pty_rows: 24, peer_addr, + _session_permit: session_permit, } } @@ -77,8 +82,11 @@ impl BasculeHandler { session: &mut Session, command: Option<&str>, ) -> anyhow::Result<()> { - let info = self.session_info.as_ref() - .ok_or_else(|| anyhow::anyhow!("No session info"))?.clone(); + let info = self + .session_info + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No session info"))? + .clone(); self.session_handler.on_session_start(&info).await?; @@ -89,25 +97,58 @@ impl BasculeHandler { let (cmd, args) = match command { Some(c) => ("/bin/sh", vec!["-c".to_string(), c.to_string()]), - None => { let (c, a) = self.shell_command(); (c, a.to_vec()) } + None => { + let (c, a) = self.shell_command(); + (c, a.to_vec()) + } }; - let bridge = pty::spawn_pty(cmd, &args, env_pairs, self.pty_cols, self.pty_rows)?; + let mut bridge = pty::spawn_pty(cmd, &args, env_pairs, self.pty_cols, self.pty_rows)?; + + // Take the reader out so it can be moved to a dedicated OS thread. + // This avoids blocking a tokio worker thread on synchronous PTY reads. + let reader = bridge + .take_reader() + .ok_or_else(|| anyhow::anyhow!("PTY reader already taken"))?; + let bridge = Arc::new(Mutex::new(bridge)); self.backend = Some(SessionBackend::Local(bridge.clone())); - let handle = session.handle(); - tokio::spawn(async move { + // Channel to bridge blocking reads to async world + let (tx, mut rx) = tokio::sync::mpsc::channel::>(32); + + // Dedicated OS thread for blocking PTY reads + std::thread::spawn(move || { + let mut reader = reader; let mut buf = [0u8; 4096]; loop { - let n = { - let mut b = bridge.lock().await; - match b.reader.read(&mut buf) { - Ok(0) | Err(_) => break, - Ok(n) => n, + match reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + if tx.blocking_send(buf[..n].to_vec()).is_err() { + break; + } } - }; - if handle.data(channel, CryptoVec::from_slice(&buf[..n])).await.is_err() { break; } + } + } + }); + + // Async task: receive from thread and forward to SSH channel + let handle = session.handle(); + let handler = self.session_handler.clone(); + let session_info = info; + tokio::spawn(async move { + while let Some(data) = rx.recv().await { + if handle + .data(channel, CryptoVec::from_slice(&data)) + .await + .is_err() + { + break; + } + } + if let Err(e) = handler.on_session_end(&session_info).await { + tracing::warn!(error = %e, "SessionHandler on_session_end error"); } let _ = handle.close(channel).await; }); @@ -121,34 +162,60 @@ impl BasculeHandler { session: &mut Session, command: Option<&str>, ) -> anyhow::Result<()> { - let info = self.session_info.as_ref() - .ok_or_else(|| anyhow::anyhow!("No session info"))?.clone(); + let info = self + .session_info + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No session info"))? + .clone(); self.session_handler.on_session_start(&info).await?; - let proxy_config = self.config.proxy.as_ref() + let proxy_config = self + .config + .proxy + .as_ref() .ok_or_else(|| anyhow::anyhow!("Proxy config missing"))?; let mut upstream = proxy::connect_upstream(proxy_config, &info.principal).await?; - let upstream_ch = upstream.channel.as_ref() + let upstream_ch = upstream + .channel + .as_ref() .ok_or_else(|| anyhow::anyhow!("No upstream channel"))?; - upstream_ch.request_pty(true, "xterm-256color", self.pty_cols as u32, self.pty_rows as u32, 0, 0, &[]).await?; + upstream_ch + .request_pty( + true, + "xterm-256color", + self.pty_cols as u32, + self.pty_rows as u32, + 0, + 0, + &[], + ) + .await?; match command { Some(cmd) => upstream_ch.exec(true, cmd).await?, None => upstream_ch.request_shell(true).await?, } - let upstream_channel = upstream.channel.take() + let upstream_channel = upstream + .channel + .take() .ok_or_else(|| anyhow::anyhow!("Channel already taken"))?; let upstream = Arc::new(Mutex::new(upstream)); self.backend = Some(SessionBackend::Proxy(upstream.clone())); let server_handle = session.handle(); + let handler = self.session_handler.clone(); + let session_info = info; tokio::spawn(async move { - proxy::bridge_upstream_to_client(upstream_channel, server_handle, channel).await; + proxy::bridge_upstream_to_client(upstream_channel, server_handle.clone(), channel) + .await; + if let Err(e) = handler.on_session_end(&session_info).await { + tracing::warn!(error = %e, "SessionHandler on_session_end error"); + } }); Ok(()) } @@ -158,32 +225,37 @@ impl BasculeHandler { &mut self, channel: ChannelId, session: &mut Session, - _command: Option<&str>, + command: Option<&str>, ) -> anyhow::Result<()> { - let info = self.session_info.as_ref() - .ok_or_else(|| anyhow::anyhow!("No session info"))?.clone(); + let info = self + .session_info + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No session info"))? + .clone(); self.session_handler.on_session_start(&info).await?; - let container_config = self.config.container.as_ref() + let container_config = self + .config + .container + .as_ref() .ok_or_else(|| anyhow::anyhow!("Container config missing"))?; let mut env = self.session_handler.build_session_env(&info).await; env.insert("BASCULE_SESSION_ID".into(), info.session_id.clone()); env.insert("BASCULE_PRINCIPAL".into(), info.principal.clone()); - let container = ContainerSession::spawn( - &info.session_id, - container_config, - &env, - ).await?; + let container = + ContainerSession::spawn(&info.session_id, container_config, &env, command).await?; let container = Arc::new(Mutex::new(container)); self.backend = Some(SessionBackend::Container(container.clone())); - // Bridge container stdout → SSH channel + // Bridge container stdout -> SSH channel let handle = session.handle(); let container_for_read = container.clone(); + let handler = self.session_handler.clone(); + let session_info = info; tokio::spawn(async move { let mut buf = [0u8; 4096]; loop { @@ -198,7 +270,18 @@ impl BasculeHandler { None => break, } }; - if handle.data(channel, CryptoVec::from_slice(&buf[..n])).await.is_err() { break; } + if handle + .data(channel, CryptoVec::from_slice(&buf[..n])) + .await + .is_err() + { + break; + } + } + + // Session ended — call handler before cleanup + if let Err(e) = handler.on_session_end(&session_info).await { + tracing::warn!(error = %e, "SessionHandler on_session_end error"); } // Container process exited — clean up @@ -233,59 +316,132 @@ impl BasculeHandler { impl Handler for BasculeHandler { type Error = anyhow::Error; - async fn auth_publickey(&mut self, user: &str, public_key: &key::PublicKey) -> Result { + async fn auth_publickey( + &mut self, + user: &str, + public_key: &key::PublicKey, + ) -> Result { + if self._session_permit.is_none() { + tracing::warn!(user = %user, peer = %self.peer_addr, "Auth rejected: max sessions reached"); + return Ok(Auth::Reject { + proceed_with_methods: None, + }); + } if self.auth.check_public_key(user, public_key).await { let principal = self.auth.principal_for_user(user); tracing::info!(method = "ssh-key", principal = %principal, peer = %self.peer_addr, "Auth accepted"); - self.session_info = Some(SessionInfo::new(principal, "ssh-key".into(), self.peer_addr.clone())); + self.session_info = Some(SessionInfo::new( + principal, + "ssh-key".into(), + self.peer_addr.clone(), + )); Ok(Auth::Accept) } else { tracing::warn!(method = "ssh-key", user = %user, peer = %self.peer_addr, "Auth rejected"); - Ok(Auth::Reject { proceed_with_methods: None }) + Ok(Auth::Reject { + proceed_with_methods: None, + }) } } async fn auth_password(&mut self, user: &str, password: &str) -> Result { + if self._session_permit.is_none() { + tracing::warn!(user = %user, peer = %self.peer_addr, "Auth rejected: max sessions reached"); + return Ok(Auth::Reject { + proceed_with_methods: None, + }); + } if self.auth.check_password(user, password).await { let principal = self.auth.principal_for_user(user); tracing::info!(method = "password", principal = %principal, peer = %self.peer_addr, "Auth accepted"); - self.session_info = Some(SessionInfo::new(principal, "password".into(), self.peer_addr.clone())); + self.session_info = Some(SessionInfo::new( + principal, + "password".into(), + self.peer_addr.clone(), + )); Ok(Auth::Accept) } else { tracing::warn!(method = "password", user = %user, peer = %self.peer_addr, "Auth rejected"); - Ok(Auth::Reject { proceed_with_methods: None }) + Ok(Auth::Reject { + proceed_with_methods: None, + }) } } - async fn channel_open_session(&mut self, _channel: Channel, _session: &mut Session) -> Result { + async fn channel_open_session( + &mut self, + _channel: Channel, + _session: &mut Session, + ) -> Result { Ok(true) } - async fn pty_request(&mut self, _channel: ChannelId, _term: &str, col_width: u32, row_height: u32, _pix_width: u32, _pix_height: u32, _modes: &[(russh::Pty, u32)], session: &mut Session) -> Result<(), Self::Error> { + async fn pty_request( + &mut self, + _channel: ChannelId, + _term: &str, + col_width: u32, + row_height: u32, + _pix_width: u32, + _pix_height: u32, + _modes: &[(russh::Pty, u32)], + session: &mut Session, + ) -> Result<(), Self::Error> { self.pty_cols = col_width.min(500) as u16; self.pty_rows = row_height.min(200) as u16; session.request_success(); Ok(()) } - async fn window_change_request(&mut self, _channel: ChannelId, col_width: u32, row_height: u32, _pix_width: u32, _pix_height: u32, _session: &mut Session) -> Result<(), Self::Error> { + async fn window_change_request( + &mut self, + _channel: ChannelId, + col_width: u32, + row_height: u32, + _pix_width: u32, + _pix_height: u32, + _session: &mut Session, + ) -> Result<(), Self::Error> { self.pty_cols = col_width.min(500) as u16; self.pty_rows = row_height.min(200) as u16; match &self.backend { - Some(SessionBackend::Local(bridge)) => { let b = bridge.lock().await; let _ = b.resize(self.pty_cols, self.pty_rows); } - Some(SessionBackend::Proxy(_)) => { tracing::debug!("Window change in proxy mode (not forwarded)"); } - Some(SessionBackend::Container(_)) => { tracing::debug!("Window change in container mode (handled by runtime)"); } + Some(SessionBackend::Local(bridge)) => { + let b = bridge.lock().await; + let _ = b.resize(self.pty_cols, self.pty_rows); + } + Some(SessionBackend::Proxy(_)) => { + tracing::debug!("Window change in proxy mode (not forwarded)"); + } + Some(SessionBackend::Container(_)) => { + tracing::debug!("Window change in container mode (handled by runtime)"); + } None => {} } Ok(()) } - async fn shell_request(&mut self, channel: ChannelId, session: &mut Session) -> Result<(), Self::Error> { - let backend_type = if self.config.proxy.is_some() { "proxy" } - else if self.config.container.is_some() { "container" } - else { "pty" }; - let session_id = self.session_info.as_ref().map(|i| i.session_id.as_str()).unwrap_or("unknown"); - let principal = self.session_info.as_ref().map(|i| i.principal.as_str()).unwrap_or("unknown"); + async fn shell_request( + &mut self, + channel: ChannelId, + session: &mut Session, + ) -> Result<(), Self::Error> { + let backend_type = if self.config.proxy.is_some() { + "proxy" + } else if self.config.container.is_some() { + "container" + } else { + "pty" + }; + let session_id = self + .session_info + .as_ref() + .map(|i| i.session_id.as_str()) + .unwrap_or("unknown"); + let principal = self + .session_info + .as_ref() + .map(|i| i.principal.as_str()) + .unwrap_or("unknown"); tracing::info!( session.id = %session_id, session.principal = %principal, @@ -296,7 +452,10 @@ impl Handler for BasculeHandler { if let Some(info) = &self.session_info { let display = self.session_handler.display_name(info); - let banner = self.config.banner.as_deref() + let banner = self + .config + .banner + .as_deref() .map(|b| format!("{}\r\n", b)) .unwrap_or_else(|| format!("Welcome, {}.\r\n", display)); session.data(channel, CryptoVec::from_slice(banner.as_bytes())); @@ -306,9 +465,18 @@ impl Handler for BasculeHandler { Ok(()) } - async fn exec_request(&mut self, channel: ChannelId, data: &[u8], session: &mut Session) -> Result<(), Self::Error> { + async fn exec_request( + &mut self, + channel: ChannelId, + data: &[u8], + session: &mut Session, + ) -> Result<(), Self::Error> { let command = String::from_utf8_lossy(data).to_string(); - let session_id = self.session_info.as_ref().map(|i| i.session_id.as_str()).unwrap_or("unknown"); + let session_id = self + .session_info + .as_ref() + .map(|i| i.session_id.as_str()) + .unwrap_or("unknown"); tracing::info!(session.id = %session_id, command = %command, "Exec request"); if let Some(info) = &self.session_info { @@ -324,7 +492,12 @@ impl Handler for BasculeHandler { Ok(()) } - async fn data(&mut self, _channel: ChannelId, data: &[u8], _session: &mut Session) -> Result<(), Self::Error> { + async fn data( + &mut self, + _channel: ChannelId, + data: &[u8], + _session: &mut Session, + ) -> Result<(), Self::Error> { match &self.backend { Some(SessionBackend::Local(bridge)) => { let mut b = bridge.lock().await; @@ -333,7 +506,10 @@ impl Handler for BasculeHandler { } Some(SessionBackend::Proxy(upstream)) => { let u = upstream.lock().await; - let _ = u.handle.data(u.channel_id, CryptoVec::from_slice(data)).await; + let _ = u + .handle + .data(u.channel_id, CryptoVec::from_slice(data)) + .await; } Some(SessionBackend::Container(container)) => { let mut c = container.lock().await; diff --git a/crates/bascule-core/src/proxy.rs b/crates/bascule-core/src/proxy.rs index 9285449..626bfa2 100644 --- a/crates/bascule-core/src/proxy.rs +++ b/crates/bascule-core/src/proxy.rs @@ -14,7 +14,6 @@ use async_trait::async_trait; use russh::client; use russh::ChannelMsg; use russh_keys::key; -use tokio::sync::Mutex; use crate::config::ProxyConfig; @@ -62,10 +61,7 @@ pub async fn connect_upstream( proxy_config: &ProxyConfig, username: &str, ) -> anyhow::Result { - let target_user = proxy_config - .target_user - .as_deref() - .unwrap_or(username); + let target_user = proxy_config.target_user.as_deref().unwrap_or(username); let addr = format!("{}:{}", proxy_config.target_host, proxy_config.target_port); tracing::info!(target = %addr, user = %target_user, "Connecting to upstream SSH host"); @@ -100,7 +96,11 @@ pub async fn connect_upstream( tracing::info!("Upstream channel opened"); let channel_id = channel.id(); - Ok(UpstreamSession { handle, channel: Some(channel), channel_id }) + Ok(UpstreamSession { + handle, + channel: Some(channel), + channel_id, + }) } /// Bridge I/O between the server-side SSH channel and the upstream client channel. @@ -135,7 +135,9 @@ pub async fn bridge_upstream_to_client( } Some(ChannelMsg::ExitStatus { exit_status }) => { tracing::info!(exit_status, "Upstream session exited"); - let _ = server_handle.exit_status_request(server_channel_id, exit_status).await; + let _ = server_handle + .exit_status_request(server_channel_id, exit_status) + .await; let _ = server_handle.close(server_channel_id).await; break; } diff --git a/crates/bascule-core/src/pty.rs b/crates/bascule-core/src/pty.rs index b710432..95e3e10 100644 --- a/crates/bascule-core/src/pty.rs +++ b/crates/bascule-core/src/pty.rs @@ -3,11 +3,8 @@ use anyhow::{Context, Result}; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use std::io::{Read, Write}; -use tokio::sync::mpsc; -/// Spawn a PTY with the given command and bridge I/O. -/// -/// Returns (writer_tx, reader_rx) channels for the SSH handler to use. +/// Spawn a PTY with the given command and return the bridge. pub fn spawn_pty( command: &str, args: &[String], @@ -51,7 +48,7 @@ pub fn spawn_pty( Ok(PtyBridge { master: pair.master, child, - reader, + reader: Some(reader), writer, }) } @@ -60,11 +57,18 @@ pub fn spawn_pty( pub struct PtyBridge { pub master: Box, pub child: Box, - pub reader: Box, + /// PTY reader — `take()` this to move it into a dedicated read thread. + pub reader: Option>, pub writer: Box, } impl PtyBridge { + /// Take the reader out of this bridge for use on a dedicated thread. + /// Returns None if already taken. + pub fn take_reader(&mut self) -> Option> { + self.reader.take() + } + /// Resize the PTY. pub fn resize(&self, cols: u16, rows: u16) -> Result<()> { self.master diff --git a/crates/bascule-core/src/server.rs b/crates/bascule-core/src/server.rs index 982ef52..040d927 100644 --- a/crates/bascule-core/src/server.rs +++ b/crates/bascule-core/src/server.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use russh::server::{Config, Server}; use russh_keys::key::KeyPair; +use tokio::sync::Semaphore; use crate::auth::AuthProvider; use crate::config::BasculeConfig; @@ -16,6 +17,7 @@ pub struct BasculeServer { app_config: Arc, auth: Arc, session_handler: Arc, + session_semaphore: Arc, } impl BasculeServer { @@ -23,6 +25,14 @@ impl BasculeServer { config: BasculeConfig, auth: impl AuthProvider + 'static, session_handler: impl SessionHandler + 'static, + ) -> anyhow::Result { + Self::with_arc_auth(config, Arc::new(auth), session_handler) + } + + pub fn with_arc_auth( + config: BasculeConfig, + auth: Arc, + session_handler: impl SessionHandler + 'static, ) -> anyhow::Result { let host_key = if let Some(path) = &config.host_key_path { if std::path::Path::new(path).exists() { @@ -42,11 +52,19 @@ impl BasculeServer { ..Default::default() }; + let max_sessions = if config.max_sessions > 0 { + config.max_sessions + } else { + 10_000 // reasonable upper bound when "unlimited" + }; + tracing::info!(max_sessions, "Session limit configured"); + Ok(Self { ssh_config: Arc::new(ssh_config), app_config: Arc::new(config), - auth: Arc::new(auth), + auth, session_handler: Arc::new(session_handler), + session_semaphore: Arc::new(Semaphore::new(max_sessions)), }) } @@ -54,7 +72,8 @@ impl BasculeServer { let addr = &self.app_config.listen_addr; tracing::info!(addr = %addr, "Starting Bascule SSH server"); let socket_addr: std::net::SocketAddr = addr.parse()?; - self.run_on_address(self.ssh_config.clone(), socket_addr).await?; + self.run_on_address(self.ssh_config.clone(), socket_addr) + .await?; Ok(()) } } @@ -63,13 +82,37 @@ impl russh::server::Server for BasculeServer { type Handler = BasculeHandler; fn new_client(&mut self, peer_addr: Option) -> BasculeHandler { - let addr = peer_addr.map(|a| a.to_string()).unwrap_or_else(|| "unknown".to_string()); - tracing::info!(peer = %addr, "New SSH connection"); - BasculeHandler::new( - self.auth.clone(), - self.session_handler.clone(), - self.app_config.clone(), - addr, - ) + let addr = peer_addr + .map(|a| a.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let permit = self.session_semaphore.clone().try_acquire_owned(); + match permit { + Ok(permit) => { + tracing::info!(peer = %addr, "New SSH connection"); + BasculeHandler::new( + self.auth.clone(), + self.session_handler.clone(), + self.app_config.clone(), + addr, + Some(permit), + ) + } + Err(_) => { + tracing::warn!( + peer = %addr, + max = self.session_semaphore.available_permits(), + "Max sessions reached, connection will be rejected" + ); + // Return a handler that will reject auth — permit is None + BasculeHandler::new( + self.auth.clone(), + self.session_handler.clone(), + self.app_config.clone(), + addr, + None, + ) + } + } } } diff --git a/crates/bascule-server/src/main.rs b/crates/bascule-server/src/main.rs index 7d08715..091f4aa 100644 --- a/crates/bascule-server/src/main.rs +++ b/crates/bascule-server/src/main.rs @@ -4,11 +4,15 @@ //! bascule --config /etc/bascule/config.toml //! bascule # uses default config (accept-all auth, port 2222) +use std::sync::Arc; + use anyhow::Result; use clap::Parser; use tracing_subscriber::EnvFilter; -use bascule_core::auth::AcceptAllKeys; +#[cfg(feature = "agent-id")] +use bascule_core::auth::CompositeAuthProvider; +use bascule_core::auth::{AcceptAllKeys, AuthProvider, AuthorizedKeysProvider}; use bascule_core::config::BasculeConfig; use bascule_core::hooks::DefaultHandler; use bascule_core::server::BasculeServer; @@ -21,26 +25,72 @@ struct Cli { config: Option, } -fn init_tracing(config: &BasculeConfig) { - // Structured logging (JSON if BASCULE_LOG_FORMAT=json, otherwise pretty) - // OTel OTLP export: deferred to future release (version compatibility WIP) +fn init_tracing(_config: &BasculeConfig) { let json_format = std::env::var("BASCULE_LOG_FORMAT") .map(|v| v == "json") .unwrap_or(false); if json_format { tracing_subscriber::fmt() - .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) .json() .with_target(true) .init(); } else { tracing_subscriber::fmt() - .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) .init(); } } +fn build_auth_provider(config: &BasculeConfig) -> Arc { + let base_provider: Box = match config.auth.mode.as_str() { + "accept-all" => { + tracing::warn!("Using accept-all authentication -- FOR DEVELOPMENT ONLY"); + Box::new(AcceptAllKeys) + } + "authorized-keys" => { + let keys_path = config + .auth + .authorized_keys_path + .clone() + .unwrap_or_else(|| "/etc/bascule/authorized_keys".to_string()); + tracing::info!(path = %keys_path, "Using authorized-keys authentication"); + Box::new(AuthorizedKeysProvider::new(keys_path)) + } + other => { + tracing::error!(mode = %other, "Unknown auth mode. Valid: accept-all, authorized-keys"); + std::process::exit(1); + } + }; + + // If agent_id is also configured, compose: SSH keys + Agent ID token-as-password + #[cfg(feature = "agent-id")] + if let Some(ref agent_config) = config.auth.agent_id { + tracing::info!(tenant = %agent_config.tenant_id, "Entra Agent ID auth enabled (composite)"); + let agent_provider = if agent_config.multi_tenant { + bascule_auth_agent_id::EntraAgentIdProvider::multi_tenant( + agent_config.audiences.clone(), + ) + } else { + bascule_auth_agent_id::EntraAgentIdProvider::new( + &agent_config.tenant_id, + agent_config.audiences.clone(), + ) + }; + return Arc::new(CompositeAuthProvider::new(vec![ + base_provider, + Box::new(agent_provider), + ])); + } + + Arc::from(base_provider) +} + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -52,9 +102,18 @@ async fn main() -> Result<()> { init_tracing(&config); - let backend = if config.proxy.is_some() { "proxy" } - else if config.container.is_some() { "container" } - else { "pty" }; + // Validate container config at startup (fail fast on bad values) + if let Some(ref container_config) = config.container { + container_config.validate()?; + } + + let backend = if config.proxy.is_some() { + "proxy" + } else if config.container.is_some() { + "container" + } else { + "pty" + }; tracing::info!( listen = %config.listen_addr, @@ -64,6 +123,7 @@ async fn main() -> Result<()> { "Bascule starting" ); - let server = BasculeServer::new(config, AcceptAllKeys, DefaultHandler)?; + let auth = build_auth_provider(&config); + let server = BasculeServer::with_arc_auth(config, auth, DefaultHandler)?; server.run().await } diff --git a/crates/bascule-shell/Cargo.toml b/crates/bascule-shell/Cargo.toml new file mode 100644 index 0000000..2bcf39b --- /dev/null +++ b/crates/bascule-shell/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "bascule-shell" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Identity-aware shell with TPM attestation — client companion for Bascule" + +[[bin]] +name = "bascule-shell" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = "0.10" +hex = "0.4" +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } +chrono = { workspace = true } +dirs = "5" +toml = { workspace = true } +nix = { version = "0.29", features = ["process"] } diff --git a/crates/bascule-shell/src/attestation.rs b/crates/bascule-shell/src/attestation.rs new file mode 100644 index 0000000..6391464 --- /dev/null +++ b/crates/bascule-shell/src/attestation.rs @@ -0,0 +1,135 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Attestation { + pub tpm_available: bool, + pub tpm_version: Option, + pub pcr_values: BTreeMap, + pub ima_available: bool, + pub ima_measurement_count: u32, + pub ima_log_hash: Option, + pub keylime_active: bool, + pub entra_device_compliant: Option, + pub entra_device_id: Option, + pub composite_hash: String, + pub timestamp: String, +} + +pub fn detect(pcr_indices: &[u32]) -> Attestation { + let tpm = detect_tpm(); + let pcrs = if tpm.0 { read_pcrs(pcr_indices) } else { BTreeMap::new() }; + let ima = detect_ima(); + let keylime = detect_keylime(); + let entra = detect_entra_device(); + + let mut evidence = String::new(); + for (idx, value) in &pcrs { + evidence.push_str(&format!("pcr{}:{};", idx, value)); + } + if let Some(ref hash) = ima.1 { + evidence.push_str(&format!("ima:{};", hash)); + } + if keylime { evidence.push_str("keylime:active;"); } + if let Some(c) = entra.0 { evidence.push_str(&format!("entra:{};", c)); } + + let composite = format!("{:x}", Sha256::digest(evidence.as_bytes())); + + Attestation { + tpm_available: tpm.0, + tpm_version: tpm.1, + pcr_values: pcrs, + ima_available: ima.0 > 0, + ima_measurement_count: ima.0, + ima_log_hash: ima.1, + keylime_active: keylime, + entra_device_compliant: entra.0, + entra_device_id: entra.1, + composite_hash: composite, + timestamp: chrono::Utc::now().to_rfc3339(), + } +} + +fn detect_tpm() -> (bool, Option) { + let exists = std::path::Path::new("/dev/tpmrm0").exists() + || std::path::Path::new("/dev/tpm0").exists(); + if !exists { return (false, None); } + let version = std::fs::read_to_string("/sys/class/tpm/tpm0/tpm_version_major") + .ok().map(|v| format!("{}.0", v.trim())); + (true, version) +} + +fn read_pcrs(indices: &[u32]) -> BTreeMap { + let mut pcrs = BTreeMap::new(); + for &idx in indices { + if let Some(v) = read_pcr_tool(idx).or_else(|| read_pcr_sysfs(idx)) { + pcrs.insert(idx, v); + } + } + pcrs +} + +fn read_pcr_tool(idx: u32) -> Option { + let output = std::process::Command::new("tpm2_pcrread") + .arg(format!("sha256:{}", idx)) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output().ok()?; + if !output.status.success() { return None; } + let text = String::from_utf8_lossy(&output.stdout); + text.lines() + .find(|l| l.contains("0x")) + .and_then(|l| l.split("0x").nth(1)) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +fn read_pcr_sysfs(idx: u32) -> Option { + std::fs::read_to_string(format!("/sys/class/tpm/tpm0/pcr-sha256/{}", idx)) + .ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) +} + +fn detect_ima() -> (u32, Option) { + let content = match std::fs::read_to_string( + "/sys/kernel/security/ima/ascii_runtime_measurements" + ) { + Ok(c) => c, + Err(_) => return (0, None), + }; + let count = content.lines().count() as u32; + let hash = format!("{:x}", Sha256::digest(content.as_bytes())); + (count, Some(hash)) +} + +fn detect_keylime() -> bool { + std::process::Command::new("systemctl") + .args(["is-active", "keylime_agent"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status().map(|s| s.success()).unwrap_or(false) +} + +fn detect_entra_device() -> (Option, Option) { + if !std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists() { + return (None, None); + } + let output = std::process::Command::new("powershell.exe") + .args(["-Command", "dsregcmd /status"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output(); + let text = match output { + Ok(ref out) if out.status.success() => String::from_utf8_lossy(&out.stdout).to_string(), + _ => return (None, None), + }; + let compliant = text.lines() + .find(|l| l.to_lowercase().contains("iscompliant")) + .and_then(|l| l.split(':').nth(1)) + .map(|v| v.trim().eq_ignore_ascii_case("yes")); + let device_id = text.lines() + .find(|l| l.to_lowercase().contains("deviceid")) + .and_then(|l| l.split(':').nth(1)) + .map(|v| v.trim().to_string()); + (compliant, device_id) +} diff --git a/crates/bascule-shell/src/banner.rs b/crates/bascule-shell/src/banner.rs new file mode 100644 index 0000000..8f8ac28 --- /dev/null +++ b/crates/bascule-shell/src/banner.rs @@ -0,0 +1,38 @@ +use crate::attestation::Attestation; +use crate::identity::Identity; + +pub fn display(identity: &Identity, attestation: &Attestation) { + let tpm_status = if attestation.tpm_available { + format!("available ({} PCRs verified)", attestation.pcr_values.len()) + } else { + "not available".to_string() + }; + + let ima_status = if attestation.ima_available { + format!("{} measurements", attestation.ima_measurement_count) + } else { + "not available".to_string() + }; + + let platform_short = &attestation.composite_hash[..16.min(attestation.composite_hash.len())]; + + println!("╔═══════════════════════════════════════════════════════╗"); + println!("║ Bascule Shell v0.1.0 ║"); + println!("║ Principal: {:<42}║", identity.principal); + println!("║ Method: {:<42}║", identity.auth_method); + if let Some(ref domain) = identity.domain { + println!("║ Domain: {:<42}║", domain); + } + println!("║ TPM: {:<42}║", tpm_status); + println!("║ IMA: {:<42}║", ima_status); + if attestation.keylime_active { + println!("║ Keylime: {:<42}║", "active"); + } + match attestation.entra_device_compliant { + Some(true) => println!("║ Device: {:<42}║", "Entra compliant"), + Some(false) => println!("║ Device: {:<42}║", "Entra non-compliant"), + None => {} + } + println!("║ Platform: {:<42}║", format!("sha256:{}...", platform_short)); + println!("╚═══════════════════════════════════════════════════════╝"); +} diff --git a/crates/bascule-shell/src/config.rs b/crates/bascule-shell/src/config.rs new file mode 100644 index 0000000..5a11f51 --- /dev/null +++ b/crates/bascule-shell/src/config.rs @@ -0,0 +1,56 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize, Clone)] +pub struct ShellConfig { + #[serde(default = "default_shell")] + pub inner_shell: String, + #[serde(default = "default_true")] + pub show_banner: bool, + #[serde(default)] + pub servers: Vec, + #[serde(default = "default_pcr_indices")] + pub pcr_indices: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct BasculeServer { + pub alias: String, + pub hostname: String, + #[serde(default = "default_port")] + pub port: u16, +} + +fn default_shell() -> String { + std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string()) +} +fn default_true() -> bool { true } +fn default_port() -> u16 { 2222 } +fn default_pcr_indices() -> Vec { vec![0, 1, 2, 7, 10, 14] } + +impl Default for ShellConfig { + fn default() -> Self { + Self { + inner_shell: default_shell(), + show_banner: true, + servers: vec![], + pcr_indices: default_pcr_indices(), + } + } +} + +impl ShellConfig { + pub fn load() -> Self { + let config_path = dirs::config_dir() + .map(|d| d.join("bascule").join("shell.toml")); + if let Some(path) = config_path { + if path.exists() { + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(config) = toml::from_str::(&content) { + return config; + } + } + } + } + Self::default() + } +} diff --git a/crates/bascule-shell/src/identity.rs b/crates/bascule-shell/src/identity.rs new file mode 100644 index 0000000..3faba44 --- /dev/null +++ b/crates/bascule-shell/src/identity.rs @@ -0,0 +1,128 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Identity { + pub principal: String, + pub auth_method: String, + pub domain: Option, + pub source: String, + pub has_token: bool, +} + +pub fn detect() -> Identity { + if let Some(id) = detect_entra_wsl2() { return id; } + if let Some(id) = detect_az_cli() { return id; } + if let Some(id) = detect_kerberos() { return id; } + if let Some(id) = detect_cached_oidc() { return id; } + detect_system_user() +} + +fn detect_entra_wsl2() -> Option { + if !std::path::Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists() { + return None; + } + let username = run_cmd("cmd.exe", &["/c", "echo %USERNAME%"])?; + let domain = run_cmd("cmd.exe", &["/c", "echo %USERDNSDOMAIN%"])?; + if username.is_empty() || domain.is_empty() || domain.starts_with('%') { + return None; + } + Some(Identity { + principal: format!("{}@{}", username, domain.to_lowercase()), + auth_method: "oidc-entra".into(), + domain: Some(domain.to_lowercase()), + source: "WSL2 Entra interop".into(), + has_token: detect_az_token(), + }) +} + +fn detect_az_cli() -> Option { + let principal = run_cmd("az", &["account", "show", "--query", "user.name", "-o", "tsv"])?; + if principal.is_empty() { return None; } + Some(Identity { + principal, + auth_method: "oidc-entra".into(), + domain: None, + source: "Azure CLI".into(), + has_token: detect_az_token(), + }) +} + +fn detect_az_token() -> bool { + run_cmd("az", &["account", "get-access-token", "--query", "accessToken", "-o", "tsv"]) + .map(|t| !t.is_empty()) + .unwrap_or(false) +} + +fn detect_kerberos() -> Option { + let status = std::process::Command::new("klist") + .arg("-s") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .ok()?; + if !status.success() { return None; } + + let klist_output = run_cmd("klist", &[])?; + let principal = klist_output.lines() + .find(|l| l.contains("Default principal")) + .and_then(|l| l.split_whitespace().last()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let domain = principal.split('@').nth(1).map(|s| s.to_lowercase()); + + Some(Identity { + principal, + auth_method: "kerberos".into(), + domain, + source: "Kerberos TGT".into(), + has_token: false, + }) +} + +fn detect_cached_oidc() -> Option { + let token_path = dirs::config_dir()? + .join("bascule") + .join("token.json"); + let content = std::fs::read_to_string(&token_path).ok()?; + let data: serde_json::Value = serde_json::from_str(&content).ok()?; + let expires_at = data.get("expires_at")?.as_f64()?; + let now = chrono::Utc::now().timestamp() as f64; + if now >= expires_at { return None; } + let principal = data.get("principal") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + Some(Identity { + principal, + auth_method: "oidc-cached".into(), + domain: None, + source: "Cached OIDC token".into(), + has_token: true, + }) +} + +fn detect_system_user() -> Identity { + let username = std::env::var("USER") + .or_else(|_| std::env::var("LOGNAME")) + .unwrap_or_else(|_| "operator".to_string()); + Identity { + principal: username, + auth_method: "ssh-key".into(), + domain: None, + source: "System user".into(), + has_token: false, + } +} + +fn run_cmd(cmd: &str, args: &[&str]) -> Option { + let output = std::process::Command::new(cmd) + .args(args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + if !output.status.success() { return None; } + let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if text.is_empty() { None } else { Some(text) } +} diff --git a/crates/bascule-shell/src/main.rs b/crates/bascule-shell/src/main.rs new file mode 100644 index 0000000..f57e334 --- /dev/null +++ b/crates/bascule-shell/src/main.rs @@ -0,0 +1,114 @@ +//! Bascule Shell — identity-aware shell with TPM attestation. +//! +//! Wraps bash/zsh/fish, detects identity and platform attestation at startup, +//! and carries both into every Bascule server connection via environment variables. + +use std::ffi::CString; + +use anyhow::Result; +use clap::Parser; + +mod attestation; +mod banner; +mod config; +mod identity; + +#[derive(Parser)] +#[command(name = "bascule-shell", about = "Identity-aware shell with TPM attestation")] +#[command(version)] +struct Cli { + /// Show identity and attestation info, then exit + #[arg(long)] + info: bool, + + /// Output as JSON + #[arg(long)] + json: bool, + + /// Override inner shell (default: $SHELL) + #[arg(long)] + shell: Option, + + /// Skip banner + #[arg(long)] + no_banner: bool, + + /// Config file path + #[arg(long, short)] + config: Option, + + /// Run a single command through the inner shell + #[arg(long)] + exec: Option, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + let config = if let Some(ref path) = cli.config { + let content = std::fs::read_to_string(path)?; + toml::from_str(&content)? + } else { + config::ShellConfig::load() + }; + + let id = identity::detect(); + let attest = attestation::detect(&config.pcr_indices); + + // --info: print and exit + if cli.info { + if cli.json { + let output = serde_json::json!({ "identity": id, "attestation": attest }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + banner::display(&id, &attest); + } + return Ok(()); + } + + // Banner + if config.show_banner && !cli.no_banner { + banner::display(&id, &attest); + } + + // Set BASCULE_* env vars + set_env(&id, &attest); + + // Determine inner shell + let shell = cli.shell.unwrap_or_else(|| config.inner_shell.clone()); + + // --exec: single command + if let Some(ref cmd) = cli.exec { + let status = std::process::Command::new(&shell) + .args(["-c", cmd]) + .status()?; + std::process::exit(status.code().unwrap_or(1)); + } + + // Interactive: exec the inner shell (replaces this process) + let shell_c = CString::new(shell.as_str())?; + let args = [ + CString::new(shell.as_str())?, + CString::new("--login")?, + ]; + + nix::unistd::execvp(&shell_c, &args)?; + anyhow::bail!("Failed to exec inner shell: {}", shell); +} + +fn set_env(id: &identity::Identity, attest: &attestation::Attestation) { + std::env::set_var("BASCULE_PRINCIPAL", &id.principal); + std::env::set_var("BASCULE_AUTH_METHOD", &id.auth_method); + if let Some(ref domain) = id.domain { + std::env::set_var("BASCULE_DOMAIN", domain); + } + std::env::set_var("BASCULE_ATTESTATION_HASH", &attest.composite_hash); + std::env::set_var("BASCULE_TPM_AVAILABLE", attest.tpm_available.to_string()); + std::env::set_var("BASCULE_PCR_COUNT", attest.pcr_values.len().to_string()); + if let Some(ref ima_hash) = attest.ima_log_hash { + std::env::set_var("BASCULE_IMA_HASH", ima_hash); + } + std::env::set_var("BASCULE_IMA_COUNT", attest.ima_measurement_count.to_string()); + std::env::set_var("BASCULE_PLATFORM_SUMMARY", + format!("tpm:{}pcr,ima:{}", attest.pcr_values.len(), attest.ima_measurement_count)); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..87c2d1e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + bascule: + build: . + ports: + - "2222:2222" + volumes: + - ./config/bascule.example.toml:/etc/bascule/config.toml:ro + - bascule-keys:/etc/bascule/keys + - bascule-hostkey:/var/lib/bascule + environment: + - RUST_LOG=info,bascule=debug + +volumes: + bascule-keys: + bascule-hostkey: diff --git a/docs/authentication.md b/docs/authentication.md index a52dc9d..f0681ce 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -28,13 +28,19 @@ ssh-ed25519 AAAAC3NzaC1l... user@host ssh-rsa AAAAB3NzaC1yc2... another-user@host ``` +The `authorized_keys_path` can point to either: +- A single file (all users share the same key list) +- A directory with per-user key files: `{dir}/{username}/authorized_keys` + ## Entra Agent ID (AI Agents) -Microsoft Entra Agent ID authentication for AI agents. Agents present their OAuth token as the SSH password. +Microsoft Entra Agent ID authentication for AI agents. Requires the `agent-id` feature flag. + +Agents present their OAuth token as the SSH password. ```toml [auth] -mode = "accept-all" # For human SSH key auth (or authorized-keys) +mode = "authorized-keys" [auth.agent_id] tenant_id = "your-entra-tenant-id" @@ -42,6 +48,8 @@ audiences = ["api://bascule-proxy"] multi_tenant = false ``` +When both `authorized-keys` and `[auth.agent_id]` are configured, Bascule composes them: SSH key auth for humans, token-as-password auth for agents. + ### How agents authenticate 1. Agent obtains an OAuth token from Entra via `client_credentials` flow diff --git a/docs/comparison.md b/docs/comparison.md index 12aa364..7a3d39f 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -8,7 +8,7 @@ | Container sessions | Native | Via agents | No | No | | AI Agent Identity | Native (Entra Agent ID) | No | No | No | | Binary size | ~7MB | ~150MB | ~100MB | N/A (SaaS) | -| Auth | SSH keys, OIDC, Certs, Agent ID | OIDC, SAML, GitHub | OIDC, LDAP | SAML, OIDC | +| Auth | SSH keys, Entra Agent ID | OIDC, SAML, GitHub | OIDC, LDAP | SAML, OIDC | | Session recording | Via SessionHandler | Built-in | Built-in | Built-in | | Kubernetes | Any (pod) | Requires agent | Requires worker | SaaS | | Extensibility | SessionHandler trait | Plugin system | No | No | diff --git a/docs/observability.md b/docs/observability.md index 9b9b735..f3bb9d5 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -40,18 +40,18 @@ RUST_LOG=bascule=debug ./bascule --config config.toml # debug bascule only ## OTel Tracing (Planned) -OpenTelemetry OTLP export is planned as an optional feature flag (`--features telemetry`). Session lifecycle maps to OTel spans: +OpenTelemetry OTLP export is planned as an optional feature flag (`--features telemetry`). Not yet implemented. Session lifecycle will map to OTel spans: ``` session (root) -├── auth (ssh-key / oidc / agent-id) +├── auth (ssh-key / agent-id) ├── backend_setup (pty / proxy / container) └── session_active (commands, I/O) ``` ## Prometheus Metrics (Planned) -Prometheus-compatible metrics endpoint planned as `--features metrics`: +Prometheus-compatible metrics endpoint is planned as `--features metrics`. Not yet implemented. Planned metrics: ``` bascule_sessions_total{backend,auth_method,outcome} diff --git a/docs/quickstart.md b/docs/quickstart.md index 0b8c64c..45de379 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -3,7 +3,7 @@ ## Option 1: Build from Source ```bash -git clone https://github.com/guildhouse/bascule.git +git clone https://github.com/your-org/bascule.git cd bascule cargo build --release -p bascule-server ./target/release/bascule --config config/bascule.example.toml