ASU VPN SSO
Click-to-connect for the ASU VPN on Linux. A GNOME panel applet and an
asuvpn command line client for Arizona State University's SSL VPN
(sslvpn.asu.edu), wrapping openconnect
and openconnect-sso.
Why
ASU's VPN uses Cisco AnyConnect with SAML single sign-on and Duo. There is no
official Linux client, so the practical route is openconnect-sso — which works
well, but is a foreground terminal program. It wants a terminal for its prompts
and for sudo, it dies with the shell you started it in, and if it is stopped
the wrong way it can leave your routing table and DNS in pieces, with no network
until you reboot or bounce the interface.
This turns that into something you click. Launch it, finish the ASU sign-in in the browser window that appears, and the tunnel stays up in the background with an icon in the notification area for disconnect, reconnect and the log. Everything the icon does is also available from the command line, so it scripts as well as it clicks.
It also fixes something the usual setup gets quietly wrong. A split tunnel
needs split DNS — ASU's names resolved by ASU's resolver, every other name left
on the resolver you already had — and the stock vpnc-script cannot arrange
that on a machine running systemd-resolved, which is to say on Ubuntu. It
writes the VPN's resolver into /etc/resolv.conf, a file systemd-resolved
owns and rewrites at the next network change, and internal names quietly stop
resolving while the tunnel still looks perfectly healthy — ssh to a machine
you were on ten minutes ago just hangs. This applet configures the resolver on
the tunnel's own link instead, correctly scoped and nobody else's to overwrite,
and the watchdog checks every twenty seconds that it is still there. See
DNS, and why it is not written to /etc/resolv.conf.
Most of the engineering here is in the part nobody enjoys: making sure that
however the tunnel ends — you disconnect, you log out, the applet crashes, the
helper is killed — openconnect still gets to put your routes and DNS back.
Who it is for
Built and tested for ASU on Ubuntu/Debian with GNOME. If that is you, the Quick start should be the whole story.
Outside that, treat it as a starting point rather than a supported configuration:
-
Another university or company VPN. Anything that
openconnect-ssocan sign in to should work — pass--server your.vpn.edu. Untested beyond ASU. -
Another desktop. The tray icon needs a StatusNotifier host. KDE has one built in; GNOME needs the AppIndicator extension, which
bootstrap.shinstalls. Untested outside GNOME. -
Another distribution. Honestly: only Ubuntu/Debian is tested end to end, and only there does anyone run the tray, connect a tunnel and tear it down. What is checked on Fedora, Arch, Debian and Ubuntu 22.04 on every push is the portable half — the contract, the state machine and the split-DNS rules, which are pure Python and need no desktop. That has already earned its keep: its first run found a Python 3.12-only construct that made the shipped self-check unparseable on Ubuntu 22.04 and Debian 12, both of which this project claims to support.
Everything above that line — GTK, the tray icon,
openconnect,pkexec— is unverified outside Ubuntu/Debian. It is expected to work, since none of it is Debian-specific, but expected is not tested. If you run Fedora, Arch or openSUSE and it works, or does not, please open an issue or a PR — a report from someone with the machine is worth more than any amount of guessing here, and the package names below were written from each distribution's index rather than from experience.bootstrap.shchecks what is present rather than which Linux you are on, so a machine that already has GTK,polkitandopenconnectinstalls with no packages and no password at all. Where something is missing it names the gap and the packages that fill it fordnf,pacmanorzypper, and installs none of them: only the apt path is exercised in CI, and running a guessed package name throughsudoon someone else's machine is worse than a list they can read first.It does not stop there, though. Everything after that point is your own half —
~/.local, no root, no distribution knowledge — so it carries on and installs it, and one run leaves you withasuvpnandasuvpn selftesteven where the packages are missing. Run the self-check: it inspects this machine and is a better list than anything printed by a script that has not looked at it. Install what it names with your own package manager; nothing needs re-running afterwards. The applet itself is plain Python and GTK 3.
[!NOTE] On a machine that is missing them,
bootstrap.shinstalls around thirty apt packages including a build toolchain, compileslxmlfrom source, may add the deadsnakes PPA for Python 3.12, and enables a GNOME extension. It checks first and installs only what is absent — on a desktop that already has everything it asks for no password — but read Requirements first if any of that matters to you.
Contents
- Quick start
- Using it
- Settings
- The command line
- What gets installed, and where
- Removing it
- Security
- How it works
- Requirements
- Troubleshooting
- Repository layout
- Design notes
- Status
- License
Quick start
git clone https://github.com/Qing-LAB/ASU_VPN_SSO.git
cd ASU_VPN_SSO
./bootstrap.sh
bootstrap.sh installs whatever is missing, installs the app into ~/.local,
and registers the launcher. It is safe to re-run: nothing already satisfied is
reinstalled. It asks the machine what is
missing before it reaches for sudo at all: on a desktop that already has
everything, it installs nothing and asks for no password. When something is
missing it runs sudo apt-get update first, because stale package lists cause
packages to be silently skipped — so that is the run that asks for sudo.
--no-deps skips the dependency half entirely. It asks
before adding the deadsnakes PPA (see Requirements for why
Python 3.12 is needed).
| Flag | Effect |
|---|---|
--server HOST |
The endpoint for both the launcher and the asuvpn command (default sslvpn.asu.edu) |
--yes |
Never prompt, including for the PPA |
--no-deps |
Skip system packages, just install and register the app |
--link |
Run from the checkout instead of copying into ~/.local |
One-time step if you have never signed in with openconnect-sso on this machine
— it needs to save your password to the login keyring, and the applet will not
guess a blank one:
openconnect-sso --server sslvpn.asu.edu --authenticate=shell
--authenticate=shell matters: without it, openconnect-sso goes on to run
sudo openconnect itself and leaves a foreground tunnel you have to Ctrl-C.
From PyPI instead
The same app is on PyPI as a delivery channel. The package is not a second installer — it carries this repository's programs and both of its shell scripts, and its two commands just run them, so a PyPI install and a checkout install put the same files in the same places and end with the same self-check.
On Ubuntu or Debian, the whole thing:
pipx install asuvpn
asuvpn-bootstrap # system packages, then the app. Uses sudo.
asuvpn-bootstrap is bootstrap.sh — the same apt packages, the same
openconnect-sso on Python 3.12, the same GNOME extension — and it finishes by
running the user-half install itself, so that is the only command you need.
If you would rather handle the system half yourself, or you are not on apt:
pipx install asuvpn
asuvpn-install # user half only; accepts install.sh's flags
That writes nothing outside $HOME and asks for no privileges. What it cannot
do is the part no wheel can carry — the GTK bindings, openconnect, pkexec,
the GNOME tray extension are apt packages and a shell extension, not Python
distributions. On Ubuntu/Debian one line covers the minimum
(asuvpn-bootstrap installs all of this except vpnc-scripts, which apt
pulls in behind openconnect as a Recommends, plus the Python 3.12 story in
Requirements):
sudo apt install openconnect vpnc-scripts policykit-1 python3-gi \
gir1.2-gtk-3.0 gir1.2-ayatanaappindicator3-0.1 gir1.2-notify-0.7
Either way, asuvpn selftest at the end says what is still missing rather than
leaving you to find out at the first connect.
Both commands install to your home directory and register the desktop launcher, so ASU VPN appears in the Activities overview and app grid exactly as it does from a checkout — see What gets installed, and where for the full list of paths, including where the wheel itself lives.
Updating later is pipx upgrade asuvpn && asuvpn-install — both steps, because
what runs is the copy under ~/.local/share/asuvpn/, not the wheel.
Using it
Launching the app connects straight away:
- A browser window opens for the ASU sign-in, Duo push included — that is
openconnect-ssodoing the login. - The system asks for your own password, in the same dialog software
updates use (polkit). One step of building the tunnel — creating the
virtual network device — needs administrator rights, and this dialog is
how a desktop app asks for them (a terminal
sudoprompt would have nowhere to appear). You are asked once per connect, and never to disconnect. - The tunnel stays up in the background.
End to end, a first connect looks like this: click ASU VPN → browser window appears → sign in and approve the Duo push → browser closes → password dialog → a VPN connected notification with the address you were assigned. From then on, the panel icon is the whole interface.
The panel icon
| Icon | State |
|---|---|
| Filled VPN badge | Connected |
| Dashed VPN badge | Disconnected |
| Acquiring badge | Signing in, connecting, retrying a lost link, not carrying traffic, or disconnecting — the status line says which |
| Error badge | Something failed |
The menu shows only what applies to the current state: Connect when down,
Disconnect and Reconnect when up, and Cancel while signing in or
connecting. While the applet is waiting to retry a connection that dropped,
Stop reconnecting appears — it calls the retries off without changing any
setting. Disconnect also appears in one other place: after a teardown that
timed out, where the applet is still holding a live tunnel it could not close,
so there has to be a way to try again. The teardown itself is never
interrupted — but asuvpn disconnect during a reconnect now stops the
sign-in that would have followed it. Show log… opens a live log window, and Quit closes the
tunnel first. The bottom of the menu names the running build — the same
answer asuvpn --version gives, and the applet logs it once at startup.
Start on login (applet only) puts the icon in your tray at login without connecting — so you are not met with a Duo push before you have asked for one.
What it tells you, and when
A badge quietly changing to a spinner is easy to miss, so every transition that matters raises a desktop notification:
| Notification | When | Costs you |
|---|---|---|
| VPN connected | the tunnel came up for the first time | — |
| VPN connection lost | openconnect lost the link and is re-establishing it |
nothing — same session, no sign-in |
| VPN reconnected | it came back, possibly on a new address | — |
| VPN not carrying traffic | the watchdog's free re-establish did not help, or could not be tried — the device or probe source |
— |
| VPN DNS not configured | the same, when the source that objected was the resolver check | — |
| VPN reconnecting | openconnect was nudged to rebuild the tunnel |
nothing — same session |
| VPN carrying traffic again | the tunnel recovered | — |
| VPN signing in again | the free fix did not help — or the tunnel died outright and is being rebuilt — and automatic reconnection is on | a Duo push and a password |
| VPN still not connected | automatic reconnection gave up after MAX_REBUILDS attempts at a tunnel that had died outright; it will wait to be asked |
— |
| VPN still not usable | the same budget, spent on a tunnel that was up and useless; it will wait to be asked | — |
| VPN not connected | the authorization prompt went unanswered for signin-timeout, so it was dismissed |
— |
| VPN disconnected | the tunnel closed, with the reason if it was not you | — |
| Sign-in failed | the sign-in step itself failed; the text says why | — |
| VPN warning | the helper hit trouble — most seriously, the network may not have been restored after teardown | — |
| Cannot start | openconnect-sso is not installed, so there is nothing to sign in with |
— |
The distinction the wording carries is deliberate: connection lost and reconnecting are free and need nothing from you, while signing in again is the one that will interrupt you.
Right-clicking the app icon in the dash also offers Disconnect and Reconnect.
Settings
One file, ~/.config/asuvpn/asuvpn.conf, generated by install.sh from the
schema itself — so the file you get is the reference for what can be changed,
with every setting listed at its default and a sentence saying what it does:
# Seconds between dead-peer probes, forced on because some servers
# negotiate detection off and then a dropped tunnel looks connected. 0
# leaves the server's choice alone.
dpd = 30
Most of it takes effect without reconnecting: the built-in health checker
(the "watchdog" below) re-reads the file on every check, so changing
health-interval, probe, probe-target or the rate limits applies within
seconds. dpd, dns and dns-domains reach openconnect and its script on
the command line, so those three need a reconnect. A malformed line costs that setting its default
and logs a sentence saying so — a config file is never a reason to be unable to
connect.
| Setting | Default | What it decides |
|---|---|---|
server |
sslvpn.asu.edu |
The endpoint. Set by install.sh --server. |
autoreconnect |
on |
Let the applet sign in again by itself — both when a stalled tunnel cannot be freed and when one has died outright. It is always the last thing tried, never the first, and it costs a Duo push and a password when it fires. |
dpd |
30 |
Dead-peer probe interval, forced on. 0 leaves the server's choice alone — the escape hatch if forcing it ever misbehaves. |
dns |
on |
Put the resolver the VPN pushes on the tunnel's own link, through systemd-resolved, instead of letting vpnc-script rewrite /etc/resolv.conf. This is what makes internal names resolve and keep resolving. See DNS, and why it is not written to /etc/resolv.conf. |
dns-domains |
(empty) | Which names to resolve through the tunnel when the gateway names none itself. Empty derives one from server — sslvpn.asu.edu gives asu.edu. What the gateway pushes always wins over both. |
health-interval |
20 |
Seconds between checks. 0 turns the watchdog off — the config file is still re-read at the default cadence, so turning it back on needs no restart. |
health-strikes |
2 |
Consecutive bad checks before the badge stops claiming Connected. |
probe |
on |
Ask the network whether traffic still flows. The only check that catches a tunnel that looks perfect and delivers nothing. |
probe-target |
(empty) | Address to probe. Empty means the resolver the VPN itself pushed. |
probe-port, probe-every, probe-timeout |
53, 3, 5 |
Port (1–65535; 0 would blind the probe and is refused), how often, how long to wait (the wait is capped at 120). |
nudge-min-gap |
120 |
Seconds between free re-establish requests — the recovery that costs you nothing. |
autoreconnect-min-gap |
300 |
Seconds between unattended sign-ins. One budget, shared by both kinds, so they cannot add up to more than you allowed. |
signin-timeout |
300 |
How long a sign-in may take before it is given up on. No off switch (60–3600 only): an unattended one must never be able to sit behind a browser window forever. |
teardown-timeout |
75 |
Seconds to wait for the helper. Must outlast its signal escalation, so values below 35 are refused and the default used. |
log-max-kb |
4096 |
Size the session log may reach before it is rotated. 0 never rotates on size. |
log-keep |
3 |
Rotated logs kept as session.log.1, .2, … Connecting rotates too, so this is also how many past sessions survive. 0 keeps none; capped at 99. |
asuvpn autoreconnect on edits the file in place, changing that one line and
leaving everything else — comments included — as you left it. Most settings
are re-read on every health check, so a change takes effect within seconds
rather than at the next connect.
If ASU's address ever changes
Nothing here is hardwired to sslvpn.asu.edu — it is only the default.
Three ways to point at a different endpoint, most permanent first:
./install.sh --server vpn.other.edu # or bootstrap.sh --server …
or edit the server = … line in ~/.config/asuvpn/asuvpn.conf (then
asuvpn quit and connect again — the endpoint is fixed for an applet's
lifetime), or for one run only:
asuvpn --server vpn.other.edu connect
A running applet always refuses a different server (exit code 4) rather than quietly connecting somewhere you did not ask — so the switch is always explicit: stop the old applet, start against the new address.
The command line
asuvpn does the same things as the menu. If an applet is already running it
drives that one rather than starting a second.
asuvpn # same as: asuvpn connect
asuvpn connect # sign in and bring the tunnel up
asuvpn status # what state is it in
asuvpn disconnect # close the tunnel (or call off a pending retry)
asuvpn reconnect # sign in again and replace the tunnel
asuvpn log -f # follow this session's log
asuvpn quit # close the tunnel and stop the applet
asuvpn tray # run the applet in the foreground, do not connect
asuvpn selftest # check this installation against this machine
asuvpn autoreconnect # show or set automatic reconnection: [on|off]
Every command prints the resulting state, so nothing happens silently.
By default connect returns as soon as the applet is running, while the sign-in
is still in progress — it does not tie up your terminal waiting for a Duo push.
Add --wait when the next thing you do depends on the tunnel actually being up:
asuvpn connect --wait && rsync -a ./data internal-host:/srv/
Exit codes are meant for scripting:
| Exit code | Meaning |
|---|---|
0 |
The request was carried out — and for status or --wait, connected |
1 |
status or --wait: not connected. disconnect: the tunnel did not close. quit: still shutting down. log: no log yet |
2 |
A mis-typed command line (the standard Python argument-parsing code) |
3 |
status — or a --wait the applet vanished under: it is not running |
4 |
A different server was asked for than the running applet is using |
Actions report whether the action worked, so a successful disconnect exits 0.
Only status and --wait report connectedness. asuvpn log exits 1 when there
is no log yet.
A tunnel that fails reports openconnect's own exit status, or 128 + n if it
was killed by signal n. Codes in the twenties come from the helper refusing to
start before openconnect was ever run. Each one is reported to you as the
helper's own sentence rather than as the number — the helper marks those lines,
so this does not depend on how any of them happen to be worded:
| Code | The helper refused because |
|---|---|
20 |
openconnect is not installed |
21 |
no session cookie arrived on stdin |
22 |
the interface name you passed could not name a device |
23 |
that interface already exists, and it was not created by this session |
24 |
no free asuvpnN name (a hundred tunnels is not a real scenario) |
25 |
an option was passed that would detach openconnect from the helper |
26 |
the helper, its directory, asuvpn-notify or the shared asuvpn_contract.py is writable by someone else, or owned by someone else |
27 |
a negative dead-peer interval was passed |
28 |
--host, --fingerprint or --ac-version was not one — a value openconnect would read as an option rather than as what it is |
asuvpn status >/dev/null || asuvpn connect --wait
Other options: --server HOST for a different endpoint, --foreground to
keep the applet attached to the terminal, --version to print the release
and wire-contract versions, and a bare -- to pass extra arguments through
to openconnect. Flags may go before or after the command.
Arguments after -- are checked for options that would detach openconnect
from its supervisor — --background, --syslog, --pid-file and friends are
refused, because a detached openconnect is a root process nothing can stop.
Bundled short options are refused too: openconnect reads -bv as -b -v,
so a bundle could smuggle in a refused option, and picking bundles apart
reliably would mean tracking its option table across releases. Write them
separately: -i lo, not -ilo.
asuvpn --server vpn.other.edu connect
asuvpn connect -- --script /path/to/vpnc-script
An applet only ever serves one server. Asking a running one for a different
endpoint is refused with exit code 4 rather than quietly connecting to the
wrong place; asuvpn quit first. This compares the endpoint actually in
use, so it also catches the case where install.sh was re-run with a new
--server while an applet was still up.
What gets installed, and where
Everything lands in your home directory — the program under ~/.local, your
settings under ~/.config/asuvpn. Nothing is written outside $HOME, no
system files are touched, and no part of the installation needs root. The
program is copied, so the checkout can be moved or deleted afterwards.
| Path | What it is |
|---|---|
~/.local/share/asuvpn/ |
asuvpn-tray, asuvpn-helper, asuvpn-notify, asuvpn-selftest (0755) and asuvpn_contract.py (0644) — nothing group-writable |
~/.local/share/applications/asuvpn.desktop |
Launcher, with Disconnect/Reconnect actions |
~/.local/share/icons/hicolor/scalable/apps/asuvpn.svg |
The app-grid icon |
~/.local/bin/asuvpn |
Symlink to the installed asuvpn-tray |
~/.config/asuvpn/asuvpn.conf |
Every setting, generated from the schema with each one documented (0600 in a 0700 directory) |
The .desktop file is what puts ASU VPN in the Activities overview and app
grid. install.sh refreshes the desktop and icon caches, so it appears without
a logout.
Runtime state lives elsewhere, and is created on demand:
| Path | What it is |
|---|---|
~/.cache/asuvpn/session.log |
Session log (0600 in a 0700 directory). Rotated — not wiped — on each connect and at log-max-kb; log-keep past logs survive as session.log.1, .2, … |
~/.config/autostart/asuvpn-tray.desktop |
Written only when you tick "Start on login (applet only)" |
abstract socket asuvpn-tray-$UID |
Single-instance guard and CLI channel; peer uid is checked, and it vanishes with the process |
Separately, bootstrap.sh installs system packages with apt, and installs
openconnect-sso into its own pipx venv under ~/.local/share/pipx/.
Installing from PyPI lands in exactly the same places. pipx install asuvpn only puts the wheel in a pipx venv of its own
(~/.local/share/pipx/venvs/asuvpn/); nothing there is the app. Running
asuvpn-bootstrap or asuvpn-install then copies the payload out to the same
~/.local/share/asuvpn/ and writes the same launcher, icon, symlink and
settings file as the table above — because it is the same install.sh, shipped
inside the wheel. So ASU VPN appears in the Activities overview and the app
grid either way, with the same Disconnect and Reconnect entries on its
right-click menu, and the desktop and icon caches are refreshed the same way so
it shows up without a logout. Nothing about the desktop integration depends on
how the files arrived.
One consequence worth knowing: the copy under ~/.local/share/asuvpn/ is what
runs, not the one in the pipx venv, so pipx upgrade asuvpn alone changes
nothing — run asuvpn-install after it to copy the new version out. Forgetting
that half is silent by nature, so asuvpn selftest asks the question for you:
it compares the running copy against the version the installed package
carries, and says which command finishes the job.
Running from the checkout instead
--link skips the copy and points the launcher at the checkout, so your edits
take effect immediately. The checkout then has to stay where it is.
./bootstrap.sh --link
Removing it
asuvpn quit
# If you have XDG_DATA_HOME, XDG_CONFIG_HOME or XDG_CACHE_HOME set to
# somewhere other than the defaults, the installer honoured them and these
# paths will not exist. `asuvpn selftest` prints where everything actually
# went; so does the tail of `install.sh`.
rm -rf ~/.local/share/asuvpn ~/.cache/asuvpn ~/.config/asuvpn
rm -f ~/.local/share/applications/asuvpn.desktop \
~/.local/share/icons/hicolor/scalable/apps/asuvpn.svg \
~/.local/bin/asuvpn \
~/.config/autostart/asuvpn-tray.desktop
update-desktop-database ~/.local/share/applications
That removes the app completely, however it was installed — those paths are
where it lives in both cases. If you installed from PyPI, pipx uninstall asuvpn removes the delivery wheel as well; on its own it would leave the
installed copy above running, since that is a copy and not a link.
It deliberately leaves the things it did not own: openconnect-sso
(pipx uninstall openconnect-sso), the apt packages, the deadsnakes PPA, the
AppIndicator GNOME extension, your keyring entries, and any polkit rule you
added by hand.
Security
The design assumes you would rather understand the privilege boundary than trust it, so here it is in full.
What runs as you
Nearly everything. The applet, the CLI, and the sign-in browser all run as your normal user, with no elevation:
- reads
~/.config/openconnect-sso/config.toml - runs
openconnect-sso, which opens the Qt browser window for ASU SSO and Duo - reads your password from the login keyring — see below
- writes
~/.cache/asuvpn/session.log - writes
~/.config/autostart/…only when you tick "Start on login (applet only)" - opens a private control channel (an abstract Unix socket) for the
single-instance guard and the CLI. That kind of channel has no file
permissions of its own, so on every connection the applet asks the kernel
who is calling (
SO_PEERCRED) and refuses any user id but yours — otherwise another local user could drop your tunnel, read your assigned VPN address, or triggerconnectto raise an admin password prompt on your desktop at will. Someone grabbing the channel's name first can only stop the applet starting (it reports "already running"); they cannot impersonate it, because the CLI checks the server's identity the same way the applet checks its callers
What runs as root
Only the pieces that build the tunnel, and every one of them is reached
through the same door: asuvpn-helper runs via pkexec after you approve
the password dialog, it runs openconnect, and at each connection change
openconnect runs asuvpn-notify, which reports one status message and
hands over to the routing script. The helper's whole job is supervision:
- runs
openconnectwith the session cookie fed on stdin, and stays alive as its supervisor for the life of the tunnel — it does not exec and step aside, - signals
openconnectwhen asked — the freeSIGUSR2re-establish nudge over the already-open control pipe, and shutdown when that pipe closes, - after exit, deletes a tunnel interface that outlived
openconnectand reads the default route back, to confirm your network was restored.
It also opens a datagram socket for asuvpn-notify to report state on. That
lives in a 0700 directory under /run owned by root, so no other account can
reach it — and every message carries a per-session token, so two concurrent
sessions cannot be confused for one another.
Before doing any of that, the helper refuses to run if itself, its
directory, asuvpn-notify or asuvpn_contract.py is world-writable, writable
by a shared group, or owned by anyone but you or root. Permissions say who
may write a file besides its owner; an owner rewrites their own file whenever
they like, so ownership is part of the same question rather than a separate
one, and install.sh --link from a checkout somebody else owns is a documented
way to arrive there. That turns
the caveat below from something you have to remember into something enforced. A
user-private group is not treated as a finding: Debian and Ubuntu default to
umask 002, so an ordinary checkout is 0775 with gid == uid and the "group"
is one person.
The ownership half is asked before asuvpn_contract.py is executed, not
after. It used to be asked only in main(), which runs long after the module
has been imported — so a contract owned by another user was run as root and
refused afterwards. sec.sh case (e3) stages exactly that, with a payload, so
"refused" has to mean "did not run".
What it never does
-
Never sees your password.
openconnect-ssoreads it from the login keyring, and the polkit password goes to GNOME's authentication agent. Neither passes through this app. The pre-flight check asks the keyring only whether a password exists and receives back one of three literal strings —ready,no-passwordorno-credentials— never the password itself. Anything else is treated as unknown, and a probe that hangs is treated as a locked keyring and fails closed rather than let a blank answer become the saved password. -
Never writes to the keyring.
-
Never logs the session cookie, and never puts it on a command line where
pscould show it. It travels on a pipe from the sign-in, through the applet's memory, into the helper's stdin, and nowhere else — never a file, never a terminal, neverargv.That much is structural, and it only covers the paths this project wrote. The log also carries
openconnect's own output verbatim, so as a second line of defence every log line is redacted before it is written: anything shaped like a session token —COOKIE,webvpn,acSamlv2Token,sso-token,sessionid— has its value replaced with<redacted>, while the key and the rest of the line survive so the log stays readable. This is whyasuvpn connect -- --dump-http-traffic, which makes openconnect print the cookie header, no longer leaks it. -
Never modifies system files or settings — at run time. Installing the app is confined to
$HOMEand needs no root at all.bootstrap.shis the sole exception, and only on the dependency pass: it runsaptundersudo, asks before adding the deadsnakes PPA, and enables the AppIndicator GNOME extension (a dconf setting in your own profile).--no-depsskips all of it. -
Never leaves a privileged process behind. See the control pipe.
The password prompt, and why it is not cached
pkexec is polkit's equivalent of sudo. Ubuntu's default for
org.freedesktop.policykit.exec is auth_admin:
<allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive>
<allow_active>auth_admin</allow_active>
auth_admin does not cache, so you are asked once for every connect and
reconnect. You are never asked to disconnect, because closing the control pipe
requires no privileges at all.
Making it prompt less
The keyring is not the mechanism here, and it is worth being clear why: polkit
authenticates through PAM against your account password. It never consults the
login keyring, so there is nothing to store there. What can cache is polkit's
own temporary authorization — auth_admin_keep, which retains an
authorization for a few minutes.
Two facts about how that works, both checkable on your own machine with
pkaction --action-id org.freedesktop.policykit.exec --verbose:
pkexecruns every program under the single action idorg.freedesktop.policykit.exec, whose implicit result isauth_adminfor active, inactive and any.- The program is passed as a detail, not as part of the action id. A rule can
read it with
action.lookup("program"), but the action being authorized is the same one everypkexecon the system uses.
An earlier version of this file concluded from that pair that any
AUTH_ADMIN_KEEP rule leaves pkexec <anything> passwordless. That is true of
an unscoped rule and overstated for a scoped one: polkit re-evaluates the
rules on every check and only consults a stored temporary authorization when the
current evaluation itself returns a _KEEP result, so a rule that returns
auth_admin_keep for one program and falls through for everything else does not
hand out a general exemption.
The reason not to do it anyway is simpler and does not depend on that detail:
The helper lives in your home directory, and you can write it.
A rule keyed on that path caches an authorization for whatever is at that path.
Today the per-connect prompt means an attacker who has replaced the file still
has to wait for you to connect and approve it; with caching they can invoke it
themselves inside the retention window. polkit.Result.YES removes even the
waiting, permanently.
So the honest options are:
| Option | Prompt | Safe? |
|---|---|---|
| As shipped | once per connect | yes — nothing to widen |
| Connect less often, stay connected | rarely | yes, and the session lasts weeks |
auth_admin_keep scoped to the helper in $HOME |
once per session | no — caches root for a file you can overwrite |
| Move the helper to root-owned storage first, then scope a rule to that path | once per session | yes — and it removes the caveat below as well |
The last row is the only way to get one password per session without giving
something up, because it fixes the underlying problem rather than working around
it. It costs the property that this app needs no root to install: the helper
would have to be placed outside $HOME by an administrator once. That is a
deliberate trade and is not done by default.
Note that a drop already costs you nothing — openconnect re-establishes
using the same session, and the applet's nudge goes down a pipe that is already
open, needing no privilege at all. Only a fresh connect prompts.
Reporting a problem
Found something that crosses a privilege or user boundary? Please report it
privately rather than in an issue — SECURITY.md says how, what
is in scope, and what belongs upstream with openconnect instead. It also asks
you to keep real hostnames and addresses out of the report, for the same reason
none appear anywhere in this repository.
Things worth knowing before you trust it
- The helper lives in your home directory. Anything able to write
~/.local/share/asuvpn/asuvpn-helperruns as root at your next connect, when you approve the dialog. That is inherent topkexec-ing a user-owned script. The helper runs underpython3 -Iso that its directory is not onsys.path— otherwise dropping asignal.pybeside it would be enough, with no need to touch the helper itself. --passes arguments straight toopenconnect, as root.asuvpn connect -- --script /path/to/scriptwill run that script with root privileges. This is deliberate — it is how you supply a customvpnc-script— but it is a real capability, not a cosmetic passthrough.- The applet trusts the fingerprint from
openconnect-sso. Certificate pinning isopenconnect's job, via the--servercertvalue produced during sign-in; this app passes it through unmodified.
How it works
Connecting is split across a privilege boundary, which is the whole point of the design:
you ──▶ asuvpn-tray ──▶ openconnect-sso ──▶ browser window (ASU SSO + Duo)
│ │
│ └──▶ host + certificate fingerprint + cookie
│
└──▶ pkexec ──▶ asuvpn-helper (root) ──▶ openconnect ──▶ asuvpn0
▲
cookie on stdin, never on argv
asuvpn-trayruns as you. It drivesopenconnect-sso --authenticate=shell, which performs the SAML/Duo login in a browser and prints back a host, a server certificate fingerprint, and a short-lived session cookie.asuvpn-helperruns as root underpkexec. It receives only the cookie, on stdin, and hands it toopenconnect.
The browser never runs as root, and the cookie never appears in the log or on a
command line where ps would show it.
Why pkexec instead of sudo
Run on its own, openconnect-sso finishes the browser login and then shells out
to sudo openconnect … itself — that is the terminal password prompt you get
when you run it by hand. Passing --authenticate=shell stops it one step
earlier: it prints the host, fingerprint and cookie and exits, never reaching
its sudo call.
The applet then makes the privileged call itself, with pkexec. That is not
cosmetic. A .desktop launcher has no terminal, so a sudo password prompt
would have nowhere to appear and the connect would just hang; pkexec asks
through the GNOME polkit dialog instead.
Knowing whether it is connected
State comes from openconnect's script contract, not from matching its log
output. openconnect runs vpnc-script at every transition with the state in the
environment — reason is one of pre-init, connect, disconnect,
attempt-reconnect, reconnect, alongside TUNDEV and
INTERNAL_IP4_ADDRESS. That interface is documented, versioned and inherited
from vpnc; the log wording is neither.
asuvpn-notify is installed as that script. It forwards one
datagram to the helper, configures the tunnel's DNS on the tunnel's own link,
and then execs the real vpnc-script, so routing is configured exactly as it
would have been. DNS is the one thing it keeps — see
DNS, and why it is not written to /etc/resolv.conf
for why leaving it to the real script silently stops working on Ubuntu. If you
pass your own --script, it is chained rather than discarded.
Which script it chains to is asked of openconnect, not assumed:
openconnect --version reports its own compiled-in default, which is
/usr/share/vpnc-scripts/vpnc-script on Debian and Ubuntu but /etc/vpnc/vpnc-script
on Fedora and Arch, and anything at all in a source build. This matters because
passing --script replaces that default — chain to a path that is not there
and the tunnel comes up with no routes and no DNS, restores nothing on the way
out, and the tray reports "Connected" throughout. If the script cannot be found
or is not executable, the helper does not interpose at all: openconnect keeps
its own default and state falls back to reading its output. Losing state
precision is a fair trade; losing your routing table is not.
What openconnect reports, and what you see:
reason |
Badge |
|---|---|
pre-init |
nothing yet — the tunnel is not configured |
connect |
Connected, with the address it assigned you |
attempt-reconnect |
Connecting… — link lost, retrying |
reconnect |
Connected, with the new address if it changed |
disconnect |
left to the exit path, which knows whether you asked for it |
Three sources, in order of authority:
| Source | What it gives | When it is used |
|---|---|---|
| Script contract | reason, device, assigned address |
Always, when available |
/sys/class/net/<dev>/ifindex |
Proof the device is the one we created | Teardown ownership |
| Log patterns | Best guess | Fallback only, if no event arrives |
This is not hypothetical tidying. Matching on Connected as … — which
openconnect stopped saying in v8 — meant the applet could only ever reach
"Connected" when DTLS happened to negotiate. On a network blocking UDP/443 or
behind a proxy the tunnel worked while the tray sat in "Connecting…" forever.
The contract has no such failure mode, and it hands over the assigned address,
which the log patterns never reliably did.
When the link drops
There are two different things called "reconnection" here, and they cost very different amounts. This matters, because one of them is free and invisible and the other interrupts you:
| openconnect re-establishes | asuvpn reconnect |
|
|---|---|---|
| Session cookie | reuses the existing one | fetches a new one |
| Your password | not asked | not asked — read from the keyring |
| Duo / 2FA | no | yes |
| polkit password prompt | no | yes, typed |
| Triggered by | dead peer detection, or a nudge | the menu, the CLI, the watchdog escalating |
| Good until | the session expires — openconnect logs the date | — |
openconnect handles the first kind itself and the applet follows rather than
interferes: it retries for --reconnect-timeout seconds (default 300) using the
same session cookie, so a WiFi toggle or moving between networks normally
recovers with no sign-in and no Duo push. It copes with your address changing
underneath it. Past the retry window, or once the session expires, a fresh
sign-in is needed and that is the expensive kind.
The tray shows the cheap kind as Connecting… — link lost, retrying, driven
by attempt-reconnect and reconnect events, so asuvpn status stops exiting 0
while traffic is going nowhere.
A concrete example, from a real session: close the laptop lid mid-tunnel and reopen it a minute later. The forced dead-peer detection notices the gap, the badge flips to Connecting… — link lost, retrying, and about eighteen seconds after waking the tunnel is back on the same session — no browser, no Duo, no password. The only trace is a notification pair: VPN connection lost, then VPN reconnected.
Dead peer detection is forced on
That first mechanism only works if dead peer detection is running — the periodic "are you still there?" exchange between the two ends of the tunnel — and ASU's server turns it off. A real session logs:
CSTP connected. DPD 0, Keepalive 0
With DPD 0 there are no probes and no keepalives, so openconnect has no way
to learn the far end stopped answering — it sits in a connected state
indefinitely while nothing flows. That is what a silent breakage is.
So the helper passes --force-dpd 30, which openconnect documents as using
DPD "even if the server hasn't requested it". Being wrong about the interval is
cheap: a DPD failure makes openconnect re-establish with the same cookie, so
an over-eager setting costs a brief reconnect, never a re-authentication. Pass
your own to override it:
asuvpn connect -- --force-dpd 60
Watching the tunnel itself
Dead peer detection covers the far end going away. It cannot see the case where the far end is fine but the tunnel has stopped being usable locally — a resume from suspend, a network change, or another daemon rewriting the routing table. Every layer still believes it is connected, which is why that failure is silent.
So the applet checks the tunnel every 20 seconds while it claims to be connected, and acts after two consecutive bad checks. What it checks is deliberately narrow, because three of the four things one would check first are wrong — each was tried against a live ASU tunnel and each would have raised a false alarm on a perfectly healthy link:
| Tempting check | Why it is wrong here |
|---|---|
operstate == "up" |
a tun device reports unknown, never up |
IFF_RUNNING |
not set on a tun device either |
| a default route through the tunnel | ASU is a split tunnel — the default route stays on your WiFi, and only the advertised prefixes are routed in |
| traffic is flowing | an idle tunnel moves no bytes at all |
What is left is what is true of every working tunnel and false of a broken one:
the device still exists, it is still the same device this session created (by
ifindex, not by name), it is still administratively up, and the routes that
make it useful are still installed. That last one is precisely what dead peer
detection cannot see.
Every one of those is a couple of reads from /sys and /proc — no packets and
no subprocess. But they all share a blind spot: a tunnel can have a healthy
device, correct routes, and still carry nothing. So every third cycle the applet
also asks the network.
The target is never hardcoded — by default it is the resolver the VPN itself
pushed (INTERNAL_IP4_DNS), forwarded through the same script contract
everything else uses. Whatever this tunnel says to resolve against is by
definition something that ought to answer through it. The probe-target
setting overrides that derivation if you know better for your network. A VPN
that pushes no resolver simply gets the device checks.
There is a third check, and it catches something the other two cannot: the
tunnel is up, the routes are installed, packets cross in both directions — and
internal names still resolve to whatever the public internet says, because the
resolver the VPN pushed is no longer configured on the tunnel's link. Every
20 seconds the applet asks systemd-resolved whether that resolver is still
in force — on the tunnel's link, or in /etc/resolv.conf if the handover fell
back to vpnc-script; either counts, because either works. Only while
dns = on, which is the same setting that decides whether the applet
configures DNS in the first place: with it off, nothing here is asked and no
demotion can come from this source. It only reads;
putting it back needs root, and the free re-establish does exactly that by
running the script again. If systemd-resolved does not
answer — a machine that does not run it never had this configured — that is not
a verdict, and nothing is demoted over it.
This one is not hypothetical. It is the failure that prompted the whole
mechanism, and it ran for hours at a time looking perfectly healthy. See
DNS, and why it is not written to /etc/resolv.conf.
The probe opens one TCP connection and closes it. A refusal counts as alive, which is the part worth understanding: measured against a live ASU tunnel, one pushed resolver accepted the connection in 29 ms and the other answered "nothing is listening here" (a RST packet) in 20 ms — and the refusal is just as good an answer, because it proves a packet crossed in each direction. Only silence means the tunnel is not carrying traffic. Whether the service behind the probe is up is none of our business.
The three sources are tracked separately, so a probe failure is only cleared by a
successful probe. Letting the device check promote the badge back would flap it
every twenty seconds against a tunnel that is genuinely carrying nothing. The
same rule holds against openconnect's own word: the reconnect a nudge produces
does not promote the badge either — the source that demoted has to pass again
first, which for the device checks is the very next check and for the probe is
at most one probe cycle (probe-every × health-interval seconds). Route
families are counted separately too, because ip route flush removes only
IPv4 and a summed count sat green through exactly that break on a live tunnel.
Every failing check and every recovery is logged with the facts attached, so the next silent break leaves evidence instead of a mystery (a quietly healthy tunnel logs nothing — a line every twenty seconds forever would bury the log). A routes-lost incident reads like this:
[tray] tunnel device check 1/2: the tunnel's IPv4 routes are gone (dev=asuvpn0, ifindex=6, flags=0x1091, routes4=0, routes6=6)
[tray] tunnel device check 2/2: the tunnel's IPv4 routes are gone (dev=asuvpn0, ifindex=6, flags=0x1091, routes4=0, routes6=6)
[tray] the tunnel is not usable: the tunnel's IPv4 routes are gone
[tray] asked openconnect to re-establish (same session, no sign-in needed)
What it does about it
Two different things can go wrong, and they need different answers:
| What happened | What is left to work with | What the applet does |
|---|---|---|
| The tunnel is up but useless — routes wiped, or a black hole | a live openconnect, a device, a session cookie |
the ladder: nudge, then say so, then sign in again |
The tunnel is gone — openconnect gave up and exited |
nothing at all | rebuild it from scratch, up to three times |
When the tunnel is up but useless
Cheapest first, because the two recoveries are not interchangeable — one is free and one costs you a Duo approval:
-
Nudge it. Send
openconnectaSIGUSR2. It documents this as forcing "an immediate disconnection and reconnection", and it reuses the session cookie — so there is no sign-in, no Duo push and no password. You need not even be at the keyboard.One nudge per incident. If the tunnel is demoted again after its nudge, the nudge demonstrably did not fix it, and asking again every two minutes is what a routes-lost tunnel actually got before this rule.
-
Say so. The badge leaves Connected, so
asuvpn statusstops exiting 0, and one notification explains what was seen. The menu still offers Disconnect and Reconnect, because the tunnel is established — just not usable. -
Sign in again. On by default. By the time this step is reached the free option has been tried and the tunnel is still carrying nothing, so the only thing left is a fresh session. It costs a Duo approval and a password, so it happens at most once every five minutes.
Picking up where the log above left off — the nudge has been sent, and the routes still have not come back:
[tray] tunnel device check 2/2: the tunnel's IPv4 routes are gone (dev=asuvpn0, routes4=0, routes6=6)
[tray] the tunnel is not usable: the tunnel's IPv4 routes are gone
[tray] the free re-establish was already tried for this incident and traffic did not return
[tray] signing in again to rebuild the tunnel
Every pass logs the decision it took and the reason, including the passes that decide to do nothing — "holding the free re-establish for another 47s", "automatic sign-in is off". A log that shows verdicts but never decisions reads as a hang, which is exactly how the first live routes-lost break read.
When the tunnel is gone
openconnect retries on its own for five minutes and then gives up. Any
suspend, or any WiFi outage longer than that, ends this way. There is now no
tunnel to nudge and no device to watch, so the ladder above cannot help — the
applet signs in again from scratch instead.
[tray] connection dropped
[tray] rebuilding it shortly
[tray] rebuilding the dropped tunnel (attempt 1 of 3)
[tray] ---- reconnecting; the lines above say why ----
[tray] authenticating to sslvpn.asu.edu via /home/you/.local/bin/openconnect-sso
If the network never comes back, it stops — and says so rather than going quiet:
[tray] the tunnel did not come back in 3 attempts; waiting to be asked
Four rules keep that from becoming a nuisance:
- Only a tunnel that was working gets rebuilt. If it never came up at all, something is wrong that a second attempt will not fix — a rejected cookie, a dismissed password prompt — and retrying just repeats the browser window and the Duo push.
- Three attempts, then it stops and says so once. A network that is down stays down; spacing the attempts out is not the same as bounding them.
- What earns the attempts back is a session that lasted. A tunnel that outlives the five-minute gap did not flap, so its next death starts with a fresh three. This is the difference between recovering all day from real outages and firing a Duo push every five minutes at a link that comes up and falls straight over.
- Anything you do yourself takes over. Connect, Cancel, or Disconnect — from the menu or the command line — calls off whatever was pending.
While a rebuild is owed, the badge says so and the menu offers Stop reconnecting, which calls it off without touching your settings:
$ asuvpn status
ASU VPN: Not connected (sslvpn.asu.edu) — connection dropped; rebuilding
$ asuvpn disconnect # the same thing from the command line
ASU VPN: Disconnected (sslvpn.asu.edu)
Turning it off
asuvpn autoreconnect # show the current setting
asuvpn autoreconnect off # or use the menu checkbox
The menu item is Reconnect automatically if traffic stops. Off, the applet stops at the diagnosis: the badge leaves Connected, the notification says what was seen, and nothing asks you for a password until you pick Connect or Reconnect yourself.
Two smaller guarantees
A sign-in always ends. openconnect-sso opens a browser window and waits
for a Duo approval. That is fine when you clicked Connect and are watching it;
it is not fine when the applet started it and nobody is there. signin-timeout
(five minutes, and there is deliberately no way to switch it off) ends one that
never finishes, so the applet cannot be left stuck in Signing in… behind a
login window nobody is looking at.
An automatic reconnect does not wipe the log. The lines explaining why it happened are the only record of a break that was silent by definition. A connect you asked for still starts a fresh log.
DNS, and why it is not written to /etc/resolv.conf
A split tunnel wants split DNS. ASU routes only its own prefixes down the tunnel and leaves everything else on your own connection; DNS should have the same shape. Names that live behind the tunnel — internal hosts, licence servers, a cluster login node — have to be resolved by ASU's resolver, and every other name should stay on the resolver your machine already had.
The stock vpnc-script cannot do that here, and fails in a way that looks
like nothing at all. It decides how to install a resolver by grepping
/etc/nsswitch.conf for resolve. On Ubuntu that word is absent — the
libnss-resolve package is not installed by default, and systemd-resolved is
reached through its stub listener at 127.0.0.53 instead. The grep fails, the
script concludes there is no resolver manager to talk to, and falls all the way
through its chain to the generic branch, which writes the VPN's resolver
straight into /etc/resolv.conf.
That file is a symlink to /run/systemd/resolve/stub-resolv.conf, which
belongs to systemd-resolved. The write lands, DNS works — and then
systemd-resolved rewrites its own file at the next link change and the VPN's
resolver is silently gone. The tunnel is still up. The routes are still
installed. Traffic still crosses. An internal hostname resolves to whatever
the public internet says — for a name fronted by a CDN, that is the CDN's edge,
which answers nothing you were asking for — and reconnecting the VPN is the
only thing that fixes it, until the next rewrite.
So this applet configures DNS itself, on the tunnel's own link, through
systemd-resolved's own interface:
resolvectl default-route asuvpn0 no # this link is not for every name
resolvectl domain asuvpn0 asu.edu # these names, though, are its own
resolvectl dns asuvpn0 192.0.2.53
Three properties come out of that, and none of them is available from a global resolver list:
- It is scoped. A domain listed on a link both completes single-label names
and routes matching queries there, so
asu.edugoes to ASU and nothing else does./etc/resolv.confis one flat list and cannot express this at all — the reason the obvious fix (installinglibnss-resolveso the stock script takes itssystemd-resolvedbranch) is still the worse answer: that branch sets no routing domain unless the gateway pushes one, and a link with servers and no domains becomes a candidate for every lookup on the machine. - It has the right lifetime. Link configuration dies with the link. No backup file, no restore step, and nothing to lose a race with.
- Nobody else owns it.
systemd-resolvedrewriting its stub file cannot disturb per-link configuration, because that is where it keeps its own.
Which names count as the tunnel's is the gateway's call first:
CISCO_DEF_DOMAIN and CISCO_SPLIT_DNS, which openconnect passes to the
script, are it saying which names it serves. The CISCO_ prefix is historical
and not a limitation — it comes from vpnc, and openconnect funnels every
protocol it speaks (AnyConnect, Juniper/Pulse, GlobalProtect, Fortinet, Array)
into those same two variables, so this works the same against any of them.
Only when the gateway names none does the local answer apply — the
dns-domains setting, or, left empty, the domain derived from the server
address (sslvpn.asu.edu → asu.edu). Nothing is hardcoded; point the applet
at a different VPN and it derives that one's domain instead.
If no domain can be worked out at all, the link takes every lookup rather than
none — never worse than the behaviour being replaced — and the log says so and
names the setting that would narrow it.
The handover is total, and it fails safe. The real vpnc-script guards
both its DNS branches on INTERNAL_IP4_DNS being set, so once the link is
configured that variable is removed from the environment the script inherits.
Routing is handed over untouched; DNS has exactly one owner. If any part of it
does not work — no resolvectl, systemd-resolved not running, a call that
fails — the variable stays, the link is reverted rather than left half
configured, and the stock script does whatever it would have done. Falling back
to the old behaviour beats half-configuring DNS.
Every connect logs what was done:
15:38:44 [vpn] asuvpn: DNS for asu.edu on asuvpn0 via 192.0.2.53 (pushed by the gateway); /etc/resolv.conf left alone
dns = off turns the whole thing off and gives vpnc-script its old job back.
The control pipe
The helper keeps reading the same stdin pipe for the life of the tunnel. Closing the pipe is the disconnect signal, which buys two things:
- Disconnecting never asks for your password again. The tray already holds the write end; it just closes it. No second privileged call is needed.
- A crashed tray cannot strand the tunnel. The helper sees EOF and tears
openconnectdown, instead of leaving a root process holding your routing table. This is verified by killing the applet withSIGKILLand checking that nothing survives.
The helper also relays openconnect's output rather than handing it the tray's
pipe directly. That costs a thread, but it means a dead tray cannot hit
openconnect with SIGPIPE and kill it before it restores your routes and
DNS.
Every way of stopping it
All of these end in the same graceful teardown: openconnect receives SIGINT
and gets 15 seconds to run vpnc-script before anything harsher is considered.
| How you stop it | What happens |
|---|---|
| Tray menu → Disconnect | The tray closes the control pipe, the helper signals openconnect, routes are restored. The applet stays running. |
| Tray menu → Quit | The same teardown, then the applet exits. |
asuvpn disconnect / asuvpn quit |
Routed to the applet over the control socket. Identical to the menu. |
Ctrl+C, in tray or --foreground mode |
The applet catches SIGINT and runs the orderly teardown. |
Closing the terminal (SIGHUP) |
Same as Ctrl+C. |
Logging out (SIGTERM) |
Same as Ctrl+C. |
The applet crashes or is kill -9ed |
The control pipe closes, the helper sees EOF and tears the tunnel down. Nothing is left holding your routes. |
The helper itself dies (out of memory, kill -9) |
The kernel itself notices — the helper registered for that (PR_SET_PDEATHSIG) — and signals openconnect, so the routing script still runs. |
[!NOTE]
asuvpn connectfrom a terminal puts the applet in its own session and returns. Ctrl+C afterwards does nothing, because the applet is no longer attached to your terminal — useasuvpn disconnect.
The helper is deliberately started in its own session, so a Ctrl+C aimed at
the applet does not also land on openconnect. That leaves exactly one teardown
path instead of two racing ones. An earlier version got this wrong: the terminal
signalled every process at once, two shutdowns ran concurrently, and the escalation
timer misread a healthy teardown as a hang and escalated to SIGKILL — which is
the one thing guaranteed to stop vpnc-script from restoring the routing table.
Putting the network back
openconnect restores your routes and DNS by running vpnc-script with
reason=disconnect on the way out — but only when it exits gracefully. If it
is killed outright the script never runs, which is the classic way to end up
with no working network until you reboot or toggle the interface. Three things
guard against that, and a fourth reports what they could not prevent:
- Gentle escalation. Teardown sends the polite stop signal (
SIGINT), waits 15 seconds, sends the firmer one (SIGTERM), waits 10 more, and only then force-kills (SIGKILL). Both polite signals makeopenconnectrun the cleanup script; the force-kill is the one thing that prevents it, which is why the waits are deliberately generous. - No broken-pipe deaths. The helper relays
openconnect's output instead of wiring it straight to the applet, so an applet that vanished cannot make a write fail in a way that killsopenconnect(SIGPIPE) before it has finished cleaning up. - A dead supervisor still triggers teardown. The control pipe covers the
tray dying;
PR_SET_PDEATHSIGcovers the helper dying. Without it, killing the helper leftopenconnectrunning as root with nothing able to reach it, while the tray cheerfully reported the connection as dropped. (In that path the kernel may deliver the signal twice, a millisecond apart;openconnecttreats the second as a repeat of the same cancellation.) - A check afterwards. After
openconnectexits, the helper deletes the tunnel device if it outlived it — removing an interface takes its routes with it — then prints the restored default route, or a warning if there is none. Warnings also raise a desktop notification, so a broken teardown is visible instead of silent.
The tunnel device is named asuvpn0 (or the first free asuvpnN) rather
than the usual tun0. That is
what makes the cleanup above safe: teardown can prove which interface is its own
instead of deleting anything that merely looks like a tunnel. Guessing by name
prefix would eventually delete a VM's tap0 or a second VPN's tun1 — as root,
while trying to fix your networking. Anything else that appeared meanwhile is
reported and left alone. Passing your own --interface through -- overrides
the name, and teardown then tracks that one instead.
A healthy disconnect ends with a line like:
18:32:14 [helper] NOTE default route restored: default via 198.51.100.1 dev wlan0 proto dhcp metric 600
If instead you see [helper] WARNING no default route after teardown — network is likely broken (the kind word carries no colon in the session log), the
recovery is:
nmcli networking off && nmcli networking on
Why the sign-in needs its own browser window
openconnect can do SAML by itself, handing the login to your normal browser
with --external-browser. That would remove most of what bootstrap.sh
installs — Qt, PyQt6-WebEngine, the Python 3.12 they pin, the compiler that
builds lxml. It was measured, and ASU's gateway refuses that method: asked
for it alone it answers error 108, does not support the requested authentication type, and only single-sign-on-v2 — the embedded browser
openconnect-sso drives — is accepted. asuvpn selftest re-asks on every run,
so if that ever changes you will hear it from the self-check rather than from a
failed sign-in. DESIGN.md
has the detail.
Two upstream quirks worth knowing
The TOTP prompt. openconnect-sso asks for a TOTP secret on the terminal
whenever its keyring entry is empty, which is every run for a Duo-push setup.
Launched from a desktop icon there is no terminal, so getpass hits EOFError
and the sign-in dies before the browser even opens. The applet answers that
prompt with a blank line, meaning "not required", and lets the browser handle
the second factor.
The password prompt behind it. The same code path asks for your password
first when the keyring has none, and a blank answer there would be silently
saved as your password. So the applet probes the keyring first and stops with
no saved password rather than guessing.
Requirements
bootstrap.sh handles all of this. It is written down because the Python
version in particular is easy to get wrong.
The applet
Runs on the system python3 with GTK 3, Ayatana AppIndicator and libnotify
bindings:
python3-gi gir1.2-gtk-3.0 gir1.2-ayatanaappindicator3-0.1 gir1.2-notify-0.7
It deliberately uses /usr/bin/python3 in its shebang rather than whatever is
first on PATH, because a conda or pyenv install will shadow it and will not
have gi.
Python 3.10 or newer for the applet itself — the floor Ubuntu 22.04 sets,
and CI runs the portable half on 3.10, 3.11 and 3.12+ on every push to
main and every pull request, so the
claim is tested rather than asserted. Note this is a different requirement
from the Python 3.12 openconnect-sso needs below: that one lives in its own
pipx venv and the applet never runs on it.
Also needed: openconnect, pkexec, and the GNOME AppIndicator extension
(gnome-shell-ubuntu-extensions) — without the extension GNOME has nowhere to
draw a tray icon.
openconnect-sso needs Python 3.12
This is the fiddly part. openconnect-sso pins lxml <5 and
PyQt6-WebEngine <7, and neither publishes wheels for Python 3.13+. Ubuntu
26.04 ships only 3.14, so 3.12 has to come from the deadsnakes PPA, and lxml
compiles from source, which needs a toolchain and headers:
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.12 python3.12-venv python3.12-dev \
build-essential libxml2-dev libxslt1-dev zlib1g-dev \
libffi-dev libssl-dev pkg-config
pipx install --python /usr/bin/python3.12 'openconnect-sso[full]'
pipx inject openconnect-sso 'setuptools<71' --force
Three details that are easy to miss:
- The
[full]extra pulls in keyring support. - The
setuptools<71pin matters.openconnect-ssoimportspkg_resources— in its sign-in browser process, to load theuser.jsit injects — and current setuptools no longer ships that module. Measured on this machine: setuptools78.1.1still haspkg_resources,83.0.0does not. So<71is a known-good pin, not the exact boundary; it is the bound that has been made to work end to end, which is why it is not relaxed. --forceon that inject is not optional.pipxdecides whether a package is already injected with a version-blind test — it asks only whether somesetuptoolsis present, and one always is. Without--forceit prints "already seems to be injected", installs nothing, and still exits 0, so the pin silently never applies and the venv keeps asetuptoolsthat breaks sign-in at import.asuvpn selftestchecks the pin by its effect, not by that exit status.
Qt6 WebEngine also dlopens a set of shared libraries for the sign-in window
(libnss3, libxcomposite1, libxdamage1, libxrandr2, libxkbcommon-x11-0,
libxcb-cursor0, libgl1, libegl1, libxtst6, libdbus-1-3, fontconfig).
libxcb-cursor0 is the one people hit on X11 sessions.
Troubleshooting
Start with the log, either from the menu's Show log… or:
asuvpn log -f
Every line has the same shape — a timestamp, a source tag, and (for helper lines) a kind word. Knowing the tags makes the log greppable:
HH:MM:SS [tray] … the applet: decisions, checks, state changes
HH:MM:SS [config] … a problem in asuvpn.conf, reported once
HH:MM:SS [sso] … openconnect-sso's own output during sign-in
HH:MM:SS [helper] NOTE … the root helper, informational
HH:MM:SS [helper] WARNING … needs attention — most seriously, the network
may not have been restored
HH:MM:SS [helper] FATAL … refused before openconnect ever started
HH:MM:SS [helper] STATE … a transition from openconnect's script contract
HH:MM:SS [helper] DEVICE … the tunnel device this session will create
HH:MM:SS [vpn] … one line of openconnect's own output, relayed
HH:MM:SS [old tunnel] … a superseded helper's last words
The kind word is part of the line — grep for WARNING (no colon), not
WARNING:. A line like [tray] ignoring 'check' while disconnecting is the
applet declining a message that arrived too late to matter — normal during
teardown, and logged precisely so a declined action never reads as a hang.
| Symptom | Cause and fix |
|---|---|
no saved password |
The keyring has no password and the applet will not guess a blank one. Run openconnect-sso --server sslvpn.asu.edu --authenticate=shell once in a terminal. |
keyring did not answer |
The login keyring is locked, so the probe timed out rather than risk a blank answer being saved as your password. Unlock the keyring and connect again. |
authorization cancelled |
The polkit dialog was dismissed, or the password was wrong. Run asuvpn connect again. |
| No icon in the panel | gnome-extensions enable ubuntu-appindicators@ubuntu.com |
asuvpn: command not found |
~/.local/bin is not on your PATH. Add it, or run pipx ensurepath and open a new terminal. |
| The sign-in window never appears | That is openconnect-sso, not this applet. Run openconnect-sso --server sslvpn.asu.edu --authenticate=shell to see the real error. |
could not load the Qt platform plugin "xcb" |
Missing libxcb-cursor0. Re-run ./bootstrap.sh. |
Log stops at signed in, starting openconnect |
The polkit dialog never appeared. Check that a polkit agent is running, and that you are in the sudo group. |
[helper] WARNING no default route after teardown |
Teardown could not restore routing. nmcli networking off && nmcli networking on. |
[helper] FATAL refusing to run: … is world-writable |
Anything writable by another account runs as root at your next connect, so the helper stops. chmod go-w ~/.local/share/asuvpn and re-run. |
refusing --background / refusing -bv |
That option would detach openconnect from the helper, leaving a root process nothing can stop. Drop it; write bundled short options separately (-i lo, not -ilo). |
[helper] WARNING … is not executable, so openconnect's own default script is left in place |
The vpnc-script this system uses could not be found, so state falls back to reading openconnect's output. Routing is unaffected. Install vpnc-scripts. |
Script … returned error 127 |
The vpnc-script failed, so routes and DNS were never configured. Install vpnc-scripts, then asuvpn selftest. |
ModuleNotFoundError: No module named 'pkg_resources' |
The setuptools<71 pin did not take. pipx inject openconnect-sso 'setuptools<71' --force — the --force is what makes it apply. |
| Badge says not carrying traffic | The watchdog found the tunnel device or its routes gone, or nothing answering through it. It has already nudged openconnect once; asuvpn log says what it saw. If it does not recover, the automatic sign-in fires (unless turned off) — or asuvpn reconnect yourself. |
| Badge says DNS not configured | The resolver the VPN pushed is no longer on the tunnel's link, so internal names resolve to whatever public DNS says. The applet asks openconnect to re-establish, which reconfigures it. resolvectl dns asuvpn0 shows the live state. |
ssh to an internal host hangs while the VPN is up |
The name is resolving to a public address instead of the internal one — resolvectl query <host> says which, and resolvectl dns asuvpn0 says whether the VPN's resolver is on the link. If it is empty, asuvpn log will say why the handover did not take. With dns = off this is the stock vpnc-script behaviour and is expected to come back. |
| Badge says …; rebuilding | The tunnel died on its own and the applet will sign in again shortly, up to three times. Stop reconnecting in the menu — or asuvpn disconnect — calls it off. |
sign-in did not finish within 300s |
A sign-in sat unanswered — usually a Duo push or browser window with nobody at the keyboard — and was ended by signin-timeout. Connect again when you are there to answer it. |
| Tunnel silently stops working, badge stays green | Should no longer happen: --force-dpd 30 is passed because ASU negotiates DPD off, and the watchdog covers what DPD cannot see. If it recurs, asuvpn log now records every check. |
| Self-check reports a failure | asuvpn selftest prints a detail line under each failure saying what will break and how to fix it. |
Repository layout
| Path | What it is |
|---|---|
asuvpn-tray |
The applet and the asuvpn CLI. Runs as you, on the system python3. |
asuvpn-helper |
The root side, run under pkexec. Owns openconnect's lifetime. |
asuvpn-notify |
The vpnc-script wrapper. Reports state, configures the tunnel's DNS on its own link, then chains to the real script for routing. |
asuvpn_contract.py |
What the programs agree on: wire format, control verbs, event fields, the settings schema — and the release version, stated once and read by everything including the build. Loaded, not run. |
asuvpn-selftest |
Checks the install against the machine. Run by install.sh, and by asuvpn selftest. |
bootstrap.sh |
Installs dependencies, then calls install.sh. |
install.sh |
Copies the app into ~/.local and registers it. No system changes. |
asuvpn.svg |
App icon. |
tests/sandbox/ |
Scenario tests: the real programs run whole lifetimes against stand-ins, in a namespace. |
IMPLEMENTATION_GUIDE.md |
Read before changing anything: what is proven and what is only believed, the traps, and the lessons each bug paid for. |
ruff.toml |
Lint config. Its ignore list records which rules are off and why. |
DESIGN.md |
How the code works: the state machine and its full as-built transition table, who owns DNS, the invariants, and how it is all tested. |
SECURITY.md |
How to report a vulnerability privately, what is in scope, and what belongs upstream. |
pyproject.toml + asuvpn_dist/ |
The PyPI delivery channel: a wheel carrying these same files, plus asuvpn-bootstrap and asuvpn-install, which run the bundled bootstrap.sh and install.sh. No second installer. |
.github/workflows/ |
CI: the self-test and analysers on every push to main and every PR; the scenario sandbox on main; PyPI publishing on a v* tag, gated on a verify job because a tag push runs neither of the other two. |
LICENSE |
MIT. |
Checking it
install.sh runs the self-check at the end of every install, and you can run it
yourself whenever something behaves oddly:
asuvpn selftest
asuvpn selftest --tier environment
asuvpn selftest --quiet # only failures and warnings
It exits 0 if nothing failed, 1 otherwise. Nothing in it needs privileges, and
nothing it does can change this machine or the tunnel. It does reach the
network three times, all read-only: the real openconnect is run only to
describe itself (--version for its script path, --help for its
give-up-retrying default); a loopback connect and a SYN to RFC 5737
documentation space exercise the liveness probe and leave nothing behind; and
one unauthenticated capability handshake asks your gateway which sign-in method
it offers. That last one carries no credentials — it is the first request any
VPN client makes — and it is skipped, not failed, when the server is
unreachable.
The reason it is shaped the way it is: ordinary unit tests would not have
caught a single one of the bugs that hurt this project. Matching on
Connected as, which openconnect stopped saying in v8; hardcoding the
vpnc-script path, which differs between distributions; comparing whole argv
elements when getopt bundles short options — every one was a wrong assumption
about the environment, and a test that only checked the code against itself
would have passed happily while the code was dead. So there are three tiers, and
the middle one is the point:
| Tier | What it checks |
|---|---|
logic |
Pure functions and the state machine: the option blocklist, interface-name validation, --interface/--script parsing, the permission rules, state-payload parsing, the transition table driven with real message sequences, the rebuild bounds, the sign-in deadline against a child that would outlive the run, that openconnect's output cannot forge a helper message, the split-DNS domain rules against option-like and shell-punctuation payloads, and every verdict the resolver check can reach |
environment |
Our assumptions put to the installed binaries — that openconnect exists, that it gives up retrying rather than trying forever (the fact the rebuild rests on, read back out of its own --help), that the vpnc-script it names is executable and handles every reason we send, that our fallback log patterns still appear in its message catalogue, that the split-DNS variables the handover reads are still named what we think, and that nothing the helper runs as root is writable by anyone else |
wiring |
asuvpn-notify actually executed: the event arrives with the right token and fields, the real vpnc-script still runs with its environment intact, the token is scrubbed before it sees it, no event can emit more than one line — and the DNS handover driven against a stand-in resolvectl, asserting the call order, the revert on every failure path, and that a failure always leaves the stock script its DNS job |
CI adds one thing the three tiers cannot do on a single machine: it runs the
logic tier inside Ubuntu 22.04, Debian 12, Fedora and Arch containers. That
tier is pure
Python — the GUI stack is imported behind a try/except, so a machine with
no GTK at all still loads every module and runs the contract, the state machine
and the split-DNS rules. It proves the portable half is portable. It proves
nothing about the tray on those distributions: no GTK, no openconnect, no
pkexec is exercised there, and bootstrap.sh still installs packages on apt
only.
The environment tier reads the installed libopenconnect's own strings, so
asuvpn selftest is what tells you the fallback patterns have gone stale on
some future release, instead of finding out during an outage.
The suite is itself checked by breaking the code on purpose and confirming it notices — from reverting the bundled-option fix to un-wiring the log scrubber from the write path. The full table of verified mutations, every row run and caught, lives in DESIGN.md.
The linters run separately. The four programs have no .py extension, so copy
them under one first; the contract comes along so it is checked too:
mkdir -p /tmp/asuvpn-lint && cp ruff.toml asuvpn_contract.py /tmp/asuvpn-lint/
cp asuvpn-tray /tmp/asuvpn-lint/asuvpn_tray.py
cp asuvpn-helper /tmp/asuvpn-lint/asuvpn_helper.py
cp asuvpn-selftest /tmp/asuvpn-lint/asuvpn_selftest.py
cp asuvpn-notify /tmp/asuvpn-lint/asuvpn_notify.py
cp asuvpn_dist/install.py /tmp/asuvpn-lint/asuvpn_dist_install.py
ruff check /tmp/asuvpn-lint
pyflakes /tmp/asuvpn-lint/*.py
pylint --disable=all --enable=E --ignored-modules=gi,gi.repository /tmp/asuvpn-lint/*.py
bandit -q -r /tmp/asuvpn-lint -ll
vulture --min-confidence 70 /tmp/asuvpn-lint/*.py
mypy --ignore-missing-imports /tmp/asuvpn-lint/*.py
shellcheck -S style bootstrap.sh install.sh tests/sandbox/*.sh \
tests/sandbox/bin/pkexec tests/sandbox/bin/sso-python \
tests/sandbox/bin/vpnc-script
All of these are expected to be clean. None of them need installing: pipx run <tool> runs each from pipx's own cache and touches nothing else (the last one
as pipx run --spec shellcheck-py shellcheck). CI runs this same list plus
the full self-test on every push to main and every pull request
(checks.yml), and the whole scenario sandbox
on pushes to main (scenarios.yml). mypy's --ignore-missing-imports
is for gi, which ships no stubs.
The plain mypy run exits 0; the few annotation-unchecked notes it prints are
pointers to the stricter mode, not findings. Stricter mypy
(--check-untyped-defs) reports exactly two kinds of complaint,
both typeshed limitations rather than defects: Popen pipe attributes typed
IO | None (PIPE guarantees them, and every such write is exception-guarded
anyway), and spec_from_loader typed as returning ModuleSpec | None in each
program's contract loader (with an explicit SourceFileLoader it cannot).
Anything outside those two kinds is new, and worth reading.
Design notes
DESIGN.md covers the internals for anyone reading or changing the
code: the process and privilege model, the state machine and where state comes
from, the threading model and the locks, the teardown guarantees, the full exit
code list, the invariants the code exists to maintain, how it is tested, and
what to update in lockstep when you change something.
Status
Exercised with stand-in openconnect-sso, pkexec and openconnect binaries,
the last of which modeled a teardown that took real time to restore routes:
connect, disconnect, reconnect while connected, quit while connected, Ctrl+C in
foreground mode, crash safety under SIGKILL, a dismissed authorization dialog,
four simultaneous launches racing for the single-instance guard, every exit code,
the -- passthrough, and installing from an arbitrary directory in both copy and
--link modes. Each teardown path was checked for a single SIGINT, no spurious
escalation, and a restored default route.
The state framework was exercised through the real script contract: a stand-in
openconnect that invokes --script with reason=connect, attempt-reconnect,
reconnect and disconnect, confirming the tray follows each one and displays
the assigned address. The permission refusal was tested by making each file and
the directory world-writable in turn, by staging a file owned by a genuinely
second group and watching the real helper refuse it (the scenario asserts,
and fails loudly if the refusal does not happen), and by confirming an ordinary
user-private-group checkout still runs.
The stand-ins and the scenarios live in
tests/sandbox and run inside an unprivileged
user + mount namespace, bind-mounted over /usr/sbin/openconnect,
/usr/bin/pkexec, openconnect-sso and the
vpnc-script, with a guard that refuses to start unless every one of them
resolves to a fake. Earlier rounds used symlinks, and twice a gap in that
arrangement let a test reach a real binary. A namespace cannot leak: the real
filesystem is not modified at all, and is byte-identical afterwards.
The reason values the framework depends on were confirmed present in the
installed libopenconnect.so.5 rather than assumed. reconnect and connect
do not appear as separate strings in the binary because the build stores
them inside the longer word (tail-merging), overlapping them into
attempt-reconnect — they are at attempt-reconnect+8 and +10 respectively —
and the installed vpnc-script enumerates exactly those five in its own
case statement.
Behaviours confirmed by first reproducing the bug and then the fix: a failure
after a successful connect now reports Failed to connect to host … rather than
a bare exit status; a reconnect event racing a user-requested disconnect ends
Disconnected rather than in the error state; a bundled -bv is refused; a
missing vpnc-script leaves openconnect's own default in place instead of
silently disabling all routing.
Specific defences were tested by trying to defeat them, not by inspection:
- Import injection. A
signal.pydropped beside the helper executes when the helper runs without-I, and is ignored with it. The shebang is load-bearing. - Control socket. With the peer check inverted so an ordinary connection looks foreign, the applet refuses it and logs the uid.
- Interface safety.
--interface <an existing device>is refused rather than deleted;--interface ../../etc/passwdis rejected; the parser takes the last--interface, matching what getopt hands openconnect. - Cookie hygiene. The cookie string appears zero times in the session log and
zero times in the journal; only the
--cookie-on-stdinflag name is there. - State reporting. A rejected cookie surfaces as "Cookie was rejected by
server", not a bare exit code; a mid-tunnel
Connection lostdrops the badge out of Connected soasuvpn statusstops exiting 0.
Checked against the real tools: the sign-in browser reaches ASU's SAML page, the keyring probe reports correctly, and GNOME resolves the launcher and icon.
[!IMPORTANT] Stand-ins are only as good as the strings they imitate. An earlier version of the fake
openconnectprintedConnected as …, which openconnect stopped saying in v8 — so the applet's connect detection passed every test while being dead against the real v9.12 binary, and would have hung in "Connecting…" on any network without DTLS. The patterns are now taken from the installed binary's own message catalogue (strings libopenconnect.so.5) rather than from memory, and replayed against realistic v9.12 transcripts for the DTLS, TLS-only, proxied, rehandshake and drop-then-recover cases.asuvpn selftestnow also holds the stand-in itself to that rule: every line it prints must be marked[stand-in]or begin with a prefix from the installed binary's own catalogue, so a fake cannot drift back into invented wording.
Exercised end to end since: a full connect through the real pkexec
prompt, a real Duo approval and ASU's own gateway, watched over a working
session and torn down cleanly — openconnect exited with status 0,
default route restored: in the log, the device gone and nothing left running.
The --force-dpd 30 the helper passes was confirmed safe against ASU: the
server declines DPD but answers forced probes, with zero reconnect events over
the measured window.
The stand-in exercises earlier in this section — the launch races, the exit code sweep, the arbitrary-directory installs — predate the committed suite and were run with earlier versions of the same fakes before the harness moved into the repository; the fifteen committed scenarios are the ones tabulated in tests/sandbox.
What is proven live against the real gateway (full connects and teardowns, forced-DPD safety, free recovery through WiFi loss and suspend, the nudge) versus only in the sandbox (the full escalation ladder including the unattended sign-in, the shared-group refusal) is kept, with dates and evidence, in IMPLEMENTATION_GUIDE.md — that ledger, not this paragraph, is current.
License
MIT. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file asuvpn-0.12.0.tar.gz.
File metadata
- Download URL: asuvpn-0.12.0.tar.gz
- Upload date:
- Size: 274.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bd768a2b52c1f1966a798cade30f50b6eaf730717fbc6234b225d96456bd70fd
|
|
| MD5 |
ea624860f421125ef4cda63d069f7af8
|
|
| BLAKE2b-256 |
e15dafade616e9b90c559041ce95f4e311982733ab246caf6d59a10b9f557c7c
|
Provenance
The following attestation bundles were made for asuvpn-0.12.0.tar.gz:
Publisher:
workflow.yml on Qing-LAB/ASU_VPN_SSO
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
asuvpn-0.12.0.tar.gz -
Subject digest:
bd768a2b52c1f1966a798cade30f50b6eaf730717fbc6234b225d96456bd70fd - Sigstore transparency entry: 2717624843
- Sigstore integration time:
-
Permalink:
Qing-LAB/ASU_VPN_SSO@a3968bd57e6ca2e9e905fb714c17e864be4d09a4 -
Branch / Tag:
refs/tags/v0.12.0 - Owner: https://github.com/Qing-LAB
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
workflow.yml@a3968bd57e6ca2e9e905fb714c17e864be4d09a4 -
Trigger Event:
push
-
Statement type:
File details
Details for the file asuvpn-0.12.0-py3-none-any.whl.
File metadata
- Download URL: asuvpn-0.12.0-py3-none-any.whl
- Upload date:
- Size: 209.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
266aab0cd34bfe675409872f3b4a4c58a02df898071d1047bd03d3a19cd4b9f2
|
|
| MD5 |
e966f9c3490a1efeab0e3b8be279651f
|
|
| BLAKE2b-256 |
26cd454e5da8c7e98d9233f0ba1a554bbf367c3918ad865ec65d5a9da01788e2
|
Provenance
The following attestation bundles were made for asuvpn-0.12.0-py3-none-any.whl:
Publisher:
workflow.yml on Qing-LAB/ASU_VPN_SSO
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
asuvpn-0.12.0-py3-none-any.whl -
Subject digest:
266aab0cd34bfe675409872f3b4a4c58a02df898071d1047bd03d3a19cd4b9f2 - Sigstore transparency entry: 2717625707
- Sigstore integration time:
-
Permalink:
Qing-LAB/ASU_VPN_SSO@a3968bd57e6ca2e9e905fb714c17e864be4d09a4 -
Branch / Tag:
refs/tags/v0.12.0 - Owner: https://github.com/Qing-LAB
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
workflow.yml@a3968bd57e6ca2e9e905fb714c17e864be4d09a4 -
Trigger Event:
push
-
Statement type: