TACACS-rs Documentation
TACACS-rs is a Rust workspace for TACACS+ authentication, authorization, accounting, local agent IPC, SONiC integration, and compatibility surfaces for existing TACACS+ clients.
This site collects the project guides into a browsable static documentation set. Start with the CLI and daemon guides for day-to-day usage, then use the configuration and platform guides for deployment-specific work.
Primary guides
- tacon Usage Guide - CLI client reference, connection modes, batch execution, and debugging flags.
- tacacsrs-agentd Usage Guide - Central daemon deployment, local IPC, failover behavior, and TACACS+ proxy mode.
- YANG Config Guide - RFC 7951 TACACS+ configuration shape, parsing APIs, credential bundles, and TLS key material formats.
- Plain TACACS+ to TACACS+ over TLS Transition Guide - Host-by-host migration for existing plain TACACS+ clients.
Platform and integration guides
- Building for SONiC - Linux GNU binaries and Debian packages for SONiC switches.
- SONiC ConfigDB Integration - Mapping SONiC TACACS+ ConfigDB data into TACACS-rs configuration.
- Session Wrapper Deployment Guide - SSH
ForceCommandintegration and command authorization flow. - Session Wrapper Testing - Linux smoke and integration checks for the session wrapper.
Source repository
The source code, release assets, issue tracker, and discussion area are available in the AuthScaffold/tacacs-rs GitHub repository.
tacon — TACACS+ Client CLI
tacon is a command-line TACACS+ client for sending authentication, authorization, and accounting (AAA) requests to TACACS+ servers.
Connection Modes
tacon supports two connection modes, selected by which endpoint flag you provide:
Direct Mode (--server-addr)
Connects directly to a TACACS+ server. You manage encryption, TLS, and connection settings yourself.
tacon -s tacacs-server:49 -k shared_secret \
--user admin --port tty0 --rem-addr 10.0.0.1 \
accounting "show version"
Best for: testing, one-off requests, development, scripts.
Service Mode (--service-endpoint)
Connects to the central tacacsrs-agentd service, which maintains persistent upstream connections with automatic failover.
tacon --service-endpoint /run/tacacs/tacacs.sock \
--user admin --port tty0 --rem-addr 10.0.0.1 \
accounting "show version"
Best for: production deployments where multiple clients share TACACS+ connections with automatic failover.
Global Options
Transport Target (one required)
| Flag | Description |
|---|---|
-s, --server-addr <ADDR> | Direct connection to a TACACS+ server (e.g. 192.168.1.1:49) |
--config <FILE> | Load the direct connection from a YANG JSON config file |
--service-endpoint <PATH> | Connect via the agent service (Unix socket or TCP address) |
Encryption (direct mode only)
| Flag | Description |
|---|---|
-k, --shared-secret <KEY> | Shared secret for TACACS+ packet obfuscation |
--use-tls | Enable TLS 1.3 |
--client-certificate <FILE> | Client TLS certificate (requires --client-key) |
--client-key <FILE> | Client TLS private key (requires --client-certificate) |
--insecure-disable-certificate-verification | Skip server certificate verification |
--psk-identity <ID> | TLS 1.3 pre-shared key identity (requires psk feature) |
--psk-key <KEY> | TLS 1.3 pre-shared key (requires psk feature) |
Connection Behaviour (direct mode only)
| Flag | Description |
|---|---|
--dedicated | Use a one-shot connection per request (no session multiplexing) |
Debugging
| Flag | Description |
|---|---|
-v | Warnings |
-vv | Info |
-vvv | Debug |
-vvvv | Trace |
Commands
accounting
Record command execution to a TACACS+ server.
tacon -s server:49 \
--user admin --port tty0 --rem-addr 10.0.0.1 \
accounting "show running-config" arg1 arg2
| Argument | Description |
|---|---|
<CMD> | The command being recorded |
[ARGS...] | Optional command arguments |
authentication
Authenticate a user against the TACACS+ server. (Not yet implemented.)
authorization
Check whether a user is authorized to execute a command. (Not yet implemented.)
batch
Execute multiple requests from a JSON file.
tacon -s server:49 -k secret batch requests.json
Batch File Format
Batch files are JSON documents containing metadata and a list of requests.
Minimal Example
{
"requests": [
{
"type": "accounting",
"user": "admin",
"port": "tty0",
"rem_addr": "10.0.0.1",
"cmd": "show version"
}
]
}
Full Example
{
"metadata": {
"description": "Nightly accounting audit",
"parallel": true,
"load_test": {
"repetitions": 100,
"max_parallel": 10
}
},
"requests": [
{
"type": "accounting",
"user": "admin",
"port": "tty0",
"rem_addr": "10.0.0.1",
"cmd": "show running-config",
"cmd_args": ["brief"]
}
]
}
Metadata Fields
| Field | Type | Default | Description |
|---|---|---|---|
description | string | — | Optional description |
parallel | bool | false | Execute all requests concurrently |
load_test | object | — | Enable load testing mode |
load_test.repetitions | number | — | Number of times to repeat all requests |
load_test.max_parallel | number | 10 | Maximum concurrent requests |
Execution Modes
parallel | load_test | Behaviour |
|---|---|---|
false | absent | Requests execute sequentially |
true | absent | All requests execute concurrently |
| any | present | All requests repeat N times with bounded parallelism |
Batch with Different Connection Modes
# Direct — multiplexed sessions on one connection
tacon -s server:49 -k secret batch requests.json
# Dedicated — one TCP/TLS connection per request
tacon -s server:49 -k secret --dedicated batch requests.json
# Service — routed through the agent with failover
tacon --service-endpoint /run/tacacs/tacacs.sock batch requests.json
Transport Options
Legacy TACACS+ (obfuscation only)
tacon -s tacacs-server:49 -k tac_plus_key \
--user admin --port tty0 --rem-addr 10.0.0.1 \
accounting "show version"
YANG JSON configuration
tacon --config ./tacacs.json \
--user admin --port tty0 --rem-addr 10.0.0.1 \
accounting "show version"
The config file must use RFC 7951 JSON encoding with the root key ietf-system-tacacs-plus:tacacs-plus.
TLS 1.3 with Certificates
tacon -s tacacs-server:449 --use-tls \
--client-certificate client.crt.der --client-key client.key.der \
--user admin --port tty0 --rem-addr 10.0.0.1 \
accounting "show version"
TLS 1.3 with Pre-Shared Keys
(Requires the psk feature.)
tacon -s tacacs-server:449 --use-tls \
--psk-identity client1 --psk-key "shared_secret_at_least_16_bytes" \
--user admin --port tty0 --rem-addr 10.0.0.1 \
accounting "show version"
Exit Codes
| Code | Meaning |
|---|---|
0 | All requests succeeded |
1 | One or more requests failed |
tacacsrs-agentd — Central TACACS+ Service
tacacsrs-agentd is a long-running daemon that maintains persistent TACACS+ connections to one or more upstream servers and exposes a local IPC interface for clients like tacon. It handles connection pooling, session multiplexing, and ordered failover automatically.
Architecture
┌──────────┐ ┌──────────┐
│ tacon │ │ auditd │ ... other local consumers
│ client │ │ plugin │
└────┬─────┘ └────┬─────┘
│ gRPC/Unix │
└──────┬──────┘
▼
┌─────────────────┐
│ tacacsrs-agentd │
│ (this daemon) │
└────────┬────────┘
│ TACACS+ (TCP / TLS 1.3)
┌──────┴──────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ TACACS+ │ │ TACACS+ │
│ server 1 │ │ server 2 │ (ordered preference)
└──────────┘ └──────────┘
Quick Start
tacacsrs-agentd \
--server-addr tacacs1.example.com:49 \
--server-addr tacacs2.example.com:49 \
--listen-endpoint /run/tacacs/tacacs.sock \
--proxy-endpoint /run/tacacs/tacacs-proxy.sock \
--shared-secret "shared_secret"
Then from any client on the same host:
tacon --service-endpoint /run/tacacs/tacacs.sock \
--user admin --port tty0 --rem-addr 10.0.0.1 \
accounting "show version"
Command-Line Options
Upstream Servers (required)
| Flag | Description |
|---|---|
--server-addr <ADDR> | TACACS+ server address (repeatable, ordered by preference). First entry is the preferred server. |
--config <FILE> | Load upstream server definitions from a YANG JSON config file |
IPC Listener
| Flag | Default | Description |
|---|---|---|
--listen-endpoint <ENDPOINT> | /run/tacacs/tacacs.sock | Unix socket path (Linux) or TCP address (other platforms) |
--proxy-endpoint <ENDPOINT> | (disabled) | Optional TACACS+ proxy listener on a Unix socket path or loopback TCP address |
--service-mode <MODE> | client-api, or both when --proxy-endpoint is set | Runtime services to host: client-api, tacacs-proxy, or both |
--socket-mode <MODE> | 660 | File permission mode for the Unix socket (octal) |
Runtime Service Modes
tacacsrs-agentd can host the typed local client API, the raw TACACS+ proxy, or both services in one process:
| Mode | Services hosted | Required endpoint flags |
|---|---|---|
client-api | gRPC/protobuf client API only | --listen-endpoint optional; defaults to the platform local endpoint |
tacacs-proxy | raw TACACS+ proxy only | --proxy-endpoint required |
both | client API and raw TACACS+ proxy | --proxy-endpoint required; --listen-endpoint optional |
If --service-mode is omitted, the daemon preserves the old behavior: it runs client-api by default, and switches to both when --proxy-endpoint is supplied. Configurations with no hosted services are rejected.
TACACS+ Proxy Mode
When the TACACS+ proxy service is enabled, tacacsrs-agentd accepts local TACACS+ client connections and presents itself like a TACACS+ server. This is intended for clients that already speak TACACS+ directly and need a migration path onto the agent without using the gRPC IPC protocol.
For a host-by-host cutover plan from plain TACACS+ clients such as pam_tacplus or audisp-tacplus, see the Plain TACACS+ to TACACS+ over TLS Transition Guide.
Proxy mode is deliberately packet-transparent:
- Each accepted downstream connection is mapped to one upstream TACACS+ session.
- The downstream listener does not provide TACACS+ single-connection multiplexing.
- The proxy rejects a downstream connection if packets on that connection switch to a different TACACS+ session id.
- Packet bodies are forwarded unchanged. The proxy rewrites only the TACACS+ header session id so the downstream client's session id maps to the upstream session id selected by the networking layer.
- Accounting and authorization sessions close after one reply. Authentication sessions can continue across challenge replies and close when the reply status is terminal.
FOLLOWreplies are forwarded to the downstream client and then the connection is closed. Per RFC 8907, authorization and accountingFOLLOWuse the authenticationFOLLOWbehavior; authenticationFOLLOWis treated likeFAIL.- Authentication
RESTARTreplies are forwarded to the downstream client and then the connection is closed. The proxy enforces one TACACS+ session id per downstream connection, so a restarted authentication sequence must reconnect with a new session.
Proxy TCP endpoints must be loopback addresses. Unix domain socket endpoints use the same --socket-mode value as the IPC listener. When client-api and tacacs-proxy run together, the proxy endpoint must be different from --listen-endpoint.
The downstream TACACS+ shared-secret behavior follows the upstream server selected for that connection. If the selected upstream server has a shared-secret, the proxy uses that secret to deobfuscate downstream packets and obfuscate replies. If the selected upstream server has no shared secret, downstream packets must be sent with the unencrypted flag. When configured upstream servers use different shared secrets, a reconnect or failover can select a server with a different downstream secret; keep upstream shared secrets identical when using proxy mode.
Upstream Encryption
| Flag | Description |
|---|---|
-k, --shared-secret <KEY> | Shared secret for TACACS+ packet obfuscation |
--use-tls | Enable TLS 1.3 for upstream connections |
--client-certificate <FILE> | PEM- or DER-encoded client TLS certificate (requires --client-key) |
--client-key <FILE> | PEM- or DER-encoded client TLS private key (requires --client-certificate) |
--insecure-disable-certificate-verification | Skip TLS cert verification |
--psk-identity <ID> | TLS 1.3 pre-shared key identity (requires psk feature) |
--psk-key <KEY> | TLS 1.3 pre-shared key (requires psk feature) |
--psk-key-exchange <MODE> | Select psk-dhe or explicit psk-only interoperability mode (requires psk feature) |
--psk-key-exchange-groups <GROUP[,GROUP...]> | Comma-separated PSK-DHE supported groups in preferred order, for example secp384r1,secp256r1 (requires psk feature) |
TLS Client Certificates and Keys
When --use-tls is set, --client-certificate and --client-key let the daemon present a TLS client identity to upstream TACACS+ servers.
- Provide both flags together.
- Both files may be PEM or DER. PEM input is detected at runtime and normalized to DER internally before the daemon builds its runtime connection settings.
- Windows "export with private key" workflows commonly produce PKCS#12 (
.pfx/.p12) bundles. Those container formats are not accepted by these flags; provide PEM or DER certificate/key material instead. - This PEM-or-DER behavior applies only to the CLI flags. If upstream TLS material is loaded through
--config, the YANG-backedtacacsrs-configpath remains DER-only.
Timeouts and Failover
| Flag | Default | Description |
|---|---|---|
--connect-timeout-seconds <SECS> | 5 | Timeout for upstream TACACS+ connections |
--preferred-probe-interval-seconds <SECS> | 30 | How often to check if the preferred server has recovered |
Debugging
| Flag | Description |
|---|---|
-v | Warnings |
-vv | Info |
-vvv | Debug |
-vvvv | Trace |
Failover Behaviour
Servers are tried in the order they are specified. The first server (--server-addr index 0) is always the preferred server.
State Transitions
startup
│
▼
┌────────────────┐
│ PreferredActive│◄──── probe succeeds
│ (server 0) │
└───────┬────────┘
│ connection fails
▼
┌────────────────┐
│ FailedOver │──── try server 1, 2, ...
│ (server N) │
└───────┬────────┘
│ all servers fail
▼
┌───────────────────┐
│ NoResponsiveServer│
│ (returns error) │──── requests get retriable error
└───────────────────┘
Preferred Server Recovery
While failed over to a backup server, the daemon periodically probes the preferred server (index 0) at the configured interval. When a probe succeeds, traffic is automatically routed back to the preferred server.
Reconnect Behaviour
When multiple IPC requests arrive simultaneously during a reconnect, only one connection attempt runs per server. Other callers wait for the result rather than triggering duplicate TLS handshakes.
Startup Warm-up
On startup the daemon attempts to connect to servers in order and stops at the first success. This prevents connection storms when many instances start simultaneously (e.g. during a fleet rollout). If no server is reachable at startup, the daemon still starts and requests will retry on demand.
IPC Protocol
The daemon communicates with clients via gRPC over Unix domain sockets (Linux) or loopback TCP (other platforms). The protocol is defined in protobuf:
Available RPCs:
| RPC | Description |
|---|---|
Accounting | Record user activity (unary) |
Authorization | Check command authorization (unary) |
Error responses include a retriable flag. When true, the client should retry the request — this typically means the daemon is reconnecting to a different upstream server.
The optional TACACS+ proxy endpoint is separate from IPC. It accepts raw TACACS+ packets and is configured with --proxy-endpoint.
Deployment Examples
Systemd Service
[Unit]
Description=TACACS+ Agent Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/tacacsrs-agentd \
--server-addr tacacs1.example.com:49 \
--server-addr tacacs2.example.com:49 \
--listen-endpoint /run/tacacs/tacacs.sock \
--socket-mode 660 \
--shared-secret "shared_secret" \
--preferred-probe-interval-seconds 30
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
With TLS
tacacsrs-agentd \
--server-addr tacacs1.example.com:449 \
--server-addr tacacs2.example.com:449 \
--use-tls \
--client-certificate /etc/tacacs/client.crt.pem \
--client-key /etc/tacacs/client.key.pem \
--listen-endpoint /run/tacacs/tacacs.sock
DER input is also supported for the same flags:
tacacsrs-agentd \
--server-addr tacacs1.example.com:449 \
--server-addr tacacs2.example.com:449 \
--use-tls \
--client-certificate /etc/tacacs/client.crt.der \
--client-key /etc/tacacs/client.key.der \
--listen-endpoint /run/tacacs/tacacs.sock
Multiple Servers with Fast Failover
tacacsrs-agentd \
--server-addr primary.dc1.example.com:49 \
--server-addr secondary.dc1.example.com:49 \
--server-addr primary.dc2.example.com:49 \
--connect-timeout-seconds 3 \
--preferred-probe-interval-seconds 15 \
--shared-secret "shared_secret" \
--listen-endpoint /run/tacacs/tacacs.sock \
-vv
Local TACACS+ Proxy
tacacsrs-agentd \
--server-addr tacacs1.example.com:49 \
--server-addr tacacs2.example.com:49 \
--listen-endpoint /run/tacacs/tacacs.sock \
--proxy-endpoint /run/tacacs/tacacs-proxy.sock \
--socket-mode 660 \
--shared-secret "shared_secret"
For proxy-only deployments, select the proxy service explicitly:
tacacsrs-agentd \
--server-addr tacacs1.example.com:49 \
--server-addr tacacs2.example.com:49 \
--service-mode tacacs-proxy \
--proxy-endpoint /run/tacacs/tacacs-proxy.sock \
--socket-mode 660 \
--shared-secret "shared_secret"
On non-Linux development hosts, use a loopback TCP proxy endpoint:
tacacsrs-agentd \
--server-addr 192.0.2.20:49 \
--listen-endpoint 127.0.0.1:9049 \
--proxy-endpoint 127.0.0.1:9050 \
--shared-secret "shared_secret"
Loading a YANG JSON config
tacacsrs-agentd \
--config /etc/tacacs/tacacs.json \
--listen-endpoint /run/tacacs/tacacs.sock
When --config is used, upstream server definitions are loaded from the ietf-system-tacacs-plus RFC 7951 JSON document instead of repeated --server-addr flags.
Connection Reuse
The daemon maintains persistent upstream connections and multiplexes TACACS+ sessions over them. This avoids TCP/TLS handshake overhead for every request. When a connection can no longer accept new sessions (e.g. the server does not support single-connect mode), the daemon transparently reconnects.
Transition Plain TACACS+ Clients to TACACS+ over TLS
This guide is for hosts that already use local TACACS+ clients such as
pam_tacplus or
audisp-tacplus, but need to
move the network path to TACACS+ over TLS.
Those clients speak classic TACACS+ over TCP. tacacsrs-agentd can run on the
same host as a local TACACS+ proxy, accept the existing client traffic on
loopback, and open protected upstream connections to TACACS+ servers.
pam_tacplus / audisp-tacplus
|
| TACACS+ over loopback TCP
v
tacacsrs-agentd --service-mode tacacs-proxy
|
| TACACS+ over TLS 1.3
v
upstream TACACS+ servers
When to Use This
Use this transition path when:
- Existing consumers are hard-wired to
pam_tacplus,audisp-tacplus, or another client that already emits TACACS+ packets. - You want to avoid changing PAM or auditd integration code during the TLS cutover.
- You can install and run
tacacsrs-agentdon the same host as the legacy client. - The local host boundary is acceptable for the temporary plain TACACS+ hop.
Do not use this as proof that the legacy client itself supports TLS. The legacy
client still speaks plain TACACS+ to localhost. The TLS security boundary starts
at tacacsrs-agentd and covers the upstream network path.
What Changes
Move these responsibilities from each legacy client into tacacsrs-agentd:
| Concern | Before | After |
|---|---|---|
| Upstream server list | Repeated server= entries in PAM or audisp config | --server-addr flags or --config in tacacsrs-agentd |
| Upstream transport security | None, because the client only speaks classic TACACS+ | --use-tls, mTLS, TLS PSK-DHE, or TLS PSK-only in tacacsrs-agentd |
| Failover | Client-specific server-list behavior | Ordered upstream failover in tacacsrs-agentd |
| Local client target | Remote TACACS+ server | Loopback TACACS+ proxy endpoint |
Keep these in the legacy client configuration:
- PAM stack placement and control flags.
- TACACS+
service,protocol,login, timeout, and accounting options that describe the local request. - The downstream TACACS+ shared secret, unless the client and server are configured to use the TACACS+ unencrypted flag.
Shared Secret Compatibility
Proxy mode currently uses the selected upstream server's TACACS+ shared secret for the downstream client connection too. That means:
- Keep the legacy client
secret=value and thetacacsrs-agentd --shared-secretvalue aligned. - Use the same TACACS+ shared secret across the upstream servers used by one proxy deployment.
- Avoid a cutover where the local client secret differs from the upstream server secret unless the proxy gains a separate downstream-secret setting.
- If the upstream TLS server disables TACACS+ packet obfuscation entirely, verify whether the legacy client can send unencrypted TACACS+ packets before relying on proxy mode.
TLS protects the upstream connection, but it does not replace the downstream
TACACS+ packet format expected by pam_tacplus or audisp-tacplus.
Start the Local Proxy
Use a loopback TCP endpoint for pam_tacplus and audisp-tacplus because these
clients are normal TACACS+ TCP clients. Port 9049 is useful for testing because
it does not require privileged bind rights. Use 127.0.0.1:49 only when the
legacy client cannot be pointed at a non-standard port and the daemon has the
required permissions.
TLS Server Authentication
# Load this value from the host's secret manager.
TACACS_SHARED_SECRET='replace-with-secret-from-secret-store'
sudo tacacsrs-agentd \
--server-addr tacacs1.example.com:449 \
--server-addr tacacs2.example.com:449 \
--service-mode tacacs-proxy \
--proxy-endpoint 127.0.0.1:9049 \
--shared-secret "$TACACS_SHARED_SECRET" \
--use-tls \
-vv
TLS mTLS
# Load this value from the host's secret manager.
TACACS_SHARED_SECRET='replace-with-secret-from-secret-store'
sudo tacacsrs-agentd \
--server-addr tacacs1.example.com:449 \
--server-addr tacacs2.example.com:449 \
--service-mode tacacs-proxy \
--proxy-endpoint 127.0.0.1:9049 \
--shared-secret "$TACACS_SHARED_SECRET" \
--use-tls \
--client-certificate /etc/tacacs/client.crt.pem \
--client-key /etc/tacacs/client.key.pem \
-vv
TLS PSK-DHE
TLS PSK support requires a build with the psk feature. PSK-DHE is the preferred
PSK mode because it adds ephemeral key exchange. If no PSK key-exchange mode is
specified, tacacsrs-agentd defaults to PSK-DHE with the preferred group order
secp384r1,secp256r1.
# Load these values from the host's secret manager.
TACACS_SHARED_SECRET='replace-with-secret-from-secret-store'
TACACS_TLS_PSK='replace-with-tls-psk-from-secret-store'
sudo tacacsrs-agentd \
--server-addr tacacs1.example.com:449 \
--server-addr tacacs2.example.com:449 \
--service-mode tacacs-proxy \
--proxy-endpoint 127.0.0.1:9049 \
--shared-secret "$TACACS_SHARED_SECRET" \
--use-tls \
--psk-identity host01@example.com \
--psk-key "$TACACS_TLS_PSK" \
--psk-key-exchange-groups secp384r1,secp256r1 \
-vv
TLS PSK-only
Use PSK-only only for interoperability with a peer that cannot negotiate
PSK-DHE. PSK-only cannot be combined with --psk-key-exchange-groups.
# Load these values from the host's secret manager.
TACACS_SHARED_SECRET='replace-with-secret-from-secret-store'
TACACS_TLS_PSK='replace-with-tls-psk-from-secret-store'
sudo tacacsrs-agentd \
--server-addr legacy-tacacs.example.com:449 \
--service-mode tacacs-proxy \
--proxy-endpoint 127.0.0.1:9049 \
--shared-secret "$TACACS_SHARED_SECRET" \
--use-tls \
--psk-identity host01@example.com \
--psk-key "$TACACS_TLS_PSK" \
--psk-key-exchange psk-only \
-vv
Repoint pam_tacplus
Change only the TACACS+ server target first. Keep the PAM control flow and the request-shaping options that already work in the environment.
Before:
auth required pam_tacplus.so server=tacacs1.example.com:49 secret=<existing-shared-secret> login=pap
account required pam_tacplus.so server=tacacs1.example.com:49 secret=<existing-shared-secret> service=shell protocol=ssh
session required pam_tacplus.so server=tacacs1.example.com:49 secret=<existing-shared-secret> service=shell protocol=ssh
After:
auth required pam_tacplus.so server=127.0.0.1:9049 secret=<existing-shared-secret> login=pap
account required pam_tacplus.so server=127.0.0.1:9049 secret=<existing-shared-secret> service=shell protocol=ssh
session required pam_tacplus.so server=127.0.0.1:9049 secret=<existing-shared-secret> service=shell protocol=ssh
If the existing PAM configuration repeats several server= values for failover,
replace them with the one local proxy endpoint. Put the ordered upstream list in
tacacsrs-agentd instead.
For validation, use the same PAM test tool and PAM service file already used for
local changes. pamtester is useful for proving authenticate, account, open
session, and close session behavior before editing production PAM services.
Repoint audisp-tacplus
audisp-tacplus uses a TACACS+ accounting configuration with options derived
from pam_tacplus. Keep the auditd plugin wiring and audit rules unchanged, and
move only the TACACS+ server target to the local proxy.
Before:
server=tacacs1.example.com:49
secret=<existing-shared-secret>
service=shell
protocol=ssh
After:
server=127.0.0.1:9049
secret=<existing-shared-secret>
service=shell
protocol=ssh
Then restart or reload auditd/audisp using the operating-system procedure for the
host. Validate by producing a known audited command event and confirming that the
upstream TACACS+ server receives the accounting record through tacacsrs-agentd.
Rollout Plan
- Inventory every
pam_tacplusandaudisp-tacplusconfiguration file on the host. Recordserver=,secret=,service,protocol,login, timeout, and anyacct_alluse. - Deploy
tacacsrs-agentdin proxy-only mode on a loopback test port such as127.0.0.1:9049. - Configure the upstream TLS mode that matches the TACACS+ server: server-auth TLS, mTLS, PSK-DHE, or PSK-only.
- Test with a non-production PAM service or a controlled auditd event.
- Repoint one production consumer at a time to the local proxy endpoint.
- After confidence is established, remove direct outbound access from the host to the old plain TACACS+ server path so only
tacacsrs-agentdcan reach upstream TACACS+ servers.
Behavioral Differences to Check
acct_allfan-out is not the same as agent failover. Proxy mode sends each downstream connection to one selected upstream server. If accounting must be written to multiple collectors, plan a separate fan-out path.pam_tacplusrecords the successful authentication server for later account/session phases. After the cutover, that server is always the local proxy; upstream server choice is owned bytacacsrs-agentd.- Debug logging in these legacy clients may include passwords or secrets. Use debug briefly, collect logs carefully, and disable it after validation.
- Local loopback TACACS+ is still plain TACACS+. Bind the proxy to loopback, avoid exposing the proxy endpoint on non-loopback interfaces, and restrict host access to the process and config files that need it.
Rollback
Keep a copy of the previous PAM and audisp configuration. Roll back by restoring
the previous remote server= entries, or by leaving the clients pointed at the
local proxy while starting tacacsrs-agentd against the old plain upstream
server without --use-tls. The second option keeps the local configuration
stable while isolating the rollback to the daemon command line or service unit.
YANG Config Guide
tacacsrs-config provides TACACS+ configuration support using the RFC 7951 JSON encoding of the ietf-system-tacacs-plus YANG module. The crate owns parsing, generated model types, validation, local credential bundle enumeration, and helper APIs used by tacon and tacacsrs-agentd.
Use this guide when you need to author, validate, or consume TACACS+ server configuration from JSON.
What the configuration crate provides
- Generated Rust types that mirror the expanded TACACS+ YANG tree.
- RFC 7951 JSON parsing into the generated TACACS+ model.
- Validation for required server data, unique addresses, TLS choice constraints, SNI requirements, key format identities, base64-encoded key material, and config-local credential references.
- Bundle enumeration helpers that inline local
client-credentialsandserver-credentialsreferences onto server entries. - A
TacacsPlusServerBuilderfor constructing valid server definitions in Rust. - A project-owned TACACS+/TLS augmentation for TLS 1.3 PSK-DHE key exchange group selection.
External secret providers are intentionally kept outside this crate. Parse and enumerate configuration first, then pass the resulting server values to a runtime or provider layer that can materialize external secrets.
Basic JSON shape
Both tacon and tacacsrs-agentd can load TACACS+ server definitions from an RFC 7951 JSON document:
{
"ietf-system-tacacs-plus:tacacs-plus": {
"server": [
{
"name": "primary",
"server-type": "authentication authorization accounting",
"address": "192.0.2.2",
"port": 49,
"shared-secret": "example-shared-secret",
"timeout": 10
}
]
}
}
Use --config <file> with tacon or tacacsrs-agentd to load the configuration.
Parsing API
For simple application code, parse the JSON and enumerate runtime server entries:
#![allow(unused)] fn main() { use tacacsrs_config::{enumerate_servers, parse_yang_json}; let config = parse_yang_json(json_str)?; let servers = enumerate_servers(&config)?; anyhow::Ok::<()>(()) }
The primary entry points are:
| Function | Purpose |
|---|---|
parse_yang_json(&str) | Parse and structurally validate a JSON string without mutating credential references. |
parse_yang_json_file(&Path) | Parse a JSON file from disk. |
validate_credential_references(&TacacsPlus) | Validate config-local credential bundle references. |
enumerate_servers(&TacacsPlus) | Inline shared credential bundles onto every server. |
enumerate_server(&TacacsPlus, &str) | Inline shared credential bundles for one named server. |
The module-oriented API is also available for callers that want grouped imports:
builders- programmatic construction helpers.model- generated YANG model types and namespaces.extensions- helper traits layered over generated model types.pipeline- step-by-step parsing and processing.runtime- bundle enumeration helpers.stats- runtime statistics types.
Three-layer processing model
The crate separates parsing and runtime preparation into three layers.
Raw YANG model
Use the raw pipeline when you need the submitted YANG model preserved exactly for round-tripping or reporting:
#![allow(unused)] fn main() { use tacacsrs_config::pipeline; let raw_config = pipeline::parse_root_json(json_str)?; anyhow::Ok::<()>(()) }
Config-local bundle enumeration
Credential references may point to bundles in the same configuration. Validate those references, then enumerate the selected server:
#![allow(unused)] fn main() { use tacacsrs_config::{enumerate_server, parse_yang_json, validate_credential_references}; let config = parse_yang_json(json_str)?; validate_credential_references(&config)?; let server = enumerate_server(&config, "primary")?; anyhow::Ok::<()>(()) }
External secret resolution
External secret material should be resolved after enumeration by a separate runtime or provider layer. That keeps the generated configuration model safe for logging, reporting, and round-tripping while runtime code receives normalized server values.
Programmatic server construction
Use TacacsPlusServerBuilder when code needs to construct server definitions directly instead of parsing RFC 7951 JSON:
#![allow(unused)] fn main() { use tacacsrs_config::{TacacsPlusServerBuilder, TacacsPlusServerExt, TacacsPlusServerType}; let server = TacacsPlusServerBuilder::new( "primary", TacacsPlusServerType::ACCOUNTING, "192.0.2.10", 49, ) .with_timeout(10) .with_shared_secret("example-shared-secret") .build(); assert_eq!(server.socket_address(), "192.0.2.10:49"); assert!(server.is_obfuscation()); anyhow::Ok::<(), anyhow::Error>(()) }
The builder is for runtime construction of valid server shapes. It is not a replacement for schema validation of submitted YANG JSON.
Inline key format identities
TLS inline key material uses YANG identityref fields from ietf-crypto-types. RFC 7951 JSON represents those values as module-qualified strings.
Private key format
| JSON value | Meaning |
|---|---|
ietf-crypto-types:rsa-private-key-format | DER-encoded RSAPrivateKey. |
ietf-crypto-types:ec-private-key-format | DER-encoded ECPrivateKey. |
ietf-crypto-types:one-asymmetric-key-format | DER-encoded CMS OneAsymmetricKey. |
Public key format
| JSON value | Meaning |
|---|---|
ietf-crypto-types:subject-public-key-info-format | DER-encoded SubjectPublicKeyInfo. |
ietf-crypto-types:ssh-public-key-format | SSH public key format. |
The TACACS+ YANG model constrains TLS client identity and server authentication paths to subject-public-key-info-format.
Symmetric key format
| JSON value | Meaning |
|---|---|
ietf-crypto-types:octet-string-key-format | Raw octet string. |
ietf-crypto-types:one-symmetric-key-format | DER-encoded CMS OneSymmetricKey. |
TLS with inline certificate material
{
"ietf-system-tacacs-plus:tacacs-plus": {
"server": [
{
"name": "tls-inline",
"server-type": "authentication",
"address": "192.0.2.1",
"port": 49,
"domain-name": "tacacs.example.com",
"sni-enabled": true,
"client-identity": {
"certificate": {
"inline-definition": {
"public-key-format": "ietf-crypto-types:subject-public-key-info-format",
"public-key": "BASE64VALUE=",
"private-key-format": "ietf-crypto-types:rsa-private-key-format",
"cleartext-private-key": "BASE64VALUE=",
"cert-data": "BASE64VALUE="
}
}
},
"server-authentication": {
"ca-certs": {
"inline-definition": {
"certificate": [
{
"name": "CA-1",
"cert-data": "BASE64VALUE="
}
]
}
}
}
}
]
}
}
TACACS+/TLS PSK-DHE augmentation
The repository includes a local tacacsrs YANG module that augments TACACS+ TLS 1.3 EPSK configuration with psk-dhe-ke-groups. RFC 7951 JSON uses the module-qualified key tacacsrs:psk-dhe-ke-groups:
{
"ietf-system-tacacs-plus:tacacs-plus": {
"server": [
{
"name": "tls-psk-dhe",
"server-type": "accounting",
"address": "192.0.2.10",
"port": 49,
"client-identity": {
"tls13-epsk": {
"inline-definition": {
"cleartext-symmetric-key": "BASE64VALUE="
},
"external-identity": "client@example.com",
"tacacsrs:psk-dhe-ke-groups": [
"x25519",
"secp256r1",
"ffdhe3072"
]
}
}
}
]
}
}
Supported group values are x25519, secp256r1, secp384r1, secp521r1, ffdhe2048, ffdhe3072, ffdhe4096, ffdhe6144, and ffdhe8192. Unknown values fail during JSON deserialization.
Code generation workflow
The generated Rust types come from the checked-in YANG tooling under libraries/tacacsrs_config/yang/:
plugins/yang2rust.pyemits Rust structs, enums, bitflags, and helper methods.expand_yang_tree.pyrefreshes the expanded tree reference and generated Rust output.modules/contains project-owned YANG modules passed topyangalongside the upstream TACACS+ model.generated_types.rsis copied intosrc/generated.rsafter regeneration.
Regenerate after YANG module updates:
python -m pip install -r requirements.txt
cd libraries/tacacsrs_config/yang
python expand_yang_tree.py --list-features
python expand_yang_tree.py --list-features --list-features-format ini > feature-flags.ini
python expand_yang_tree.py --features-ini feature-flags.ini > expanded-tree.txt
python expand_yang_tree.py \
-f rust \
--features-ini feature-flags.ini \
-o generated_types.rs
cp generated_types.rs ../src/generated.rs
Run formatting, clippy, build, and tests after regeneration.
Building for SONiC
SONiC runs on a Debian/glibc userspace, so the supported TACACS-rs build flow is based on GNU Linux binaries and Debian packages.
Rust toolchain
Provision a current stable Rust toolchain inside WSL when the base image does not already provide one:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- \
--default-toolchain stable -y
Build products
Build the SONiC-relevant artifacts from a Linux environment with GNU targets:
cargo build --release --target x86_64-unknown-linux-gnu -p tacon --features psk
cargo build --release --target x86_64-unknown-linux-gnu -p tacacsrs-agentd --features psk
cargo build --release --target x86_64-unknown-linux-gnu -p tacacsrs-bash-plugin
The resulting artifacts are:
target/x86_64-unknown-linux-gnu/release/tacon
target/x86_64-unknown-linux-gnu/release/tacacsrs-agentd
target/x86_64-unknown-linux-gnu/release/libtacacsrs_bash_plugin.so
For package-oriented validation, prefer the GNU Debian packages produced by CI
or the local cargo deb --no-build flow described in DEBIAN_PACKAGING.md.
If you are starting from Windows, run these Linux-targeted Cargo commands from WSL. Do not copy Windows-built binaries or libraries into SONiC.
Publishing artifacts to the SONiC VM
Use the existing helper scripts instead of raw scp or ssh commands.
Publish GNU binaries:
.\lde\sonic-vm\Publish-SonicBinary.ps1 -Package tacon -Profile release
.\lde\sonic-vm\Publish-SonicBinary.ps1 -Package tacacsrs-agentd -Bin tacacsrs-agentd -Profile release
Publish the bash plugin shared library:
.\lde\sonic-vm\Publish-SonicSharedLibrary.ps1 -Package tacacsrs-bash-plugin -Profile release
The shared library publisher copies libtacacsrs_bash_plugin.so to the VM and
is the preferred path for SONiC bash plugin smoke testing.
Component roles on a SONiC switch
tacacsrs-agentd: long-running daemon that owns upstream TACACS+ connections and local IPC.tacon: operator CLI for ad-hoc requests and debugging.tacacsrs-bash-plugin: shared library loaded by patched bash for per-command authorization throughtacacsrs-agentd.
systemd interaction
Only tacacsrs-agentd needs a systemd unit on SONiC. A typical unit file looks
like this:
[Unit]
Description=TACACS+ client agent for local IPC consumers
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/tacacsrs-agentd --config /etc/tacacsrs/agentd.yaml
Restart=on-failure
RestartSec=2s
[Install]
WantedBy=multi-user.target
Enable it before testing any wrapper or plugin flow:
sudo systemctl daemon-reload
sudo systemctl enable --now tacacsrs-agentd
Bash plugin installation path
The standalone Debian package installs the plugin to:
/usr/lib/x86_64-linux-gnu/security/tacacsrs_bash_plugin.so
Reference that path from /etc/bash_plugins.conf:
plugin=/usr/lib/x86_64-linux-gnu/security/tacacsrs_bash_plugin.so
Verification
Before enabling SONiC command authorization broadly, verify that:
tacacsrs-agentdis active and its IPC socket exists.- A manual
taconrequest against the configured endpoint succeeds. - The plugin shared library is present at the expected security path.
- Any SONiC VM testing is performed with Linux artifacts built from WSL, not Windows binaries.
SONiC ConfigDB integration
tacacsrs-agentd can run as a native SONiC service, sourcing its TACACS+
configuration from SONiC's Redis-backed Configuration Database (CONFIG_DB,
database index 4) and reacting to ConfigDB changes via Redis keyspace
notifications.
This document covers the runtime side (how the daemon talks to ConfigDB). Building static binaries for SONiC is covered separately in sonic-build-guide.md.
Architecture
The agent does not depend directly on Redis. Instead, configuration sources
are abstracted behind the tacacsrs-datastore::ConfigDatastore trait, and
the SONiC bridge (tacacsrs-sonic::SonicConfigDb) is one concrete backend.
Any future vendor datastore (for example a different NOS, a YAML config
service, or a remote management plane) can be plugged in by implementing the
same trait.
+-----------------------+ +----------------------------+
| tacacsrs-agentd | -----> | ConfigDatastore (trait) |
+-----------------------+ +----------------------------+
|
+--------------------+----------------------+
| |
+----------------+ +-----------------------+
| StaticDatastore| | SonicConfigDb |
| (file / CLI) | | (Redis CONFIG_DB) |
+----------------+ +-----------------------+
StaticDatastore is used for file-based and CLI-based configuration; it
returns a single snapshot and never emits change events. SonicConfigDb
reads TACPLUS|global and TACPLUS_SERVER|* rows from Redis and emits
change events whenever a TACPLUS-prefixed key changes (subject to the
configured debounce window).
ConfigDB schema mapping
The bridge maps SONiC's existing TACACS+ tables onto the
ietf-system-tacacs-plus YANG model used by the rest of the workspace.
TACPLUS|global
auth_type "pap" # logged only; no PAM-style selector yet
timeout "5" # default per-server timeout
passkey "shared-secret" # default shared secret
src_intf "Management0" # default source interface
TACPLUS_SERVER|192.0.2.10
priority "1" # lower = preferred (failover order)
tcp_port "49"
timeout "10" # overrides TACPLUS|global.timeout
passkey "..." # overrides TACPLUS|global.passkey
Each TACPLUS_SERVER row becomes one TacacsPlusServer in the YANG
configuration. Per-server fields fall back to the matching TACPLUS|global
field when absent. The synthesized YANG name for each server is
sonic-server-<index>-<address>.
Forward-compatible extension keys
Operators and SONiC schema maintainers can experiment with TLS-aware fields
ahead of upstream ConfigDB schema work by adding the following keys to
TACPLUS_SERVER|<addr>:
| Key | YANG field | Notes |
|---|---|---|
use_tls | server-authentication: {} | Accepts the same boolean forms as sni_enabled; also supported on `TACPLUS |
domain_name | domain-name | Used as SNI hostname |
sni_enabled | sni-enabled | true/false/yes/no/1/0 |
single_connection | single-connection | Boolean |
vrf_name | vrf-instance | VRF name for outbound traffic |
src_ip | source-ip | Mutually exclusive with src_intf |
src_intf | source-interface | Falls back to the global TACPLUS row |
server_type | server-type | Defaults to all; tokens accept authentication, authorization, accounting, or all |
Unknown fields are logged at warn level and ignored, so legacy operator
annotations on TACPLUS rows do not break the agent.
Capability gap (TLS)
SONiC's upstream TACACS+ ConfigDB schema does not yet expose certificate
material, trust anchors, TLS 1.3 ePSKs, or other TLS-only fields from the
YANG model. Until SONiC adopts a richer schema, the bridge supports the
shared-secret / obfuscation path (passkey) plus a forward-compatible
use_tls extension that selects the empty server-authentication container
used by tacon --use-tls when no explicit certificate material is configured.
The mapping is structured so that promoting richer TLS support upstream will
only require new ConfigDB fields and a corresponding update to
tacacsrs_sonic::mapping; no daemon-level plumbing changes will be required.
Running on SONiC
1. Enable Redis keyspace notifications
Hot reload requires Redis keyspace notifications to be turned on for the
ConfigDB instance. On SONiC, run once (and persist via /etc/redis/redis.conf
in your image build):
redis-cli -n 4 CONFIG SET notify-keyspace-events KEA
KEA enables Keyspace events, Event expiration, and All command
classes. The bridge subscribes to __keyspace@4__:TACPLUS*.
2. Install the systemd unit
The repository ships an example unit file at
executables/tacacsrs_agentd/sonic/tacacsrs-agentd.service.
sudo install -m 0644 \
executables/tacacsrs_agentd/sonic/tacacsrs-agentd.service \
/etc/systemd/system/tacacsrs-agentd.service
sudo systemctl daemon-reload
sudo systemctl enable --now tacacsrs-agentd.service
The unit ordering pulls in database.service so the daemon starts only
after CONFIG_DB is available.
3. Register in the SONiC FEATURE table
executables/tacacsrs_agentd/sonic/feature_table.json is a sample row that
exposes the agent through SONiC's standard config feature CLI:
sonic-cfggen -j executables/tacacsrs_agentd/sonic/feature_table.json --write-to-db
config save -y
config feature state tacacsrs-agentd enabled
4. Start the daemon manually (for development)
sudo /usr/local/bin/tacacsrs-agentd \
--sonic \
--listen-endpoint /run/tacacs/tacacs.sock \
-vv
You can override the Redis URL or database index for staging/testing environments:
tacacsrs-agentd --sonic \
--sonic-redis-url redis://127.0.0.1:6379 \
--sonic-redis-db 4
Local Redis smoke test
For branch validation on a development machine, run Redis locally over TCP and
seed database 4 with SONiC-style TACACS+ rows. On Windows, Docker Desktop or
WSL Redis is usually the simplest path.
The repository includes a PowerShell helper that runs the whole proof with Podman, including Redis startup, seed data, example execution, ConfigDB mutations, and output assertions:
.\lde\run-sonic-configdb-smoke.ps1
From WSL, make sure Cargo is on the PowerShell process path:
export PATH="$HOME/.cargo/bin:$PATH"
pwsh -NoLogo -NoProfile -File ./lde/run-sonic-configdb-smoke.ps1
To validate the SONiC-style Unix-domain socket path instead of TCP, run the
same helper from WSL with -RedisTransport UnixSocket:
export PATH="$HOME/.cargo/bin:$PATH"
pwsh -NoLogo -NoProfile -File ./lde/run-sonic-configdb-smoke.ps1 -RedisTransport UnixSocket
To run the same flow manually, start Redis first:
docker run --rm -p 6379:6379 redis
In a second terminal, prepare the CONFIG_DB-like database:
redis-cli -n 4 CONFIG SET notify-keyspace-events KEA
redis-cli -n 4 DEL 'TACPLUS|global'
for key in $(redis-cli -n 4 --raw KEYS 'TACPLUS_SERVER|*'); do
redis-cli -n 4 DEL "$key"
done
redis-cli -n 4 HSET 'TACPLUS|global' \
timeout 5 passkey shared-secret auth_type pap src_intf Management0
redis-cli -n 4 HSET 'TACPLUS_SERVER|192.0.2.10' \
priority 1 tcp_port 49 timeout 10 passkey server-secret \
domain_name tacacs-a.example.test sni_enabled true single_connection true
Run the tacacsrs-sonic watcher example to validate the datastore contract
directly:
cargo run -p tacacsrs-sonic --example configdb_watch -- \
--redis-url redis://127.0.0.1:6379 \
--redis-db 4
Then mutate ConfigDB rows and confirm the example emits a ConfigChange with
the expected delta:
redis-cli -n 4 HSET 'TACPLUS_SERVER|192.0.2.20' \
priority 2 tcp_port 49 passkey backup-secret
redis-cli -n 4 HSET 'TACPLUS_SERVER|192.0.2.10' timeout 20
redis-cli -n 4 DEL 'TACPLUS_SERVER|192.0.2.20'
redis-cli -n 4 HSET 'TACPLUS|global' timeout 7
Expected results:
- The initial load prints one server synthesized from
TACPLUS_SERVER|192.0.2.10. - Adding
TACPLUS_SERVER|192.0.2.20reports an added server. - Changing
TACPLUS_SERVER|192.0.2.10.timeoutreports a modified server. - Deleting
TACPLUS_SERVER|192.0.2.20reports a removed server. - Updating
TACPLUS|globalemits a change event after the debounce window; the exact delta depends on whether the global value changes the effective validated YANG snapshot.
To validate the daemon path, start tacacsrs-agentd against the same Redis
instance:
cargo run -p tacacsrs-agentd -- \
--sonic \
--sonic-redis-url redis://127.0.0.1:6379 \
--sonic-redis-db 4 \
--listen-endpoint /tmp/tacacs.sock \
-vv
Mutating the Redis rows should produce the documented configuration-change log
message. The current daemon observes changes but does not hot-swap upstream
connections in place; restart tacacsrs-agentd to apply a changed server set.
Hot reload behaviour
When a TACPLUS-prefixed key changes in CONFIG_DB, the bridge:
- Coalesces additional changes that arrive within a short debounce window.
- Re-reads the full TACPLUS / TACPLUS_SERVER tables.
- Validates the new snapshot against the YANG schema.
- Emits a
ConfigChangeevent carrying the new snapshot and a delta describing which servers were added, removed, or modified.
The current daemon does not yet hot-swap upstream connections in place: it records the change and asks the operator to restart the service to apply the new configuration. This is the smallest safe step that keeps in-flight TACACS+ sessions intact, and the plumbing is shaped so that a future implementation can replace the listener body with an atomic state-rebuild without changing the surrounding lifecycle or the datastore contract.
Operational commands
# Inspect what the bridge will see.
redis-cli -n 4 HGETALL TACPLUS\|global
redis-cli -n 4 KEYS 'TACPLUS_SERVER|*'
# Tail change notifications the bridge subscribes to.
redis-cli -n 4 PSUBSCRIBE '__keyspace@4__:TACPLUS*'
# Drive the daemon's logs.
journalctl -u tacacsrs-agentd.service -f
Secret handling
The bridge currently reads passkey directly from CONFIG_DB. The
ConfigDatastore trait does not constrain how secrets are fetched, so a
future implementation can compose a secret-resolution backend (HashiCorp
Vault, Azure Key Vault, encrypted ConfigDB fields, etc.) by wrapping
SonicConfigDb and rewriting the per-server shared-secret before
returning the snapshot from load.
session-wrapper — SSH-driven TACACS+ command authorization
session-wrapper is a per-session login wrapper that mediates command execution
through TACACS+ authorization. It is launched once per SSH login (typically by
sshd via ForceCommand), forks the user's shell under a seccomp
user-notification filter, and asks the local tacacsrs-agentd daemon whether
each execve should be allowed.
For module-level architecture and the wrapper's process lifecycle, see the crate-level README. For local smoke and integration tests, see the testing guide. For deploying the static binary to SONiC, see the SONiC build guide.
Architecture
┌─────────┐ PAM/login ┌────────────────────┐ seccomp notif ┌────────┐
│ sshd │─────────────▶│ session-wrapper │◀════════════════════▶│ bash │
│ │ ForceCommand│ (supervisor in │ fork+exec │ (user │
│ │ │ parent) │─────────────────────▶│ shell) │
└─────────┘ └─────────┬──────────┘ └────────┘
│ gRPC over Unix socket
▼
┌────────────────────┐ TACACS+ TCP/TLS ┌────────┐
│ tacacsrs-agentd │─────────────────────▶│ TACACS+│
│ (long-running) │ │ server │
└────────────────────┘ └────────┘
Key properties:
- The supervisor lives in the parent process. The user's shell runs in the child, after privilege drop, with the seccomp filter already installed.
- The seccomp filter is inherited across
fork/cloneand preserved acrossexec, so nested shells, subshells, pipelines, background jobs, and shell scripts in the wrapped session all hit the same supervisor without any re-installation. - The supervisor keeps processing notifications until the wrapped session is drained — that is, until the initial shell and every reparented descendant have exited. It does not stop when the first shell PID exits.
SSH integration
session-wrapper is invoked by sshd after a successful authentication. There
are two supported integration modes; pick whichever fits your platform.
Option 1: sshd_config ForceCommand (recommended)
Add a Match block to /etc/ssh/sshd_config so users in a designated group
are forced through the wrapper regardless of which command they request:
Because ForceCommand takes a single command string and does not expand all
the SSH environment we want, the cleanest pattern is to point it at a tiny
shim script:
Match Group tacacs-authorized
ForceCommand /usr/local/sbin/tacacs-forcecommand
#!/bin/sh
# /usr/local/sbin/tacacs-forcecommand
#
# Invoked by sshd as the matched user, with $USER, $SSH_TTY, $SSH_CONNECTION,
# and (for non-interactive sessions) $SSH_ORIGINAL_COMMAND already in the
# environment. We translate those into session-wrapper flags and exec.
exec /usr/local/bin/session-wrapper \
--user "$USER" \
--user-uid "$(id -u)" \
--user-gid "$(id -g)" \
--service-endpoint /run/tacacs/tacacs.sock \
--fail-policy closed \
--port "${SSH_TTY:-ssh}" \
--rem-addr "${SSH_CONNECTION%% *}" \
-- /bin/bash ${SSH_ORIGINAL_COMMAND:+-c "$SSH_ORIGINAL_COMMAND"}
The shim must be 0755 and owned by root:root. sshd runs ForceCommand
with /bin/sh -c, so any single command string works directly, but a shim is
easier to maintain than a long inline command.
Why ForceCommand?
ForceCommand runs unconditionally for matched logins, even when the SSH
client requests a specific command (ssh user@host -- whoami). The original
command is exposed to the forced command via the SSH_ORIGINAL_COMMAND
environment variable, but the wrapper's seccomp filter still mediates
everything the user shell tries to exec.
Option 2: Login shell via /etc/passwd or NSS
For platforms where modifying sshd_config is impractical, set
session-wrapper as the user's login shell. There are two common patterns:
-
Direct
/etc/passwdentry — set the shell field to a small wrapper script that exec'ssession-wrapperwith the right arguments:tacuser:x:1100:100:TACACS user:/home/tacuser:/usr/local/sbin/tacacs-login#!/bin/sh # /usr/local/sbin/tacacs-login exec /usr/local/bin/session-wrapper \ --user "$USER" \ --user-uid "$(id -u)" \ --user-gid "$(id -g)" \ --service-endpoint /run/tacacs/tacacs.sock \ --fail-policy closed \ --port "${SSH_TTY:-login}" \ --rem-addr "${SSH_CONNECTION%% *}" \ -- /bin/bash -l "$@"The wrapper script must be listed in
/etc/shellsand have mode0755. -
NSS-provided shell — when users come from
libnss-tacplusor a similar NSS module, configure that module to return the wrapper script path as the shell field. The mechanics are NSS-module specific; the wrapper script itself is identical to the one above.
Compared to ForceCommand, the login-shell pattern relies on the user not
being able to bypass their shell (e.g. ssh -t user@host /bin/bash would skip
it). Use ForceCommand whenever possible.
SSH environment variables
sshd exposes connection metadata as environment variables that map directly
to TACACS+ context fields:
| SSH variable | Format | Wrapper flag |
|---|---|---|
SSH_CONNECTION | <client_ip> <client_port> <server_ip> <server_port> | --rem-addr (first field) |
SSH_CLIENT | <client_ip> <client_port> <server_port> (legacy) | --rem-addr (first field) |
SSH_TTY | /dev/pts/N when a tty is allocated | --port |
SSH_ORIGINAL_COMMAND | command requested under ForceCommand | (logged via accounting) |
Recommended extraction:
REM_ADDR="${SSH_CONNECTION%% *}" # first whitespace-delimited field
PORT="${SSH_TTY:-ssh}" # fall back to "ssh" for non-tty sessions
These should be passed to --rem-addr and --port in your ForceCommand or
login-shell wrapper.
CLI reference
session-wrapper [OPTIONS] -- COMMAND [ARGS]...
| Option | Default | Purpose |
|---|---|---|
--user <NAME> | (required) | Target username for TACACS+ accounting context |
--user-uid <UID> | (required) | UID to drop to before exec |
--user-gid <GID> | (required) | Primary GID to drop to before exec |
--service-endpoint <PATH> | /run/tacacs/tacacs.sock | Unix socket (or host:port) for tacacsrs-agentd IPC |
--fail-policy <closed|open> | closed | What to do when IPC is unreachable |
--authorization-timeout-ms <N> | 5000 | Per-request authorization timeout (ms) |
--privilege-level <0..=15> | 1 | Current TACACS+ privilege level |
--allowlist <FILE> | (none) | Extra exec allowlist file (built-ins always active) |
--port <STR> | (none) | TACACS+ port context, typically $SSH_TTY |
--rem-addr <STR> | (none) | TACACS+ remote address context, typically the first field of $SSH_CONNECTION |
-v / -vv / -vvv / -vvvv | 0 | Increase log verbosity |
COMMAND [ARGS]... | (required) | Program execed in the child after privilege drop |
Run session-wrapper --help for the authoritative list (it is generated from
clap annotations and tracks the source).
Configuration examples
Fail-closed (production default)
Deny the session if the local agent or the upstream TACACS+ server is unreachable. This is the safe default for managed network devices.
session-wrapper \
--user alice --user-uid 1100 --user-gid 100 \
--service-endpoint /run/tacacs/tacacs.sock \
--fail-policy closed \
--authorization-timeout-ms 3000 \
-- /bin/bash
Fail-open (lab / bring-up only)
Allow the session if authorization cannot be reached. Use only during bring-up, lab testing, or for break-glass roles where lockout is worse than unaudited access.
session-wrapper \
--user alice --user-uid 1100 --user-gid 100 \
--fail-policy open \
-- /bin/bash
Allowlist for high-frequency built-ins
Authorization round-trips on every execve are expensive for shells that
fork frequently (prompt rendering, completion, pipelines). The wrapper ships a
built-in allowlist for shell infrastructure (/bin/bash, /bin/sh,
/usr/bin/env, /usr/bin/id, …); add site-specific tools by file:
# /etc/session-wrapper.allow
# One absolute path per line; '#' comments and blank lines are ignored.
/usr/local/bin/show-version
/usr/local/bin/show-interfaces
/opt/vendor/diag
session-wrapper \
--user alice --user-uid 1100 --user-gid 100 \
--allowlist /etc/session-wrapper.allow \
-- /bin/bash
Allowlist entries match the resolved exec path. They bypass the IPC round-trip entirely, so they are not visible in TACACS+ accounting — keep the list to genuinely uninteresting helpers.
Security considerations
TOCTOU (time-of-check / time-of-use)
When a notification fires, the supervisor reads the target process's argv
from /proc/[pid]/mem while the notifying thread is held at the syscall
boundary. That does not make the process address space immutable: another
thread in the same process can still rewrite the exec path after the supervisor
reads it and before the kernel resumes the syscall. check_notification_valid()
only confirms that the notification is still pending, for example because the
target process was not killed or reaped mid-read; it does not prove that argv
memory is unchanged.
This TOCTOU window is inherent to seccomp user notifications and cannot be
completely eliminated inside the seccomp authorization path. Practical mitigations
can reduce exploitability, such as denying userfaultfd and process_vm_writev,
but complete protection requires a kernel-enforced execution boundary such as
Landlock, AppArmor, SELinux, or an equivalent LSM policy.
ptrace is blocked
ptrace(2) is forced to fail with EPERM inside the wrapped session. This
prevents a debugger inside the session from rewriting another process's argv
between notify and exec, attaching to the supervisor, or detaching the
seccomp filter. Tools that legitimately need ptrace (gdb, strace) will
not work inside the wrapped shell — that is intentional.
Privilege drop
The wrapper expects to be started as root (so it can setgid/setuid to
the target user) and drops to --user-uid / --user-gid in the child
before execve. The supervisor parent retains its original privileges only
long enough to receive the seccomp listener fd, then services notifications
without any need to be root for the wrapped session itself.
If the wrapper is started as a non-root user that already matches --user-uid
the drop is a no-op; this is the expected configuration when launched from a
PAM session that already changed identity.
Descendant coverage
The seccomp filter is installed once, in the child, before its first
execve. It is inherited across fork/clone and preserved across
exec, so:
- Subshells, pipelines, and background jobs are mediated.
- Nested
bash,sh -c "…", and shell scripts are mediated. - A long-lived background job that outlives the user's interactive shell is
still mediated for its remaining
execves.
The parent registers itself as a child subreaper (prctl(PR_SET_CHILD_SUBREAPER)),
so descendants whose original parent exits are reparented back to the
wrapper. The supervisor keeps the notification fd open and continues to
service notifications until the entire subtree has been reaped — not just
until the initial shell PID exits.
IPC trust boundary
The wrapper only authenticates the IPC endpoint via filesystem permissions
on the Unix socket (or network ACLs for TCP endpoints). The
tacacsrs-agentd socket should be mode 0660 and owned by a group that
includes the wrapper's UID. Do not point --service-endpoint at a
user-writable path.
Troubleshooting
"child setup failed: …" on the first command
The child reports setup errors back to the parent over the control socket before exec. Look at the message text:
execv …: No such file or directory— the wrapped command does not exist on the target. Check the absolute path passed after--.setgid/setuidfailure — the wrapper was not started with enough privilege to drop to the requested UID/GID. Run as root, or have PAM hand the wrapper an already-correct identity and pass matching--user-uid/--user-gid.prctl(PR_SET_NO_NEW_PRIVS)failure — the kernel is older than 3.5 or the process already has restrictive flags. The wrapper requiresNO_NEW_PRIVSto install an unprivileged seccomp filter.
Session hangs immediately after login
The supervisor likely failed to start. Possible causes:
tacacsrs-agentdis not running. Confirm the socket exists (ls -l /run/tacacs/tacacs.sock) and the daemon is listening.- The wrapper does not have permission to connect to the socket. Check the socket's mode and the wrapped user's group membership.
- A
--fail-policy closeddeployment combined with an unreachable agent will hang only briefly — then the session will be denied with a clear error in the wrapper's log. If you see an indefinite hang instead, increase verbosity with-vvand re-run.
Session does not exit after the user logs out
The wrapper waits for the entire wrapped subtree, not just the interactive shell. Common causes:
- A background job (
some-tool &ornohup …) is still running and still inherits the seccomp filter. Send SIGTERM/SIGKILL to that PID, or have the user usedisown -hand detach vianohup … </dev/null >/dev/null 2>&1 &before logging out so the descendant is fully detached. - A shell function or trap kept a subshell alive. Inspect
ps --ppid <wrapper_pid>and the wider subtree (pstree -p <wrapper_pid>). - A daemonized process forgot to
setsidand is still parented to the subreaper. Either fix the daemon or kill the orphan.
This is by design: stopping supervision while a descendant is still alive would create an authorization gap. If you need to forcibly tear down the wrapped session, signal the wrapper PID — it will propagate signals and reap descendants.
Built-in commands cause unexpected denies
Built-in shell commands (cd, echo when implemented in the shell, if,
for, …) do not call execve and are never seen by the wrapper. If
/bin/echo is being denied while echo works, the user is invoking the
external binary explicitly — add it to the allowlist or to TACACS+ command
authorization rules.
Authorization round-trips are slow
Every execve outside the allowlist costs a TACACS+ round-trip. For
prompt-heavy interactive shells this is visible as latency on each command.
Mitigations, in order of preference:
- Add high-frequency, harmless binaries to
--allowlist. - Tune
--authorization-timeout-msdown so failed servers are detected faster (only useful with multiple agentd upstreams configured). - Ensure
tacacsrs-agentdis configured with multiple upstream servers so failover does not stall.
Verifying with the demo scripts
The wrapper ships allow-all demos in executables/session_wrapper/demo/ that
exercise the lifecycle without needing TACACS+ infrastructure:
executables/session_wrapper/demo/allow-all-basic.sh
executables/session_wrapper/demo/allow-all-descendants.sh
executables/session_wrapper/demo/allow-all-interactive-bash.sh
If these demos pass but a real SSH login does not, the problem is in the SSH
integration (ForceCommand arguments, login shell wrapper, environment
variables) rather than in the wrapper itself.
Session Wrapper Smoke and Integration Testing
This document describes local checks that verify the Linux session-wrapper path is operating as expected. The real process mediation backend is Linux x86_64 only. Other platforms build the portable CLI, allowlist, deny-message, and authorization decision logic over a mock PAL backend that returns an explicit unsupported-platform error instead of executing or mediating commands.
For architecture, CLI shape, and current implementation scope, see the session-wrapper README.
Current scope
The session wrapper currently verifies process lifecycle and seccomp user notification wiring. TACACS+ authorization decisioning is intentionally stubbed as allow-all for now, so these smoke tests do not require a running tacacsrs-agentd service or TACACS+ server.
The trailing COMMAND [ARGS]... is the process that is executed under supervision. Smoke tests use small temporary scripts as the wrapped command.
Demo scripts
Runnable allow-all demos live in executables/session_wrapper/demo/:
| Script | Purpose |
|---|---|
allow-all-basic.sh | Builds session-wrapper, runs a short wrapped script, and verifies the wrapped process wrote a marker file |
allow-all-descendants.sh | Runs a wrapped script that exits while a descendant continues |
allow-all-interactive-bash.sh | Starts an interactive Bash session under the current allow-all supervisor for manual exploration |
Run them from anywhere inside a Linux x86_64 checkout:
executables/session_wrapper/demo/allow-all-basic.sh
executables/session_wrapper/demo/allow-all-descendants.sh
executables/session_wrapper/demo/allow-all-interactive-bash.sh
Prerequisites
On Debian or Ubuntu, install the native Linux dependencies:
sudo apt-get update
sudo apt-get install -y build-essential gperf libseccomp-dev linux-libc-dev musl-tools
rustup target add x86_64-unknown-linux-musl
GNU builds can use the distribution libseccomp-dev. Musl builds require a musl-targeted static libseccomp. In CI this is built and cached by the shared Rust setup action. Locally, point Cargo at a musl libseccomp install before running musl checks:
export LIBSECCOMP_LIB_PATH=/path/to/libseccomp-musl/lib
export LIBSECCOMP_LINK_TYPE=static
export PKG_CONFIG_ALLOW_CROSS=1
export PKG_CONFIG_PATH=/path/to/libseccomp-musl/lib/pkgconfig
Native Alpine builds have one extra libseccomp/musl linking caveat. See the crate-local Alpine Linux technical note.
Compile-time integration checks
On non-Linux development machines, run the portable checks first:
cargo check -p session-wrapper
cargo test -p session-wrapper
cargo clippy -p session-wrapper --all-targets -- -D warnings
These checks exercise the portable modules and the mock PAL backend. They do not
validate fork, seccomp, /proc, signal, or child-reaping behavior.
Run these on Linux x86_64:
cargo clippy -p session-wrapper --all-targets -- -D warnings
cargo test -p session-wrapper
Run these when validating the static musl path:
cargo clippy -p session-wrapper --target x86_64-unknown-linux-musl --all-targets -- -D warnings
cargo test -p session-wrapper --target x86_64-unknown-linux-musl
These tests validate CLI parsing, seccomp policy generation, file descriptor passing, child setup status reporting, and socket close handling. They do not prove that a real child process can execute under the wrapper, so run the smoke tests below as well.
Smoke test: child starts and exits
This verifies the parent receives the notification fd, starts the temporary allow-all supervisor, releases the child, handles the child's initial execve, and exits when the child exits.
cargo build -p session-wrapper
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
cat >"$tmp/exit-zero" <<'EOF'
#!/bin/sh
printf 'ok\n' > "$SESSION_WRAPPER_SMOKE_MARKER"
EOF
chmod +x "$tmp/exit-zero"
SESSION_WRAPPER_SMOKE_MARKER="$tmp/marker" \
timeout 10s target/debug/session-wrapper \
--user "$(id -un)" \
--user-uid "$(id -u)" \
--user-gid "$(id -g)" \
--fail-policy open \
-- "$tmp/exit-zero"
test "$(cat "$tmp/marker")" = "ok"
Expected result: the command exits successfully and the marker contains ok. A timeout usually means the supervisor is not responding to seccomp notifications or the child was not released.
Smoke test: child setup failures are reported
This verifies the child reports setup errors back to the parent instead of hanging or silently exiting.
cargo build -p session-wrapper
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
if timeout 10s target/debug/session-wrapper \
--user "$(id -un)" \
--user-uid "$(id -u)" \
--user-gid "$(id -g)" \
--fail-policy open \
-- "$tmp/does-not-exist" 2>"$tmp/error"; then
echo "expected session-wrapper to fail for a missing command" >&2
exit 1
fi
grep -E 'child setup failed|execv' "$tmp/error"
Expected result: the command fails quickly and stderr includes the child setup or execv failure.
Smoke test: descendant execution remains supervised
This verifies seccomp inheritance and subreaper lifecycle tracking. The initial child starts a background descendant and exits; the wrapper should stay alive until the descendant has run its own exec path and exited.
cargo build -p session-wrapper
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
cat >"$tmp/descendant" <<'EOF'
#!/bin/sh
(
sleep 0.2
/bin/sh -c 'printf "descendant-ok\n" > "$SESSION_WRAPPER_DESCENDANT_MARKER"'
) &
exit 0
EOF
chmod +x "$tmp/descendant"
SESSION_WRAPPER_DESCENDANT_MARKER="$tmp/descendant-marker" \
timeout 10s target/debug/session-wrapper \
--user "$(id -un)" \
--user-uid "$(id -u)" \
--user-gid "$(id -g)" \
--fail-policy open \
-- "$tmp/descendant"
test "$(cat "$tmp/descendant-marker")" = "descendant-ok"
Expected result: the marker contains descendant-ok. A missing marker or timeout indicates the wrapper stopped supervising before descendants completed, or fork/exec notifications were not continued.
Optional smoke test: privileged identity drop
Run this when you need to verify the root-to-user path used by login integrations. It requires sudo.
cargo build -p session-wrapper
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
cat >"$tmp/identity" <<'EOF'
#!/bin/sh
printf '%s:%s\n' "$(id -u)" "$(id -g)" > "$SESSION_WRAPPER_IDENTITY_MARKER"
EOF
chmod +x "$tmp/identity"
target_user=$(id -un)
target_uid=$(id -u)
target_gid=$(id -g)
sudo env SESSION_WRAPPER_IDENTITY_MARKER="$tmp/identity-marker" \
target/debug/session-wrapper \
--user "$target_user" \
--user-uid "$target_uid" \
--user-gid "$target_gid" \
--fail-policy open \
-- "$tmp/identity"
test "$(cat "$tmp/identity-marker")" = "$target_uid:$target_gid"
Expected result: the marker contains the target user's UID and primary GID, not 0:0.
Automation candidates
These smoke tests are good candidates for a Linux-only integration test job once the wrapper behavior stabilizes:
| Check | Requires root | Purpose |
|---|---|---|
| Compile-time integration checks | No | Validate Rust code, seccomp policy construction, fd passing, and musl compatibility |
| Child starts and exits | No | Validate notification fd handoff, ready synchronization, and child exec |
| Missing shell failure | No | Validate child-to-parent setup error reporting |
| Descendant execution | No | Validate inherited seccomp coverage and subreaper lifecycle handling |
| Privileged identity drop | Yes | Validate login-style root-to-user execution |
When real IPC authorization is wired in, keep these smoke tests but replace the allow-all assumption with a test IPC service that records each authorization request and responds with the desired decision.