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-keys",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"toml",
|
||||
|
|
@ -201,6 +202,24 @@ dependencies = [
|
|||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bascule-shell"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs",
|
||||
"hex",
|
||||
"nix 0.29.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
|
|
@ -576,6 +595,27 @@ dependencies = [
|
|||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
|
|
@ -669,6 +709,12 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f"
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
|
|
@ -926,6 +972,12 @@ version = "0.5.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hex-literal"
|
||||
version = "0.4.1"
|
||||
|
|
@ -1304,6 +1356,21 @@ version = "0.2.16"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
|
|
@ -1396,6 +1463,18 @@ dependencies = [
|
|||
"pin-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
|
|
@ -1496,6 +1575,12 @@ version = "0.3.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.13.2"
|
||||
|
|
@ -1693,7 +1778,7 @@ dependencies = [
|
|||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"nix",
|
||||
"nix 0.25.1",
|
||||
"serial",
|
||||
"shared_library",
|
||||
"shell-words",
|
||||
|
|
@ -1897,6 +1982,17 @@ dependencies = [
|
|||
"bitflags 2.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
|
|
@ -2150,6 +2246,19 @@ dependencies = [
|
|||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
|
|
@ -2584,6 +2693,19 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termios"
|
||||
version = "0.2.2"
|
||||
|
|
@ -3313,6 +3435,15 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
|
|
@ -3340,6 +3471,21 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
|
|
@ -3373,6 +3519,12 @@ dependencies = [
|
|||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
|
|
@ -3385,6 +3537,12 @@ version = "0.53.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
|
|
@ -3397,6 +3555,12 @@ version = "0.53.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
|
|
@ -3421,6 +3585,12 @@ version = "0.53.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
|
|
@ -3433,6 +3603,12 @@ version = "0.53.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
|
|
@ -3445,6 +3621,12 @@ version = "0.53.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
|
|
@ -3457,6 +3639,12 @@ version = "0.53.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[workspace]
|
||||
members = ["crates/bascule-core", "crates/bascule-server", "crates/bascule-auth-agent-id"]
|
||||
members = ["crates/bascule-core", "crates/bascule-server", "crates/bascule-auth-agent-id", "crates/bascule-shell"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
|
|
|||
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.
|
||||
|
||||
**Bascule** is a lightweight SSH proxy that authenticates users via SSH keys, OIDC, or AI agent tokens, then connects them to a local shell, remote host, or ephemeral container. No agents. No control plane. One binary.
|
||||
**Bascule** is a lightweight SSH proxy that authenticates users via SSH keys or AI agent tokens, then connects them to a local shell, remote host, or ephemeral container. No agents. No control plane. One binary.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
|
@ -39,8 +39,9 @@ memory_limit = "512m"
|
|||
|
||||
- **Three backends** — local PTY, remote SSH proxy, ephemeral containers
|
||||
- **Identity-aware sessions** — every connection authenticated and attributed
|
||||
- **SSH key authentication** — standard authorized_keys, no surprises
|
||||
- **AI agent authentication** — native Microsoft Entra Agent ID support
|
||||
- **SSH key authentication** — standard authorized_keys file
|
||||
- **AI agent authentication** — Microsoft Entra Agent ID support (optional feature)
|
||||
- **Session limiting** — configurable max concurrent sessions
|
||||
- **Right-sized images** — curated container images (minimal, k8s-ops, net-ops, dev)
|
||||
- **SessionHandler trait** — extend with custom policy, audit, or recording
|
||||
- **Structured logging** — JSON format for production observability
|
||||
|
|
@ -55,6 +56,7 @@ memory_limit = "512m"
|
|||
| License | Apache 2.0 | AGPL | MPL |
|
||||
| Container sessions | Native | No | No |
|
||||
| AI Agent Identity | Native | No | No |
|
||||
| Auth | SSH keys, Entra Agent ID | OIDC, SAML, GitHub | OIDC, LDAP |
|
||||
| Binary size | ~7MB | ~150MB | ~100MB |
|
||||
|
||||
See [docs/comparison.md](docs/comparison.md) for the full comparison.
|
||||
|
|
@ -82,7 +84,7 @@ impl SessionHandler for MyAuditHandler {
|
|||
}
|
||||
```
|
||||
|
||||
Projects like [Guildhouse](https://guildhouse.dev) use the SessionHandler trait to add authorization contexts, completion receipts, and merkle-anchored audit trails.
|
||||
Projects like [Guildhouse](https://guildhouse.dev) use the SessionHandler trait to add custom authorization, audit logging, and session governance.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
|
@ -94,6 +96,15 @@ Projects like [Guildhouse](https://guildhouse.dev) use the SessionHandler trait
|
|||
- [Comparison](docs/comparison.md)
|
||||
- [Container Images](images/README.md)
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] OIDC authentication (Keycloak, Entra, Okta, Google)
|
||||
- [ ] Certificate-based authentication
|
||||
- [ ] OpenTelemetry OTLP trace export
|
||||
- [ ] Prometheus metrics endpoint
|
||||
- [ ] Session recording
|
||||
- [ ] Web UI for session management
|
||||
|
||||
## License
|
||||
|
||||
Apache 2.0
|
||||
|
|
|
|||
|
|
@ -118,10 +118,7 @@ impl EntraAgentIdProvider {
|
|||
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);
|
||||
validation.set_audience(&self.expected_audiences);
|
||||
|
||||
let tenant_issuer = format!(
|
||||
"https://login.microsoftonline.com/{}/v2.0",
|
||||
self.tenant_id
|
||||
);
|
||||
let tenant_issuer = format!("https://login.microsoftonline.com/{}/v2.0", self.tenant_id);
|
||||
if self.allow_multi_tenant {
|
||||
let issuers = [
|
||||
tenant_issuer,
|
||||
|
|
@ -208,11 +205,7 @@ impl EntraAgentIdProvider {
|
|||
|
||||
#[async_trait]
|
||||
impl AuthProvider for EntraAgentIdProvider {
|
||||
async fn check_password(
|
||||
&self,
|
||||
user: &str,
|
||||
password: &str,
|
||||
) -> bool {
|
||||
async fn check_password(&self, user: &str, password: &str) -> bool {
|
||||
match self.validate_token(password).await {
|
||||
Ok(identity) => {
|
||||
tracing::info!(
|
||||
|
|
|
|||
|
|
@ -23,3 +23,6 @@ chrono = { workspace = true }
|
|||
uuid = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
portable-pty = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
//! Pluggable authentication providers.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use russh_keys::key::PublicKey;
|
||||
|
||||
|
|
@ -39,3 +41,228 @@ impl AuthProvider for AcceptAllKeys {
|
|||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// SSH authorized_keys file authentication provider.
|
||||
///
|
||||
/// Reads OpenSSH-format public keys from either:
|
||||
/// - `{keys_path}` (single file mode, when path points to a file)
|
||||
/// - `{keys_path}/{username}/authorized_keys` + `{keys_path}/authorized_keys` (directory mode)
|
||||
pub struct AuthorizedKeysProvider {
|
||||
keys_path: PathBuf,
|
||||
}
|
||||
|
||||
impl AuthorizedKeysProvider {
|
||||
pub fn new(keys_path: impl Into<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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthConfig {
|
||||
/// Auth mode: "accept-all" (dev), "authorized-keys"
|
||||
#[serde(default = "default_auth_mode")]
|
||||
|
|
@ -80,6 +80,16 @@ pub struct AuthConfig {
|
|||
pub agent_id: Option<AgentIdConfig>,
|
||||
}
|
||||
|
||||
impl Default for AuthConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: default_auth_mode(),
|
||||
authorized_keys_path: None,
|
||||
agent_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentIdConfig {
|
||||
/// Entra tenant ID.
|
||||
|
|
@ -200,9 +210,219 @@ pub struct MetricsConfig {
|
|||
pub port: u16,
|
||||
}
|
||||
|
||||
fn default_service_name() -> String { "bascule".to_string() }
|
||||
fn default_metrics_port() -> u16 { 9090 }
|
||||
impl ContainerConfig {
|
||||
/// Validate config values to prevent CLI argument injection.
|
||||
/// Call at startup before accepting any connections.
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
// Memory limit: digits + optional k/m/g suffix
|
||||
if let Some(ref mem) = self.memory_limit {
|
||||
if !mem
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_digit() || "kmgbKMGB".contains(c))
|
||||
{
|
||||
anyhow::bail!("Invalid memory_limit: '{}'. Expected format: 512m, 1g", mem);
|
||||
}
|
||||
}
|
||||
|
||||
fn default_runtime() -> String { "auto".to_string() }
|
||||
fn default_pull_policy() -> String { "if-not-present".to_string() }
|
||||
fn default_true() -> bool { true }
|
||||
// CPU limit: valid float
|
||||
if let Some(ref cpu) = self.cpu_limit {
|
||||
if cpu.parse::<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.
|
||||
//!
|
||||
//! ```text
|
||||
//! SSH client ←→ bascule (auth + hooks) ←→ container (docker/podman/nerdctl)
|
||||
//! SSH client <-> bascule (auth + hooks) <-> container (docker/podman/nerdctl)
|
||||
//! ```
|
||||
//!
|
||||
//! Uses CLI execution for maximum portability — no libdocker dependency.
|
||||
|
|
@ -12,7 +12,6 @@
|
|||
use std::collections::HashMap;
|
||||
use std::process::Stdio;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::process::{Child, Command};
|
||||
|
||||
use crate::config::ContainerConfig;
|
||||
|
|
@ -56,7 +55,7 @@ impl ContainerRuntime {
|
|||
}
|
||||
|
||||
/// Parse from config string.
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
pub fn from_config(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"docker" => Some(Self::Docker),
|
||||
"podman" => Some(Self::Podman),
|
||||
|
|
@ -80,8 +79,9 @@ impl ContainerSession {
|
|||
session_id: &str,
|
||||
config: &ContainerConfig,
|
||||
extra_env: &HashMap<String, String>,
|
||||
command: Option<&str>,
|
||||
) -> 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"))?;
|
||||
|
||||
// Pull image if needed
|
||||
|
|
@ -135,10 +135,14 @@ impl ContainerSession {
|
|||
// Security hardening
|
||||
if config.hardened {
|
||||
args.extend([
|
||||
"--security-opt".into(), "no-new-privileges".into(),
|
||||
"--cap-drop".into(), "ALL".into(),
|
||||
"--cap-add".into(), "SETUID".into(),
|
||||
"--cap-add".into(), "SETGID".into(),
|
||||
"--security-opt".into(),
|
||||
"no-new-privileges".into(),
|
||||
"--cap-drop".into(),
|
||||
"ALL".into(),
|
||||
"--cap-add".into(),
|
||||
"SETUID".into(),
|
||||
"--cap-add".into(),
|
||||
"SETGID".into(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -146,8 +150,10 @@ impl ContainerSession {
|
|||
if config.read_only_rootfs {
|
||||
args.extend([
|
||||
"--read-only".into(),
|
||||
"--tmpfs".into(), "/tmp:rw,noexec,nosuid,size=64m".into(),
|
||||
"--tmpfs".into(), "/run:rw,noexec,nosuid,size=16m".into(),
|
||||
"--tmpfs".into(),
|
||||
"/tmp:rw,noexec,nosuid,size=64m".into(),
|
||||
"--tmpfs".into(),
|
||||
"/run:rw,noexec,nosuid,size=16m".into(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -159,10 +165,15 @@ impl ContainerSession {
|
|||
// Image
|
||||
args.push(config.image.clone());
|
||||
|
||||
// Shell command override
|
||||
if let Some(ref shell) = config.shell {
|
||||
// Command: exec request or configured shell or image default
|
||||
if let Some(cmd) = command {
|
||||
// Exec mode: run this specific command in the container
|
||||
args.extend(["/bin/sh".into(), "-c".into(), cmd.to_string()]);
|
||||
} else if let Some(ref shell) = config.shell {
|
||||
// Interactive mode: use configured shell
|
||||
args.push(shell.clone());
|
||||
}
|
||||
// else: use image's ENTRYPOINT/CMD
|
||||
|
||||
tracing::info!(
|
||||
runtime = runtime.binary(),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ use russh::server::{Auth, Handler, Msg, Session};
|
|||
use russh::{Channel, ChannelId, CryptoVec};
|
||||
use russh_keys::key;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::{Mutex, OwnedSemaphorePermit};
|
||||
|
||||
use crate::auth::AuthProvider;
|
||||
use crate::config::BasculeConfig;
|
||||
|
|
@ -42,6 +42,9 @@ pub struct BasculeHandler {
|
|||
pty_cols: u16,
|
||||
pty_rows: u16,
|
||||
peer_addr: String,
|
||||
/// Semaphore permit — held for the lifetime of this connection.
|
||||
/// None means max sessions reached; auth will be rejected.
|
||||
_session_permit: Option<OwnedSemaphorePermit>,
|
||||
}
|
||||
|
||||
impl BasculeHandler {
|
||||
|
|
@ -50,6 +53,7 @@ impl BasculeHandler {
|
|||
session_handler: Arc<dyn SessionHandler>,
|
||||
config: Arc<BasculeConfig>,
|
||||
peer_addr: String,
|
||||
session_permit: Option<OwnedSemaphorePermit>,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth,
|
||||
|
|
@ -60,6 +64,7 @@ impl BasculeHandler {
|
|||
pty_cols: 80,
|
||||
pty_rows: 24,
|
||||
peer_addr,
|
||||
_session_permit: session_permit,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,8 +82,11 @@ impl BasculeHandler {
|
|||
session: &mut Session,
|
||||
command: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
let info = self.session_info.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No session info"))?.clone();
|
||||
let info = self
|
||||
.session_info
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No session info"))?
|
||||
.clone();
|
||||
|
||||
self.session_handler.on_session_start(&info).await?;
|
||||
|
||||
|
|
@ -89,25 +97,58 @@ impl BasculeHandler {
|
|||
|
||||
let (cmd, args) = match command {
|
||||
Some(c) => ("/bin/sh", vec!["-c".to_string(), c.to_string()]),
|
||||
None => { let (c, a) = self.shell_command(); (c, a.to_vec()) }
|
||||
None => {
|
||||
let (c, a) = self.shell_command();
|
||||
(c, a.to_vec())
|
||||
}
|
||||
};
|
||||
|
||||
let bridge = pty::spawn_pty(cmd, &args, env_pairs, self.pty_cols, self.pty_rows)?;
|
||||
let mut bridge = pty::spawn_pty(cmd, &args, env_pairs, self.pty_cols, self.pty_rows)?;
|
||||
|
||||
// Take the reader out so it can be moved to a dedicated OS thread.
|
||||
// This avoids blocking a tokio worker thread on synchronous PTY reads.
|
||||
let reader = bridge
|
||||
.take_reader()
|
||||
.ok_or_else(|| anyhow::anyhow!("PTY reader already taken"))?;
|
||||
|
||||
let bridge = Arc::new(Mutex::new(bridge));
|
||||
self.backend = Some(SessionBackend::Local(bridge.clone()));
|
||||
|
||||
let handle = session.handle();
|
||||
tokio::spawn(async move {
|
||||
// Channel to bridge blocking reads to async world
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<u8>>(32);
|
||||
|
||||
// Dedicated OS thread for blocking PTY reads
|
||||
std::thread::spawn(move || {
|
||||
let mut reader = reader;
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
let n = {
|
||||
let mut b = bridge.lock().await;
|
||||
match b.reader.read(&mut buf) {
|
||||
match reader.read(&mut buf) {
|
||||
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;
|
||||
});
|
||||
|
|
@ -121,34 +162,60 @@ impl BasculeHandler {
|
|||
session: &mut Session,
|
||||
command: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
let info = self.session_info.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No session info"))?.clone();
|
||||
let info = self
|
||||
.session_info
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No session info"))?
|
||||
.clone();
|
||||
|
||||
self.session_handler.on_session_start(&info).await?;
|
||||
|
||||
let proxy_config = self.config.proxy.as_ref()
|
||||
let proxy_config = self
|
||||
.config
|
||||
.proxy
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Proxy config missing"))?;
|
||||
|
||||
let mut upstream = proxy::connect_upstream(proxy_config, &info.principal).await?;
|
||||
|
||||
let upstream_ch = upstream.channel.as_ref()
|
||||
let upstream_ch = upstream
|
||||
.channel
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No upstream channel"))?;
|
||||
upstream_ch.request_pty(true, "xterm-256color", self.pty_cols as u32, self.pty_rows as u32, 0, 0, &[]).await?;
|
||||
upstream_ch
|
||||
.request_pty(
|
||||
true,
|
||||
"xterm-256color",
|
||||
self.pty_cols as u32,
|
||||
self.pty_rows as u32,
|
||||
0,
|
||||
0,
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
match command {
|
||||
Some(cmd) => upstream_ch.exec(true, cmd).await?,
|
||||
None => upstream_ch.request_shell(true).await?,
|
||||
}
|
||||
|
||||
let upstream_channel = upstream.channel.take()
|
||||
let upstream_channel = upstream
|
||||
.channel
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("Channel already taken"))?;
|
||||
|
||||
let upstream = Arc::new(Mutex::new(upstream));
|
||||
self.backend = Some(SessionBackend::Proxy(upstream.clone()));
|
||||
|
||||
let server_handle = session.handle();
|
||||
let handler = self.session_handler.clone();
|
||||
let session_info = info;
|
||||
tokio::spawn(async move {
|
||||
proxy::bridge_upstream_to_client(upstream_channel, server_handle, channel).await;
|
||||
proxy::bridge_upstream_to_client(upstream_channel, server_handle.clone(), channel)
|
||||
.await;
|
||||
if let Err(e) = handler.on_session_end(&session_info).await {
|
||||
tracing::warn!(error = %e, "SessionHandler on_session_end error");
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -158,32 +225,37 @@ impl BasculeHandler {
|
|||
&mut self,
|
||||
channel: ChannelId,
|
||||
session: &mut Session,
|
||||
_command: Option<&str>,
|
||||
command: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
let info = self.session_info.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No session info"))?.clone();
|
||||
let info = self
|
||||
.session_info
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("No session info"))?
|
||||
.clone();
|
||||
|
||||
self.session_handler.on_session_start(&info).await?;
|
||||
|
||||
let container_config = self.config.container.as_ref()
|
||||
let container_config = self
|
||||
.config
|
||||
.container
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Container config missing"))?;
|
||||
|
||||
let mut env = self.session_handler.build_session_env(&info).await;
|
||||
env.insert("BASCULE_SESSION_ID".into(), info.session_id.clone());
|
||||
env.insert("BASCULE_PRINCIPAL".into(), info.principal.clone());
|
||||
|
||||
let container = ContainerSession::spawn(
|
||||
&info.session_id,
|
||||
container_config,
|
||||
&env,
|
||||
).await?;
|
||||
let container =
|
||||
ContainerSession::spawn(&info.session_id, container_config, &env, command).await?;
|
||||
|
||||
let container = Arc::new(Mutex::new(container));
|
||||
self.backend = Some(SessionBackend::Container(container.clone()));
|
||||
|
||||
// Bridge container stdout → SSH channel
|
||||
// Bridge container stdout -> SSH channel
|
||||
let handle = session.handle();
|
||||
let container_for_read = container.clone();
|
||||
let handler = self.session_handler.clone();
|
||||
let session_info = info;
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
|
|
@ -198,7 +270,18 @@ impl BasculeHandler {
|
|||
None => break,
|
||||
}
|
||||
};
|
||||
if handle.data(channel, CryptoVec::from_slice(&buf[..n])).await.is_err() { break; }
|
||||
if handle
|
||||
.data(channel, CryptoVec::from_slice(&buf[..n]))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Session ended — call handler before cleanup
|
||||
if let Err(e) = handler.on_session_end(&session_info).await {
|
||||
tracing::warn!(error = %e, "SessionHandler on_session_end error");
|
||||
}
|
||||
|
||||
// Container process exited — clean up
|
||||
|
|
@ -233,59 +316,132 @@ impl BasculeHandler {
|
|||
impl Handler for BasculeHandler {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
async fn auth_publickey(&mut self, user: &str, public_key: &key::PublicKey) -> Result<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 {
|
||||
let principal = self.auth.principal_for_user(user);
|
||||
tracing::info!(method = "ssh-key", principal = %principal, peer = %self.peer_addr, "Auth accepted");
|
||||
self.session_info = Some(SessionInfo::new(principal, "ssh-key".into(), self.peer_addr.clone()));
|
||||
self.session_info = Some(SessionInfo::new(
|
||||
principal,
|
||||
"ssh-key".into(),
|
||||
self.peer_addr.clone(),
|
||||
));
|
||||
Ok(Auth::Accept)
|
||||
} else {
|
||||
tracing::warn!(method = "ssh-key", user = %user, peer = %self.peer_addr, "Auth rejected");
|
||||
Ok(Auth::Reject { proceed_with_methods: None })
|
||||
Ok(Auth::Reject {
|
||||
proceed_with_methods: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn auth_password(&mut self, user: &str, password: &str) -> Result<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 {
|
||||
let principal = self.auth.principal_for_user(user);
|
||||
tracing::info!(method = "password", principal = %principal, peer = %self.peer_addr, "Auth accepted");
|
||||
self.session_info = Some(SessionInfo::new(principal, "password".into(), self.peer_addr.clone()));
|
||||
self.session_info = Some(SessionInfo::new(
|
||||
principal,
|
||||
"password".into(),
|
||||
self.peer_addr.clone(),
|
||||
));
|
||||
Ok(Auth::Accept)
|
||||
} else {
|
||||
tracing::warn!(method = "password", user = %user, peer = %self.peer_addr, "Auth rejected");
|
||||
Ok(Auth::Reject { proceed_with_methods: None })
|
||||
Ok(Auth::Reject {
|
||||
proceed_with_methods: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn channel_open_session(&mut self, _channel: Channel<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)
|
||||
}
|
||||
|
||||
async fn pty_request(&mut self, _channel: ChannelId, _term: &str, col_width: u32, row_height: u32, _pix_width: u32, _pix_height: u32, _modes: &[(russh::Pty, u32)], session: &mut Session) -> Result<(), Self::Error> {
|
||||
async fn pty_request(
|
||||
&mut self,
|
||||
_channel: ChannelId,
|
||||
_term: &str,
|
||||
col_width: u32,
|
||||
row_height: u32,
|
||||
_pix_width: u32,
|
||||
_pix_height: u32,
|
||||
_modes: &[(russh::Pty, u32)],
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.pty_cols = col_width.min(500) as u16;
|
||||
self.pty_rows = row_height.min(200) as u16;
|
||||
session.request_success();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn window_change_request(&mut self, _channel: ChannelId, col_width: u32, row_height: u32, _pix_width: u32, _pix_height: u32, _session: &mut Session) -> Result<(), Self::Error> {
|
||||
async fn window_change_request(
|
||||
&mut self,
|
||||
_channel: ChannelId,
|
||||
col_width: u32,
|
||||
row_height: u32,
|
||||
_pix_width: u32,
|
||||
_pix_height: u32,
|
||||
_session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.pty_cols = col_width.min(500) as u16;
|
||||
self.pty_rows = row_height.min(200) as u16;
|
||||
match &self.backend {
|
||||
Some(SessionBackend::Local(bridge)) => { let b = bridge.lock().await; let _ = b.resize(self.pty_cols, self.pty_rows); }
|
||||
Some(SessionBackend::Proxy(_)) => { tracing::debug!("Window change in proxy mode (not forwarded)"); }
|
||||
Some(SessionBackend::Container(_)) => { tracing::debug!("Window change in container mode (handled by runtime)"); }
|
||||
Some(SessionBackend::Local(bridge)) => {
|
||||
let b = bridge.lock().await;
|
||||
let _ = b.resize(self.pty_cols, self.pty_rows);
|
||||
}
|
||||
Some(SessionBackend::Proxy(_)) => {
|
||||
tracing::debug!("Window change in proxy mode (not forwarded)");
|
||||
}
|
||||
Some(SessionBackend::Container(_)) => {
|
||||
tracing::debug!("Window change in container mode (handled by runtime)");
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shell_request(&mut self, channel: ChannelId, session: &mut Session) -> Result<(), Self::Error> {
|
||||
let backend_type = if self.config.proxy.is_some() { "proxy" }
|
||||
else if self.config.container.is_some() { "container" }
|
||||
else { "pty" };
|
||||
let session_id = self.session_info.as_ref().map(|i| i.session_id.as_str()).unwrap_or("unknown");
|
||||
let principal = self.session_info.as_ref().map(|i| i.principal.as_str()).unwrap_or("unknown");
|
||||
async fn shell_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
let backend_type = if self.config.proxy.is_some() {
|
||||
"proxy"
|
||||
} else if self.config.container.is_some() {
|
||||
"container"
|
||||
} else {
|
||||
"pty"
|
||||
};
|
||||
let session_id = self
|
||||
.session_info
|
||||
.as_ref()
|
||||
.map(|i| i.session_id.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let principal = self
|
||||
.session_info
|
||||
.as_ref()
|
||||
.map(|i| i.principal.as_str())
|
||||
.unwrap_or("unknown");
|
||||
tracing::info!(
|
||||
session.id = %session_id,
|
||||
session.principal = %principal,
|
||||
|
|
@ -296,7 +452,10 @@ impl Handler for BasculeHandler {
|
|||
|
||||
if let Some(info) = &self.session_info {
|
||||
let display = self.session_handler.display_name(info);
|
||||
let banner = self.config.banner.as_deref()
|
||||
let banner = self
|
||||
.config
|
||||
.banner
|
||||
.as_deref()
|
||||
.map(|b| format!("{}\r\n", b))
|
||||
.unwrap_or_else(|| format!("Welcome, {}.\r\n", display));
|
||||
session.data(channel, CryptoVec::from_slice(banner.as_bytes()));
|
||||
|
|
@ -306,9 +465,18 @@ impl Handler for BasculeHandler {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn exec_request(&mut self, channel: ChannelId, data: &[u8], session: &mut Session) -> Result<(), Self::Error> {
|
||||
async fn exec_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
data: &[u8],
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
let command = String::from_utf8_lossy(data).to_string();
|
||||
let session_id = self.session_info.as_ref().map(|i| i.session_id.as_str()).unwrap_or("unknown");
|
||||
let session_id = self
|
||||
.session_info
|
||||
.as_ref()
|
||||
.map(|i| i.session_id.as_str())
|
||||
.unwrap_or("unknown");
|
||||
tracing::info!(session.id = %session_id, command = %command, "Exec request");
|
||||
|
||||
if let Some(info) = &self.session_info {
|
||||
|
|
@ -324,7 +492,12 @@ impl Handler for BasculeHandler {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn data(&mut self, _channel: ChannelId, data: &[u8], _session: &mut Session) -> Result<(), Self::Error> {
|
||||
async fn data(
|
||||
&mut self,
|
||||
_channel: ChannelId,
|
||||
data: &[u8],
|
||||
_session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
match &self.backend {
|
||||
Some(SessionBackend::Local(bridge)) => {
|
||||
let mut b = bridge.lock().await;
|
||||
|
|
@ -333,7 +506,10 @@ impl Handler for BasculeHandler {
|
|||
}
|
||||
Some(SessionBackend::Proxy(upstream)) => {
|
||||
let u = upstream.lock().await;
|
||||
let _ = u.handle.data(u.channel_id, CryptoVec::from_slice(data)).await;
|
||||
let _ = u
|
||||
.handle
|
||||
.data(u.channel_id, CryptoVec::from_slice(data))
|
||||
.await;
|
||||
}
|
||||
Some(SessionBackend::Container(container)) => {
|
||||
let mut c = container.lock().await;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ use async_trait::async_trait;
|
|||
use russh::client;
|
||||
use russh::ChannelMsg;
|
||||
use russh_keys::key;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::config::ProxyConfig;
|
||||
|
||||
|
|
@ -62,10 +61,7 @@ pub async fn connect_upstream(
|
|||
proxy_config: &ProxyConfig,
|
||||
username: &str,
|
||||
) -> anyhow::Result<UpstreamSession> {
|
||||
let target_user = proxy_config
|
||||
.target_user
|
||||
.as_deref()
|
||||
.unwrap_or(username);
|
||||
let target_user = proxy_config.target_user.as_deref().unwrap_or(username);
|
||||
|
||||
let addr = format!("{}:{}", proxy_config.target_host, proxy_config.target_port);
|
||||
tracing::info!(target = %addr, user = %target_user, "Connecting to upstream SSH host");
|
||||
|
|
@ -100,7 +96,11 @@ pub async fn connect_upstream(
|
|||
tracing::info!("Upstream channel opened");
|
||||
|
||||
let channel_id = channel.id();
|
||||
Ok(UpstreamSession { handle, channel: Some(channel), channel_id })
|
||||
Ok(UpstreamSession {
|
||||
handle,
|
||||
channel: Some(channel),
|
||||
channel_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bridge I/O between the server-side SSH channel and the upstream client channel.
|
||||
|
|
@ -135,7 +135,9 @@ pub async fn bridge_upstream_to_client(
|
|||
}
|
||||
Some(ChannelMsg::ExitStatus { exit_status }) => {
|
||||
tracing::info!(exit_status, "Upstream session exited");
|
||||
let _ = server_handle.exit_status_request(server_channel_id, exit_status).await;
|
||||
let _ = server_handle
|
||||
.exit_status_request(server_channel_id, exit_status)
|
||||
.await;
|
||||
let _ = server_handle.close(server_channel_id).await;
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,8 @@
|
|||
use anyhow::{Context, Result};
|
||||
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
||||
use std::io::{Read, Write};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Spawn a PTY with the given command and bridge I/O.
|
||||
///
|
||||
/// Returns (writer_tx, reader_rx) channels for the SSH handler to use.
|
||||
/// Spawn a PTY with the given command and return the bridge.
|
||||
pub fn spawn_pty(
|
||||
command: &str,
|
||||
args: &[String],
|
||||
|
|
@ -51,7 +48,7 @@ pub fn spawn_pty(
|
|||
Ok(PtyBridge {
|
||||
master: pair.master,
|
||||
child,
|
||||
reader,
|
||||
reader: Some(reader),
|
||||
writer,
|
||||
})
|
||||
}
|
||||
|
|
@ -60,11 +57,18 @@ pub fn spawn_pty(
|
|||
pub struct PtyBridge {
|
||||
pub master: Box<dyn portable_pty::MasterPty + Send>,
|
||||
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>,
|
||||
}
|
||||
|
||||
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.
|
||||
pub fn resize(&self, cols: u16, rows: u16) -> Result<()> {
|
||||
self.master
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use std::sync::Arc;
|
|||
|
||||
use russh::server::{Config, Server};
|
||||
use russh_keys::key::KeyPair;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::auth::AuthProvider;
|
||||
use crate::config::BasculeConfig;
|
||||
|
|
@ -16,6 +17,7 @@ pub struct BasculeServer {
|
|||
app_config: Arc<BasculeConfig>,
|
||||
auth: Arc<dyn AuthProvider>,
|
||||
session_handler: Arc<dyn SessionHandler>,
|
||||
session_semaphore: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl BasculeServer {
|
||||
|
|
@ -23,6 +25,14 @@ impl BasculeServer {
|
|||
config: BasculeConfig,
|
||||
auth: impl AuthProvider + '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> {
|
||||
let host_key = if let Some(path) = &config.host_key_path {
|
||||
if std::path::Path::new(path).exists() {
|
||||
|
|
@ -42,11 +52,19 @@ impl BasculeServer {
|
|||
..Default::default()
|
||||
};
|
||||
|
||||
let max_sessions = if config.max_sessions > 0 {
|
||||
config.max_sessions
|
||||
} else {
|
||||
10_000 // reasonable upper bound when "unlimited"
|
||||
};
|
||||
tracing::info!(max_sessions, "Session limit configured");
|
||||
|
||||
Ok(Self {
|
||||
ssh_config: Arc::new(ssh_config),
|
||||
app_config: Arc::new(config),
|
||||
auth: Arc::new(auth),
|
||||
auth,
|
||||
session_handler: Arc::new(session_handler),
|
||||
session_semaphore: Arc::new(Semaphore::new(max_sessions)),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -54,7 +72,8 @@ impl BasculeServer {
|
|||
let addr = &self.app_config.listen_addr;
|
||||
tracing::info!(addr = %addr, "Starting Bascule SSH server");
|
||||
let socket_addr: std::net::SocketAddr = addr.parse()?;
|
||||
self.run_on_address(self.ssh_config.clone(), socket_addr).await?;
|
||||
self.run_on_address(self.ssh_config.clone(), socket_addr)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -63,13 +82,37 @@ impl russh::server::Server for BasculeServer {
|
|||
type Handler = BasculeHandler;
|
||||
|
||||
fn new_client(&mut self, peer_addr: Option<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");
|
||||
BasculeHandler::new(
|
||||
self.auth.clone(),
|
||||
self.session_handler.clone(),
|
||||
self.app_config.clone(),
|
||||
addr,
|
||||
Some(permit),
|
||||
)
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
peer = %addr,
|
||||
max = self.session_semaphore.available_permits(),
|
||||
"Max sessions reached, connection will be rejected"
|
||||
);
|
||||
// Return a handler that will reject auth — permit is None
|
||||
BasculeHandler::new(
|
||||
self.auth.clone(),
|
||||
self.session_handler.clone(),
|
||||
self.app_config.clone(),
|
||||
addr,
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,15 @@
|
|||
//! bascule --config /etc/bascule/config.toml
|
||||
//! bascule # uses default config (accept-all auth, port 2222)
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use bascule_core::auth::AcceptAllKeys;
|
||||
#[cfg(feature = "agent-id")]
|
||||
use bascule_core::auth::CompositeAuthProvider;
|
||||
use bascule_core::auth::{AcceptAllKeys, AuthProvider, AuthorizedKeysProvider};
|
||||
use bascule_core::config::BasculeConfig;
|
||||
use bascule_core::hooks::DefaultHandler;
|
||||
use bascule_core::server::BasculeServer;
|
||||
|
|
@ -21,26 +25,72 @@ struct Cli {
|
|||
config: Option<String>,
|
||||
}
|
||||
|
||||
fn init_tracing(config: &BasculeConfig) {
|
||||
// Structured logging (JSON if BASCULE_LOG_FORMAT=json, otherwise pretty)
|
||||
// OTel OTLP export: deferred to future release (version compatibility WIP)
|
||||
fn init_tracing(_config: &BasculeConfig) {
|
||||
let json_format = std::env::var("BASCULE_LOG_FORMAT")
|
||||
.map(|v| v == "json")
|
||||
.unwrap_or(false);
|
||||
|
||||
if json_format {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
)
|
||||
.json()
|
||||
.with_target(true)
|
||||
.init();
|
||||
} else {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
}
|
||||
|
||||
fn build_auth_provider(config: &BasculeConfig) -> Arc<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]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
|
@ -52,9 +102,18 @@ async fn main() -> Result<()> {
|
|||
|
||||
init_tracing(&config);
|
||||
|
||||
let backend = if config.proxy.is_some() { "proxy" }
|
||||
else if config.container.is_some() { "container" }
|
||||
else { "pty" };
|
||||
// Validate container config at startup (fail fast on bad values)
|
||||
if let Some(ref container_config) = config.container {
|
||||
container_config.validate()?;
|
||||
}
|
||||
|
||||
let backend = if config.proxy.is_some() {
|
||||
"proxy"
|
||||
} else if config.container.is_some() {
|
||||
"container"
|
||||
} else {
|
||||
"pty"
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
listen = %config.listen_addr,
|
||||
|
|
@ -64,6 +123,7 @@ async fn main() -> Result<()> {
|
|||
"Bascule starting"
|
||||
);
|
||||
|
||||
let server = BasculeServer::new(config, AcceptAllKeys, DefaultHandler)?;
|
||||
let auth = build_auth_provider(&config);
|
||||
let server = BasculeServer::with_arc_auth(config, auth, DefaultHandler)?;
|
||||
server.run().await
|
||||
}
|
||||
|
|
|
|||
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
|
||||
```
|
||||
|
||||
The `authorized_keys_path` can point to either:
|
||||
- A single file (all users share the same key list)
|
||||
- A directory with per-user key files: `{dir}/{username}/authorized_keys`
|
||||
|
||||
## Entra Agent ID (AI Agents)
|
||||
|
||||
Microsoft Entra Agent ID authentication for AI agents. Agents present their OAuth token as the SSH password.
|
||||
Microsoft Entra Agent ID authentication for AI agents. Requires the `agent-id` feature flag.
|
||||
|
||||
Agents present their OAuth token as the SSH password.
|
||||
|
||||
```toml
|
||||
[auth]
|
||||
mode = "accept-all" # For human SSH key auth (or authorized-keys)
|
||||
mode = "authorized-keys"
|
||||
|
||||
[auth.agent_id]
|
||||
tenant_id = "your-entra-tenant-id"
|
||||
|
|
@ -42,6 +48,8 @@ audiences = ["api://bascule-proxy"]
|
|||
multi_tenant = false
|
||||
```
|
||||
|
||||
When both `authorized-keys` and `[auth.agent_id]` are configured, Bascule composes them: SSH key auth for humans, token-as-password auth for agents.
|
||||
|
||||
### How agents authenticate
|
||||
|
||||
1. Agent obtains an OAuth token from Entra via `client_credentials` flow
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
| Container sessions | Native | Via agents | No | No |
|
||||
| AI Agent Identity | Native (Entra Agent ID) | No | No | No |
|
||||
| Binary size | ~7MB | ~150MB | ~100MB | N/A (SaaS) |
|
||||
| Auth | SSH keys, OIDC, Certs, Agent ID | OIDC, SAML, GitHub | OIDC, LDAP | SAML, OIDC |
|
||||
| Auth | SSH keys, Entra Agent ID | OIDC, SAML, GitHub | OIDC, LDAP | SAML, OIDC |
|
||||
| Session recording | Via SessionHandler | Built-in | Built-in | Built-in |
|
||||
| Kubernetes | Any (pod) | Requires agent | Requires worker | SaaS |
|
||||
| Extensibility | SessionHandler trait | Plugin system | No | No |
|
||||
|
|
|
|||
|
|
@ -40,18 +40,18 @@ RUST_LOG=bascule=debug ./bascule --config config.toml # debug bascule only
|
|||
|
||||
## OTel Tracing (Planned)
|
||||
|
||||
OpenTelemetry OTLP export is planned as an optional feature flag (`--features telemetry`). Session lifecycle maps to OTel spans:
|
||||
OpenTelemetry OTLP export is planned as an optional feature flag (`--features telemetry`). Not yet implemented. Session lifecycle will map to OTel spans:
|
||||
|
||||
```
|
||||
session (root)
|
||||
├── auth (ssh-key / oidc / agent-id)
|
||||
├── auth (ssh-key / agent-id)
|
||||
├── backend_setup (pty / proxy / container)
|
||||
└── session_active (commands, I/O)
|
||||
```
|
||||
|
||||
## Prometheus Metrics (Planned)
|
||||
|
||||
Prometheus-compatible metrics endpoint planned as `--features metrics`:
|
||||
Prometheus-compatible metrics endpoint is planned as `--features metrics`. Not yet implemented. Planned metrics:
|
||||
|
||||
```
|
||||
bascule_sessions_total{backend,auth_method,outcome}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
## Option 1: Build from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/guildhouse/bascule.git
|
||||
git clone https://github.com/your-org/bascule.git
|
||||
cd bascule
|
||||
cargo build --release -p bascule-server
|
||||
./target/release/bascule --config config/bascule.example.toml
|
||||
|
|
|
|||
Loading…
Reference in a new issue