Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

Platform and integration guides

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)

FlagDescription
-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)

FlagDescription
-k, --shared-secret <KEY>Shared secret for TACACS+ packet obfuscation
--use-tlsEnable 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-verificationSkip 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)

FlagDescription
--dedicatedUse a one-shot connection per request (no session multiplexing)

Debugging

FlagDescription
-vWarnings
-vvInfo
-vvvDebug
-vvvvTrace

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
ArgumentDescription
<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

FieldTypeDefaultDescription
descriptionstringOptional description
parallelboolfalseExecute all requests concurrently
load_testobjectEnable load testing mode
load_test.repetitionsnumberNumber of times to repeat all requests
load_test.max_parallelnumber10Maximum concurrent requests

Execution Modes

parallelload_testBehaviour
falseabsentRequests execute sequentially
trueabsentAll requests execute concurrently
anypresentAll 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

CodeMeaning
0All requests succeeded
1One 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)

FlagDescription
--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

FlagDefaultDescription
--listen-endpoint <ENDPOINT>/run/tacacs/tacacs.sockUnix 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 setRuntime services to host: client-api, tacacs-proxy, or both
--socket-mode <MODE>660File 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:

ModeServices hostedRequired endpoint flags
client-apigRPC/protobuf client API only--listen-endpoint optional; defaults to the platform local endpoint
tacacs-proxyraw TACACS+ proxy only--proxy-endpoint required
bothclient 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.
  • FOLLOW replies are forwarded to the downstream client and then the connection is closed. Per RFC 8907, authorization and accounting FOLLOW use the authentication FOLLOW behavior; authentication FOLLOW is treated like FAIL.
  • Authentication RESTART replies 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

FlagDescription
-k, --shared-secret <KEY>Shared secret for TACACS+ packet obfuscation
--use-tlsEnable 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-verificationSkip 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-backed tacacsrs-config path remains DER-only.

Timeouts and Failover

FlagDefaultDescription
--connect-timeout-seconds <SECS>5Timeout for upstream TACACS+ connections
--preferred-probe-interval-seconds <SECS>30How often to check if the preferred server has recovered

Debugging

FlagDescription
-vWarnings
-vvInfo
-vvvDebug
-vvvvTrace

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:

RPCDescription
AccountingRecord user activity (unary)
AuthorizationCheck 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-agentd on 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:

ConcernBeforeAfter
Upstream server listRepeated server= entries in PAM or audisp config--server-addr flags or --config in tacacsrs-agentd
Upstream transport securityNone, because the client only speaks classic TACACS+--use-tls, mTLS, TLS PSK-DHE, or TLS PSK-only in tacacsrs-agentd
FailoverClient-specific server-list behaviorOrdered upstream failover in tacacsrs-agentd
Local client targetRemote TACACS+ serverLoopback 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 the tacacsrs-agentd --shared-secret value 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

  1. Inventory every pam_tacplus and audisp-tacplus configuration file on the host. Record server=, secret=, service, protocol, login, timeout, and any acct_all use.
  2. Deploy tacacsrs-agentd in proxy-only mode on a loopback test port such as 127.0.0.1:9049.
  3. Configure the upstream TLS mode that matches the TACACS+ server: server-auth TLS, mTLS, PSK-DHE, or PSK-only.
  4. Test with a non-production PAM service or a controlled auditd event.
  5. Repoint one production consumer at a time to the local proxy endpoint.
  6. After confidence is established, remove direct outbound access from the host to the old plain TACACS+ server path so only tacacsrs-agentd can reach upstream TACACS+ servers.

Behavioral Differences to Check

  • acct_all fan-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_tacplus records the successful authentication server for later account/session phases. After the cutover, that server is always the local proxy; upstream server choice is owned by tacacsrs-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-credentials and server-credentials references onto server entries.
  • A TacacsPlusServerBuilder for 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:

FunctionPurpose
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 valueMeaning
ietf-crypto-types:rsa-private-key-formatDER-encoded RSAPrivateKey.
ietf-crypto-types:ec-private-key-formatDER-encoded ECPrivateKey.
ietf-crypto-types:one-asymmetric-key-formatDER-encoded CMS OneAsymmetricKey.

Public key format

JSON valueMeaning
ietf-crypto-types:subject-public-key-info-formatDER-encoded SubjectPublicKeyInfo.
ietf-crypto-types:ssh-public-key-formatSSH 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 valueMeaning
ietf-crypto-types:octet-string-key-formatRaw octet string.
ietf-crypto-types:one-symmetric-key-formatDER-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.py emits Rust structs, enums, bitflags, and helper methods.
  • expand_yang_tree.py refreshes the expanded tree reference and generated Rust output.
  • modules/ contains project-owned YANG modules passed to pyang alongside the upstream TACACS+ model.
  • generated_types.rs is copied into src/generated.rs after 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 through tacacsrs-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:

  1. tacacsrs-agentd is active and its IPC socket exists.
  2. A manual tacon request against the configured endpoint succeeds.
  3. The plugin shared library is present at the expected security path.
  4. 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>:

KeyYANG fieldNotes
use_tlsserver-authentication: {}Accepts the same boolean forms as sni_enabled; also supported on `TACPLUS
domain_namedomain-nameUsed as SNI hostname
sni_enabledsni-enabledtrue/false/yes/no/1/0
single_connectionsingle-connectionBoolean
vrf_namevrf-instanceVRF name for outbound traffic
src_ipsource-ipMutually exclusive with src_intf
src_intfsource-interfaceFalls back to the global TACPLUS row
server_typeserver-typeDefaults 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:

  1. The initial load prints one server synthesized from TACPLUS_SERVER|192.0.2.10.
  2. Adding TACPLUS_SERVER|192.0.2.20 reports an added server.
  3. Changing TACPLUS_SERVER|192.0.2.10.timeout reports a modified server.
  4. Deleting TACPLUS_SERVER|192.0.2.20 reports a removed server.
  5. Updating TACPLUS|global emits 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:

  1. Coalesces additional changes that arrive within a short debounce window.
  2. Re-reads the full TACPLUS / TACPLUS_SERVER tables.
  3. Validates the new snapshot against the YANG schema.
  4. Emits a ConfigChange event 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/clone and preserved across exec, 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.

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:

  1. Direct /etc/passwd entry — set the shell field to a small wrapper script that exec's session-wrapper with 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/shells and have mode 0755.

  2. NSS-provided shell — when users come from libnss-tacplus or 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 variableFormatWrapper 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_COMMANDcommand 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]...
OptionDefaultPurpose
--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.sockUnix socket (or host:port) for tacacsrs-agentd IPC
--fail-policy <closed|open>closedWhat to do when IPC is unreachable
--authorization-timeout-ms <N>5000Per-request authorization timeout (ms)
--privilege-level <0..=15>1Current 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 / -vvvv0Increase 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 / setuid failure — 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 requires NO_NEW_PRIVS to install an unprivileged seccomp filter.

Session hangs immediately after login

The supervisor likely failed to start. Possible causes:

  • tacacsrs-agentd is 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 closed deployment 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 -vv and 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 & or nohup …) is still running and still inherits the seccomp filter. Send SIGTERM/SIGKILL to that PID, or have the user use disown -h and detach via nohup … </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 setsid and 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:

  1. Add high-frequency, harmless binaries to --allowlist.
  2. Tune --authorization-timeout-ms down so failed servers are detected faster (only useful with multiple agentd upstreams configured).
  3. Ensure tacacsrs-agentd is 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/:

ScriptPurpose
allow-all-basic.shBuilds session-wrapper, runs a short wrapped script, and verifies the wrapped process wrote a marker file
allow-all-descendants.shRuns a wrapped script that exits while a descendant continues
allow-all-interactive-bash.shStarts 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:

CheckRequires rootPurpose
Compile-time integration checksNoValidate Rust code, seccomp policy construction, fd passing, and musl compatibility
Child starts and exitsNoValidate notification fd handoff, ready synchronization, and child exec
Missing shell failureNoValidate child-to-parent setup error reporting
Descendant executionNoValidate inherited seccomp coverage and subreaper lifecycle handling
Privileged identity dropYesValidate 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.