Run the Sangfor aTrust client in a podman container with supervised auto-login, for headless hosts (routers, workstations, CI boxes): you get the VPN tunnel plus the SOCKS5/HTTP proxies the image provides, and you do not have to pass portal cookies around as parameters.
Not affiliated with, or endorsed by, Sangfor. The aTrust client itself is not part of this
project's source: the image builds it in from Sangfor's own package (see base/),
which is downloaded from their public CDN at build time and pinned by sha256.
| Capability | What it means |
|---|---|
| Automatic login | the engine logs in to the portal for you and keeps the client's own tokens fresh, so the client side stops asking for the captcha - there is no cookie option to pass |
| Automatic re-login | the supervisor watches the tunnel and logs in again on its own when the session goes away; a portal-forced logout costs roughly two minutes, without a human |
| The client's own window is driven for you | portal address, account, password and agreement are filled and submitted with X level input (the address only when the client does not already know it - a recreated container seeds it back), so the client accepts the session |
| Captcha or first login | the supervisor writes a NEED_VNC hint plus the captcha image and waits: open VNC, finish the login in the desktop, and supervision continues by itself |
| The login in your browser | the client's own HTTPS API is on the host's loopback (127.0.0.1:54642, off with one drop-in), so the portal's own web login detects the container's client and hands the session over - the captcha path without VNC, typed into the portal's page |
| The apps behind the tunnel | every login publishes what the portal grants this account (name, launch URL, launch method, server address); atrustd --apps prints it |
| podman, not docker | everything is podman and Quadlet; the image builds the client, the VNC X server and the proxies itself (base/, from Sangfor's own package) with the rootless-podman plumbing the client expects |
| Loopback-only ports | VNC 5901, HTTP proxy 1081 (the container's 8888), SOCKS5 1080, the client's own API 54642 - published on 127.0.0.1, nothing is exposed to the network. The proxy pair is the one host-side aTrust clients use, so a browser proxy profile works across clients |
podman pull ghcr.io/yangtsesu/atrust-quadlet:latest
install -d ~/.config/containers/systemd
install -d ~/.atrust-data ~/.atrust-data/run # mounted at /root and /run/atrustd; podman wants
# the bind sources to exist
cp quadlet/atrust.container ~/.config/containers/systemd/
install -m600 quadlet/atrust.env.example ~/.config/atrust.env # edit it first
systemctl --user daemon-reload
systemctl --user start atrust.service
journalctl --user -u atrust.service -fA unit file copied before the host HTTP port moved still publishes 8888: re-copy
quadlet/atrust.container, or change its PublishPort line to 127.0.0.1:1081:8888, then
systemctl --user daemon-reload && systemctl --user restart atrust. The host-facing proxy pair is
1080 (SOCKS5) and 1081 (HTTP), the same one host-side aTrust clients use.
That start is for this session only: the unit carries no [Install] section, so nothing pulls it in
at boot. To start it on every login, write the one drop-in Quadlet documents for [Install] and
reload (a drop-in is the only way - the unit is generated, so systemctl --user enable atrust.service answers Unit ... is transient or generated):
mkdir -p ~/.config/containers/systemd/atrust.container.d
printf '[Install]\nWantedBy=default.target\n' \
> ~/.config/containers/systemd/atrust.container.d/50-boot.conf
systemctl --user daemon-reloadRemove that file and reload to go back to a manual start. Everything else is unchanged: whether the
unit comes up at boot or not, systemctl --user start|stop|restart atrust behaves the same.
The image is public, built for linux/amd64 and linux/arm64: :latest, :main and :<git sha>
are published; a v* tag adds the version tags (e.g. :1.0.0, :1.0) and moves :latest to the
release. To build it yourself instead, the client image first (it downloads Sangfor's package,
~200 MB) and then this repository's image:
podman build -f base/Containerfile --build-arg-file base/build-args/amd64.args \
-t localhost/atrust-base:latest base/
podman build --build-arg BASE_IMAGE=localhost/atrust-base:latest \
-t ghcr.io/yangtsesu/atrust-quadlet:latest .The client image is also published, content-addressed
(ghcr.io/yangtsesu/atrust-quadlet:base-$(bash base/ref.sh amd64)), so a hand build can skip the
Sangfor download and use it as the BASE_IMAGE.
Old versions of the package are pruned by hand from .github/workflows/prune-packages.yml, the only
consumer of the repository's PACKAGES_TOKEN secret (a PAT with read:packages + delete:packages).
Container state lives in ~/.atrust-data: the client's own profile (mounted at /root) and the
atrustd state file (mounted at /run/atrustd, on the host ~/.atrust-data/run), so a restart
normally needs no login at all - the client resumes its session and the supervisor keeps its last
state.
All configuration is environment-only (Quadlet Environment= / EnvironmentFile=):
| Variable | Default | Meaning |
|---|---|---|
ATRUST_PORTAL_URL |
– (required) | portal, e.g. https://vpn.example.com/ |
ATRUST_USERNAME |
– (required) | account name (the @<domain> suffix is added automatically) |
ATRUST_PASSWORD / ATRUST_PASSWORD_FILE |
– (required) | password, or a file containing it |
ATRUST_PROBE_TARGET |
empty | comma separated host:port inside the VPN used to prove the tunnel carries traffic - give it an HTTP endpoint: the probe sends a real request, which is also what keeps the portal's session from expiring (an https target is probed with CONNECT and does not count as activity). It is a witness: with ATRUST_PROBE_DISCOVER on, the addresses the tunnel itself routes are asked beside it, and the tunnel is ONLINE as long as one witness answers - the state detail names the one that did |
ATRUST_PROBE_DISCOVER |
1 |
with ATRUST_PROBE_TARGET empty: find the witness yourself, from the addresses the tunnel's own route table carries one by one (each asked once on a short leash, the first that answers becomes the witness). 0 asks nothing at all and leaves the interface and its routes as the proof - for a deployment whose routed hosts come and go, or one that wants no traffic sent through the tunnel. With 0 and no target nothing keeps the portal's session alive either: a session whose tunnel only carried bare CONNECTs was measured expired after ~10.3 minutes, against over an hour with real requests - so the daemon warns about that combination once at start |
ATRUST_WATCH_INTERVAL |
90 |
seconds between supervision cycles; a client-side transition (the session ending, the tunnel coming back) wakes the next cycle earlier |
ATRUST_VNC_WAIT |
900 |
how long to wait for a human in VNC before retrying |
ATRUST_STATE_DIR |
/run/atrustd |
where state.json, NEED_VNC, the captcha image and device-id are written |
ATRUST_CLIENT_COOKIE_DB |
/root/.aTrust/AppCache/Cookies |
the client's own cookie store |
ATRUST_CLIENT_ADDR_CONF |
/usr/share/sangfor/.aTrust/var/conf/addr.conf |
the client's own portal address (seeded before it starts) |
ATRUST_CLIENT_LOG_DIR |
/root/.aTrust/logs |
the client's log, read to tell a captcha request from a failed login |
ATRUST_CLIENT_API_PORT_FILE |
/usr/share/sangfor/.aTrust/var/run/httpserver |
the client's own run file holding the port of its loopback API, where the client's state is read |
ATRUST_CLIENT_API |
1 |
the client's own loopback interface: the state read beside every transition, the event channel that can wake a cycle early, and the login by ticket. 0 turns all three off and leaves the data-plane supervisor (the interface is measured, not documented, so a client bump can move it) |
ATRUST_DISPLAY |
:1 |
X display of the client's window (the image runs tigervnc on :1) |
ATRUST_PROXY |
127.0.0.1:8888 |
HTTP proxy used by the data-plane probe inside the container; the host publishes that listener as 1081 |
ATRUST_TUN |
utun7 |
tunnel interface created by the client |
ATRUST_DEVICE_ID |
empty | device id sent as x-sdp-env; empty keeps the one atrustd generates in $ATRUST_STATE_DIR/device-id, see below |
ATRUST_TOTP_KEY |
empty | the base32 secret behind the portal's TOTP second factor, if the deployment asks for one: atrustd --totp prints codes from it for the human to type into the VNC session, and the NEED_VNC hint then names the command without -e; nothing is submitted automatically |
PASSWORD |
password |
VNC password of the container's desktop |
VNC_SIZE |
1110x620 |
desktop geometry of the VNC session; the client's own window is pinned by the supervisor, so this only changes the space around it |
The container runs under the host's hostname (HostName=%H in the Quadlet unit): the client reports
the system hostname to the portal and the portal's terminal list shows it, while podman's default -
the container ID - would add another hex name to that list on every recreation; the names earlier
logins left there stay until they are removed in the portal. Give the container a
name of its own with a [Container] override in
~/.config/containers/systemd/atrust.container.d/10-hostname.conf (HostName=<name>) plus a
systemctl --user daemon-reload.
A changed ATRUST_PORTAL_URL needs systemctl --user restart atrust, and nothing else: the unit's
stop removes the container (Quadlet generates ExecStop=podman rm -f), the next start builds a fresh
one, and atrustd seeds the new address into the client's own config before the client starts, so
the window opens on the new portal (measured 2026-10-13: a container created after its predecessor
had written the old address loaded the client's saved-address store with 0 entries, the window's own
defaultSdpcAddr and its store then carried the seeded address, and the tunnel stayed down against
the address nothing answers). What the restart keeps is the mounted profile in ~/.atrust-data -
the client's cookies and logs, and the supervisor's state directory - which is what lets a restart
skip the captcha. podman restart atrust by hand is not that: it keeps the container, and with it
the layer the client's saved address lives in and the engine's own environment as it was at creation,
so the address only moves with a systemctl --user restart atrust (or a podman rm -f first).
The device id the supervisor reports as x-sdp-env is the deployment's own: ATRUST_DEVICE_ID if
set, otherwise one it generates on first use and keeps in $ATRUST_STATE_DIR/device-id. Setting
your own is the recommendation - the image ships a single /etc/machine-id that every container
built from it shares (and every rebuild replaces), so an id derived from it is neither unique per
deployment nor stable, and quadlet/atrust.env.example carries a command that generates one.
That is the supervisor's identity. The client's own - the sdpcguid it reports, which the
portal's terminal list is built from - is derived by the client at runtime (a container with nothing
running has no guid anywhere in its filesystem) and, measured 2026-10-11, every container of the
image on one host reports the same one: two containers of the image on the same host, different
accounts, different state directories, different x-sdp-env values, one guid. What it derives it
from is not pinned; the containers share the image's machine id and the host's hardware address.
So a portal that binds a "trust this terminal" grant to that guid would see a container rebuilt
from the same image on the same host as the terminal it was granted to - that binding is an
inference, not measured here (no portal on this deployment grants one). The sharing itself is
measured, and it is the part to expect.
podman exec atrust python3 -m atrustd --status # JSON: last state, attempts, detail
podman exec atrust python3 -m atrustd --once # one supervision cycle
podman exec atrust python3 -m atrustd --login-probe # only test the portal login
podman exec atrust python3 -m atrustd --apps # the apps this account may launch
podman exec atrust python3 -m atrustd --bundle # one tarball of evidence, into the state dir
podman exec atrust ls /run/atrustd # NEED_VNC hint + captcha image, if any
podman exec -e ATRUST_TOTP_KEY=<secret> atrust python3 -m atrustd --totp # a second-factor codeA second factor (TOTP, SMS, a QR enrolment, a terminal approval) is never driven by this project: the
session is handed over to the VNC desktop and the hint says why. When that factor is TOTP, --totp
prints the current code from the shared secret the portal showed at enrolment - ATRUST_TOTP_KEY,
which belongs in ~/.config/atrust.env beside the password, and the hint then names the command
without -e. Nothing is submitted for you, and --totp needs no other configuration.
--apps prints what the client's own "App Details" panel shows - the launch method and the URL of
every app the portal grants this account (e.g. a "Default Browser" app is reachable from the host
through the container's proxies). It reads the copy the last login published; --apps --refresh
logs in again to update it (that creates a new session, so the supervisor logs the client back in
right after).
--bundle is what a client bump or a bug report should be read with: one timestamped tarball (0600)
it writes into ATRUST_STATE_DIR - on the host ~/.atrust-data/run - holding the state file, the
NEED_VNC hint and its captcha when one is pending, the tunnel's own route table, the client's own
status answer, the address the client carries, the tail of the client's own log (256 KiB of the
newest aTrustTray*.log, not the directory: the profile grows ~0.2 GB/day), and the versions - the
client image's /etc/atrust-version, the version the running client reports, and the base image the
app image was built from (the image records it; an older image says "not recorded"). It carries no
credential by construction: device-id, the client's cookie store and every token stay out, and the
manifest inside the tarball names which member was not there and why - a bundle from a healthy
container has no hint and no captcha in it. With ATRUST_CLIENT_API=0 the client's own read is
skipped too, as that switch promises.
Open that URL in the browser through 127.0.0.1:1081 (HTTP) or 127.0.0.1:1080 (SOCKS5); an
extension such as Zero Omega can send that URL alone through the container, everything else direct.
If the host runs a proxy of its own in TUN mode (Clash, Mihomo and friends), it captures this
container's traffic too, because that traffic leaves through the host's own stack. The portal host
and the intranet ranges then belong on that proxy's direct list: the client's own login and the
stream it tunnels through (the gateway, often one of those ranges) are ordinary traffic on that
stack, and a rule that hands them to a proxy node breaks the login - or, when the node is this
container's own proxy (127.0.0.1:1081/1080), sends the packet back into the client, which
encapsulates it and sends it again, a loop. Pointing the ranges at the container's proxies instead
works too, if host apps should reach the intranet without a per-app proxy setting, but then the
client's own peers must stay off that rule: EZ4Connect
does exactly that on its own host (auth and aTrust domains direct, its own process excluded from the
TUN), and zju-connect
keeps the RVPN servers direct and the campus range in its own proxy - a container has no process for
the TUN to exclude, so direct is the shape to start from.
systemctl --user stop atrust (and restart) is quick and clean: atrustd sweeps the client on
SIGTERM - the agent, the tunnel, the trays - SIGKILLs what does not leave on its own, and then
exits, so the container is down in about two seconds instead of waiting out podman's stop timeout.
podman exec atrust kill -TERM 1 does the same thing by hand.
A start does not wait for the network: the unit drops Quadlet's implicit
podman-user-wait-network-online.service dependency, whose poll of the system
network-online.target burns its full 90 s on a host where nothing pulls that target in - saying
nothing about the link either way. The supervisor carries a start that finds no network, and a start
that cannot succeed yet (a first run with no image, a pull with no registry) keeps retrying every
15 s instead of ending failed. A start that would be pointless is held instead. pasta fixes the sandbox's addresses when the
container starts, so a host that has no usable route at that moment - a login before the link is up -
would leave the container with no way out and no repair of its own. The unit then refuses the start
(its ExecStartPre reads the host's own route tables, and the journal says why), and it starts by
itself, within 15 s, once the host has a route. Nothing else is held: a host that is IPv6-only, that
has a static route and no default, or that has no default route at all passes.
When the state is NEED_VNC, ATRUST_STATE_DIR holds the hint (NEED_VNC) and the captcha image
the portal is serving (captcha.png, or captcha.jpg - the portal picks the format), and the same
instructions are in the journal. Finish the login in the VNC desktop; the supervisor notices the
tunnel coming up and goes back to ONLINE on its own.
One NEED_VNC needs no VNC: a hint that names a refused password (wrong-password) or a lockout
(rate-limited). The portal counts the login attempts it checks and prints how many are left, so the
supervisor stops trying - no login, no window submit - until the tunnel is up or the service
restarts. Correct ATRUST_PASSWORD in ~/.config/atrust.env and restart the service (a restart
re-reads the file and clears the hold); podman exec atrust python3 -m atrustd --login-probe tests
the credential once by hand, and costs one of the account's attempts, which is why nothing runs it
for you.
The client's own HTTPS API - the interface its workbench speaks and this project's supervisor reads -
is published on the host's loopback (127.0.0.1:54642), which is what lets the portal's own web
login drive the container's client: the page detects a client on the machine, hands the session
over, and the tunnel comes up. That is the vendor's own flow and the one way a login that wants a
captcha needs no VNC - the human types it on the portal's page instead of into the container's
desktop.
Host side, once:
echo '127.0.0.1 localhost.sangfor.com.cn' | sudo tee -a /etc/hoststhen open https://localhost.sangfor.com.cn:54642 once and add the certificate exception (the
client serves its own self-signed one). From then on the portal's login page finds the client.
Without a browser: curl -sk -X POST https://127.0.0.1:54642/v1/detect -d '{}' answers
detect successfully.
The host port has to be one the vendor's workbench probes. It races
https://localhost.sangfor.com.cn:{54631,54642,54653} against
https://127.0.0.1:{54630,54641,54652} and takes the first answer, so any of the six works; the unit
publishes 54642, a spare, because the spares are exactly what a native aTrust client on this host
leaves free (it owns 54630/54631 while it runs). Two clients cannot hold one port: publishing
54631 would keep this container from starting at all while a native client runs - pasta fails the
bind and the unit retries every 15 s.
What is exposed, and how to take it away: the API has no authentication - being on the loopback is
the whole of its trust - and it can read the status, stream the events, log the client out and drop
the tunnel. It is published on 127.0.0.1 only, so nothing off-host reaches it. One drop-in removes
it from the host's loopback as well:
# ~/.config/containers/systemd/atrust.container.d/50-no-agent-api.conf
[Container]
PublishPort=systemctl --user daemon-reload && systemctl --user restart atrust; an empty PublishPort= clears
the unit's list (Quadlet merges it out). The portal's page then simply never finds a client here -
the client's own API still exists inside the container, where the supervisor uses it.
The container side of the mapping is the image's own relay, socat 0.0.0.0:20000 -> 127.0.0.1:54631: the client binds loopback only, and under pasta a published port is delivered to
the container's non-loopback address, never to its loopback (docs/CLIENT-LOCAL-API.md has the
measurements). Nothing else in the image uses 20000.
When the supervisor asks for a human, the desktop notification names this page too, with nothing to configure: it reads "or finish the login in your browser: " once the client's API answers on the published port (see Desktop notifications). Measured 2026-10-16: a human's own login on the portal's page (captcha and all) brought a logged-out client's tunnel up in 55 s, with no VNC, no X11 and nothing typed inside the container.
quadlet/atrust-notify.sh mirrors the supervisor's states to the desktop: NEED_VNC as a critical
notification carrying the hint and the captcha image, DEGRADED / LOGGED_OUT with the probe
detail, and the recovery to ONLINE - exactly one notification per transition. ONLINE leads with
the engine's own sentence ("tunnel up after the ticket login"), names how long the tunnel was down
and carries the client's own view (client online/tunnelAvailable user=... since=...) when the
supervisor recorded one. STALE is a class of its own and critical: the state file has stopped
being written, which means the container or atrust.service is gone - the last state the file
published is in the body, so a stopped deployment rings instead of going quiet. It reads the state
directory that quadlet/atrust.container mounts at ~/.atrust-data/run (falling back to
podman cp when the mount is missing, i.e. on an older container unit) and writes only its own
marker and temporaries - never into the container's state. Skip it and nothing changes, and the
container, the supervisor and the client never learn it exists. Install (Quadlet ignores .timer
files, so these two go into the systemd user directory, not ~/.config/containers/systemd/; the
container unit has to be the current one - re-copy it, install -d ~/.atrust-data/run and restart
atrust.service if you installed it earlier):
install -m755 quadlet/atrust-notify.sh ~/.local/bin/
install -m644 quadlet/atrust-notify.service quadlet/atrust-notify.timer ~/.config/systemd/user/
systemctl --user daemon-reload && systemctl --user enable --now atrust-notify.timerThe timer polls every 15 s and the marker keeps that to one notification per transition; the first
run after install seeds it silently, except when the tunnel is already waiting for a human or
nothing is writing the state file any more. It samples rather than logs: a state shorter than the
tick can be missed or caught mid-walk, so a real ONLINE → DEGRADED → LOGGED_OUT → ONLINE walk of
13.2 s is 0 to 3 notifications depending on where the tick lands - what you get is the state you
would find by looking now. NEED_VNC and STALE are critical and the tunnel changes are normal;
classes can be muted in a drop-in (systemctl --user edit atrust-notify.service):
[Service]
Environment=ATRUST_NOTIFY_MUTE=ONLINE,DEGRADEDATRUST_NOTIFY_MUTE takes NEED_VNC, DEGRADED, LOGGED_OUT, ONLINE and STALE in any case; a
muted class still moves the marker, so it neither rings nor delays a later unmuted class.
ATRUST_NOTIFY_STALE_AFTER is the seconds of silence that ring before STALE (default 300; 0
never rings it). The supervisor refreshes the state file at least once a minute while it runs, so the
number only has to clear a minute of quiet - anything below that would ring on a healthy deployment.
A repeated notification of the same class replaces its own earlier one, while a different class still
stacks beside it: the notifier remembers the id this notification daemon gave the class
($XDG_RUNTIME_DIR/atrust-notify.ids, one line per class) and hands it back as replaces_id, the one
mechanism the freedesktop spec has for it. On a host without a session bus the same states can go
somewhere else, through one executable called with four arguments, urgency title body icon (the icon
empty when there is none) - with a command of your own there are no ids and no replacement:
[Service]
Environment=ATRUST_NOTIFY_CMD=/usr/local/bin/atrust-to-ntfyWith the client's API published - the default, see "The login in your browser" - a NEED_VNC
notification also names the browser, and nothing has to be configured: the notifier reads the
deployment's own portal address from ~/.config/atrust.env (ATRUST_PORTAL_URL; ATRUST_ENV_FILE
for another path) and mentions it only after the client's API has answered a probe on one of the host
ports the vendor's own page uses (one /v1/detect per port on 127.0.0.1, curl). A port that is
published with nothing behind it - the container gone, the relay stopped - does not count, so the line
appears exactly when the browser path would work. Two overrides:
[Service]
Environment=ATRUST_NOTIFY_WORKBENCH=https://vpn.example.com/portal/ # name this URL, no probing
#Environment=ATRUST_NOTIFY_WORKBENCH=no # never mention a browserWhat the line says is where the portal's own page hands a session to the container's client: the
password and the captcha are typed there and no VNC is involved (atrustd's own hint never mentions a
browser - inside the container nothing knows whether a port is published). One class does not get the
line: a refused credential or a lockout, whose fix is the env file and which a browser login would be
refused for as well.
Complete removal - nothing else depends on it:
systemctl --user disable --now atrust-notify.timer
rm -f ~/.local/bin/atrust-notify.sh ~/.config/systemd/user/atrust-notify.service \
~/.config/systemd/user/atrust-notify.timer "$XDG_RUNTIME_DIR/atrust-notify.last" \
"$XDG_RUNTIME_DIR/atrust-notify.ids"
systemctl --user daemon-reloaddocs/DESIGN.md |
how it works: the portal protocol, the client's own window, the supervisor |
docs/plans/ |
the live plan: one file per step, its progress inside the file |
docs/ROADMAP.md |
the direction and what the project deliberately does not do |
docs/archive/ |
the retired records: the milestone log to 2026-10-10 and the item roadmap it came from |
GPL-3.0-or-later, see LICENSES/GPL-3.0-or-later.txt. This project is REUSE
compliant: every file carries SPDX-FileCopyrightText / SPDX-License-Identifier tags
(reuse lint).