feat: bascule-shell — identity-aware shell with TPM attestation
New crate: bascule-shell (471 lines, 1.8MB binary) Login shell that detects identity + platform attestation at startup. Wraps bash/zsh/fish — operator works normally, identity travels with them. Identity detection (priority order): 1. Entra via WSL2 interop 2. Azure CLI 3. Kerberos TGT 4. Cached OIDC token 5. System user (fallback) Platform attestation: TPM 2.0 PCR values via tpm2_pcrread (PCRs 0,1,2,7,10,14) IMA measurement log hash + count Keylime agent state Entra device compliance (WSL2 only) Composite SHA-256 hash over all evidence Shell features: Banner with identity + attestation summary BASCULE_* env vars injected into inner shell --info mode for dry-run display --json mode for machine-readable output --exec mode for single-command execution Configurable via ~/.config/bascule/shell.toml Tested on Fedora with real TPM 2.0: 6 PCRs successfully read from hardware All env vars propagated to inner shell 1.8MB binary, 0 substrate deps Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e7fc9fa5e1
commit
043b9b9bdc
27 changed files with 1871 additions and 127 deletions
58
.github/workflows/ci.yml
vendored
Normal file
58
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -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
|
||||||
190
Cargo.lock
generated
190
Cargo.lock
generated
|
|
@ -181,6 +181,7 @@ dependencies = [
|
||||||
"russh",
|
"russh",
|
||||||
"russh-keys",
|
"russh-keys",
|
||||||
"serde",
|
"serde",
|
||||||
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
|
|
@ -201,6 +202,24 @@ dependencies = [
|
||||||
"tracing-subscriber",
|
"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]]
|
[[package]]
|
||||||
name = "base16ct"
|
name = "base16ct"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -576,6 +595,27 @@ dependencies = [
|
||||||
"subtle",
|
"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]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
|
|
@ -669,6 +709,12 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "ff"
|
name = "ff"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
|
|
@ -926,6 +972,12 @@ version = "0.5.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hex"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hex-literal"
|
name = "hex-literal"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
|
|
@ -1304,6 +1356,21 @@ version = "0.2.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
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]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
|
|
@ -1396,6 +1463,18 @@ dependencies = [
|
||||||
"pin-utils",
|
"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]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
|
|
@ -1496,6 +1575,12 @@ version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "option-ext"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "p256"
|
name = "p256"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
|
|
@ -1693,7 +1778,7 @@ dependencies = [
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"nix",
|
"nix 0.25.1",
|
||||||
"serial",
|
"serial",
|
||||||
"shared_library",
|
"shared_library",
|
||||||
"shell-words",
|
"shell-words",
|
||||||
|
|
@ -1897,6 +1982,17 @@ dependencies = [
|
||||||
"bitflags 2.11.0",
|
"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]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
|
|
@ -2150,6 +2246,19 @@ dependencies = [
|
||||||
"semver",
|
"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]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.37"
|
version = "0.23.37"
|
||||||
|
|
@ -2584,6 +2693,19 @@ dependencies = [
|
||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "termios"
|
name = "termios"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|
@ -3313,6 +3435,15 @@ dependencies = [
|
||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
|
|
@ -3340,6 +3471,21 @@ dependencies = [
|
||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -3373,6 +3519,12 @@ dependencies = [
|
||||||
"windows_x86_64_msvc 0.53.1",
|
"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]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -3385,6 +3537,12 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -3397,6 +3555,12 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -3421,6 +3585,12 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -3433,6 +3603,12 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -3445,6 +3621,12 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -3457,6 +3639,12 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[workspace]
|
[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"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|
|
||||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
202
LICENSE
Normal file
202
LICENSE
Normal file
|
|
@ -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.
|
||||||
19
README.md
19
README.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Identity-aware SSH proxy for modern infrastructure.
|
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
|
## Quick Start
|
||||||
|
|
||||||
|
|
@ -39,8 +39,9 @@ memory_limit = "512m"
|
||||||
|
|
||||||
- **Three backends** — local PTY, remote SSH proxy, ephemeral containers
|
- **Three backends** — local PTY, remote SSH proxy, ephemeral containers
|
||||||
- **Identity-aware sessions** — every connection authenticated and attributed
|
- **Identity-aware sessions** — every connection authenticated and attributed
|
||||||
- **SSH key authentication** — standard authorized_keys, no surprises
|
- **SSH key authentication** — standard authorized_keys file
|
||||||
- **AI agent authentication** — native Microsoft Entra Agent ID support
|
- **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)
|
- **Right-sized images** — curated container images (minimal, k8s-ops, net-ops, dev)
|
||||||
- **SessionHandler trait** — extend with custom policy, audit, or recording
|
- **SessionHandler trait** — extend with custom policy, audit, or recording
|
||||||
- **Structured logging** — JSON format for production observability
|
- **Structured logging** — JSON format for production observability
|
||||||
|
|
@ -55,6 +56,7 @@ memory_limit = "512m"
|
||||||
| License | Apache 2.0 | AGPL | MPL |
|
| License | Apache 2.0 | AGPL | MPL |
|
||||||
| Container sessions | Native | No | No |
|
| Container sessions | Native | No | No |
|
||||||
| AI Agent Identity | 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 |
|
| Binary size | ~7MB | ~150MB | ~100MB |
|
||||||
|
|
||||||
See [docs/comparison.md](docs/comparison.md) for the full comparison.
|
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
|
## Documentation
|
||||||
|
|
||||||
|
|
@ -94,6 +96,15 @@ Projects like [Guildhouse](https://guildhouse.dev) use the SessionHandler trait
|
||||||
- [Comparison](docs/comparison.md)
|
- [Comparison](docs/comparison.md)
|
||||||
- [Container Images](images/README.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
|
## License
|
||||||
|
|
||||||
Apache 2.0
|
Apache 2.0
|
||||||
|
|
|
||||||
|
|
@ -118,10 +118,7 @@ impl EntraAgentIdProvider {
|
||||||
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);
|
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);
|
||||||
validation.set_audience(&self.expected_audiences);
|
validation.set_audience(&self.expected_audiences);
|
||||||
|
|
||||||
let tenant_issuer = format!(
|
let tenant_issuer = format!("https://login.microsoftonline.com/{}/v2.0", self.tenant_id);
|
||||||
"https://login.microsoftonline.com/{}/v2.0",
|
|
||||||
self.tenant_id
|
|
||||||
);
|
|
||||||
if self.allow_multi_tenant {
|
if self.allow_multi_tenant {
|
||||||
let issuers = [
|
let issuers = [
|
||||||
tenant_issuer,
|
tenant_issuer,
|
||||||
|
|
@ -208,11 +205,7 @@ impl EntraAgentIdProvider {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl AuthProvider for EntraAgentIdProvider {
|
impl AuthProvider for EntraAgentIdProvider {
|
||||||
async fn check_password(
|
async fn check_password(&self, user: &str, password: &str) -> bool {
|
||||||
&self,
|
|
||||||
user: &str,
|
|
||||||
password: &str,
|
|
||||||
) -> bool {
|
|
||||||
match self.validate_token(password).await {
|
match self.validate_token(password).await {
|
||||||
Ok(identity) => {
|
Ok(identity) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,6 @@ chrono = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
rand = { workspace = true }
|
rand = { workspace = true }
|
||||||
portable-pty = { workspace = true }
|
portable-pty = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
//! Pluggable authentication providers.
|
//! Pluggable authentication providers.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use russh_keys::key::PublicKey;
|
use russh_keys::key::PublicKey;
|
||||||
|
|
||||||
|
|
@ -39,3 +41,228 @@ impl AuthProvider for AcceptAllKeys {
|
||||||
true
|
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<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
keys_path: keys_path.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_keys_for_user(&self, user: &str) -> Vec<PublicKey> {
|
||||||
|
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<Vec<PublicKey>> {
|
||||||
|
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<Box<dyn AuthProvider>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CompositeAuthProvider {
|
||||||
|
pub fn new(providers: Vec<Box<dyn AuthProvider>>) -> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ pub struct ProxyConfig {
|
||||||
pub accept_target_host_key: bool,
|
pub accept_target_host_key: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Default)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct AuthConfig {
|
pub struct AuthConfig {
|
||||||
/// Auth mode: "accept-all" (dev), "authorized-keys"
|
/// Auth mode: "accept-all" (dev), "authorized-keys"
|
||||||
#[serde(default = "default_auth_mode")]
|
#[serde(default = "default_auth_mode")]
|
||||||
|
|
@ -80,6 +80,16 @@ pub struct AuthConfig {
|
||||||
pub agent_id: Option<AgentIdConfig>,
|
pub agent_id: Option<AgentIdConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for AuthConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mode: default_auth_mode(),
|
||||||
|
authorized_keys_path: None,
|
||||||
|
agent_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct AgentIdConfig {
|
pub struct AgentIdConfig {
|
||||||
/// Entra tenant ID.
|
/// Entra tenant ID.
|
||||||
|
|
@ -200,9 +210,219 @@ pub struct MetricsConfig {
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_service_name() -> String { "bascule".to_string() }
|
impl ContainerConfig {
|
||||||
fn default_metrics_port() -> u16 { 9090 }
|
/// 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() }
|
// CPU limit: valid float
|
||||||
fn default_pull_policy() -> String { "if-not-present".to_string() }
|
if let Some(ref cpu) = self.cpu_limit {
|
||||||
fn default_true() -> bool { true }
|
if cpu.parse::<f64>().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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
//! The container is destroyed when the session ends.
|
//! The container is destroyed when the session ends.
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```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.
|
//! Uses CLI execution for maximum portability — no libdocker dependency.
|
||||||
|
|
@ -12,7 +12,6 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
use tokio::process::{Child, Command};
|
use tokio::process::{Child, Command};
|
||||||
|
|
||||||
use crate::config::ContainerConfig;
|
use crate::config::ContainerConfig;
|
||||||
|
|
@ -56,7 +55,7 @@ impl ContainerRuntime {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse from config string.
|
/// Parse from config string.
|
||||||
pub fn from_str(s: &str) -> Option<Self> {
|
pub fn from_config(s: &str) -> Option<Self> {
|
||||||
match s {
|
match s {
|
||||||
"docker" => Some(Self::Docker),
|
"docker" => Some(Self::Docker),
|
||||||
"podman" => Some(Self::Podman),
|
"podman" => Some(Self::Podman),
|
||||||
|
|
@ -80,8 +79,9 @@ impl ContainerSession {
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
config: &ContainerConfig,
|
config: &ContainerConfig,
|
||||||
extra_env: &HashMap<String, String>,
|
extra_env: &HashMap<String, String>,
|
||||||
|
command: Option<&str>,
|
||||||
) -> anyhow::Result<Self> {
|
) -> anyhow::Result<Self> {
|
||||||
let runtime = ContainerRuntime::from_str(&config.runtime)
|
let runtime = ContainerRuntime::from_config(&config.runtime)
|
||||||
.ok_or_else(|| anyhow::anyhow!("No container runtime available"))?;
|
.ok_or_else(|| anyhow::anyhow!("No container runtime available"))?;
|
||||||
|
|
||||||
// Pull image if needed
|
// Pull image if needed
|
||||||
|
|
@ -135,10 +135,14 @@ impl ContainerSession {
|
||||||
// Security hardening
|
// Security hardening
|
||||||
if config.hardened {
|
if config.hardened {
|
||||||
args.extend([
|
args.extend([
|
||||||
"--security-opt".into(), "no-new-privileges".into(),
|
"--security-opt".into(),
|
||||||
"--cap-drop".into(), "ALL".into(),
|
"no-new-privileges".into(),
|
||||||
"--cap-add".into(), "SETUID".into(),
|
"--cap-drop".into(),
|
||||||
"--cap-add".into(), "SETGID".into(),
|
"ALL".into(),
|
||||||
|
"--cap-add".into(),
|
||||||
|
"SETUID".into(),
|
||||||
|
"--cap-add".into(),
|
||||||
|
"SETGID".into(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,8 +150,10 @@ impl ContainerSession {
|
||||||
if config.read_only_rootfs {
|
if config.read_only_rootfs {
|
||||||
args.extend([
|
args.extend([
|
||||||
"--read-only".into(),
|
"--read-only".into(),
|
||||||
"--tmpfs".into(), "/tmp:rw,noexec,nosuid,size=64m".into(),
|
"--tmpfs".into(),
|
||||||
"--tmpfs".into(), "/run:rw,noexec,nosuid,size=16m".into(),
|
"/tmp:rw,noexec,nosuid,size=64m".into(),
|
||||||
|
"--tmpfs".into(),
|
||||||
|
"/run:rw,noexec,nosuid,size=16m".into(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,10 +165,15 @@ impl ContainerSession {
|
||||||
// Image
|
// Image
|
||||||
args.push(config.image.clone());
|
args.push(config.image.clone());
|
||||||
|
|
||||||
// Shell command override
|
// Command: exec request or configured shell or image default
|
||||||
if let Some(ref shell) = config.shell {
|
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());
|
args.push(shell.clone());
|
||||||
}
|
}
|
||||||
|
// else: use image's ENTRYPOINT/CMD
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
runtime = runtime.binary(),
|
runtime = runtime.binary(),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ use russh::server::{Auth, Handler, Msg, Session};
|
||||||
use russh::{Channel, ChannelId, CryptoVec};
|
use russh::{Channel, ChannelId, CryptoVec};
|
||||||
use russh_keys::key;
|
use russh_keys::key;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::{Mutex, OwnedSemaphorePermit};
|
||||||
|
|
||||||
use crate::auth::AuthProvider;
|
use crate::auth::AuthProvider;
|
||||||
use crate::config::BasculeConfig;
|
use crate::config::BasculeConfig;
|
||||||
|
|
@ -42,6 +42,9 @@ pub struct BasculeHandler {
|
||||||
pty_cols: u16,
|
pty_cols: u16,
|
||||||
pty_rows: u16,
|
pty_rows: u16,
|
||||||
peer_addr: String,
|
peer_addr: String,
|
||||||
|
/// Semaphore permit — held for the lifetime of this connection.
|
||||||
|
/// None means max sessions reached; auth will be rejected.
|
||||||
|
_session_permit: Option<OwnedSemaphorePermit>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BasculeHandler {
|
impl BasculeHandler {
|
||||||
|
|
@ -50,6 +53,7 @@ impl BasculeHandler {
|
||||||
session_handler: Arc<dyn SessionHandler>,
|
session_handler: Arc<dyn SessionHandler>,
|
||||||
config: Arc<BasculeConfig>,
|
config: Arc<BasculeConfig>,
|
||||||
peer_addr: String,
|
peer_addr: String,
|
||||||
|
session_permit: Option<OwnedSemaphorePermit>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
auth,
|
auth,
|
||||||
|
|
@ -60,6 +64,7 @@ impl BasculeHandler {
|
||||||
pty_cols: 80,
|
pty_cols: 80,
|
||||||
pty_rows: 24,
|
pty_rows: 24,
|
||||||
peer_addr,
|
peer_addr,
|
||||||
|
_session_permit: session_permit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,8 +82,11 @@ impl BasculeHandler {
|
||||||
session: &mut Session,
|
session: &mut Session,
|
||||||
command: Option<&str>,
|
command: Option<&str>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let info = self.session_info.as_ref()
|
let info = self
|
||||||
.ok_or_else(|| anyhow::anyhow!("No session info"))?.clone();
|
.session_info
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No session info"))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
self.session_handler.on_session_start(&info).await?;
|
self.session_handler.on_session_start(&info).await?;
|
||||||
|
|
||||||
|
|
@ -89,25 +97,58 @@ impl BasculeHandler {
|
||||||
|
|
||||||
let (cmd, args) = match command {
|
let (cmd, args) = match command {
|
||||||
Some(c) => ("/bin/sh", vec!["-c".to_string(), c.to_string()]),
|
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));
|
let bridge = Arc::new(Mutex::new(bridge));
|
||||||
self.backend = Some(SessionBackend::Local(bridge.clone()));
|
self.backend = Some(SessionBackend::Local(bridge.clone()));
|
||||||
|
|
||||||
let handle = session.handle();
|
// Channel to bridge blocking reads to async world
|
||||||
tokio::spawn(async move {
|
let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<u8>>(32);
|
||||||
|
|
||||||
|
// Dedicated OS thread for blocking PTY reads
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut reader = reader;
|
||||||
let mut buf = [0u8; 4096];
|
let mut buf = [0u8; 4096];
|
||||||
loop {
|
loop {
|
||||||
let n = {
|
match reader.read(&mut buf) {
|
||||||
let mut b = bridge.lock().await;
|
|
||||||
match b.reader.read(&mut buf) {
|
|
||||||
Ok(0) | Err(_) => break,
|
Ok(0) | Err(_) => break,
|
||||||
Ok(n) => n,
|
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;
|
let _ = handle.close(channel).await;
|
||||||
});
|
});
|
||||||
|
|
@ -121,34 +162,60 @@ impl BasculeHandler {
|
||||||
session: &mut Session,
|
session: &mut Session,
|
||||||
command: Option<&str>,
|
command: Option<&str>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let info = self.session_info.as_ref()
|
let info = self
|
||||||
.ok_or_else(|| anyhow::anyhow!("No session info"))?.clone();
|
.session_info
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No session info"))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
self.session_handler.on_session_start(&info).await?;
|
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"))?;
|
.ok_or_else(|| anyhow::anyhow!("Proxy config missing"))?;
|
||||||
|
|
||||||
let mut upstream = proxy::connect_upstream(proxy_config, &info.principal).await?;
|
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"))?;
|
.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 {
|
match command {
|
||||||
Some(cmd) => upstream_ch.exec(true, cmd).await?,
|
Some(cmd) => upstream_ch.exec(true, cmd).await?,
|
||||||
None => upstream_ch.request_shell(true).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"))?;
|
.ok_or_else(|| anyhow::anyhow!("Channel already taken"))?;
|
||||||
|
|
||||||
let upstream = Arc::new(Mutex::new(upstream));
|
let upstream = Arc::new(Mutex::new(upstream));
|
||||||
self.backend = Some(SessionBackend::Proxy(upstream.clone()));
|
self.backend = Some(SessionBackend::Proxy(upstream.clone()));
|
||||||
|
|
||||||
let server_handle = session.handle();
|
let server_handle = session.handle();
|
||||||
|
let handler = self.session_handler.clone();
|
||||||
|
let session_info = info;
|
||||||
tokio::spawn(async move {
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -158,32 +225,37 @@ impl BasculeHandler {
|
||||||
&mut self,
|
&mut self,
|
||||||
channel: ChannelId,
|
channel: ChannelId,
|
||||||
session: &mut Session,
|
session: &mut Session,
|
||||||
_command: Option<&str>,
|
command: Option<&str>,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let info = self.session_info.as_ref()
|
let info = self
|
||||||
.ok_or_else(|| anyhow::anyhow!("No session info"))?.clone();
|
.session_info
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No session info"))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
self.session_handler.on_session_start(&info).await?;
|
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"))?;
|
.ok_or_else(|| anyhow::anyhow!("Container config missing"))?;
|
||||||
|
|
||||||
let mut env = self.session_handler.build_session_env(&info).await;
|
let mut env = self.session_handler.build_session_env(&info).await;
|
||||||
env.insert("BASCULE_SESSION_ID".into(), info.session_id.clone());
|
env.insert("BASCULE_SESSION_ID".into(), info.session_id.clone());
|
||||||
env.insert("BASCULE_PRINCIPAL".into(), info.principal.clone());
|
env.insert("BASCULE_PRINCIPAL".into(), info.principal.clone());
|
||||||
|
|
||||||
let container = ContainerSession::spawn(
|
let container =
|
||||||
&info.session_id,
|
ContainerSession::spawn(&info.session_id, container_config, &env, command).await?;
|
||||||
container_config,
|
|
||||||
&env,
|
|
||||||
).await?;
|
|
||||||
|
|
||||||
let container = Arc::new(Mutex::new(container));
|
let container = Arc::new(Mutex::new(container));
|
||||||
self.backend = Some(SessionBackend::Container(container.clone()));
|
self.backend = Some(SessionBackend::Container(container.clone()));
|
||||||
|
|
||||||
// Bridge container stdout → SSH channel
|
// Bridge container stdout -> SSH channel
|
||||||
let handle = session.handle();
|
let handle = session.handle();
|
||||||
let container_for_read = container.clone();
|
let container_for_read = container.clone();
|
||||||
|
let handler = self.session_handler.clone();
|
||||||
|
let session_info = info;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut buf = [0u8; 4096];
|
let mut buf = [0u8; 4096];
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -198,7 +270,18 @@ impl BasculeHandler {
|
||||||
None => break,
|
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
|
// Container process exited — clean up
|
||||||
|
|
@ -233,59 +316,132 @@ impl BasculeHandler {
|
||||||
impl Handler for BasculeHandler {
|
impl Handler for BasculeHandler {
|
||||||
type Error = anyhow::Error;
|
type Error = anyhow::Error;
|
||||||
|
|
||||||
async fn auth_publickey(&mut self, user: &str, public_key: &key::PublicKey) -> Result<Auth, Self::Error> {
|
async fn auth_publickey(
|
||||||
|
&mut self,
|
||||||
|
user: &str,
|
||||||
|
public_key: &key::PublicKey,
|
||||||
|
) -> Result<Auth, Self::Error> {
|
||||||
|
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 {
|
if self.auth.check_public_key(user, public_key).await {
|
||||||
let principal = self.auth.principal_for_user(user);
|
let principal = self.auth.principal_for_user(user);
|
||||||
tracing::info!(method = "ssh-key", principal = %principal, peer = %self.peer_addr, "Auth accepted");
|
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)
|
Ok(Auth::Accept)
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(method = "ssh-key", user = %user, peer = %self.peer_addr, "Auth rejected");
|
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<Auth, Self::Error> {
|
async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
|
||||||
|
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 {
|
if self.auth.check_password(user, password).await {
|
||||||
let principal = self.auth.principal_for_user(user);
|
let principal = self.auth.principal_for_user(user);
|
||||||
tracing::info!(method = "password", principal = %principal, peer = %self.peer_addr, "Auth accepted");
|
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)
|
Ok(Auth::Accept)
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(method = "password", user = %user, peer = %self.peer_addr, "Auth rejected");
|
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<Msg>, _session: &mut Session) -> Result<bool, Self::Error> {
|
async fn channel_open_session(
|
||||||
|
&mut self,
|
||||||
|
_channel: Channel<Msg>,
|
||||||
|
_session: &mut Session,
|
||||||
|
) -> Result<bool, Self::Error> {
|
||||||
Ok(true)
|
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_cols = col_width.min(500) as u16;
|
||||||
self.pty_rows = row_height.min(200) as u16;
|
self.pty_rows = row_height.min(200) as u16;
|
||||||
session.request_success();
|
session.request_success();
|
||||||
Ok(())
|
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_cols = col_width.min(500) as u16;
|
||||||
self.pty_rows = row_height.min(200) as u16;
|
self.pty_rows = row_height.min(200) as u16;
|
||||||
match &self.backend {
|
match &self.backend {
|
||||||
Some(SessionBackend::Local(bridge)) => { let b = bridge.lock().await; let _ = b.resize(self.pty_cols, self.pty_rows); }
|
Some(SessionBackend::Local(bridge)) => {
|
||||||
Some(SessionBackend::Proxy(_)) => { tracing::debug!("Window change in proxy mode (not forwarded)"); }
|
let b = bridge.lock().await;
|
||||||
Some(SessionBackend::Container(_)) => { tracing::debug!("Window change in container mode (handled by runtime)"); }
|
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 => {}
|
None => {}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn shell_request(&mut self, channel: ChannelId, session: &mut Session) -> Result<(), Self::Error> {
|
async fn shell_request(
|
||||||
let backend_type = if self.config.proxy.is_some() { "proxy" }
|
&mut self,
|
||||||
else if self.config.container.is_some() { "container" }
|
channel: ChannelId,
|
||||||
else { "pty" };
|
session: &mut Session,
|
||||||
let session_id = self.session_info.as_ref().map(|i| i.session_id.as_str()).unwrap_or("unknown");
|
) -> Result<(), Self::Error> {
|
||||||
let principal = self.session_info.as_ref().map(|i| i.principal.as_str()).unwrap_or("unknown");
|
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!(
|
tracing::info!(
|
||||||
session.id = %session_id,
|
session.id = %session_id,
|
||||||
session.principal = %principal,
|
session.principal = %principal,
|
||||||
|
|
@ -296,7 +452,10 @@ impl Handler for BasculeHandler {
|
||||||
|
|
||||||
if let Some(info) = &self.session_info {
|
if let Some(info) = &self.session_info {
|
||||||
let display = self.session_handler.display_name(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))
|
.map(|b| format!("{}\r\n", b))
|
||||||
.unwrap_or_else(|| format!("Welcome, {}.\r\n", display));
|
.unwrap_or_else(|| format!("Welcome, {}.\r\n", display));
|
||||||
session.data(channel, CryptoVec::from_slice(banner.as_bytes()));
|
session.data(channel, CryptoVec::from_slice(banner.as_bytes()));
|
||||||
|
|
@ -306,9 +465,18 @@ impl Handler for BasculeHandler {
|
||||||
Ok(())
|
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 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");
|
tracing::info!(session.id = %session_id, command = %command, "Exec request");
|
||||||
|
|
||||||
if let Some(info) = &self.session_info {
|
if let Some(info) = &self.session_info {
|
||||||
|
|
@ -324,7 +492,12 @@ impl Handler for BasculeHandler {
|
||||||
Ok(())
|
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 {
|
match &self.backend {
|
||||||
Some(SessionBackend::Local(bridge)) => {
|
Some(SessionBackend::Local(bridge)) => {
|
||||||
let mut b = bridge.lock().await;
|
let mut b = bridge.lock().await;
|
||||||
|
|
@ -333,7 +506,10 @@ impl Handler for BasculeHandler {
|
||||||
}
|
}
|
||||||
Some(SessionBackend::Proxy(upstream)) => {
|
Some(SessionBackend::Proxy(upstream)) => {
|
||||||
let u = upstream.lock().await;
|
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)) => {
|
Some(SessionBackend::Container(container)) => {
|
||||||
let mut c = container.lock().await;
|
let mut c = container.lock().await;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ use async_trait::async_trait;
|
||||||
use russh::client;
|
use russh::client;
|
||||||
use russh::ChannelMsg;
|
use russh::ChannelMsg;
|
||||||
use russh_keys::key;
|
use russh_keys::key;
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
use crate::config::ProxyConfig;
|
use crate::config::ProxyConfig;
|
||||||
|
|
||||||
|
|
@ -62,10 +61,7 @@ pub async fn connect_upstream(
|
||||||
proxy_config: &ProxyConfig,
|
proxy_config: &ProxyConfig,
|
||||||
username: &str,
|
username: &str,
|
||||||
) -> anyhow::Result<UpstreamSession> {
|
) -> anyhow::Result<UpstreamSession> {
|
||||||
let target_user = proxy_config
|
let target_user = proxy_config.target_user.as_deref().unwrap_or(username);
|
||||||
.target_user
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or(username);
|
|
||||||
|
|
||||||
let addr = format!("{}:{}", proxy_config.target_host, proxy_config.target_port);
|
let addr = format!("{}:{}", proxy_config.target_host, proxy_config.target_port);
|
||||||
tracing::info!(target = %addr, user = %target_user, "Connecting to upstream SSH host");
|
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");
|
tracing::info!("Upstream channel opened");
|
||||||
|
|
||||||
let channel_id = channel.id();
|
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.
|
/// 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 }) => {
|
Some(ChannelMsg::ExitStatus { exit_status }) => {
|
||||||
tracing::info!(exit_status, "Upstream session exited");
|
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;
|
let _ = server_handle.close(server_channel_id).await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,8 @@
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use tokio::sync::mpsc;
|
|
||||||
|
|
||||||
/// Spawn a PTY with the given command and bridge I/O.
|
/// Spawn a PTY with the given command and return the bridge.
|
||||||
///
|
|
||||||
/// Returns (writer_tx, reader_rx) channels for the SSH handler to use.
|
|
||||||
pub fn spawn_pty(
|
pub fn spawn_pty(
|
||||||
command: &str,
|
command: &str,
|
||||||
args: &[String],
|
args: &[String],
|
||||||
|
|
@ -51,7 +48,7 @@ pub fn spawn_pty(
|
||||||
Ok(PtyBridge {
|
Ok(PtyBridge {
|
||||||
master: pair.master,
|
master: pair.master,
|
||||||
child,
|
child,
|
||||||
reader,
|
reader: Some(reader),
|
||||||
writer,
|
writer,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -60,11 +57,18 @@ pub fn spawn_pty(
|
||||||
pub struct PtyBridge {
|
pub struct PtyBridge {
|
||||||
pub master: Box<dyn portable_pty::MasterPty + Send>,
|
pub master: Box<dyn portable_pty::MasterPty + Send>,
|
||||||
pub child: Box<dyn portable_pty::Child + Send + Sync>,
|
pub child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||||
pub reader: Box<dyn Read + Send>,
|
/// PTY reader — `take()` this to move it into a dedicated read thread.
|
||||||
|
pub reader: Option<Box<dyn Read + Send>>,
|
||||||
pub writer: Box<dyn Write + Send>,
|
pub writer: Box<dyn Write + Send>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PtyBridge {
|
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<Box<dyn Read + Send>> {
|
||||||
|
self.reader.take()
|
||||||
|
}
|
||||||
|
|
||||||
/// Resize the PTY.
|
/// Resize the PTY.
|
||||||
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
|
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
|
||||||
self.master
|
self.master
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||||
|
|
||||||
use russh::server::{Config, Server};
|
use russh::server::{Config, Server};
|
||||||
use russh_keys::key::KeyPair;
|
use russh_keys::key::KeyPair;
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
use crate::auth::AuthProvider;
|
use crate::auth::AuthProvider;
|
||||||
use crate::config::BasculeConfig;
|
use crate::config::BasculeConfig;
|
||||||
|
|
@ -16,6 +17,7 @@ pub struct BasculeServer {
|
||||||
app_config: Arc<BasculeConfig>,
|
app_config: Arc<BasculeConfig>,
|
||||||
auth: Arc<dyn AuthProvider>,
|
auth: Arc<dyn AuthProvider>,
|
||||||
session_handler: Arc<dyn SessionHandler>,
|
session_handler: Arc<dyn SessionHandler>,
|
||||||
|
session_semaphore: Arc<Semaphore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BasculeServer {
|
impl BasculeServer {
|
||||||
|
|
@ -23,6 +25,14 @@ impl BasculeServer {
|
||||||
config: BasculeConfig,
|
config: BasculeConfig,
|
||||||
auth: impl AuthProvider + 'static,
|
auth: impl AuthProvider + 'static,
|
||||||
session_handler: impl SessionHandler + 'static,
|
session_handler: impl SessionHandler + 'static,
|
||||||
|
) -> anyhow::Result<Self> {
|
||||||
|
Self::with_arc_auth(config, Arc::new(auth), session_handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_arc_auth(
|
||||||
|
config: BasculeConfig,
|
||||||
|
auth: Arc<dyn AuthProvider>,
|
||||||
|
session_handler: impl SessionHandler + 'static,
|
||||||
) -> anyhow::Result<Self> {
|
) -> anyhow::Result<Self> {
|
||||||
let host_key = if let Some(path) = &config.host_key_path {
|
let host_key = if let Some(path) = &config.host_key_path {
|
||||||
if std::path::Path::new(path).exists() {
|
if std::path::Path::new(path).exists() {
|
||||||
|
|
@ -42,11 +52,19 @@ impl BasculeServer {
|
||||||
..Default::default()
|
..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 {
|
Ok(Self {
|
||||||
ssh_config: Arc::new(ssh_config),
|
ssh_config: Arc::new(ssh_config),
|
||||||
app_config: Arc::new(config),
|
app_config: Arc::new(config),
|
||||||
auth: Arc::new(auth),
|
auth,
|
||||||
session_handler: Arc::new(session_handler),
|
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;
|
let addr = &self.app_config.listen_addr;
|
||||||
tracing::info!(addr = %addr, "Starting Bascule SSH server");
|
tracing::info!(addr = %addr, "Starting Bascule SSH server");
|
||||||
let socket_addr: std::net::SocketAddr = addr.parse()?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -63,13 +82,37 @@ impl russh::server::Server for BasculeServer {
|
||||||
type Handler = BasculeHandler;
|
type Handler = BasculeHandler;
|
||||||
|
|
||||||
fn new_client(&mut self, peer_addr: Option<std::net::SocketAddr>) -> BasculeHandler {
|
fn new_client(&mut self, peer_addr: Option<std::net::SocketAddr>) -> BasculeHandler {
|
||||||
let addr = peer_addr.map(|a| a.to_string()).unwrap_or_else(|| "unknown".to_string());
|
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");
|
tracing::info!(peer = %addr, "New SSH connection");
|
||||||
BasculeHandler::new(
|
BasculeHandler::new(
|
||||||
self.auth.clone(),
|
self.auth.clone(),
|
||||||
self.session_handler.clone(),
|
self.session_handler.clone(),
|
||||||
self.app_config.clone(),
|
self.app_config.clone(),
|
||||||
addr,
|
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,15 @@
|
||||||
//! bascule --config /etc/bascule/config.toml
|
//! bascule --config /etc/bascule/config.toml
|
||||||
//! bascule # uses default config (accept-all auth, port 2222)
|
//! bascule # uses default config (accept-all auth, port 2222)
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use tracing_subscriber::EnvFilter;
|
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::config::BasculeConfig;
|
||||||
use bascule_core::hooks::DefaultHandler;
|
use bascule_core::hooks::DefaultHandler;
|
||||||
use bascule_core::server::BasculeServer;
|
use bascule_core::server::BasculeServer;
|
||||||
|
|
@ -21,26 +25,72 @@ struct Cli {
|
||||||
config: Option<String>,
|
config: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init_tracing(config: &BasculeConfig) {
|
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)
|
|
||||||
let json_format = std::env::var("BASCULE_LOG_FORMAT")
|
let json_format = std::env::var("BASCULE_LOG_FORMAT")
|
||||||
.map(|v| v == "json")
|
.map(|v| v == "json")
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
if json_format {
|
if json_format {
|
||||||
tracing_subscriber::fmt()
|
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()
|
.json()
|
||||||
.with_target(true)
|
.with_target(true)
|
||||||
.init();
|
.init();
|
||||||
} else {
|
} else {
|
||||||
tracing_subscriber::fmt()
|
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();
|
.init();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_auth_provider(config: &BasculeConfig) -> Arc<dyn AuthProvider> {
|
||||||
|
let base_provider: Box<dyn AuthProvider> = 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]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
@ -52,9 +102,18 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
init_tracing(&config);
|
init_tracing(&config);
|
||||||
|
|
||||||
let backend = if config.proxy.is_some() { "proxy" }
|
// Validate container config at startup (fail fast on bad values)
|
||||||
else if config.container.is_some() { "container" }
|
if let Some(ref container_config) = config.container {
|
||||||
else { "pty" };
|
container_config.validate()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let backend = if config.proxy.is_some() {
|
||||||
|
"proxy"
|
||||||
|
} else if config.container.is_some() {
|
||||||
|
"container"
|
||||||
|
} else {
|
||||||
|
"pty"
|
||||||
|
};
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
listen = %config.listen_addr,
|
listen = %config.listen_addr,
|
||||||
|
|
@ -64,6 +123,7 @@ async fn main() -> Result<()> {
|
||||||
"Bascule starting"
|
"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
|
server.run().await
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
crates/bascule-shell/Cargo.toml
Normal file
24
crates/bascule-shell/Cargo.toml
Normal file
|
|
@ -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"] }
|
||||||
135
crates/bascule-shell/src/attestation.rs
Normal file
135
crates/bascule-shell/src/attestation.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
pub pcr_values: BTreeMap<u32, String>,
|
||||||
|
pub ima_available: bool,
|
||||||
|
pub ima_measurement_count: u32,
|
||||||
|
pub ima_log_hash: Option<String>,
|
||||||
|
pub keylime_active: bool,
|
||||||
|
pub entra_device_compliant: Option<bool>,
|
||||||
|
pub entra_device_id: Option<String>,
|
||||||
|
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<String>) {
|
||||||
|
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<u32, String> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String>) {
|
||||||
|
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<bool>, Option<String>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
38
crates/bascule-shell/src/banner.rs
Normal file
38
crates/bascule-shell/src/banner.rs
Normal file
|
|
@ -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!("╚═══════════════════════════════════════════════════════╝");
|
||||||
|
}
|
||||||
56
crates/bascule-shell/src/config.rs
Normal file
56
crates/bascule-shell/src/config.rs
Normal file
|
|
@ -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<BasculeServer>,
|
||||||
|
#[serde(default = "default_pcr_indices")]
|
||||||
|
pub pcr_indices: Vec<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<u32> { 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::<ShellConfig>(&content) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
128
crates/bascule-shell/src/identity.rs
Normal file
128
crates/bascule-shell/src/identity.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
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<Identity> {
|
||||||
|
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<Identity> {
|
||||||
|
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<Identity> {
|
||||||
|
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<Identity> {
|
||||||
|
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<String> {
|
||||||
|
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) }
|
||||||
|
}
|
||||||
114
crates/bascule-shell/src/main.rs
Normal file
114
crates/bascule-shell/src/main.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
|
||||||
|
/// Skip banner
|
||||||
|
#[arg(long)]
|
||||||
|
no_banner: bool,
|
||||||
|
|
||||||
|
/// Config file path
|
||||||
|
#[arg(long, short)]
|
||||||
|
config: Option<String>,
|
||||||
|
|
||||||
|
/// Run a single command through the inner shell
|
||||||
|
#[arg(long)]
|
||||||
|
exec: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
|
|
@ -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:
|
||||||
|
|
@ -28,13 +28,19 @@ ssh-ed25519 AAAAC3NzaC1l... user@host
|
||||||
ssh-rsa AAAAB3NzaC1yc2... another-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)
|
## 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
|
```toml
|
||||||
[auth]
|
[auth]
|
||||||
mode = "accept-all" # For human SSH key auth (or authorized-keys)
|
mode = "authorized-keys"
|
||||||
|
|
||||||
[auth.agent_id]
|
[auth.agent_id]
|
||||||
tenant_id = "your-entra-tenant-id"
|
tenant_id = "your-entra-tenant-id"
|
||||||
|
|
@ -42,6 +48,8 @@ audiences = ["api://bascule-proxy"]
|
||||||
multi_tenant = false
|
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
|
### How agents authenticate
|
||||||
|
|
||||||
1. Agent obtains an OAuth token from Entra via `client_credentials` flow
|
1. Agent obtains an OAuth token from Entra via `client_credentials` flow
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
| Container sessions | Native | Via agents | No | No |
|
| Container sessions | Native | Via agents | No | No |
|
||||||
| AI Agent Identity | Native (Entra Agent ID) | No | No | No |
|
| AI Agent Identity | Native (Entra Agent ID) | No | No | No |
|
||||||
| Binary size | ~7MB | ~150MB | ~100MB | N/A (SaaS) |
|
| 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 |
|
| Session recording | Via SessionHandler | Built-in | Built-in | Built-in |
|
||||||
| Kubernetes | Any (pod) | Requires agent | Requires worker | SaaS |
|
| Kubernetes | Any (pod) | Requires agent | Requires worker | SaaS |
|
||||||
| Extensibility | SessionHandler trait | Plugin system | No | No |
|
| Extensibility | SessionHandler trait | Plugin system | No | No |
|
||||||
|
|
|
||||||
|
|
@ -40,18 +40,18 @@ RUST_LOG=bascule=debug ./bascule --config config.toml # debug bascule only
|
||||||
|
|
||||||
## OTel Tracing (Planned)
|
## 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)
|
session (root)
|
||||||
├── auth (ssh-key / oidc / agent-id)
|
├── auth (ssh-key / agent-id)
|
||||||
├── backend_setup (pty / proxy / container)
|
├── backend_setup (pty / proxy / container)
|
||||||
└── session_active (commands, I/O)
|
└── session_active (commands, I/O)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Prometheus Metrics (Planned)
|
## 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}
|
bascule_sessions_total{backend,auth_method,outcome}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
## Option 1: Build from Source
|
## Option 1: Build from Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/guildhouse/bascule.git
|
git clone https://github.com/your-org/bascule.git
|
||||||
cd bascule
|
cd bascule
|
||||||
cargo build --release -p bascule-server
|
cargo build --release -p bascule-server
|
||||||
./target/release/bascule --config config/bascule.example.toml
|
./target/release/bascule --config config/bascule.example.toml
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue