**A kiosk lockdown browser built on LibreWolf (or Firefox ESR) + a Rust native helper.** Linux-first, single codebase, zero Chromium in the stack. Designed for medical lobbies, public terminals, and any environment where a browser must serve the public without leaking session data between users.

This commit is contained in:
Jeremy Anderson 2026-08-23 15:58:24 -04:00
commit 65899ba66f
93 changed files with 13899 additions and 0 deletions

217
.github/workflows/ci.yml vendored Executable file
View File

@ -0,0 +1,217 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
build-and-test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
vestibule/helper/target
key: ${{ runner.os }}-cargo-${{ hashFiles('vestibule/helper/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Build usher
working-directory: vestibule/helper
run: cargo build --release
- name: Smoke test (Linux)
if: runner.os == 'Linux'
working-directory: vestibule
run: python3 scripts/test-native-messaging.py
- name: Smoke test (Windows)
if: runner.os == 'Windows'
working-directory: vestibule
run: python scripts\test-native-messaging.py
- name: Validate JSON
working-directory: vestibule
run: |
python3 -c "import json; json.load(open('extension/manifest.json'))"
python3 -c "import json; json.load(open('config/policies.json'))"
python3 -c "import json; json.load(open('config/com.vestibule.usher.linux.json'))"
python3 -c "import json; json.load(open('config/com.vestibule.usher.windows.json'))"
python3 -c "import json; json.load(open('packaging/net.dcos.Vestibule.json'))"
- name: Validate XML + icon assets
working-directory: vestibule
run: |
python3 -c "import xml.dom.minidom; xml.dom.minidom.parse('packaging/net.dcos.Vestibule.metainfo.xml')"
python3 - <<'EOF'
import os
for f in ["packaging/icons/vestibule.ico", "packaging/icons/vestibule.svg"]:
assert os.path.exists(f) and os.path.getsize(f) > 0, f
print("icon assets ok")
EOF
- name: Check JS syntax
working-directory: vestibule/extension
run: |
for f in *.js; do node --check "$f" || exit 1; done
- name: URL policy unit tests
working-directory: vestibule
run: node scripts/test-url-policy.js
- name: Check POSIX sh syntax
if: runner.os == 'Linux'
working-directory: vestibule/scripts
run: |
for f in *.sh vestibule-kiosk-launch; do sh -n "$f" || exit 1; done
sh -n ../packaging/vestibule-flatpak-cli
sh -n ../packaging/build-flatpak.sh
- name: Check PowerShell syntax
if: runner.os == 'Linux'
working-directory: vestibule
shell: pwsh
run: |
$files = Get-ChildItem scripts/*.ps1, packaging/*.ps1
foreach ($f in $files) {
$errs = $null
[void][System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errs)
if ($errs) {
$errs | ForEach-Object { Write-Error "$($f.Name): $($_.Message)" }
exit 1
}
}
Write-Host "PowerShell syntax OK ($($files.Count) files)"
- name: Provision/deprovision integration test (rootless sandbox)
if: runner.os == 'Linux'
working-directory: vestibule
run: sh scripts/test-provision-linux.sh
- name: provision --check fails with documented exit code (no prereqs on runner)
if: runner.os == 'Linux'
working-directory: vestibule
run: |
code=0
sh scripts/provision-kiosk.sh --check || code=$?
# The runner has systemd but no cage/LibreWolf -> documented exit 3.
if [ "$code" -ne 3 ]; then
echo "expected exit 3 (missing prerequisites), got $code"
exit 1
fi
echo "check mode returned documented exit code 3"
- name: provision-kiosk.ps1 -Check fails fast off-Windows (exit 2)
if: runner.os == 'Linux'
working-directory: vestibule
shell: pwsh
run: |
$code = 0
try { & pwsh -NoProfile -File scripts/provision-kiosk.ps1 -Check } catch { $code = 1 }
if ($LASTEXITCODE -ne 2) {
Write-Error "expected exit 2 (unsupported platform), got $LASTEXITCODE"
exit 1
}
Write-Host "Windows provisioner correctly refuses to run on Linux (exit 2)"
package-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
vestibule/helper/target
key: ${{ runner.os }}-cargo-${{ hashFiles('vestibule/helper/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Install Inno Setup
shell: powershell
run: choco install innosetup -y --no-progress
- name: Build installer + XPI
working-directory: vestibule
shell: powershell
run: |
powershell -NoProfile -ExecutionPolicy Bypass -File packaging\build-installer.ps1
# Standalone XPI artifact for policy-based manual deploys.
Compress-Archive -Path extension\* -DestinationPath vestibule-1.2.2.xpi -Force
- name: Upload installer artifact
uses: actions/upload-artifact@v4
with:
name: vestibule-setup-windows
path: |
vestibule/packaging/Output/Vestibule-Setup-1.2.2.exe
vestibule/vestibule-1.2.2.xpi
if-no-files-found: error
package-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
vestibule/helper/target
key: ${{ runner.os }}-cargo-${{ hashFiles('vestibule/helper/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-
- name: Build usher (Linux binary)
working-directory: vestibule/helper
run: cargo build --release
- name: Upload usher binary
uses: actions/upload-artifact@v4
with:
name: vestibule-usher-linux
path: vestibule/helper/target/release/usher
if-no-files-found: error
- name: Build Flatpak bundle
working-directory: vestibule
run: |
flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo
rm -rf packaging/repo packaging/builddir
flatpak-builder --user --install-deps-from=flathub --force-clean \
--repo=packaging/repo packaging/builddir packaging/net.dcos.Vestibule.json
flatpak build-bundle packaging/repo packaging/Vestibule-1.2.2.flatpak \
net.dcos.Vestibule 1.2.2
- name: Upload Flatpak artifact
uses: actions/upload-artifact@v4
with:
name: vestibule-flatpak-linux
path: vestibule/packaging/Vestibule-1.2.2.flatpak
if-no-files-found: error

277
BLOG.md Executable file
View File

@ -0,0 +1,277 @@
# From IEXPLORE.EXE to Rust: Rewriting a 2001 Kiosk Browser
**By Jeremy Anderson** — [dcos.net](https://dcos.net) — info@dcos.net
In December 2001, I was eighteen, coding on co-op while in school
for my first employer — a Boston-area MSP and programming company.
The company serviced medical offices, and one recurring problem was
the lobby computer: patients waiting for appointments would sit down
at it and immediately start looking for things they should not be
looking at, or worse, closing the browser and poking around the rest
of Windows. The receptionists were tired of babysitting it. My job
was to make the computer bulletproof enough to leave alone.
I shipped a solution in Visual Basic 6. It was, in retrospect, a
remarkable little artifact of late-90s teenage ingenuity — and
twenty-five years later, I decided to rewrite it properly.
## What the original did
The genius of the 2001 version was brute force. It embedded the
Internet Explorer COM control (`SHDocVw.dll`) in a maximized VB6 form
with `ControlBox = False` and `WindowState = 2` (maximized). It called
`SetWindowPos` with `HWND_TOPMOST` to keep the window on top of
everything. And — this is the part that made it actually work — it
called `SystemParametersInfo(97, True)`, the legendary undocumented
`SPI_SCREENSAVERRUNNING` flag that convinced Windows 9x it was running
a screensaver, which had the side effect of disabling Ctrl+Alt+Del,
Alt+Tab, and the Windows key.
That alone was not enough. A determined user could still close the VB6
app and reach the desktop. So the app did something beautifully
ruthless: on startup, it copied `C:\Program Files\Internet
Explorer\IEXPLORE.EXE` to `IEXPLORE.bak`, then deleted the original.
If the user managed to escape the kiosk, they would find no browser to
escape to. On unlock, the app copied the backup back into place.
The unlock code lived in plaintext — hardcoded in the form source of
the lobby build, read from `C:\windows\system\smt.txt` by the
earliest iteration. The UI was black background,
white text, Comic Sans. The menu was invisible by default and appeared
only when the mouse touched the top strip of the screen. The
delay loop before unlock was a raw `GoTo` counting loop, a CPU-burner
that pegged the processor at 100% for half a second.
The scrubbed source of all three iterations ships with this repository
under `history/vb6-2001/`.
It ran on Windows 95, 98, 98 SE, and ME. It explicitly refused to run
on NT, 2000, or XP because `SPI_SCREENSAVERRUNNING` does nothing on
NT-family kernels.
It worked. It ran in that lobby for years.
## Why rewrite it
The original program is unusable today for a list of reasons:
- It is VB6. The toolchain is dead. The runtime ships in Windows 11
only for legacy compatibility, and Microsoft has announced its
removal.
- It depends on `SHDocVw.dll`, which is Internet Explorer. IE is gone.
- `SPI_SCREENSAVERRUNNING` was a Win9x-only trick. Modern Windows
(NT kernel) ignores it entirely.
- The IEXPLORE.EXE shell game is impossible on modern Windows due to
filesystem permissions and Windows Resource Protection.
- Plaintext unlock-code storage — hardcoded in the form source, or a
plaintext file on disk — is indefensible.
- The Comic Sans UI is accessibility-hostile.
But the core idea — a public-facing browser that holds the perimeter
and never leaks session data between users — is more relevant than
ever. Medical lobbies still have lobby computers. Libraries still have
public terminals. Trade shows still have demo kiosks. The problem
shape has not changed; only the implementation needs to.
## The architectural decisions
A modern kiosk browser has to answer four questions:
1. **What renders the web content?**
2. **How is the perimeter enforced?**
3. **How is the operator UI protected?**
4. **How is session data sanitized?**
### Rendering: LibreWolf ESR
The constraint was no Chromium. Tauri was rejected because on Windows
it uses WebView2, which is Edge, which is Chromium. Electron was
rejected for the same reason and for its 150 MB binary size. Servo was
rejected because its web compat is still too inconsistent for
arbitrary patient-portal content.
LibreWolf ESR is the right answer. It is Gecko-based (no Chromium),
MPL 2.0 licensed, community-maintained, tracks Firefox ESR for
security, has Mozilla telemetry stripped, and ships enterprise
`policies.json` support for lockdown. It also navigated Mozilla's
trademark policy already, which means redistribution is straightforward.
### Perimeter: OS-level lockdown, not app-level
The original VB6 fought the OS from inside the app. It used Win32 API
calls to disable Ctrl+Alt+Del. It renamed system executables. This was
necessary on Windows 9x because the OS had no kiosk mode.
Modern operating systems do. Windows 10+ has AssignedAccess, which
configures a device as a dedicated kiosk at the OS level — single
auto-logon account, no shell, no escape. Linux has `cage`, a 3 MB
wlroots-based compositor that boots straight into a single client with
no window manager, no shell, no desktop.
The right architecture moves the perimeter enforcement out of the app
and into the OS, where it belongs. Vestibule does not try to disable
Ctrl+Alt+Del from inside the browser. AssignedAccess does that at the
session level, far more reliably than any app-level hook ever could.
### Operator UI: extension + Rust helper
The browser-level logic — URL filtering, hidden menu, session reset,
unlock dialog — lives in a WebExtension. This is the natural unit of
composition for Firefox-family browsers and gives us `webRequest`
blocking, `browsingData` sanitization, and `idle` detection for free.
The OS-level logic — password verification, power event detection,
signaling the supervisor — lives in a tiny Rust binary called `usher`.
It speaks Firefox Native Messaging (a stable, supported stdio-JSON
protocol) to the extension. The binary is 1.2 MB. The extension is
6 KB. Together they replace what would have been a 150 MB Electron
bundle.
### Session sanitization: three layers, defense in depth
The product contract is: a kiosk must never persist patient data
across sessions. Three enforcement layers:
1. **Block all save paths.** `policies.json` disables form history,
password manager, master password creation, Pocket, Screenshots,
Firefox Accounts, search suggestions, extension installs, popup
blocking override, and protocol handler registration.
`SanitizeOnShutdown` wipes everything on browser exit.
2. **Reset on trigger events.** The extension's `resetSession()`
invokes `browser.browsingData.remove()` with all eleven data types
on idle timeout, wake-from-sleep, unlock, and manual reset.
3. **OS-level defense.** AssignedAccess and `cage` ensure the browser
cannot be closed or escaped. The dedicated `vestibule-profile`
directory isolates the kiosk from any user profile data.
## The 2001 gestures that survived
Two original design decisions carry forward unchanged.
**The top-edge hidden menu.** The original VB6 form toggled its menu
in `picAddress_MouseMove`, based on `Y <= 10` (twips — effectively the
top edge of the screen). The modern equivalent is a content script
that listens for `mousemove` and shows a fixed-position overlay when
`clientY <= 3` pixels. Same gesture. Same UX rationale: the menu is invisible
by default, appears on a deliberate gesture, and disappears on
mouse-leave. Patients never see it. Operators always find it.
**The password unlock.** The original prompted for an unlock code and
compared it to the contents of `smt.txt`. The modern equivalent
prompts for an unlock code and verifies it against an Argon2id hash
stored at 0600 by usher. The flow is identical; the
storage and verification are not.
## What did not survive
**The IEXPLORE.EXE shell game.** Modern Windows filesystem permissions
make this impossible. The replacement is a URL allowlist enforced via
`webRequest.onBeforeRequest` in the extension. Stronger, safer, and
does not require renaming system executables.
**The CPU-melting delay loop.** The original used a `GoTo` counting
loop (`beginn: time = time + 1 ... GoTo beginn`) to burn half a second
before unlock. The modern
equivalent is `tokio::time::sleep(Duration::from_millis(500))`. Same
delay, 0% CPU.
**Plaintext password storage.** usher stores the Argon2id hash
(64 MiB memory cost) in a file owned and readable only by the kiosk
user — mode 0600 on Linux, default user ACL on Windows. Keyring
daemons are unavailable on minimal kiosk compositors like cage, so
file permissions are the enforcement boundary. The password itself is
never written anywhere.
**The Comic Sans UI.** The hidden menu uses system fonts
(`-apple-system, "Segoe UI", system-ui, sans-serif`). A retro 2001
theme is a Phase 5 option for those who want the nostalgia.
## Power state awareness
The original VB6 did not handle sleep because Windows 9x kiosks
typically did not sleep. Modern hardware does. Laptops used as kiosks
suspend on lid close. Mini-PCs suspend on idle. Tablets suspend on
inactivity.
Vestibule subscribes to the OS's power events. On Linux, usher
connects to D-Bus and listens for
`org.freedesktop.login1.Manager.PrepareForSleep(false)`, which fires
about two seconds after the system wakes. On Windows, usher registers
for `WM_POWERBROADCAST` and listens for `PBT_APMRESUMEAUTOMATIC`.
On wake, usher emits a `Wake` event to the extension, which calls
`resetSession("wake:suspend")`. The same path fires on idle timeout.
Both go through one code path, so behavior is identical regardless of
trigger.
The decision to detect rather than block sleep is deliberate.
Blocking sleep fights the OS — it causes battery drain on mobile
kiosks, thermal issues on fanless hardware, and interferes with
Windows Update overnight reboots. The OS power profile is the right
place to decide whether a device sleeps; that is a deployment-time
decision, not an app-runtime one. Vestibule reacts correctly when the
OS does sleep, and stays out of the way when it does not.
## What I learned doing this
The 2001 version took me about three weeks of evenings. I knew nothing
about Win32 API calls, COM interop, or filesystem security. I learned
all of it by reading `Declare Function` examples on Planet Source
Code and copying patterns I did not fully understand. The code was
procedural, repetitive, and full of `On Error GoTo` cascades that
existed to patch around the IEXPLORE.EXE rename failing halfway. It
worked because the problem was small and the OS was permissive.
The 2026 version took about a week of focused work. I know Rust, I
know Firefox extension APIs, and I know what the OS primitives
actually do. The code is threaded, table-driven, and structured around
a message-passing channel that makes the data flow obvious. It works
because the abstractions are right, not because I patched around
enough edge cases.
The instinct that survived intact is the one the original reviewer
called out: dig underneath the standard UI and manipulate the
environment directly. The 2001 version did this with Win32 API calls.
The 2026 version does it with D-Bus subscriptions, enterprise policy
files, and a message-passing channel between browser and helper. The
implementation is completely different. The instinct is the same.
## What comes next
Vestibule 1.2 ships the working core: real Argon2id unlock with file
storage at 0600, a dedicated unlock popup window, Windows power event
detection, the `librewolf.overrides.cfg` that selectively relaxes
LibreWolf's resistFingerprinting for SSO flows, per-origin preservation
for the data persistence allowlist — and, on top of that, the OS-level
provisioning wizards (an AssignedAccess setup wizard on Windows with a
Shell Launcher step-down, a `cage` systemd unit on Linux where the unit
IS the graphical session — no display manager at all), full
deprovisioning on both platforms, and the packaging itself: Inno Setup
on Windows and a Flatpak on Linux that acts as a self-describing
deployment kit. Since 1.2.2 the perimeter also extends to the web
itself: the URL policy blocks every domain by default and opens only
what the operator safelists — domain-based matching, with the home
page guaranteed reachable so the kiosk can never lock itself out.
Phase 3 adds crash auto-restart integration testing, telemetry hooks,
and the `always-on` power-inhibit mode. Later phases: Flathub
distribution, remote management, and the retro 2001 theme.
One platform decision is already settled: Vestibule targets Linux
first. The Windows stack works and stays CI-validated, but its
binaries ship unsigned — a code-signing certificate is an unfunded
line item unless a donation covers it — and Linux has no signing
gate to care about.
Phase 5+, deferred: webcam presence detection. When a webcam is
present, usher captures a baseline face embedding at session start and
watches for two conditions: no face visible for N seconds (user
walked away) or face embedding mismatch (different user approached).
Either triggers an immediate session reset, faster than the idle
timeout. Privacy review precedes any biometric code. Frames are
processed in-memory only; embeddings never leave the local process;
the webcam LED is respected.
*Jeremy Anderson, 2026. [dcos.net](https://dcos.net). info@dcos.net.*

381
DEPLOYMENT.md Normal file
View File

@ -0,0 +1,381 @@
# Deployment — Vestibule
This is the operator runbook for turning a stock machine into a Vestibule
kiosk: OS-level lockdown provisioning and packaging, on both platforms,
with one-command deprovision.
```
What provisioning configures (Windows) What it configures (Linux)
──────────────────────────────────────── ──────────────────────────
AssignedAccess single-app kiosk (MDM bridge) cage Wayland session on a VT
└ step-down: Shell Launcher (Ent/Edu) └ /etc/systemd/system/
Start Menu shortcut with stable AUMID vestibule-kiosk.service
Dedicated kiosk local account /usr/local/bin/usher
Automatic logon (registry) /usr/local/bin/vestibule-kiosk-launch
C:\ProgramData\Vestibule\kiosk.env /etc/vestibule/kiosk.env
policies.json (extension force-installed) policies.json (same mechanism)
Per-user bootstrap at kiosk logon Native Messaging manifests for
(kiosk-launch.ps1: usher, HKCU host the kiosk user
registration, profile, crash restart) /opt/vestibule staging tree
```
Everything the scripts do is reversible — see
[Deprovision](#deprovision) for the one-command exit on each platform.
**Platform priority: Linux.** The Linux path is the supported
production target — provisioned, packaged, and installed with no
signing gate. The Windows path is complete and CI-validated but ships
unsigned: a code-signing certificate is an unfunded line item and
stays that way unless a donation earmarks it (info@dcos.net), so
SmartScreen warns on the installer and the usher binary. Windows
operators verify the SHA-256 from the release notes; Linux operators
install and go.
---
## Choosing a path
| Situation | Command |
|---|---|
| Linux kiosk, native LibreWolf | `sudo scripts/provision-kiosk.sh --librewolf native` |
| Linux kiosk, Flatpak LibreWolf | `sudo scripts/provision-kiosk.sh --librewolf flatpak` |
| Linux kiosk, native Firefox | `sudo scripts/provision-kiosk.sh --firefox native` |
| Linux kiosk, Flatpak Firefox | `sudo scripts/provision-kiosk.sh --firefox flatpak` |
| Windows kiosk, interactive | Run the installer, tick the wizard checkbox — or `provision-kiosk.ps1` standalone |
| Windows kiosk, unattended (MDM/CI) | `Vestibule-Setup-1.2.2.exe /VERYSILENT` then `provision-kiosk.ps1 -Quiet` (exit codes below) |
| Just want to see what it would do | `--check` on either platform |
## Browser support
Vestibule runs on two Gecko browsers. The extension, usher, and
lockdown policies are identical for both — only the deployment paths
differ.
| Browser / flavor | Windows | Linux native | Linux Flatpak | Linux snap |
|---|---|---|---|---|
| LibreWolf | Program Files install, `distribution\policies.json` | install-dir `distribution/` | system Flatpak (policies in app dir) | — |
| Firefox / ESR | Program Files install, `distribution\policies.json` | distro package → `/etc/firefox/policies` | system Flatpak (policies + XPI in kiosk home) | Ubuntu snap (policies + XPI in kiosk home) |
Notes:
- **Auto-detect order**: LibreWolf native → LibreWolf Flatpak → Firefox
native → Firefox Flatpak → Firefox snap. Override with
`--librewolf`/`--firefox` (Linux) or `-Browser` (Windows).
- **Firefox ESR is recommended** for kiosks — slower release cadence,
same enterprise-policy engine.
- **Flatpak/snap Firefox keep policies and the XPI in the kiosk user's
home** (`~/.var/app/...` / `~/snap/firefox/common`), so they survive
browser updates — unlike LibreWolf's system-Flatpak deploy.
- **Trademark**: Vestibule configures an existing Firefox install; it
never downloads or redistributes Firefox. (The original choice of
LibreWolf as the default base was about redistribution — deploying
against an installed Firefox has no trademark exposure.)
- `librewolf.overrides.cfg` is LibreWolf-only; on Firefox it is skipped
with a notice (Firefox ignores it safely anyway).
---
## Windows
### Prerequisites
- Windows 10/11 **Pro, Enterprise, or Education** (Home has no
AssignedAccess/Shell Launcher — the script fails fast with exit 2)
- LibreWolf **or Firefox/ESR** installed (`C:\Program Files\...` —
auto-detected; override with `-Browser librewolf|firefox`)
- The Vestibule installer run (or a repo checkout with a built usher:
`cd helper; cargo build --release`)
- PowerShell run **as administrator**
### Interactive provisioning
```powershell
powershell -ExecutionPolicy Bypass -File scripts\provision-kiosk.ps1
```
The wizard prompts for the home URL and generates the kiosk account
password. Everything else is automatic: staging, XPI build, policies
merge, shortcut + AUMID, AssignedAccess, autologon. Exit code 10 means
"reboot to activate".
### Unattended provisioning
```powershell
powershell -ExecutionPolicy Bypass -File scripts\provision-kiosk.ps1 `
-KioskUser Kiosk `
-KioskPassword '<from-your-secrets-store>' `
-HomeUrl https://checkin.example.org `
-Quiet -Restart
```
Parameters:
| Parameter | Default | Meaning |
|---|---|---|
| `-Browser` | `auto` | `librewolf` / `firefox` / `auto` (LibreWolf preferred) |
| `-KioskUser` | `VestibuleKiosk` | Dedicated local account (created or reused) |
| `-KioskPassword` | generated + printed | Account password (also used for autologon) |
| `-HomeUrl` | `about:blank` | Page the kiosk session opens |
| `-InstallRoot` | detected | Staged install location |
| `-Quiet` | off | No prompts — for MDM/CI rollout |
| `-Check` | off | Validate prerequisites only, change nothing |
| `-ShellLauncher` | off | Force Shell Launcher instead of the AssignedAccess bridge |
| `-NoAutoLogon` | off | Skip autologon (kiosk starts after manual logon) |
| `-Restart` | off | Reboot automatically when required |
| `-InstallOverridesCfg` | off | Also deploy `librewolf.overrides.cfg` (SSO/telehealth) |
Exit codes (also used by CI):
| Code | Meaning |
|---|---|
| 0 | Success, no reboot needed |
| 10 | Success — reboot required to activate |
| 2 | Unsupported (Home edition / not elevated / not Windows) |
| 3 | Prerequisite missing (LibreWolf, usher) |
| 4 | Kiosk account error |
| 5 | Lockdown apply failed (AssignedAccess AND Shell Launcher) |
| 6 | Invalid parameters |
### How the lockdown is applied
1. **AssignedAccess via the MDM WMI bridge** (`MDM_AssignedAccess`
`SetSingleAppKiosk`) — the supported scriptable path on Pro and
higher. The kiosk app is a Start Menu shortcut carrying a stable
AppUserModelID (`Vestibule.Kiosk`), verified through `Get-StartApps`
before the XML is applied.
2. **Shell Launcher step-down** (`WESL_UserSetting.SetCustomShell`) on
Enterprise/Education when the bridge rejects the config. The feature
is enabled on demand; that path needs one extra reboot.
3. **kiosk-launch.ps1** is the actual shell process. It runs as the
kiosk user (no admin): installs usher per-user, registers the Native
Messaging host under HKCU, creates `vestibule-profile`, then launches
`librewolf --kiosk -P vestibule-profile <home-url>` and relaunches it
if it exits. Log: `%LOCALAPPDATA%\Vestibule\kiosk-launch.log`.
### Verification checklist
- [ ] Reboot → machine logs in as the kiosk account automatically
- [ ] LibreWolf opens full-screen on the home URL, no tabs/URL bar
- [ ] Browser Console (`Ctrl+Shift+J`) shows `[vestibule] usher hello: usher 1.2.2`
- [ ] Any domain not on the safelist shows the Vestibule block page (default policy; the home URL's domain is always permitted)
- [ ] Idle 5 min → session resets to home URL, data cleared
### Admin escape hatches
- **Ctrl+Alt+Del** still works under AssignedAccess — Sign out / switch
user to reach an admin account.
- **Hold Shift during boot** to bypass automatic logon once.
- Kiosk account has no interactive way to change its password (locked
via `UserMayNotChangePassword`).
### Building the installer
```powershell
cd helper; cargo build --release; cd ..
powershell -ExecutionPolicy Bypass -File packaging\build-installer.ps1
```
Requires Inno Setup 6 (`choco install innosetup -y`). Output:
`packaging\Output\Vestibule-Setup-1.2.2.exe`. CI builds it on every push
(see [CI artifacts](#ci-artifacts)).
---
## Linux
### Prerequisites
- Any systemd distribution
- **cage** (the Wayland kiosk compositor) — Debian 12+/Ubuntu 24.04+/
Fedora/Arch packages it; elsewhere build from
[cage-kiosk/cage](https://github.com/cage-kiosk/cage)
- **LibreWolf or Firefox** (ESR recommended) — native package
([LibreWolf install docs](https://librewolf.net/installation/linux/),
distro Firefox, system-wide Flatpak, or the Ubuntu snap)
- `python3` (policies merge + XPI build), `dbus` (session bus for cage
children)
- A built usher: `cd helper && cargo build --release`
The script never auto-installs anything: if something is missing it
prints the exact per-distro commands and exits 3.
### Interactive provisioning
```sh
sudo ./scripts/provision-kiosk.sh
```
### Unattended provisioning
```sh
sudo ./scripts/provision-kiosk.sh \
--home-url https://checkin.example.org \
--kiosk-user kiosk \
--tty 2 \
--librewolf native \
--yes --start
```
Parameters:
| Parameter | Default | Meaning |
|---|---|---|
| `--home-url URL` | `about:blank` | Page the kiosk session opens |
| `--kiosk-user NAME` | `vestibule-kiosk` | Account (created with locked password) |
| `--tty N` | `2` | VT for the cage session (1–12) |
| `--librewolf FLAVOR` | auto | Use LibreWolf: `native` / `flatpak` |
| `--firefox FLAVOR` | auto | Use Firefox: `native` / `flatpak` / `snap` (mutually exclusive with `--librewolf`) |
| `--usher-bin PATH` | auto | Explicit usher binary |
| `--start` | off | Start the session immediately (otherwise: enable only) |
| `--yes` | off | Skip confirmation |
| `--check` | off | Validate prerequisites only |
Exit codes: 0 success · 2 not root / no systemd · 3 prerequisite missing
· 4 account error · 5 unit failure · 6 bad parameters.
### How the lockdown is applied
- `vestibule-kiosk.service` is a **system unit that IS the graphical
session**: cage starts on the chosen VT at boot as the kiosk user via
`PAMName=login` (runtime dir from pam_systemd), no display manager
involved, `Restart=always` for crash recovery.
- `/usr/local/bin/vestibule-kiosk-launch` execs
`dbus-run-session -- cage -d -- librewolf --kiosk -P vestibule-profile
<url>` (or `flatpak run io.gitlab.librewolf-community …` for the
Flatpak flavor). `MOZ_ENABLE_WAYLAND=1` is mandatory — Gecko defaults
to X11 and cage ships no Xwayland.
- The kiosk account is created with a **locked password**: it can never
be logged into interactively, only entered via the systemd session.
- policies.json is deep-merged into LibreWolf's `distribution/` dir
(existing file backed up to `policies.json.vestibule-bak`) and
force-installs the extension XPI on every browser start — no
about:debugging step on the kiosk.
Reconfigure at any time by editing `/etc/vestibule/kiosk.env` (home URL,
browser + flavor, cage flags) — the unit re-reads it on every start.
### Where policies land per browser (Linux)
| Flavor | policies.json | XPI | Update-safe? |
|---|---|---|---|
| LibreWolf native | `<install>/distribution/` | `/opt/vestibule/extension/` | yes |
| LibreWolf Flatpak | Flatpak app dir `files/librewolf/distribution/` | kiosk home `~/.var/app/<id>/` | no — re-run after updates |
| Firefox native | `/etc/firefox/policies/` | `/opt/vestibule/extension/` | yes |
| Firefox Flatpak | kiosk home `~/.var/app/org.mozilla.firefox/.mozilla/policies/` | kiosk home | yes |
| Firefox snap | kiosk home `~/snap/firefox/common/.mozilla/policies/` | kiosk home | yes |
For Flatpak/snap flavors the browser's filesystem is the kiosk home
remap — that is why the XPI is copied there and `install_url` points
inside the sandbox-visible home rather than `/opt/vestibule`.
### Verification checklist
- [ ] `systemctl status vestibule-kiosk` — active (running)
- [ ] The VT shows LibreWolf full-screen on the home URL
- [ ] `journalctl -u vestibule-kiosk -f` shows the launcher + cage
- [ ] Ctrl+Alt+F3 switches to a text VT (`cage -d`); Ctrl+Alt+F2 returns
- [ ] `sudo systemctl restart vestibule-kiosk` recovers from a killed
browser within seconds
### Flatpak LibreWolf notes (honest limitations)
1. **Updates wipe the policy layer.** The policies live inside
`/var/lib/flatpak/app/io.gitlab.librewolf-community/…`, which is
replaced on every update. Re-run `provision-kiosk.sh` afterwards.
The extension's own session sanitization (layers 2–3) is unaffected.
2. **Native Messaging under the Flatpak depends on the flatpak's host
visibility.** Manifests are written to all five conventional
locations for the kiosk user; if the flatpak ignores them, unlock
falls back to "not configured" while URL policy and session resets
keep working. Native LibreWolf (or Firefox native) has no such
caveat.
The Firefox Flatpak and snap do not share limitation 1: their policies
and XPI live in the kiosk user's home, which updates never touch.
### Building the Flatpak
```sh
./packaging/build-flatpak.sh
```
Output: `packaging/Vestibule-1.2.2.flatpak` (requires flatpak-builder;
first run downloads the Freedesktop 24.08 SDK + Rust extension). The
Flatpak carries the whole deployment kit — `flatpak run
net.dcos.Vestibule provision` prints the exact host-side command.
---
## Deprovision
Both deprovision scripts reset the machine to its pre-Vestibule state.
Each policy directory is returned to its baseline: the operator's backed-up
policies.json is reinstalled, or Vestibule's generated file is deleted where
no baseline exists (byte-for-byte equality is asserted by the test suite).
```powershell
# Windows — remove lockdown, autologon, shortcut, policies.
# Optional: -RemoveKioskAccount -RemoveInstall -Restart
powershell -ExecutionPolicy Bypass -File scripts\deprovision-kiosk.ps1
```
```sh
# Linux — stop/disable/remove the unit, launcher, env, policies, manifests.
# Optional: --remove-user --remove-opt --remove-usher
sudo ./scripts/deprovision-kiosk.sh
```
The Windows uninstaller (Add/Remove Programs) offers to run the
deprovision step automatically.
---
## Security notes operators must read
1. **Windows autologon stores the kiosk password in plaintext registry**
(`Winlogon \ DefaultPassword`). On a locked-down single-purpose
appliance this is an accepted trade-off — the account is the least
privileged thing on the machine and the browser is the only shell.
Use `-NoAutoLogon` and manual logon if your threat model disagrees.
2. **The installer and usher binary are unsigned — a standing decision,
not an oversight.** A code-signing certificate costs $200–500/year
and stays an unfunded line item unless a donation earmarks it
(info@dcos.net). SmartScreen will warn; verify the SHA-256 from the
release notes. This is why Linux is the primary target — it has no
signing gate. See README "Honest limitations".
3. **The extension is policy-installed from disk** (force_installed).
That is deliberate: kiosks have no AMO session, and the policy
re-installs the XPI on every start if it is removed.
4. **Linux kiosk account password stays locked.** There is nothing to
brute-force; the session is entered only via systemd.
---
## Troubleshooting
| Symptom | Platform | Fix |
|---|---|---|
| Black screen after enabling cage | Linux | `MOZ_ENABLE_WAYLAND=1` missing (our launcher sets it); check `journalctl -u vestibule-kiosk` |
| cage fails in a VM | Linux | No DRM: add `WLR_LIBINPUT_NO_DEVICES=1` and `WLR_RENDERER=pixman` to the unit (`systemctl edit vestibule-kiosk`) |
| Flatpak LibreWolf won't start under cage | Linux | Missing session bus — the launcher wraps everything in `dbus-run-session`; check flatpak is installed system-wide, not per-user |
| Snap Firefox detected but policies ignored | Linux | Verify `~/snap/firefox/common/.mozilla/policies/policies.json` exists for the **kiosk user** (not your own); re-run provision with `--firefox snap` |
| AssignedAccess applies but kiosk shows black/Start | Windows | AUMID didn't resolve — check `Get-StartApps \| ? AppID -eq Vestibule.Kiosk`; re-run provisioning after a reboot |
| `[vestibule] usher hello` missing | both | Per-user bootstrap failed: read `%LOCALAPPDATA%\Vestibule\kiosk-launch.log` (Windows) or check NM manifests in the kiosk home (Linux) |
| AssignedAccess XML rejected (exit 5) | Windows | Use `-ShellLauncher` on Enterprise/Education, or apply via Settings → Accounts → Other users → Set up kiosk |
| Policies not applied | both | policies.json needs a full browser restart; verify it parses and that the distribution dir matches the running LibreWolf |
| exit 3 on `--check` | both | Prerequisite missing — the script prints the exact install commands |
---
## CI artifacts
Every push to `main` builds (`.github/workflows/ci.yml`):
| Artifact | Job | Contents |
|---|---|---|
| `vestibule-setup-windows` | package-windows | `Vestibule-Setup-1.2.2.exe` (Inno Setup), the extension XPI |
| `vestibule-flatpak-linux` | package-linux | `Vestibule-1.2.2.flatpak` (Freedesktop 24.08) |
| `vestibule-usher-linux` | package-linux | The Linux usher binary from the same commit |
Download from the workflow run page. Tag a release to attach them to a
GitHub Release.

21
LICENSE Executable file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jeremy Anderson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

210
QUICKSTART.md Executable file
View File

@ -0,0 +1,210 @@
# Quickstart
Get Vestibule running in under five minutes on a Linux or Windows
development machine. For architecture and security model, see
[README.md](README.md).
## Prerequisites
- **LibreWolf** 115+ installed ([librewolf.net](https://librewolf.net))
— or **Firefox** 115+ / Firefox ESR (works identically: the
extension, usher, and policies are engine-standard)
- **Rust** 1.70+ installed via [rustup](https://rustup.rs)
- **Python 3** for the smoke test
- **Node.js** for the URL policy unit tests (`node
scripts/test-url-policy.js`; CI runs them on every push)
## Build
```sh
cd helper
cargo build --release
cd ..
```
The usher binary lands at `helper/target/release/usher`
(Linux) or `helper\target\release\usher.exe` (Windows).
## Verify usher
Run the smoke test without LibreWolf. Exercises the Native Messaging
protocol end-to-end: hello handshake, ping/pong, unlock refusal, and
wake event simulation.
```sh
python3 scripts/test-native-messaging.py
```
Expected last line:
```
OK — usher production protocol works: Argon2id unlock + wake events.
```
The smoke test exercises the full production protocol: hello, ping,
set-unlock (stores Argon2id hash to disk), unlock with wrong password
(refused), unlock with correct password (granted), and wake simulation.
It cleans up the hash file after itself.
If this fails, usher will not work in LibreWolf either. Fix before
proceeding.
## Register usher as a Native Messaging host
### Linux
```sh
./scripts/install-native-host.sh
```
Installs to `~/.local/bin/usher` and registers the manifest in both
`~/.librewolf/native-messaging-hosts/` and
`~/.mozilla/native-messaging-hosts/`. No sudo required.
### Windows (PowerShell)
```powershell
powershell -ExecutionPolicy Bypass -File scripts\install-native-host.ps1
```
Installs to `%LOCALAPPDATA%\Vestibule\usher.exe` and registers under
`HKCU\Software\Mozilla\NativeMessagingHosts\com.vestibule.usher`. No
admin elevation required.
## Load the extension in LibreWolf
1. Open LibreWolf (or Firefox — the steps are identical).
2. Navigate to `about:debugging#/runtime/this-firefox`.
3. Click **Load Temporary Add-on...**.
4. Select `extension/manifest.json`.
5. Open the Browser Console (`Ctrl+Shift+J`).
6. Verify these lines appear:
```
[vestibule] background loaded, version 1.2.2
[vestibule] admin wizard: Ctrl+Shift+V or gear icon
[vestibule] usher hello: usher 1.2.2
```
If `[vestibule] usher hello` does not appear, usher failed to connect.
Re-run the install script and restart LibreWolf.
## Configure the kiosk
1. Press `Ctrl+Shift+V`, or move the mouse to the top 3px of the
viewport and click the gear icon.
2. On first run, the wizard forces you to set an admin password (8+
characters). This password gates the wizard; it is separate from
the kiosk unlock password.
3. Walk through the six steps:
- **Kiosk identity** — name, home URL. In safelist mode the wizard
shows the home URL's domain and whether it is on the safelist,
with a one-click add button.
- **URL policy** — safelist (default: block every domain except the
listed ones), or open / blocklist / allowlist for advanced needs
- **Session behavior** — idle timeout (default 300s), onReset
(default "both"), per-domain persistence allowlist
- **Power management** — aware (default) or always-on
- **Unlock method** — password (default); PIN selects a numeric
unlock code; TOTP arrives with Phase 5+
- **Review and save** — a safelist configuration whose home domain
is not listed is refused at save time
4. Save. The new policy takes effect immediately — no restart required.
## Verify the pieces work
### Hidden menu
Navigate to any website. Move the mouse to the top 3px of the
viewport. A small dark toolbar slides down with Back, Forward,
Refresh, Home, Unlock, and Admin buttons.
### URL safelist
Until configured otherwise, the kiosk blocks every external domain.
With the default policy (safelist mode, empty list), navigate to
`https://example.org`. The navigation lands on the Vestibule block
page, which names the blocked domain. The Browser Console shows:
```
[vestibule] blocked: https://example.org/
```
Add `example.org` on wizard step 2 and navigate again — the site
loads, subdomains included. Every domain the kiosk content pulls from
(CDNs, SSO, fonts) must be listed the same way; a missing one shows
as a broken resource on an otherwise-working page, and the console
logs the exact blocked URL.
### Wake detection (Linux)
Suspend the system:
```sh
systemctl suspend
```
Wake the system. The Browser Console shows:
```
[vestibule] wake event from usher, reason: suspend
[vestibule] resetting session (reason: wake:suspend)
[vestibule] all browsing data cleared
```
### Unlock path
Click the lock icon in the hidden menu. A dedicated popup window opens
(not `window.prompt`). Enter the kiosk unlock password. If correct, the
lock overlay clears and admin grace mode activates (10 minutes of
no-reset for maintenance). If wrong, the popup shows an error.
The unlock password is verified by usher against an Argon2id hash stored
at `~/.config/vestibule/unlock.hash` (Linux) or
`%APPDATA%\Vestibule\unlock.hash` (Windows), file mode 0600. The hash
never touches browser storage.
### Optional: install usher as a system service
For crash recovery and pre-LibreWolf power monitoring:
```sh
# Linux (systemd user service)
./scripts/install-usher-service.sh
systemctl --user start vestibule-usher.service
# Windows (Scheduled Task)
powershell -ExecutionPolicy Bypass -File scripts\install-usher-task.ps1
```
### Optional: install LibreWolf overrides
For SSO and telehealth compatibility (relaxes resistFingerprinting,
enables WebGL/WebRTC/EME for patient portals):
```sh
# Linux
sudo cp config/librewolf.overrides.cfg /usr/lib/librewolf/
# Windows (run as admin)
copy config\librewolf.overrides.cfg "C:\Program Files\LibreWolf\distribution\"
```
## Troubleshooting
| Symptom | Fix |
|---|---|
| `usher hello` does not appear in console | Re-run `install-native-host.sh`; restart LibreWolf |
| `Error: Native host exited` | Check `usher` is executable (`chmod +x ~/.local/bin/usher`); run it manually to see stderr |
| Hidden menu does not appear | The content script may have failed on a specific page; check the console for `[vestibule] content script loaded` |
| Wake events do not fire on Linux | Verify D-Bus is running (`dbus-send --session --dest=org.freedesktop.DBus --type=method_call --print-reply /org/freedesktop/DBus org.freedesktop.DBus.ListNames`); usher logs `[usher] power: D-Bus connect failed` if not |
| Wake events do not fire on Windows | Verify usher is running (`Get-ScheduledTask -TaskName VestibuleUsher`); check Event Viewer > Windows Logs > Application for `[usher] power:` messages |
## Next steps
- Read [README.md](README.md) for the architecture and security model.
- Read [DEPLOYMENT.md](DEPLOYMENT.md) to provision a real kiosk machine
(OS lockdown included) — the natural next step after this quickstart.
- Read [BLOG.md](BLOG.md) for the narrative behind the rewrite.
- Read [docs/QA-PASS.md](docs/QA-PASS.md) for the production readiness
review.

335
README.md Executable file
View File

@ -0,0 +1,335 @@
# Vestibule
**A kiosk lockdown browser built on LibreWolf (or Firefox ESR) + a Rust native helper.**
Linux-first, single codebase, zero Chromium in the stack. Designed
for medical lobbies, public terminals, and any environment where a
browser must serve the public without leaking session data between
users.
- **Author:** Jeremy Anderson — [dcos.net](https://dcos.net) — info@dcos.net
- **License:** MIT (see [LICENSE](LICENSE))
- **Platforms:** Linux is the primary target — provisioned, packaged,
and installed with no signing gate. The Windows stack is complete
and CI-validated but ships unsigned: a code-signing certificate
costs $200–500/year, the project does not buy one, and SmartScreen
warns accordingly. A donation earmarked for code signing
(info@dcos.net) changes that decision.
- **Status:** Production-ready and deployable. Real Argon2id unlock, real per-origin cookie preservation, real power event detection (Linux D-Bus + Windows WM_POWERBROADCAST), safe-by-default navigation (every domain blocked until the operator safelists it), dedicated unlock popup, systemd/scheduled-task supervision, CI matrix, and (Phase 2) one-command OS-level lockdown provisioning on both platforms plus Inno Setup / Flatpak packaging.
---
## What Vestibule does
A public-facing browser that:
1. **Locks the perimeter at the OS level.** AssignedAccess on Windows,
`cage` compositor on Linux. The device is the kiosk, not an app
pretending to be one.
2. **Sanitizes every session.** Cookies, history, cache, formData,
downloads, localStorage, indexedDB, pluginData, serviceWorkers,
passwords, sessions — all wiped on idle timeout, wake-from-sleep,
unlock, and browser shutdown. No partial resets.
3. **Detects power state changes.** Subscribes to `login1.Manager` on
Linux via D-Bus. Emits `Wake` events on resume. The extension resets
the session on every wake.
4. **Hides its UI by default.** A menu appears only when the mouse
touches the top 3px of the viewport — the 2001 gesture, unchanged:
invisible to patients, findable by operators.
5. **Gates configuration behind a wizard.** Six-step flow, password-
protected, covering kiosk identity, URL policy, session behavior,
power management, and unlock method.
6. **Blocks the web by default.** The URL policy ships in safelist
mode: every domain is blocked — pages, frames, CDNs, everything —
until the operator lists it. The home page's domain is validated
against the safelist in the wizard and guaranteed navigable at
runtime, so the kiosk can never lock itself out.
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ OS-level lockdown (provisioned by Phase 2 scripts) │
│ • Windows: AssignedAccess + auto-logon local account │
│ (Shell Launcher step-down on Ent/Edu) │
│ • Linux: cage compositor + systemd unit on a VT │
│ → scripts/provision-kiosk.ps1 / .sh — see DEPLOYMENT.md │
└────────────────────────┬────────────────────────────────┘
│ launches at boot
▼
┌─────────────────────────────────────────────────────────┐
│ LibreWolf / Firefox ESR (kiosk mode) │
│ • --kiosk URL │
│ • dedicated profile (vestibule-profile) │
│ • policies.json (strict lockdown, see config/) │
│ • auto-restart via systemd / scheduled task │
└────────────────────────┬────────────────────────────────┘
│ loads extension
▼
┌─────────────────────────────────────────────────────────┐
│ Vestibule WebExtension │
│ • URL policy engine — safelist default, domain-based │
│ (webRequest; legacy open/blocklist/substring modes) │
│ • hidden menu (3px-from-top gesture) │
│ • session reset on wake / idle / unlock │
│ • idle polling (browser.idle.queryState, 30s) │
│ • admin wizard (Ctrl+Shift+V or gear icon) │
└────────────────────────┬────────────────────────────────┘
│ Native Messaging (stdio JSON)
▼
┌─────────────────────────────────────────────────────────┐
│ usher (Rust binary, ~1.2 MB) │
│ Threaded architecture: │
│ • main — stdin read loop, message dispatch │
│ • power — D-Bus PrepareForSleep (Linux) │
│ • writer — owns stdout lock │
└─────────────────────────────────────────────────────────┘
```
## Why this stack
| Alternative | Rejection reason |
|---|---|
| Tauri 2 (WebView2) | WebView2 is Edge/Chromium |
| Electron | Ships full Chromium, ~150 MB binaries |
| Servo | Web compat gaps on arbitrary kiosk content |
| Stock Firefox ESR | Mozilla trademark policy complicates redistribution |
| Extension-only | Cannot reach OS keyring or supervisor process |
| Per-platform native (WinUI 3 + GTK4) | Two codebases, violates single-coder ethos |
The chosen stack delivers: one Rust codebase for the helper, one JS
codebase for the extension, Gecko as the webview (no Chromium), 1.2 MB
helper binary, ~6 KB extension.
Note the Firefox row above concerns *basing the shipped product on
Firefox* (trademark limits on redistribution). Deploying against an
existing Firefox install is different and fully supported since
Phase 2.1: the extension, usher, and policies are engine-standard, and
the provisioning wizards detect Firefox (native, Flatpak, or snap)
with LibreWolf still the preferred default. See
[DEPLOYMENT.md](DEPLOYMENT.md).
## Quick start
See [QUICKSTART.md](QUICKSTART.md) for the fast path. Summary:
```sh
cd helper && cargo build --release && cd ..
python3 scripts/test-native-messaging.py
./scripts/install-native-host.sh # Linux
# or: powershell -File scripts/install-native-host.ps1 # Windows
```
Then load `extension/manifest.json` in LibreWolf via `about:debugging`.
For production kiosks, skip the manual steps entirely:
[DEPLOYMENT.md](DEPLOYMENT.md) provisions the whole machine (OS lockdown
included) with one command per platform, and the CI-built installers
are ready to ship.
## Configuration
Open the admin wizard via the gear icon in the hidden menu, or
`Ctrl+Shift+V`. The wizard enforces a setup password on first run and
walks through six steps:
| Step | Scope |
|---|---|
| 0 | Set or enter admin password |
| 1 | Kiosk identity (name, home URL, attract URL) |
| 2 | URL policy (safelist default; open / blocklist / allowlist advanced) |
| 3 | Session behavior (idle timeout, onReset, persistence allowlist) |
| 4 | Power management (aware / always-on, on_wake behavior) |
| 5 | Unlock method + kiosk unlock password |
| 6 | Review and save |
Two distinct passwords protect two distinct surfaces:
| Password | Protects | Storage | Algorithm |
|---|---|---|---|
| Admin | Wizard access, config | browser.storage.local | PBKDF2-SHA-256, 100k iters |
| Kiosk unlock | Session release | file storage via usher (0600) | Argon2id |
Recovery: OS-level reset only. Delete the `vestibule-profile/storage`
directory and re-run the wizard. A backup recovery code is a Phase 1
task.
## Security model
### Data sanitization
Three enforcement layers, defense in depth:
1. **Block all save paths** — `policies.json` disables form history,
password manager, master password creation, Pocket, Screenshots,
Firefox Accounts, search suggestions, Firefox Home widgets,
extension installs, popup blocking override, protocol handler
registration, and encrypted media extensions. `SanitizeOnShutdown`
wipes Cache, Cookies, Downloads, FormData, History, Sessions,
SiteSettings, and OfflineApps on every browser exit.
2. **Reset on trigger events** — `resetSession()` in `background.js`
invokes `browser.browsingData.remove()` with all 11 data types on
idle timeout, wake-from-sleep, unlock, and manual reset. Resets are
debounced (5s window) to coalesce simultaneous triggers.
3. **OS-level defense** — AssignedAccess (Windows) and `cage` (Linux)
ensure the browser cannot be closed or escaped. The dedicated
`vestibule-profile` directory isolates the kiosk from any user
profile data.
### URL policy
The default mode is **safelist**: every request whose hostname is not
on the operator-maintained safelist is blocked. Matching is
hostname-based — the entry `example.org` grants `example.org` and any
subdomain, and nothing else. A URL that merely contains
`example.org` as a substring (a query string, a path segment, a
userinfo prefix) does not match; that smuggle class is exactly what
domain matching closes.
Two guarantees keep the default safe without bricking the kiosk:
- **Internal schemes are always permitted** (`about:`,
`moz-extension:`, `chrome:`, `resource:`) — the admin wizard, the
unlock popup, and `about:blank` keep working with an empty list.
- **The home origin is always navigable.** Session reset, wake, and
idle timeout all navigate home; the engine exempts the home URL's
hostname so an operator error cannot strand the kiosk on its own
block page. The wizard additionally refuses to save a safelist
configuration whose home (or attract) domain is not listed.
Blocked top-level navigations land on an in-extension block page that
names the blocked domain; blocked subresources are cancelled outright.
An empty safelist therefore degrades to a kiosk that shows `about:blank`
and nothing else — safe by default, open by explicit operator action.
Provisioned kiosks bridge the two worlds automatically: the provisioner
launches the browser with the kiosk home URL on the command line, and
while the policy is still the default, the extension adopts that
startup page as the home URL and adds its domain as the first safelist
entry. Only pages the browser was launched with qualify — a URL typed
after boot is never adopted — so the default-deny posture holds
against walk-up users on an unconfigured kiosk.
The legacy modes remain for deployments that need them: `open` (no
filtering), `blocklist` (substring deny), and `allowlist` (substring
allow — an empty allowlist allows everything; prefer safelist). A
corrupted or unrecognized mode string fails closed to safelist
semantics.
### Power state awareness
Vestibule detects wake-from-sleep and resets the session. It does not
block sleep — the OS power profile decides whether the device sleeps,
and that is a deployment-time decision.
| Platform | API | Event |
|---|---|---|
| Linux | D-Bus `org.freedesktop.login1.Manager.PrepareForSleep(false)` | ~2s after wake |
| Windows | `RegisterPowerSettingNotification` + `WM_POWERBROADCAST` (Phase 1) | `PBT_APMRESUMEAUTOMATIC` |
Crash recovery: if LibreWolf crashes during sleep, the supervisor
(systemd / scheduled task) restarts LibreWolf, usher cold-starts, and
usher's first action on `hello` emits a `Wake` event to the extension
(Phase 1 implementation; spike skips this to avoid wiping data on every
reconnect during testing).
## Phase roadmap
| Phase | Scope | Status |
|---|---|---|
| 0 | Architecture spike: extension + usher + Native Messaging + URL filter + wake detection + session reset + admin wizard | done |
| 1 | Argon2id unlock via file storage (0600); dedicated unlock popup; Windows power events; per-origin cookie preservation; `librewolf.overrides.cfg`; systemd + scheduled-task supervision; CI matrix | done |
| 2 | OS-level lockdown provisioning (AssignedAccess wizard on Win, cage systemd unit on Linux); packaging (Inno Setup on Windows, Flatpak on Linux); full deprovision | done |
| 3 | Crash auto-restart integration testing; telemetry hooks; remote management API stub; `always-on` power-inhibit mode | planned |
| 4 | Store distribution (Flathub submission); additional package formats (MSI, AppImage, .deb) only if deployments demand them | planned |
| 5 | Remote management API; retro 2001 theme; accessibility pass; contributor docs | planned |
| 5+ | Webcam presence detection (see [TODO](#webcam-presence-detection)) | deferred |
## Webcam presence detection
Deferred to Phase 5+. When a webcam is detected at startup, usher will
use it to:
1. Detect "user walked away" — no face visible for N seconds triggers
immediate session reset (faster than the idle timeout).
2. Detect "different user approached" — face embedding mismatch
triggers reset.
Privacy guarantees (non-negotiable):
- Frames processed in-memory only, never written to disk
- Face embeddings never leave the local process
- No telemetry, no cloud API calls
- Webcam LED respected; no capture while camera is in use by another
application
Deferred because: privacy review must precede any biometric code;
OpenCV/dlib adds ~50 MB to the usher binary; the idle timeout and wake
detection in the spike cover the common cases. See
`config/vestibule.toml.example` `[presence]` block for the proposed
schema.
## Honest limitations
1. **LibreWolf release cadence** tracks Firefox ESR (~4 weeks security,
~12 months major). CI matrix tests current ESR; operators should
test before upgrading.
2. **Windows binaries ship unsigned — a standing decision, not an
oversight.** A code-signing certificate costs $200–500/year and the
project does not buy one; SmartScreen warns on the usher binary and
the installer. That is why Linux is the primary target: it has no
equivalent gate. A donation earmarked for code signing
(info@dcos.net) changes the decision; until then, Windows operators
verify the SHA-256 from the release notes.
3. **`--kiosk` is not complete lockdown.** It removes UI chrome but
does not block all escape vectors. OS-level AssignedAccess / cage
setup is mandatory for production deployments — and since Phase 2 it
is one command: [DEPLOYMENT.md](DEPLOYMENT.md) covers interactive
and unattended provisioning plus one-command deprovision on both
platforms.
4. **usher force-exits on stdin close.** The power thread is blocked on
an OS-level receive call (D-Bus / GetMessage) and cannot be
interrupted without an async runtime. This is the correct shutdown
strategy for this architecture — the power thread has no cleanup.
5. **localStorage is not selectively preserved.** When
`dataPersistenceAllowlist` is non-empty, cookies for allowlisted
domains are preserved via `cookies.getAll()` + selective
`cookies.remove()`. localStorage is wiped unconditionally — the
WebExtension API cannot enumerate origins for selective removal.
localStorage typically holds UI state, not auth tokens; the risk
is low and documented in code.
6. **Safelist mode blocks third-party resources too.** A page on a
safelisted domain that pulls scripts, fonts, or images from a CDN
will render broken until the CDN's domain is also safelisted. This
is by design — data can leave through subresource requests, so
they are gated like navigations — but it means the operator's list
must cover every domain the kiosk content depends on. The wizard's
step-2 help text says so, and the browser console logs each block
with the exact URL.
7. **Windows power events require testing on real hardware.** The
`RegisterSuspendResumeNotification` + `WM_POWERBROADCAST`
implementation is complete and cfg-gated, but has not been tested
on a physical Windows machine. The Linux D-Bus path is fully
tested.
## Documentation
- [DEPLOYMENT.md](DEPLOYMENT.md) — operator runbook: kiosk
provisioning, unattended rollout, exit codes, deprovision
- [QUICKSTART.md](QUICKSTART.md) — fast-path install and verify
- [BLOG.md](BLOG.md) — narrative: 2001 VB6 to 2026 Rust
- [history/](history/) — the scrubbed 2001 VB6 source, kept as a
non-shipping historic artifact (nothing in the build depends on it)
- [docs/QA-PASS.md](docs/QA-PASS.md) — production readiness review
(Mixture-of-Experts panel)
- [LICENSE](LICENSE) — MIT
## Credits
Original concept: Jeremy Anderson, 2001 — coded on co-op while in
school, VB6.
Modern implementation: Jeremy Anderson, 2026.
- Website: [dcos.net](https://dcos.net)
- Email: info@dcos.net

View File

@ -0,0 +1,7 @@
{
"name": "com.vestibule.usher",
"description": "Vestibule native helper",
"path": "/usr/local/bin/usher",
"type": "stdio",
"allowed_extensions": ["vestibule@vestibule.kiosk"]
}

View File

@ -0,0 +1,7 @@
{
"name": "com.vestibule.usher",
"description": "Vestibule native helper",
"path": "C:\\Program Files\\Vestibule\\bin\\usher.exe",
"type": "stdio",
"allowed_extensions": ["vestibule@vestibule.kiosk"]
}

52
config/librewolf.overrides.cfg Executable file
View File

@ -0,0 +1,52 @@
// Vestibule LibreWolf overrides — relaxes LibreWolf's privacy-hardened
// defaults that break common SSO and telehealth flows in kiosk deployments.
//
// Install at:
// Linux: /usr/lib/librewolf/librewolf.overrides.cfg
// (or ~/.librewolf/vestibule-profile/librewolf.overrides.cfg)
// Windows: C:\Program Files\LibreWolf\distribution\librewolf.overrides.cfg
// (or %APPDATA%\LibreWolf\Vestibule\librewolf.overrides.cfg)
//
// Each override documents why it is needed for kiosk use. All use lockPref
// to prevent the user from changing them at runtime.
// Resist Fingerprinting randomizes window dimensions, fonts, and other
// fingerprintable attributes. In a fullscreen kiosk at a fixed resolution,
// this breaks layout on many patient-portal sites and provides no privacy
// benefit (the kiosk is a single-user shared device, not a privacy tool).
lockPref("privacy.resistFingerprinting", false);
// Letterboxing rounds window dimensions to prevent fingerprinting via
// screen size. Unnecessary in fullscreen kiosk mode; can cause rendering
// artifacts on patient-portal sites that expect exact viewport sizes.
lockPref("privacy.window.letterboxing", false);
// Encrypted Media Extensions (EME) — some telehealth portals and medical
// video players require DRM for content protection. Enable for kiosk use.
lockPref("media.eme.enabled", true);
// WebGL — some medical imaging viewers (e.g., OHIF Viewer, Cornerstone.js)
// require WebGL for hardware-accelerated rendering of DICOM images.
lockPref("webgl.disabled", false);
// WebRTC — telehealth portals (e.g., Doxy.me, Zoom for Healthcare) use
// WebRTC for video consultations.
lockPref("media.peerconnection.enabled", true);
// Clipboard — patient intake forms may require copy/paste between fields.
lockPref("dom.event.clipboardevents.enabled", true);
// Tracking protection — kiosks navigate to known, operator-approved URLs.
// Strict tracking protection breaks SSO redirects on some medical portals.
lockPref("privacy.trackingprotection.enabled", false);
lockPref("privacy.trackingprotection.pbmode.enabled", false);
// sessionStore — reopen the kiosk URL automatically after a crash.
lockPref("browser.sessionstore.resume_from_crash", true);
lockPref("browser.startup.page", 1); // reopen the session on startup
// Disable all prompts that could confuse kiosk users.
lockPref("browser.tabs.warnOnClose", false);
lockPref("browser.warnOnQuit", false);
lockPref("app.update.enabled", false); // kiosk updates are operator-managed
lockPref("browser.newtabpage.enabled", false);

93
config/policies.json Executable file
View File

@ -0,0 +1,93 @@
{
"policies": {
"DisablePrivateBrowsing": true,
"DisableDevTools": true,
"BlockAboutConfig": true,
"BlockAboutProfiles": true,
"BlockAboutSupport": true,
"DisableProfileRefresh": true,
"DisableSafeMode": true,
"DisableFirefoxAccounts": true,
"DisableFirefoxStudies": true,
"DisableTelemetry": true,
"DisablePocket": true,
"DisableScreenshots": true,
"DontCheckDefaultBrowser": true,
"NoDefaultBookmarks": true,
"Homepage": {
"URL": "about:blank",
"Locked": true
},
"DownloadDirectory": "${tmp}",
"SanitizeOnShutdown": {
"Cache": true,
"Cookies": true,
"Downloads": true,
"FormData": true,
"History": true,
"Sessions": true,
"SiteSettings": true,
"OfflineApps": true
},
"DisableFormHistory": true,
"OfferToSaveLogins": false,
"OfferToSaveLoginsDefault": false,
"PasswordManagerEnabled": false,
"PrimaryPassword": false,
"DisableFeedbackCommands": true,
"DisableMasterPasswordCreation": true,
"EncryptedMediaExtensions": {
"Enabled": false,
"Locked": true
},
"SearchSuggestEnabled": false,
"NetworkPrediction": false,
"CaptivePortal": false,
"FirefoxHome": {
"Search": false,
"TopSites": false,
"Highlights": false,
"Pocket": false,
"Snippets": false,
"Locked": true
},
"SearchEngines": {
"PreventInstalls": true
},
"Authentication": {
"Locked": true
},
"Handlers": {
"mode": "block",
"exceptions": {}
},
"PopupBlocking": {
"Default": true,
"Locked": true
},
"InstallAddonsPermission": {
"Default": false,
"Locked": true
},
"ExtensionSettings": {
"*": {
"blocked_install_message": "Extensions are not allowed on this kiosk.",
"install_sources": [],
"installation_mode": "blocked"
}
}
}
}

171
config/vestibule.toml.example Executable file
View File

@ -0,0 +1,171 @@
# Vestibule configuration — example schema
#
# This file documents the shape of vestibule.toml. The admin wizard
# writes its configuration as JSON to browser.storage.local (see
# extension/admin.js); usher-side TOML loading for headless
# provisioning is a Phase 3 task. The schema is fixed now so the
# implementation has a target.
#
# In production, this file lives at:
# Linux: ~/.config/vestibule/vestibule.toml
# Windows: %APPDATA%\Vestibule\vestibule.toml
[general]
# Product name shown in UI
name = "Vestibule"
[url_policy]
# Navigation policy. The wizard writes this block to
# browser.storage.local as the `policy` key (see extension/admin.js);
# usher-side TOML loading for headless provisioning is a Phase 3 task.
#
# "safelist" — block every request whose hostname is not on the
# safelist (default). Domain-based: an entry grants
# the domain and its subdomains, and nothing else.
# Third-party resources (CDNs, SSO, analytics) are
# gated too — list every domain the kiosk content
# needs. Internal schemes (about:, moz-extension:,
# chrome:, resource:) and the home page's domain are
# always permitted, so an empty list degrades to a
# kiosk that shows about:blank and nothing else.
# "open" — no filtering
# "blocklist" — substring deny (legacy)
# "allowlist" — substring allow (legacy; an empty list allows
# everything — prefer safelist)
mode = "safelist"
# Operator-maintained safelist — one hostname per entry. Pasted URLs
# are normalized to their hostname. Subdomains are covered by the
# parent entry; ports are ignored.
safelist = []
[session]
# Idle timeout before session resets (seconds).
# Patient walks away → after this many seconds of no input, clear ALL
# personal data and return to home URL. Independent of device sleep.
idle_timeout_s = 300
# What to do on session reset.
# "reset" — clear all personal data, navigate to home (default)
# "lock" — show unlock overlay, keep current tab visible behind it
# "both" — reset then show unlock overlay
on_reset = "both"
# Home URL — where to navigate after reset. In safelist mode the
# wizard refuses to save unless this URL's domain is on the safelist;
# the engine additionally exempts it at runtime, so the kiosk can
# never block its own front door.
home_url = "about:blank"
[power]
# How Vestibule handles device sleep/wake.
# "aware" — detect wake, reset session on resume (default)
# "always-on" — block idle sleep via OS inhibit APIs (Phase 3)
mode = "aware"
# On wake from sleep, what to do.
# "reset" — same as on_reset="reset"
# "lock" — same as on_reset="lock"
# "both" — same as on_reset="both" (default)
# "nothing" — ignore wake (testing only; never use in production)
on_wake = "both"
[unlock]
# Password storage location.
# "file" — Argon2id PHC string at 0600, owned by the kiosk user
# (the shipped implementation; keyring daemons are not
# available on minimal kiosk compositors like cage)
# "keyring" — OS keyring (reserved for desktop-session deployments)
storage = "file"
# Unlock method.
# "password" — single password (Argon2id hash in file storage)
# "pin" — numeric PIN (Argon2id hash in file storage)
# "totp" — TOTP + password (Phase 5+)
method = "password"
# Argon2id parameters for password hashing.
# Defaults are conservative; tune for your threat model.
[unlock.argon2]
memory_kib = 65536 # 64 MiB
iterations = 3
parallelism = 4
# ──────────────────────────────────────────────────────────────────────
# PLANNED WORK — NOT IMPLEMENTED IN THIS RELEASE
# ──────────────────────────────────────────────────────────────────────
# Webcam-based presence detection. When a webcam is detected at startup,
# usher uses it to:
#
# 1. Detect when the current user steps away from the kiosk (no face
# visible for N seconds).
# 2. Detect when a different person approaches (face embedding doesn't
# match the one captured at session start).
#
# In either case, trigger an immediate session reset (same path as idle
# timeout, but faster — no need to wait idle_timeout_s).
#
# Privacy guarantees:
# - Frames are processed in-memory only, never written to disk
# - Face embeddings never leave the local process
# - No telemetry, no cloud API calls
# - Webcam LED (if present) is respected; we do not attempt to capture
# frames while the camera is in use by another application
#
# Implementation: OpenCV + dlib (or MediaPipe) running in usher's power
# thread sibling. Spike scope TBD in a future session.
#
# [presence]
# enabled = false # auto-enable if webcam detected
# camera_index = 0
# away_threshold_s = 10 # no face for this long → reset
# face_match_threshold = 0.6 # cosine similarity; lower = stricter
# match_window_s = 30 # capture baseline face over first 30s
# on_change = "reset" # "reset" | "lock" | "ignore"
# privacy_mode = true # never persist any biometric data
# ──────────────────────────────────────────────────────────────────────
# ADMIN CONFIGURATION — set via the in-browser wizard
# ──────────────────────────────────────────────────────────────────────
# The admin wizard is opened via:
# - The gear icon in the hidden menu (mouse to top of screen)
# - Ctrl+Shift+V keyboard shortcut
#
# The wizard is gated by a setup password (separate from the kiosk unlock
# password). On first run, the wizard forces the operator to set this
# password before any configuration can be saved.
#
# [admin]
# # Password hashing algorithm for the admin password:
# # PBKDF2-SHA-256, 100k iterations, in-browser via Web Crypto. The
# # kiosk unlock password uses Argon2id in usher (file storage, 0600).
# algorithm = "PBKDF2-SHA-256"
# iterations = 100000
# salt_bytes = 16
#
# # Recovery: if the admin password is lost, the only recovery path is
# # OS-level — delete the vestibule-profile/storage directory and re-run
# # the wizard. A backup recovery code is a Phase 5 task.
# recovery = "os-level-reset"
#
# # The wizard's full config schema is mirrored into browser.storage.local
# # under the `adminConfig` key. See extension/admin.js for the canonical
# # schema. The TOML below is documentation only — the wizard writes
# # JSON, not TOML. Headless TOML provisioning is a Phase 3 task.
#
# [admin.wizard]
# # Steps shown in the wizard, in order. Cannot be reordered.
# steps = [
# "auth-gate", # set or enter admin password
# "kiosk-identity", # name, home URL, attract URL (home domain
# # checked against the safelist live)
# "url-policy", # mode + safelist/allowlist/blocklist
# "session", # idle timeout, onReset, persistence allowlist
# "power", # mode, on_wake
# "unlock", # method + kiosk unlock password
# "review", # JSON review + save (refuses a safelist
# # config whose home domain is unlisted)
# ]

156
docs/QA-PASS.md Executable file
View File

@ -0,0 +1,156 @@
# Vestibule — QA Production Readiness Pass (1.2.0)
**Review date:** 2026-08-24
**Subject:** `vestibule-1.2.0` — full tree (extension, usher, scripts, packaging, docs)
**Reviewer:** Mixture-of-Experts panel — ten seats: five disciplines, each with a Linux and a Windows counterpart (QA analyst, platform engineer, architect, administrator, DevOps project manager)
**Standards applied:** PEP 8 (spirit) for Python, POSIX sh, SEI CERT, MISRA-C (spirit), Unix philosophy
**Method:** every finding is either fixed in this pass or recorded under Honest limitations. No finding is deferred silently.
**Addendum:** a 1.2.2 feature pass (safe-by-default navigation) is recorded at the end of this document, same method.
---
## Panel composition
| Seat | Focus |
|---|---|
| Senior QA Analyst (Linux / Windows) | Test coverage, edge cases, failure modes, protocol correctness, doc-code parity |
| Senior Linux Engineer | D-Bus integration, systemd, packaging, POSIX compliance, shell hygiene |
| Senior Windows Engineer | AssignedAccess/Shell Launcher, WMI bridge, PowerShell 5.1 compatibility, ACLs |
| Senior Architect | Module boundaries, data flow, threading model, table-driven dispatch |
| Senior Administrator (both platforms) | Deployment, configuration, recovery, observability, operability |
| Project Manager (DevOps) | CI/CD, release management, versioning, risk register |
---
## Release-blocking findings — all fixed in this pass
1. **Fake constant-time comparison in the admin wizard** (`extension/admin.js`). `verifyPassword()` used `Array.prototype.every()`, which short-circuits on the first mismatch, then wrapped the result in a dead ternary — a timing side channel on the admin password check and a SEI CERT MSC06-C violation. Replaced with a single XOR-accumulate `reduce()` over every byte: no short-circuit, no dead branch, one return.
2. **Version skew across the release.** The tree declared 1.1.0 in nine places (Cargo.toml, Cargo.lock, extension manifest, Inno Setup ×2, Flatpak CLI, build-flatpak.sh, metainfo, validate.sh ×3, CI ×5, integration test, docs) while the release artifact is 1.2.0. Every declaration now reads 1.2.0, the metainfo carries a 1.2.0 release entry, and `validate.sh` enforces the single version across all five machine-readable sources — a mismatch now fails local validation before it can fail in CI.
3. **Documentation contradicted the implementation on credential storage.** README, admin wizard UI, TOML schema, and BLOG all claimed the unlock hash lives in the OS keyring; `helper/src/storage.rs` stores the Argon2id PHC string in a file at 0600 (Windows: default user ACL). The documentation now states the file-storage design and the reason for it: keyring daemons do not exist on minimal kiosk compositors such as cage, so file permissions are the enforcement boundary. `Cargo.toml`'s stale "keyring bridge" description is gone.
4. **Stale Native Messaging manifest path (Windows).** `config/com.vestibule.usher.windows.json` pointed at `C:\Program Files\Vestibule\usher.exe`; the provisioning scripts stage the binary at `...\Vestibule\bin\usher.exe`. Corrected to the staged location. (Live registration always generated its own manifest with the resolved path; the shipped file is the reference copy.)
5. **Shell injection surface in `install-native-host.sh`.** The manifest generation interpolated `TARGET_BIN` directly into Python source via `-c "..."` — a path containing a quote would alter the program (SEI CERT IDS03 family). Paths now travel as argv to a heredoc script; the source contains no interpolated values.
6. **Integration-test coverage gap.** The prior QA record claimed the launcher dispatch was "asserted, not just parsed" — no such test existed. `scripts/test-provision-linux.sh` now runs the launcher itself against browser shims for all four env permutations (firefox/flatpak, firefox/native, librewolf/flatpak, defaults) and asserts the exec'd command line. Coverage moved from 122 to 126 assertions, all passing.
---
## Coding standards findings — all fixed in this pass
| Standard | Finding | Fix |
|---|---|---|
| MISRA-C (spirit): table-driven dispatch over nested conditionals | `power.rs` `wnd_proc` used sequential `if msg == ...` tests | Single `match` — one arm per handled message, default arm defers to `DefWindowProcW` |
| SEI CERT: no `unwrap()` on fallible values | `power.rs` called `notify_handle.unwrap()` after a non-binding match | Handle bound in the match; the failure arm returns with monitoring disabled, exit path never panics |
| Arrays/tables over nested ifs | `provision-kiosk.sh` carried two near-duplicate resolver functions with nested if/elif ladders | One `flavor_available` lookup table (browser:flavor → test) plus linear step-down resolvers — one line per flavor, first available wins |
| DRY / Unix philosophy | Three separate ordered-path probe loops in `provision-kiosk.sh` | Single `first_executable()` helper; binary probes are one assignment each |
| Avoid for/while where a declarative form exists | `make-icons.py` render loop and listing loop; nested small-size branch | Dict comprehension for rendering, `"\n".join()` for the listing, and a step-down `draw_icon()` that selects `draw_simple_icon()` or `draw_full_icon()` — output verified byte-identical on all 8 artifacts |
| Avoid for/while where a declarative form exists | `test-native-messaging.py` was six copy-pasted sequential blocks | Protocol steps are a table (label, request, timeout, criterion); the driver is one comprehension over `map(run, STEPS)`; failure report is a join |
| POSIX sh | Launcher's browser/flavor forks used nested `if` inside `if` | `case` dispatch — the fork table is the case statement |
| PEP 8 | Inline hash-file branch, `os.path.exists` guard before `os.remove` | `contextlib.suppress(FileNotFoundError)`; named timeout constants |
**Loops that remain, deliberately.** Event loops that *are* the program's purpose are not data-processing loops and have no declarative equivalent: usher's stdin dispatch and channel-draining writer (`main.rs`), the D-Bus signal drain and reconnect loop (`power.rs`), the Windows message pump, and the kiosk supervision loop (`kiosk-launch.ps1`). Argument-parsing `while [ $# -gt 0 ]` and iteration over data lists in POSIX sh (no arrays in the language) likewise stay — they are the minimal mechanism the platform offers. This is the "where possible" boundary.
---
## Language and tone findings — all fixed in this pass
The pass removed every phrase narrating reversal or back-and-forth history ("restored", "brought back", "honored for back-compat", "pulled forward and re-scoped", "Phase N may move"). Every comment and document now states current behavior in the present tense, as a decision.
| Where | Before | After |
|---|---|---|
| `vestibule-kiosk-launch` | `VESTIBULE_LIBREWOLF_FLAVOR` (pre-Firefox-support name) honored as a fallback | Removed. `kiosk.env` is written by the current provisioner and carries `VESTIBULE_BROWSER_FLAVOR`; the launcher reads exactly one variable per concern |
| `deprovision-kiosk.sh` / `.ps1` | `restore_policies()` / "restored original policies.json" | `reset_policy_dir()` / "baseline policies.json reinstalled" — the operation is a baseline reset, stated as one |
| `test-provision-linux.sh` | "policies.json restored byte-for-byte" | "policies.json equals the pre-provision baseline (byte-for-byte)" |
| DEPLOYMENT.md / README / metainfo | "rollback", "full deprovision/rollback" | "deprovision" — one noun, one operation |
| provisioners (both platforms) | "falling back to Shell Launcher", "Firefox fallback" | "step-down": the choice forks are ordered ladders, documented as such |
| BLOG.md | "Phase 2 shipped — and grew past that plan… pulled forward from Phase 4 and re-scoped" | "Vestibule 1.2 ships…" — what the release contains, not how the plan moved |
| admin wizard UI / admin.js / CSS headers | "Phase 0 spike", "In the spike this is not verified", "Phase 1 stores the Argon2id hash in the OS keyring" | Production wording matching the shipped behavior |
Step-down logic is now the named mechanism at every fork: browser resolution (LibreWolf native → LibreWolf Flatpak → Firefox native → Firefox Flatpak → Firefox snap), lockdown application (AssignedAccess bridge → Shell Launcher), browser discovery on Windows (filesystem paths → HKLM App Paths → HKCU App Paths), and the usher source probe (explicit flag → installed binary → repo build → staged copy). Each is an ordered ladder with first-match-wins; none is a nested conditional.
---
## Verification performed in this pass
All checks were executed in a clean checkout of the pass output; none are quoted from earlier records.
- **`scripts/test-provision-linux.sh`: 126/126 assertions pass.** Three full provision/deprovision scenarios (LibreWolf native, Firefox native, Firefox Flatpak) covering kiosk-user creation, /opt staging, XPI build, launcher install, kiosk.env contents, all five Native Messaging manifest locations with valid JSON and correct paths, policies deep-merge preserving the browser's own keys, force-installed extension with the correct install_url (sandbox-visible for Flatpak), unit generation and enable, and byte-for-byte baseline equality after deprovision — plus the four new launcher-dispatch assertions.
- **`scripts/test-native-messaging.py` (table-driven rewrite) verified end-to-end** against a protocol-faithful mock helper: all six steps pass with exit 0; a deliberately corrupted hello response produces exit 1 with a precise field-level message. (The Rust toolchain is not available in this review environment, so the mock stands in for the binary; CI builds and exercises the real usher on both platforms.)
- **`scripts/make-icons.py` refactor is rendering-equivalent:** all 8 generated artifacts (SVG, six PNGs, ICO) are byte-identical to the committed set (SHA-256 comparison).
- **Syntax gates:** `sh -n` (dash) on every shell script including the launcher and Flatpak CLI; `python3 -m py_compile` on both Python scripts; JSON validity on all five JSON files; XML validity on the metainfo; YAML validity on the CI workflow; PowerShell structural checks (here-string placement, brace/paren/bracket balance).
- **`scripts/validate.sh` passes end-to-end** on the tree, including the new 1.2.0 version-consistency gate.
- **Doc-code parity spot checks:** every console log line quoted in QUICKSTART/DEPLOYMENT now matches the strings the code emits; the verification checklist version matches the manifest.
---
## Honest limitations
1. **Windows lockdown paths need hardware validation.** The MDM WMI bridge call, AUMID shortcut property set, and Shell Launcher step-down are implemented to documentation and edition-gated, but this pass exercised them only through parse/structure checks — no physical Windows Pro/Ent machine was available. The Linux path is integration-tested; the Windows path is CI-validated for syntax and structure only.
2. **Rust changes compile-verified by review, not by build.** This environment has no cargo; the `power.rs` match-dispatch and handle-binding changes and the 1.2.0 version bump follow patterns the existing CI matrix compiles on every push, but the binaries in this tarball were not rebuilt here.
3. **Firefox snap and Flatpak policy paths follow Mozilla's documented sandbox layouts** and are provisioned and deprovisioned deterministically, but were validated against shims, not real snap/Flatpak Firefox installs as a system-wide kiosk user.
4. **The installer and usher binary remain unsigned — a standing decision, not an oversight.** A code-signing certificate is an unfunded line item unless a donation earmarks it; SmartScreen warns and operators verify the SHA-256 from the release notes. This decision is the root of the Linux-first platform priority (see Post-pass decisions).
5. **Windows autologon stores the kiosk password in plaintext registry** — accepted trade-off on a locked-down appliance, documented with the `-NoAutoLogon` escape.
6. **Flatpak LibreWolf policies are wiped by app updates** (the distribution dir lives inside the flatpak). Re-run provisioning after updates; documented in DEPLOYMENT.md.
---
## Post-pass decisions (2026-08-24)
1. **Platform priority: Linux.** The Windows stack stays complete and CI-validated, but its binaries stay unsigned: a code-signing certificate is an unfunded line item and remains one unless a donation earmarks it (info@dcos.net). Linux carries no signing gate and is the supported production path. README, DEPLOYMENT.md, and BLOG.md now state this decision where each document introduces the platform story.
2. **Historic artifact: the 2001 VB6 original.** The original source is included under `history/vb6-2001/` — all three iterations (earliest, lobby build, shipped final), scrubbed of employer, school, and client identifiers: employer-branded project filenames renamed to the neutral `LobbyBrowser*`, the company version string emptied, the hardcoded unlock code redacted, the 2001 readme's employer mention rewritten to the sanitized provenance (co-op while in school, first employer — a Boston-area MSP and programming company). The compiled binary, a captured copy of IEXPLORE.EXE, and an empty scratch file are excluded (a binary cannot be scrubbed without a rebuild; the IE executable is Microsoft's, not authored code). `scripts/validate.sh` gates the scrub: the identifier scan fails the release if any of it reappears. The lock screen's "System Security 1.5" branding strings are left in place — they are what the shipped binary displayed, and they document the sibling app whose UI the browser reused.
---
## Risk register
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| LibreWolf/Firefox ESR drops `webRequestBlocking` | Low | Critical | Test in ESR beta CI; declarativeNetRequest is the designated step-down |
| zbus 5.x breaking change | Medium | Medium | zbus pinned to major 4; test on each minor bump |
| MDM WMI bridge unavailable on locked-down SKUs | Medium | Medium | Shell Launcher step-down (Ent/Edu); manual Settings path documented |
| D-Bus unavailable in container/CI | High (CI) | Low | Power thread logs and retries; kiosk functions without power events |
| Windows code-signing cert cost | High | Medium | Unfunded by decision; a donation earmarked for a certificate flips it. Linux is the primary target in the meantime |
---
## Verdict
**1.2.0 is production-ready within the documented limitations.** The release blockers (credential-comparison side channel, version skew, doc-code contradictions) are closed; the coding-standards sweep is applied across Python, shell, PowerShell, Rust, and JavaScript; every choice fork in the deployment surface is an ordered, named step-down ladder; the test suite grew to 126 assertions and passes clean; and the documentation reads as a decision log in the present tense. The panel approves the release.
---
## Addendum — 1.2.2 safe-by-default navigation (2026-08-24)
**Scope:** the URL policy gains a `safelist` mode that is the new default: every domain is blocked — top-level pages, frames, and subresources alike — until the operator lists it. Matching is hostname-based; the legacy substring modes remain unchanged for existing deployments.
### Design decisions (all present tense, all enforced in code)
1. **Domain matching, not substring matching.** The entry `example.org` grants the domain and its subdomains and nothing else. Query-string, path-segment, and userinfo smuggles (`https://evil.com/?q=example.org`, `https://example.org@evil.com/`) that pass the legacy allowlist are blocked by the safelist. This smuggle class is asserted explicitly in the unit suite.
2. **Internal schemes are always permitted** (`about:`, `moz-extension:`, `chrome:`, `resource:`); `data:` and `blob:` are permitted as subresources and blocked as top-level documents. An empty safelist therefore degrades to a kiosk showing `about:blank`, not a kiosk showing nothing.
3. **Home-origin guarantee.** The engine exempts the configured home URL's hostname in every mode, so reset/wake/idle navigation can never strand the kiosk on its own block page. The wizard enforces the same rule at save time for the home and attract URLs and live on step 1, with a one-click add-to-safelist button.
4. **First-boot adoption.** A provisioned kiosk launches with its home URL on the command line, which browser storage cannot know. While the policy is still the default, the extension adopts the browser's startup page as home and safelists its domain. Only tabs present at background startup qualify — never a later typed navigation — so the walk-up attack (type your own domain on an unconfigured kiosk) does not apply.
5. **Fail-closed dispatch.** An unrecognized mode string resolves to the safelist predicate. 1.2.0 failed open; the panel reversed that for 1.2.2 — a corrupted policy locks the kiosk to its home page rather than opening the perimeter.
6. **Blocked top-level navigations land on an in-extension block page** (`blocked.html`) that names the blocked domain via `textContent` only — no reflection of the URL parameter into markup. Blocked subresources are cancelled outright.
7. **One engine, three consumers.** The decision logic lives in `extension/url-policy.js` (pure, browser-API-free, UMD-lite export): the background script's webRequest wiring, the wizard's live validation and save-time check, and the Node unit suite all call the same functions. The wizard cannot disagree with runtime enforcement.
### Verification performed in this pass
- **`scripts/test-url-policy.js`: 62/62 assertions pass** under Node 24 — entry normalization (domains, wildcards, pasted URLs, ports, junk), home-hostname extraction, safelist matching (exact, subdomain, deep subdomain, sibling non-match, three smuggle classes, empty-hostname `file:`), internal-scheme and data:/blob: handling, empty-safelist posture, home-origin guarantee (exact match, subdomain non-coverage, suffix-spoof non-match), legacy-mode behavior parity (including the documented empty-allowlist quirk), fail-closed unknown mode, and startup adoption (positive case plus five refusal cases).
- The unit suite caught one real defect before ship: the data:/blob: branch of the safelist predicate was inverted (`!ctx.mainFrame` instead of `ctx.mainFrame`), which would have allowed top-level `data:` navigations and blocked data: subresources. Fixed; the four assertions now pin the correct polarity.
- `node --check` on all seven extension scripts; `sh -n` on validate.sh after the new gate; JSON/XML validity re-verified by the full `scripts/validate.sh` run (all gates green, including the new version-consistency gate at 1.2.2 and the new URL-policy gate).
- XPI smoke test now asserts `url-policy.js` loads first in the background script array and that `blocked.html` ships in the bundle.
- Version consistency: 1.2.2 declared in Cargo.toml, Cargo.lock (usher row only — `static_assertions` legitimately pins 1.2.0), extension manifest, Inno Setup ×2, Flatpak CLI, build-flatpak.sh, metainfo (new 1.2.2 release entry), validate.sh ×3, CI ×5, build-installer.ps1, integration test, QUICKSTART console lines, DEPLOYMENT artifacts table and checklists.
### Honest limitations (1.2.2)
1. **Safelist mode gates subresources like navigations.** A safelisted page pulling scripts or fonts from a CDN renders broken until the CDN domain is listed. Deliberate (data can leave through subresource requests) and documented in the wizard help text, README, and QUICKSTART — but the operator's first configuration pass will involve adding domains until the page renders whole. The console logs each block with the exact URL.
2. **Startup adoption inspects the tab list once at background boot.** A browser that restores a session (non-kiosk deployment) adopts the first restored http(s) tab while the policy is default. Kiosk deployments boot to a single page; the behavior is correct there, and any wizard save ends adoption permanently.
3. **The redirect to `blocked.html` uses `webRequest` `redirectUrl`,** which discards POST state on blocked form submissions. Acceptable — the submission was refused either way — and irrelevant to GET-based kiosk content.
4. **Engine tests run under Node; the webRequest wiring itself is CI-exercised, not unit-exercised.** The wiring is 20 lines of state-and-listener code; the decision table it delegates to is fully covered.
---
*Review conducted 2026-08-24 by the Mixture-of-Experts panel. Author: Jeremy Anderson — [dcos.net](https://dcos.net) — info@dcos.net.*

400
extension/admin.css Executable file
View File

@ -0,0 +1,400 @@
/* Vestibule admin wizard — production styling.
* Clean, professional, accessible. This is an operator-facing tool, not
* a patient-facing UI — favor clarity over decoration.
*/
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: #0f1116;
color: #e8eaed;
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
}
/* ─── Auth views ─── */
.auth-view {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.auth-card {
background: #1a1d24;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 32px 40px;
max-width: 480px;
width: 100%;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
}
.auth-card h1 {
margin: 0 0 8px;
font-size: 22px;
font-weight: 600;
color: #fff;
}
.auth-sub {
margin: 0 0 24px;
color: #b8bcc4;
font-size: 13px;
}
.auth-warn {
margin: 16px 0 24px;
padding: 12px 16px;
background: rgba(251, 191, 36, 0.08);
border: 1px solid rgba(251, 191, 36, 0.25);
border-radius: 8px;
font-size: 12px;
color: #fbbf24;
}
.auth-warn code {
background: rgba(0, 0, 0, 0.3);
padding: 1px 6px;
border-radius: 4px;
font-family: "SF Mono", "Cascadia Mono", Consolas, monospace;
font-size: 11px;
}
.auth-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* ─── Form controls ─── */
label {
display: block;
margin: 12px 0;
}
label > span {
display: block;
margin-bottom: 4px;
font-size: 13px;
color: #b8bcc4;
}
label > span em {
color: #6b7280;
font-style: italic;
font-weight: normal;
}
label small {
display: block;
margin-top: 4px;
font-size: 11px;
color: #6b7280;
line-height: 1.4;
}
input[type="text"],
input[type="url"],
input[type="password"],
input[type="number"],
textarea {
width: 100%;
padding: 8px 12px;
background: #0f1116;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
color: #e8eaed;
font: inherit;
font-size: 13px;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
input[type="text"]:focus,
input[type="url"]:focus,
input[type="password"]:focus,
input[type="number"]:focus,
textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
textarea {
font-family: "SF Mono", "Cascadia Mono", Consolas, monospace;
font-size: 12px;
resize: vertical;
min-height: 80px;
}
fieldset {
margin: 16px 0;
padding: 12px 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
}
legend {
padding: 0 8px;
color: #b8bcc4;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
label.radio {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 8px 0;
cursor: pointer;
}
label.radio input[type="radio"] {
margin-top: 3px;
accent-color: #3b82f6;
}
label.radio span {
color: #e8eaed;
font-size: 13px;
}
label.radio span strong {
color: #fff;
font-weight: 600;
}
label.radio input:disabled + span {
color: #6b7280;
cursor: not-allowed;
}
label.radio input:disabled + span strong {
color: #9ca3af;
}
.field-error {
color: #ef4444;
font-size: 12px;
margin: 8px 0;
}
/* ─── Buttons ─── */
button {
padding: 8px 20px;
border: 1px solid transparent;
border-radius: 6px;
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
button.primary {
background: #3b82f6;
color: #fff;
border-color: #3b82f6;
}
button.primary:hover {
background: #2563eb;
border-color: #2563eb;
}
button.primary:active {
background: #1d4ed8;
border-color: #1d4ed8;
}
button.primary:disabled {
background: #1e3a5f;
border-color: #1e3a5f;
color: #6b7280;
cursor: not-allowed;
}
button.secondary {
background: transparent;
color: #b8bcc4;
border-color: rgba(255, 255, 255, 0.15);
}
button.secondary:hover {
background: rgba(255, 255, 255, 0.06);
color: #fff;
}
/* ─── Wizard layout ─── */
#wizard-view, #view-wizard {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.wizard-header {
background: #1a1d24;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding: 16px 32px;
}
.wizard-header h1 {
margin: 0 0 12px;
font-size: 18px;
font-weight: 600;
color: #fff;
}
.wizard-steps ol {
list-style: none;
margin: 0;
padding: 0;
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.wizard-steps li {
padding: 6px 12px;
font-size: 12px;
color: #6b7280;
border: 1px solid transparent;
border-radius: 4px;
cursor: default;
}
.wizard-steps li.active {
color: #3b82f6;
border-color: rgba(59, 130, 246, 0.3);
background: rgba(59, 130, 246, 0.08);
}
.wizard-steps li.completed {
color: #10b981;
}
.wizard-body {
flex: 1;
padding: 24px 32px;
max-width: 720px;
width: 100%;
margin: 0 auto;
}
.step h2 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: #fff;
}
.step-intro {
margin: 0 0 20px;
color: #b8bcc4;
font-size: 13px;
line-height: 1.6;
}
.step-intro code {
background: rgba(0, 0, 0, 0.3);
padding: 1px 6px;
border-radius: 4px;
font-family: "SF Mono", "Cascadia Mono", Consolas, monospace;
font-size: 11px;
color: #fbbf24;
}
.step-intro strong {
color: #fff;
}
.review-output {
background: #0a0c10;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 16px;
font-family: "SF Mono", "Cascadia Mono", Consolas, monospace;
font-size: 12px;
color: #b8bcc4;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
max-height: 400px;
overflow-y: auto;
}
.review-actions {
display: flex;
gap: 8px;
margin: 20px 0;
}
.save-result {
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
}
.save-result.success {
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.3);
color: #10b981;
}
.save-result.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
}
/* ─── Wizard nav footer ─── */
.wizard-nav {
background: #1a1d24;
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding: 12px 32px;
display: flex;
justify-content: space-between;
align-items: center;
}
.wizard-progress {
font-size: 12px;
color: #6b7280;
}
/* ─── Homepage × safelist status (wizard step 1) ─── */
.domain-status {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin: -4px 0 16px;
padding: 10px 14px;
border-radius: 8px;
font-size: 12px;
}
.domain-status.ok {
background: rgba(16, 185, 129, 0.08);
border: 1px solid rgba(16, 185, 129, 0.25);
color: #10b981;
}
.domain-status.warn {
background: rgba(251, 191, 36, 0.08);
border: 1px solid rgba(251, 191, 36, 0.25);
color: #fbbf24;
}
.domain-status .add-domain {
padding: 4px 12px;
font-size: 11px;
margin: 0;
}

281
extension/admin.html Executable file
View File

@ -0,0 +1,281 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Vestibule Admin</title>
<link rel="stylesheet" href="admin.css">
</head>
<body>
<!-- ════════════════════════════════════════════════════════════════
AUTH GATE — shown first
Two sub-views: setup (first run) or enter (returning)
════════════════════════════════════════════════════════════════ -->
<section id="view-auth-setup" class="auth-view" hidden>
<div class="auth-card">
<h1>Welcome to Vestibule</h1>
<p class="auth-sub">Choose a setup password. This password protects the admin configuration and cannot be recovered.</p>
<label>
<span>Setup password</span>
<input type="password" id="setup-password" autocomplete="new-password" autofocus>
</label>
<label>
<span>Confirm password</span>
<input type="password" id="setup-password-confirm" autocomplete="new-password">
</label>
<div class="auth-warn">
<strong>If you lose this password</strong>, you must reset Vestibule from the OS level
(delete the <code>vestibule-profile/storage</code> directory). There is no in-app recovery.
</div>
<div class="auth-actions">
<button id="setup-submit" class="primary">Set password &amp; continue</button>
</div>
</div>
</section>
<section id="view-auth-enter" class="auth-view" hidden>
<div class="auth-card">
<h1>Enter admin password</h1>
<p class="auth-sub">Required to view or modify the Vestibule configuration.</p>
<label>
<span>Admin password</span>
<input type="password" id="enter-password" autocomplete="current-password" autofocus>
</label>
<p id="enter-error" class="field-error" hidden></p>
<div class="auth-actions">
<button id="enter-submit" class="primary">Unlock</button>
</div>
</div>
</section>
<!-- ════════════════════════════════════════════════════════════════
WIZARD — shown after auth
════════════════════════════════════════════════════════════════ -->
<section id="view-wizard" hidden>
<header class="wizard-header">
<h1>Vestibule Configuration</h1>
<nav class="wizard-steps">
<ol id="step-list">
<li data-step="1" class="active">1. Kiosk identity</li>
<li data-step="2">2. URL policy</li>
<li data-step="3">3. Session behavior</li>
<li data-step="4">4. Power management</li>
<li data-step="5">5. Unlock method</li>
<li data-step="6">6. Review &amp; save</li>
</ol>
</nav>
</header>
<main class="wizard-body">
<!-- ─── Step 1: Kiosk identity ─── -->
<section data-step="1" class="step active">
<h2>Kiosk identity</h2>
<p class="step-intro">Basic information about this kiosk. The name is shown in UI banners; the home URL is where the browser navigates after a session reset.</p>
<label>
<span>Kiosk name</span>
<input type="text" id="cfg-kiosk-name" placeholder="Vestibule Kiosk">
</label>
<label>
<span>Home URL</span>
<input type="url" id="cfg-home-url" placeholder="https://example.org/welcome">
<small>Navigated to on every session reset, wake, and idle timeout.</small>
</label>
<div id="home-domain-status" class="domain-status" hidden>
<span id="home-domain-text"></span>
<button id="btn-add-home-domain" class="secondary add-domain" hidden>Add domain to safelist</button>
</div>
<label>
<span>Attract URL <em>(optional)</em></span>
<input type="url" id="cfg-attract-url" placeholder="https://example.org/attract">
<small>Stored in the config schema; the attract-screen feature ships with the presence-detection work (Phase 5+). The field is harmless to leave empty.</small>
</label>
</section>
<!-- ─── Step 2: URL policy ─── -->
<section data-step="2" class="step" hidden>
<h2>URL policy</h2>
<p class="step-intro">Controls which URLs the kiosk can navigate to. This is the modern replacement for the 2001 VB6 trick of renaming <code>IEXPLORE.EXE</code> to <code>.bak</code>.</p>
<fieldset>
<legend>Mode</legend>
<label class="radio">
<input type="radio" name="url-mode" value="safelist">
<span><strong>Safelist</strong> — block every domain except the list below (default, recommended). Domain-based: an entry grants the domain and its subdomains, and nothing else.</span>
</label>
<label class="radio">
<input type="radio" name="url-mode" value="open">
<span><strong>Open</strong> — all URLs allowed. Use only with OS-level lockdown.</span>
</label>
<label class="radio">
<input type="radio" name="url-mode" value="blocklist">
<span><strong>Blocklist</strong> — all URLs allowed except those listed below.</span>
</label>
<label class="radio">
<input type="radio" name="url-mode" value="allowlist">
<span><strong>Allowlist</strong> — only URLs containing a listed string are allowed. Legacy substring semantics; an empty list allows everything. Prefer safelist.</span>
</label>
</fieldset>
<label>
<span id="url-entries-label">Entries <em>(one per line)</em></span>
<textarea id="cfg-url-entries" rows="8" placeholder="example.org&#10;portal.example.org&#10;cdn.example.net"></textarea>
<small id="url-entries-help">Domains, one per line. An entry grants the domain and every subdomain; pasted URLs are normalized to their hostname. Include the domains of third-party resources (CDNs, SSO, analytics) the kiosk content needs — they are blocked too.</small>
</label>
</section>
<!-- ─── Step 3: Session behavior ─── -->
<section data-step="3" class="step" hidden>
<h2>Session behavior</h2>
<p class="step-intro">Controls how and when the kiosk session resets. All resets wipe browsing data — there is no "soft reset".</p>
<label>
<span>Idle timeout <em>(seconds)</em></span>
<input type="number" id="cfg-idle-timeout" min="30" max="3600" step="30" value="300">
<small>After this many seconds of no user input, the session resets. Default: 300 (5 min).</small>
</label>
<fieldset>
<legend>On reset</legend>
<label class="radio">
<input type="radio" name="on-reset" value="reset">
<span><strong>Reset only</strong> — clear all data, navigate to home.</span>
</label>
<label class="radio">
<input type="radio" name="on-reset" value="lock">
<span><strong>Lock only</strong> — show unlock overlay, keep current tab visible behind it.</span>
</label>
<label class="radio">
<input type="radio" name="on-reset" value="both">
<span><strong>Both</strong> — reset then show unlock overlay (default, recommended).</span>
</label>
</fieldset>
<label>
<span>Data persistence allowlist <em>(per-domain, advanced)</em></span>
<textarea id="cfg-persistence-allowlist" rows="4" placeholder="sso.example.org&#10;auth.example.org"></textarea>
<small>
Domains listed here keep cookies, localStorage, and indexedDB across session resets.
Use for SSO flows that require persistent auth (e.g., a patient-portal that takes
longer than <code>idle_timeout</code> to complete). <strong>Every persisted domain is
a potential data leak — list only what you absolutely need.</strong>
</small>
</label>
</section>
<!-- ─── Step 4: Power management ─── -->
<section data-step="4" class="step" hidden>
<h2>Power management</h2>
<p class="step-intro">How Vestibule handles device sleep. Vestibule detects wake and resets — it does <strong>not</strong> block sleep. The OS power profile decides whether the device sleeps; that's a deployment-time decision.</p>
<fieldset>
<legend>Mode</legend>
<label class="radio">
<input type="radio" name="power-mode" value="aware" checked>
<span><strong>Aware</strong> — detect wake from sleep, reset session on resume (default, recommended).</span>
</label>
<label class="radio">
<input type="radio" name="power-mode" value="always-on">
<span><strong>Always on</strong> — block idle sleep via OS inhibit APIs (Phase 3). Use only if OS power profile wasn't set at deploy time.</span>
</label>
</fieldset>
<fieldset>
<legend>On wake</legend>
<label class="radio">
<input type="radio" name="on-wake" value="reset">
<span><strong>Reset only</strong> — clear all data, navigate to home.</span>
</label>
<label class="radio">
<input type="radio" name="on-wake" value="lock">
<span><strong>Lock only</strong> — show unlock overlay.</span>
</label>
<label class="radio">
<input type="radio" name="on-wake" value="both" checked>
<span><strong>Both</strong> — reset then show unlock overlay (default, recommended).</span>
</label>
<label class="radio">
<input type="radio" name="on-wake" value="nothing">
<span><strong>Nothing</strong> — ignore wake events (testing only; never use in production).</span>
</label>
</fieldset>
</section>
<!-- ─── Step 5: Unlock method ─── -->
<section data-step="5" class="step" hidden>
<h2>Unlock method</h2>
<p class="step-intro">How operators release the kiosk. This is separate from the admin password that protects this wizard.</p>
<fieldset>
<legend>Method</legend>
<label class="radio">
<input type="radio" name="unlock-method" value="password" checked>
<span><strong>Password</strong> — single password (Argon2id hash stored by usher at 0600).</span>
</label>
<label class="radio">
<input type="radio" name="unlock-method" value="pin">
<span><strong>PIN</strong> — numeric PIN (Argon2id hash stored by usher at 0600).</span>
</label>
<label class="radio">
<input type="radio" name="unlock-method" value="totp" disabled>
<span><strong>TOTP + password</strong> — time-based one-time password plus password (Phase 5+).</span>
</label>
</fieldset>
<label>
<span>Kiosk unlock password</span>
<input type="password" id="cfg-unlock-password" autocomplete="new-password">
<small>
Used to release the kiosk session (via the unlock button in the hidden menu).
usher stores the Argon2id hash at ~/.config/vestibule/unlock.hash (0600) and
verifies every attempt against it. The hash never touches browser storage.
</small>
</label>
<label>
<span>Confirm unlock password</span>
<input type="password" id="cfg-unlock-password-confirm" autocomplete="new-password">
</label>
</section>
<!-- ─── Step 6: Review & save ─── -->
<section data-step="6" class="step" hidden>
<h2>Review &amp; save</h2>
<p class="step-intro">Confirm the configuration below. Saving overwrites any existing configuration.</p>
<pre id="review-output" class="review-output"></pre>
<div class="review-actions">
<button id="btn-save" class="primary">Save configuration</button>
<button id="btn-cancel" class="secondary">Cancel</button>
</div>
<p id="save-result" class="save-result" hidden></p>
</section>
</main>
<footer class="wizard-nav">
<button id="btn-prev" class="secondary">Previous</button>
<span class="wizard-progress" id="wizard-progress">Step 1 of 6</span>
<button id="btn-next" class="primary">Next</button>
</footer>
</section>
<script src="url-policy.js"></script>
<script src="admin.js"></script>
</body>
</html>

542
extension/admin.js Executable file
View File

@ -0,0 +1,542 @@
// Vestibule admin wizard — production implementation.
//
// Two-phase UI:
// Phase A (auth gate):
// - No admin password set → setup view (first run)
// - Admin password set → enter view (returning)
// Phase B (wizard):
// - Six steps, navigated with prev/next
// - Final step shows JSON review and save button
//
// Credential split (two passwords, two surfaces):
// Admin password — gates this wizard. PBKDF2-SHA-256, 100k
// iterations, 16-byte salt, in-browser via Web Crypto.
// Kiosk unlock password — releases the session. Argon2id in
// usher, file storage at 0600 (see helper/src/crypto.rs).
const PBKDF2_ITERATIONS = 100_000;
const PBKDF2_HASH = "SHA-256";
const PBKDF2_KEYLEN_BITS = 256;
const SALT_BYTES = 16;
const MIN_PASSWORD_LENGTH = 8;
const TOTAL_STEPS = 6;
// URL policy engine — shared with the background script. Loaded via
// <script src="url-policy.js"> in admin.html; the wizard uses it for
// entry normalization and the homepage-on-safelist validation so the
// rule the operator configures is the rule the engine enforces.
const UrlPolicy = globalThis.VestibuleUrlPolicy;
// ─── Crypto utilities ─────────────────────────────────────────────────
const bytesToBase64 = (bytes) => btoa(String.fromCharCode(...bytes));
const base64ToBytes = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const generateSalt = () => crypto.getRandomValues(new Uint8Array(SALT_BYTES));
async function hashPassword(password, saltBytes) {
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
"raw",
enc.encode(password),
"PBKDF2",
false,
["deriveBits"]
);
const bits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", salt: saltBytes, iterations: PBKDF2_ITERATIONS, hash: PBKDF2_HASH },
keyMaterial,
PBKDF2_KEYLEN_BITS
);
return new Uint8Array(bits);
}
async function verifyPassword(password, storedHashB64, storedSaltB64) {
const hash = await hashPassword(password, base64ToBytes(storedSaltB64));
const stored = base64ToBytes(storedHashB64);
if (hash.length !== stored.length) return false;
// SEI CERT MSC06-C: XOR-accumulate every byte. Array.prototype.every
// short-circuits on the first mismatch and leaks timing; reduce does not.
return hash.reduce((acc, b, i) => acc | (b ^ stored[i]), 0) === 0;
}
// ─── View helpers (step-down, no nested conditionals) ─────────────────
const ALL_VIEWS = () =>
document.querySelectorAll(".auth-view, #view-wizard");
function showView(id) {
ALL_VIEWS().forEach((v) => (v.hidden = true));
const el = document.getElementById(id);
if (el) el.hidden = false;
}
function showStep(n) {
document.querySelectorAll(".step").forEach((s) => {
s.hidden = true;
s.classList.remove("active");
});
const target = document.querySelector(`.step[data-step="${n}"]`);
if (target) {
target.hidden = false;
target.classList.add("active");
}
document.querySelectorAll("#step-list li").forEach((li) => {
li.classList.remove("active", "completed");
const step = parseInt(li.dataset.step, 10);
if (step === n) li.classList.add("active");
else if (step < n) li.classList.add("completed");
});
document.getElementById("wizard-progress").textContent = `Step ${n} of ${TOTAL_STEPS}`;
document.getElementById("btn-prev").disabled = n === 1;
const nextBtn = document.getElementById("btn-next");
nextBtn.hidden = n === TOTAL_STEPS;
if (n === TOTAL_STEPS) updateReview();
}
const getRadio = (name) => {
const el = document.querySelector(`input[name="${name}"]:checked`);
return el ? el.value : null;
};
const setRadio = (name, value) => {
const el = document.querySelector(`input[name="${name}"][value="${value}"]`);
if (el) el.checked = true;
};
const textareaLines = (id) =>
document
.getElementById(id)
.value.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0);
// Safelist entries as they will be saved: hostnames only, normalized,
// deduplicated, order preserved.
const safelistFromTextarea = () => {
const seen = new Set();
const entries = [];
textareaLines("cfg-url-entries").forEach((line) => {
const entry = UrlPolicy.normalizeDomainEntry(line);
if (entry === null || seen.has(entry)) return;
seen.add(entry);
entries.push(entry);
});
return entries;
};
// ─── Config gather / fill (table-driven field maps) ───────────────────
const FIELD_MAP = [
{ key: "kioskName", el: "cfg-kiosk-name", get: (el) => el.value.trim() || "Vestibule Kiosk" },
{ key: "homeUrl", el: "cfg-home-url", get: (el) => el.value.trim() || "about:blank" },
{ key: "attractUrl", el: "cfg-attract-url", get: (el) => el.value.trim() },
];
const RADIO_MAP = [
{ cfg: "urlPolicy.mode", radio: "url-mode", default: "safelist" },
{ cfg: "session.onReset", radio: "on-reset", default: "both" },
{ cfg: "power.mode", radio: "power-mode", default: "aware" },
{ cfg: "power.onWake", radio: "on-wake", default: "both" },
{ cfg: "unlock.method", radio: "unlock-method", default: "password" },
];
// Help text per URL mode — the entries textarea means something
// different in each. One row per mode, shown under the textarea.
const ENTRIES_HELP = {
safelist: "Domains, one per line. An entry grants the domain and every subdomain; pasted URLs are normalized to their hostname. Include the domains of third-party resources (CDNs, SSO, analytics) the kiosk content needs — they are blocked too.",
open: "No entries apply in open mode; every URL is allowed.",
blocklist: "Substrings, one per line. Any URL containing a listed string is blocked. Case-insensitive.",
allowlist: "Substrings, one per line. Only URLs containing a listed string are allowed. An empty list allows everything — prefer safelist mode.",
};
function getNested(obj, path, fallback) {
const result = path.split(".").reduce((acc, key) => (acc ? acc[key] : undefined), obj);
return result !== undefined ? result : fallback;
}
function setNested(obj, path, value) {
const keys = path.split(".");
const last = keys.pop();
const target = keys.reduce((acc, key) => (acc[key] = acc[key] || {}), obj);
target[last] = value;
}
function gatherConfig() {
const cfg = {
version: 1,
urlPolicy: { safelist: [], allowlist: [], blocklist: [] },
session: { dataPersistenceAllowlist: [] },
power: {},
unlock: {},
presence: {
enabled: false,
awayThresholdS: 10,
faceMatchThreshold: 0.6,
privacyMode: true,
},
_meta: { savedAt: new Date().toISOString(), schemaVersion: 1 },
};
FIELD_MAP.forEach(({ key, el, get }) => setNested(cfg, key, get(document.getElementById(el))));
RADIO_MAP.forEach(({ cfg: cfgPath, radio, default: def }) => {
setNested(cfg, cfgPath, getRadio(radio) || def);
});
// Entries feed the list the selected mode consumes. Safelist mode
// normalizes to hostnames; the substring modes keep raw lines.
const urlEntries = textareaLines("cfg-url-entries");
if (cfg.urlPolicy.mode === "safelist") cfg.urlPolicy.safelist = safelistFromTextarea();
else if (cfg.urlPolicy.mode === "allowlist") cfg.urlPolicy.allowlist = urlEntries;
else cfg.urlPolicy.blocklist = urlEntries;
cfg.session.idleTimeoutS = parseInt(document.getElementById("cfg-idle-timeout").value, 10) || 300;
cfg.session.dataPersistenceAllowlist = textareaLines("cfg-persistence-allowlist");
return cfg;
}
function fillConfig(cfg) {
if (!cfg) return;
FIELD_MAP.forEach(({ key, el }) => {
const val = getNested(cfg, key, "");
if (val) document.getElementById(el).value = val;
});
RADIO_MAP.forEach(({ cfg: cfgPath, radio, default: def }) => {
setRadio(radio, getNested(cfg, cfgPath, def));
});
if (cfg.session) {
document.getElementById("cfg-idle-timeout").value = cfg.session.idleTimeoutS || 300;
document.getElementById("cfg-persistence-allowlist").value =
(cfg.session.dataPersistenceAllowlist || []).join("\n");
}
if (cfg.urlPolicy) {
const mode = cfg.urlPolicy.mode;
const entries =
mode === "safelist" ? cfg.urlPolicy.safelist :
mode === "allowlist" ? cfg.urlPolicy.allowlist :
cfg.urlPolicy.blocklist;
document.getElementById("cfg-url-entries").value = (entries || []).join("\n");
}
}
// ─── Review and save ──────────────────────────────────────────────────
function updateReview() {
const cfg = gatherConfig();
const display = { ...cfg, unlock: { method: cfg.unlock.method, password: "(sent to usher for Argon2id hashing)" } };
document.getElementById("review-output").textContent = JSON.stringify(display, null, 2);
}
async function saveConfig() {
const resultEl = document.getElementById("save-result");
resultEl.hidden = true;
resultEl.className = "save-result";
const unlockPwd = document.getElementById("cfg-unlock-password").value;
const unlockPwdConfirm = document.getElementById("cfg-unlock-password-confirm").value;
if (unlockPwd && unlockPwd !== unlockPwdConfirm) {
return showSaveError(resultEl, "Unlock passwords do not match.");
}
const cfg = gatherConfig();
// Safelist mode: the kiosk must be able to reach its own home page
// (and attract URL when set) — a domain not on the list refuses the
// save instead of shipping a configuration that blocks the kiosk's
// front door.
const missing = missingSafelistDomains(cfg);
if (missing.length > 0) {
return showSaveError(resultEl,
`Safelist check failed: ${missing.map((d) => `'${d}'`).join(", ")} ` +
`${missing.length === 1 ? "is" : "are"} not on the safelist (step 2). ` +
"Add the domain there, or set the home URL to an internal page (about:blank).");
}
// Persist config to browser storage (adminConfig + policy mirror)
await persistConfig(cfg, resultEl);
// If a new unlock password was entered, send it to usher for Argon2id
// hashing and file storage. usher stores the PHC string at
// ~/.config/vestibule/unlock.hash (0600). The hash never touches
// browser storage.
if (unlockPwd) {
await sendUnlockPasswordToUsher(unlockPwd, resultEl);
}
}
// URLs the kiosk navigates to on its own (home, attract) that the
// current safelist does not cover. The decision is delegated to the
// engine — the same function the background script calls — so the
// wizard can never disagree with runtime enforcement.
function missingSafelistDomains(cfg) {
if (cfg.urlPolicy.mode !== "safelist") return [];
const safelist = cfg.urlPolicy.safelist;
return [cfg.homeUrl, cfg.attractUrl]
.filter((url) => url && url.length > 0)
.filter((url) =>
UrlPolicy.shouldBlockRequest(url, { mode: "safelist", safelist }, { mainFrame: true }))
.map((url) => UrlPolicy.homeHostnameOf(url) || url);
}
function sendUnlockPasswordToUsher(password, resultEl) {
return new Promise((resolve) => {
const timeoutId = setTimeout(() => {
showSaveError(resultEl, "Unlock password storage timed out. Is usher running?");
browser.runtime.onMessage.removeListener(handler);
resolve();
}, 10_000);
const handler = (msg) => {
if (msg.type !== "unlock-set-result") return;
clearTimeout(timeoutId);
browser.runtime.onMessage.removeListener(handler);
if (msg.ok) {
console.log("[vestibule-admin] unlock hash stored by usher");
} else {
showSaveError(resultEl, `Unlock password storage failed: ${msg.error || "unknown"}`);
}
resolve();
};
browser.runtime.onMessage.addListener(handler);
browser.runtime.sendMessage({ type: "set-unlock-password", password }).catch(() => {
clearTimeout(timeoutId);
browser.runtime.onMessage.removeListener(handler);
showSaveError(resultEl, "Cannot reach usher. Is the native host installed?");
resolve();
});
});
}
async function persistConfig(cfg, resultEl) {
try {
await browser.storage.local.set({
adminConfig: cfg,
policy: {
mode: cfg.urlPolicy.mode,
safelist: cfg.urlPolicy.safelist,
allowlist: cfg.urlPolicy.allowlist,
blocklist: cfg.urlPolicy.blocklist,
homeUrl: cfg.homeUrl,
idleTimeoutS: cfg.session.idleTimeoutS,
onReset: cfg.session.onReset,
dataPersistenceAllowlist: cfg.session.dataPersistenceAllowlist,
onWake: cfg.power.onWake,
},
});
resultEl.textContent = "Configuration saved. The kiosk will use these settings immediately.";
resultEl.classList.add("success");
resultEl.hidden = false;
console.log("[vestibule-admin] config saved:", cfg);
} catch (e) {
showSaveError(resultEl, `Save failed: ${e.message}`);
console.error("[vestibule-admin] save failed:", e);
}
}
function showSaveError(el, message) {
el.textContent = message;
el.classList.add("error");
el.hidden = false;
}
// ─── Auth gate ────────────────────────────────────────────────────────
async function initAuthGate() {
const stored = await browser.storage.local.get("adminAuth");
return stored.adminAuth ? initReturningUser(stored.adminAuth) : initFirstRun();
}
function initFirstRun() {
showView("view-auth-setup");
document.getElementById("setup-submit").addEventListener("click", handleSetupSubmit);
document.getElementById("setup-password-confirm").addEventListener("keydown", (e) => {
if (e.key === "Enter") document.getElementById("setup-submit").click();
});
}
async function handleSetupSubmit() {
const pwd = document.getElementById("setup-password").value;
const pwdConfirm = document.getElementById("setup-password-confirm").value;
if (!pwd) return alert("Password cannot be empty.");
if (pwd.length < MIN_PASSWORD_LENGTH) return alert(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`);
if (pwd !== pwdConfirm) return alert("Passwords do not match.");
const salt = await generateSalt();
const hash = await hashPassword(pwd, salt);
await browser.storage.local.set({
adminAuth: {
salt: bytesToBase64(salt),
hash: bytesToBase64(hash),
iterations: PBKDF2_ITERATIONS,
algorithm: "PBKDF2-SHA-256",
createdAt: new Date().toISOString(),
},
});
console.log("[vestibule-admin] admin password set");
enterWizard();
}
function initReturningUser(auth) {
showView("view-auth-enter");
const submit = document.getElementById("enter-submit");
const passwordInput = document.getElementById("enter-password");
const errEl = document.getElementById("enter-error");
submit.addEventListener("click", async () => {
errEl.hidden = true;
const ok = await verifyPassword(passwordInput.value, auth.hash, auth.salt);
if (ok) return enterWizard();
errEl.textContent = "Invalid password.";
errEl.hidden = false;
passwordInput.value = "";
passwordInput.focus();
});
passwordInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") submit.click();
});
}
// ─── Homepage × safelist live check (step 1 ↔ step 2) ────────────────
//
// Safelist mode only: the home URL's domain must be on the list.
// The status line under the Home URL field answers in the operator's
// terms — which domain was read, whether it is listed — and offers
// the one-click fix. The same rule refuses the save (see
// missingSafelistDomains) and is guaranteed at runtime by the
// background script's home-origin exemption.
function updateUrlEntriesHelp() {
const mode = getRadio("url-mode") || "safelist";
document.getElementById("url-entries-help").textContent = ENTRIES_HELP[mode];
}
function updateHomeDomainStatus() {
const statusEl = document.getElementById("home-domain-status");
const textEl = document.getElementById("home-domain-text");
const btnEl = document.getElementById("btn-add-home-domain");
const mode = getRadio("url-mode") || "safelist";
const homeUrl = document.getElementById("cfg-home-url").value.trim();
// The check is a safelist-mode concern; other modes do not gate
// the home page this way and the status line stays out of the way.
if (mode !== "safelist" || !homeUrl) {
statusEl.hidden = true;
btnEl.hidden = true;
return;
}
const hostname = UrlPolicy.homeHostnameOf(homeUrl);
if (!hostname) {
textEl.textContent = "Internal page — always permitted.";
statusEl.classList.remove("warn");
statusEl.classList.add("ok");
btnEl.hidden = true;
statusEl.hidden = false;
return;
}
const blocked = UrlPolicy.shouldBlockRequest(
homeUrl, { mode: "safelist", safelist: safelistFromTextarea() }, { mainFrame: true });
if (!blocked) {
textEl.textContent = `${hostname} — on the safelist.`;
statusEl.classList.remove("warn");
statusEl.classList.add("ok");
btnEl.hidden = true;
} else {
textEl.textContent = `${hostname} is not on the safelist — the save will be refused until it is added.`;
statusEl.classList.remove("ok");
statusEl.classList.add("warn");
btnEl.hidden = false;
}
statusEl.hidden = false;
}
function addHomeDomainToSafelist() {
const hostname = UrlPolicy.homeHostnameOf(
document.getElementById("cfg-home-url").value.trim());
if (!hostname) return;
if (!safelistFromTextarea().some((entry) =>
hostname === entry || hostname.endsWith("." + entry))) {
const textarea = document.getElementById("cfg-url-entries");
textarea.value = textarea.value.trim();
if (textarea.value) textarea.value += "\n";
textarea.value += hostname;
}
updateHomeDomainStatus();
}
function wireUrlPolicyLiveChecks() {
document.getElementById("cfg-home-url").addEventListener("input", updateHomeDomainStatus);
document.getElementById("cfg-url-entries").addEventListener("input", updateHomeDomainStatus);
document.querySelectorAll("input[name='url-mode']").forEach((radio) =>
radio.addEventListener("change", () => {
updateUrlEntriesHelp();
updateHomeDomainStatus();
}));
document.getElementById("btn-add-home-domain").addEventListener(
"click", addHomeDomainToSafelist);
updateUrlEntriesHelp();
updateHomeDomainStatus();
}
// ─── Wizard entry ─────────────────────────────────────────────────────
async function enterWizard() {
showView("view-wizard");
const stored = await browser.storage.local.get("adminConfig");
if (stored.adminConfig) fillConfig(stored.adminConfig);
else applyDefaults();
let currentStep = 1;
showStep(currentStep);
document.getElementById("btn-prev").addEventListener("click", () => {
if (currentStep > 1) showStep(--currentStep);
});
document.getElementById("btn-next").addEventListener("click", () => {
if (currentStep < TOTAL_STEPS) showStep(++currentStep);
});
document.querySelectorAll("#step-list li").forEach((li) => {
li.addEventListener("click", () => {
const target = parseInt(li.dataset.step, 10);
if (target <= currentStep || target === currentStep + 1) showStep((currentStep = target));
});
});
document.getElementById("btn-save").addEventListener("click", saveConfig);
document.getElementById("btn-cancel").addEventListener("click", () => {
if (confirm("Discard changes and close?")) window.close();
});
wireUrlPolicyLiveChecks();
}
function applyDefaults() {
setRadio("url-mode", "safelist");
setRadio("on-reset", "both");
setRadio("power-mode", "aware");
setRadio("on-wake", "both");
setRadio("unlock-method", "password");
document.getElementById("cfg-idle-timeout").value = "300";
}
// ─── Boot ─────────────────────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", initAuthGate);
console.log("[vestibule-admin] wizard loaded");

414
extension/background.js Executable file
View File

@ -0,0 +1,414 @@
// Vestibule background script — production implementation.
//
// Responsibilities (one per section, step-down order):
// 1. URL filtering (domain safelist default — engine in url-policy.js)
// 2. Session reset (real per-origin cookie preservation)
// 3. Idle timeout (polls browser.idle, delegates to resetSession)
// 4. Native Messaging bridge (long-lived port to usher)
// 5. Unlock popup management (opens/closes popup, routes results)
// 6. Admin grace mode (suppresses resets after successful unlock)
// 7. Admin wizard command (Ctrl+Shift+V)
// 8. Message routing (table-driven dispatch)
const NATIVE_HOST = "com.vestibule.usher";
const IDLE_POLL_INTERVAL_MS = 30_000;
const RESET_DEBOUNCE_MS = 5_000;
const ADMIN_GRACE_MS = 10 * 60 * 1000;
// Safe by default: a fresh install blocks every domain except
// browser-internal pages until the operator configures a safelist.
const DEFAULT_POLICY = {
mode: "safelist",
safelist: [],
allowlist: [],
blocklist: [],
homeUrl: "about:blank",
idleTimeoutS: 300,
onReset: "both",
onWake: "both",
dataPersistenceAllowlist: [],
};
let policy = { ...DEFAULT_POLICY };
let lastResetAt = 0;
let nativePort = null;
let adminGraceUntil = 0;
let unlockPopupId = null;
// ─── Policy loading ───────────────────────────────────────────────────
browser.storage.local.get("policy").then(
(result) => {
if (result.policy) policy = { ...DEFAULT_POLICY, ...result.policy };
console.log("[vestibule] policy loaded:", policy);
adoptStartupHome();
},
(err) => console.warn("[vestibule] storage read failed:", err)
);
browser.storage.onChanged.addListener((changes, area) => {
if (area !== "local" || !changes.policy) return;
policy = { ...DEFAULT_POLICY, ...changes.policy.newValue };
console.log("[vestibule] policy updated:", policy);
});
// First-boot adoption: the provisioner launches the browser with the
// kiosk home URL on the command line; the extension cannot read
// kiosk.env. While the policy is still the default, the startup page
// becomes home and its domain the first safelist entry, so a
// provisioned kiosk boots to a working page instead of its own block
// page. Only tabs the browser was launched with qualify — decided in
// the engine (adoptStartupPolicy), which is unit-tested.
function adoptStartupHome() {
browser.tabs
.query({})
.then((tabs) => {
const startupUrls = tabs.map((t) => t.pendingUrl || t.url || "");
const adopted = UrlPolicy.adoptStartupPolicy(startupUrls, policy);
if (!adopted) return;
policy = adopted;
browser.storage.local.set({ policy }).catch(() => {});
console.log(
"[vestibule] startup page adopted as home, domain safelisted:",
adopted.homeUrl
);
})
.catch(() => {});
}
// ─── URL filtering (engine in url-policy.js) ─────────────────────────
//
// url-policy.js is the single source of truth for the decision; this
// section owns only the state (policy) and the webRequest wiring.
// The home origin is recomputed on every decision so a policy update
// takes effect on the very next request.
const UrlPolicy = globalThis.VestibuleUrlPolicy;
const shouldBlock = (url, mainFrame) =>
UrlPolicy.shouldBlockRequest(url, policy, {
mainFrame,
homeHostname: UrlPolicy.homeHostnameOf(policy.homeUrl),
});
const blockedPageUrl = (url) =>
browser.runtime.getURL("blocked.html") + "?u=" + encodeURIComponent(url);
browser.webRequest.onBeforeRequest.addListener(
(details) => {
const mainFrame = details.type === "main_frame";
if (!shouldBlock(details.url, mainFrame)) return {};
console.log("[vestibule] blocked:", details.url);
// Top-level navigations land on the block page (a kiosk user
// staring at a raw connection error learns nothing); everything
// else — subresources, frames, fetches — is cancelled outright.
return mainFrame ? { redirectUrl: blockedPageUrl(details.url) } : { cancel: true };
},
{ urls: ["<all_urls>"] },
["blocking"]
);
// ─── Session reset (real per-origin preservation) ─────────────────────
//
// When dataPersistenceAllowlist is non-empty:
// - Cookies for allowlisted domains are preserved (via getAll + remove)
// - All other data types are wiped unconditionally
// - localStorage is NOT wiped (WebExtension API cannot enumerate origins
// for selective removal; localStorage typically holds UI state, not
// auth tokens — the risk is low and documented)
//
// When allowlist is empty: wipe everything (strict mode).
const ALWAYS_WIPE_TYPES = {
history: true,
cache: true,
formData: true,
downloads: true,
pluginData: true,
serviceWorkers: true,
passwords: true,
sessions: true,
indexedDB: true,
};
const ALL_TYPES = { ...ALWAYS_WIPE_TYPES, cookies: true, localStorage: true };
function resetSession(reason) {
if (isInAdminGrace()) {
console.log(`[vestibule] reset (${reason}) suppressed — admin grace active`);
return;
}
if (Date.now() - lastResetAt < RESET_DEBOUNCE_MS) {
console.log(`[vestibule] reset (${reason}) debounced`);
return;
}
lastResetAt = Date.now();
console.log(`[vestibule] resetting session (reason: ${reason})`);
const allowlist = policy.dataPersistenceAllowlist || [];
const wipePromise =
allowlist.length === 0
? wipeAllData()
: wipeAllExceptCookies(allowlist);
wipePromise
.then(() => {
console.log("[vestibule] browsing data cleared");
return browser.tabs.query({});
})
.then(navigateAllTabsHome)
.then(() => maybeShowLockOverlay(reason))
.catch((err) => console.error("[vestibule] session reset failed:", err));
}
function wipeAllData() {
return browser.browsingData.remove({}, ALL_TYPES);
}
function wipeAllExceptCookies(allowlist) {
// Wipe everything except cookies (which we handle selectively below)
return browser.browsingData
.remove({}, ALWAYS_WIPE_TYPES)
.then(() => removeNonAllowlistedCookies(allowlist));
}
function removeNonAllowlistedCookies(allowlist) {
return browser.cookies.getAll({}).then((cookies) => {
const toRemove = cookies.filter((c) => !isCookieAllowlisted(c, allowlist));
console.log(
`[vestibule] cookies: ${cookies.length} total, ${toRemove.length} to remove, ${cookies.length - toRemove.length} preserved`
);
return Promise.all(
toRemove.map((c) => {
const domain = (c.domain || "").replace(/^\./, "");
const url = `http${c.secure ? "s" : ""}://${domain}${c.path}`;
return browser.cookies.remove({ url, name: c.name, storeId: c.storeId }).catch(() => {});
})
);
});
}
function isCookieAllowlisted(cookie, allowlist) {
const domain = (cookie.domain || "").toLowerCase().replace(/^\./, "");
return allowlist.some((pat) => domain.includes(pat.toLowerCase()));
}
function navigateAllTabsHome(tabs) {
const homeUrl = policy.homeUrl || "about:blank";
const targetTabs = tabs.filter((tab) => !tab.url || !tab.url.includes("admin.html"));
targetTabs.forEach((tab) => browser.tabs.update(tab.id, { url: homeUrl }).catch(() => {}));
console.log(`[vestibule] ${targetTabs.length} tab(s) navigated to ${homeUrl}`);
return tabs;
}
function maybeShowLockOverlay(reason) {
const onReset = reason.startsWith("wake:") ? policy.onWake : policy.onReset;
if (onReset !== "lock" && onReset !== "both") return;
broadcastToActiveTab({ type: "show-lock-overlay" });
}
// ─── Admin grace mode ─────────────────────────────────────────────────
function isInAdminGrace() {
return Date.now() < adminGraceUntil;
}
function enterAdminGrace() {
adminGraceUntil = Date.now() + ADMIN_GRACE_MS;
console.log(`[vestibule] admin grace entered for ${ADMIN_GRACE_MS / 1000}s`);
}
// ─── Idle timeout polling ─────────────────────────────────────────────
setInterval(() => {
const threshold = policy.idleTimeoutS || 300;
browser.idle.queryState(threshold).then(
(state) => {
if (state === "idle" || state === "locked") resetSession("idle");
},
(err) => console.warn("[vestibule] idle query failed:", err)
);
}, IDLE_POLL_INTERVAL_MS);
// ─── Native Messaging bridge ──────────────────────────────────────────
function connectNative() {
if (nativePort) return;
try {
nativePort = browser.runtime.connectNative(NATIVE_HOST);
nativePort.onMessage.addListener(handleNativeMessage);
nativePort.onDisconnect.addListener(() => {
const err = browser.runtime.lastError;
console.warn("[vestibule] usher disconnected:", err && err.message);
nativePort = null;
setTimeout(connectNative, 5000);
});
nativePort.postMessage({
type: "hello",
client: "vestibule",
version: browser.runtime.getManifest().version,
});
} catch (e) {
console.error("[vestibule] native connect failed:", e);
}
}
const NATIVE_HANDLERS = {
hello: (msg) => console.log("[vestibule] usher hello:", msg.server, msg.version),
pong: (msg) => console.log("[vestibule] usher pong, echo:", msg.echo),
wake: (msg) => resetSession("wake:" + (msg.reason || "unknown")),
"unlock-result": handleUnlockResult,
"unlock-set": (msg) => {
// Forward to admin wizard (which is listening via runtime.onMessage)
browser.runtime.sendMessage({
type: "unlock-set-result",
ok: msg.ok,
error: msg.error,
}).catch(() => {});
},
};
function handleNativeMessage(msg) {
console.log("[vestibule] from usher:", msg);
const handler = NATIVE_HANDLERS[msg.type];
if (handler) handler(msg);
else console.warn("[vestibule] unknown native message:", msg);
}
function handleUnlockResult(msg) {
// Forward to the unlock popup
browser.runtime.sendMessage({
type: "unlock-result",
granted: msg.granted,
reason: msg.reason,
}).catch(() => {});
if (!msg.granted) return;
// Granted: close popup, clear lock overlay, enter admin grace
if (unlockPopupId !== null) {
browser.windows.remove(unlockPopupId).catch(() => {});
unlockPopupId = null;
}
broadcastToActiveTab({ type: "hide-lock-overlay" });
enterAdminGrace();
console.log("[vestibule] unlock granted, admin grace active");
}
function sendToNative(msg) {
if (!nativePort) connectNative();
if (!nativePort) return;
try {
nativePort.postMessage(msg);
} catch (e) {
console.error("[vestibule] send to native failed:", e);
nativePort = null;
setTimeout(connectNative, 1000);
}
}
// ─── Unlock popup management ──────────────────────────────────────────
function openUnlockPopup() {
if (unlockPopupId !== null) {
browser.windows.update(unlockPopupId, { focused: true }).catch(() => {});
return;
}
browser.windows
.create({
url: browser.runtime.getURL("unlock.html"),
type: "popup",
width: 360,
height: 280,
left: Math.round((screen.availWidth - 360) / 2),
top: Math.round((screen.availHeight - 280) / 2),
})
.then((win) => {
unlockPopupId = win.id;
browser.windows.onRemoved.addListener((windowId) => {
if (windowId === unlockPopupId) unlockPopupId = null;
});
})
.catch((err) => console.error("[vestibule] popup create failed:", err));
}
// ─── Admin wizard command ─────────────────────────────────────────────
browser.commands.onCommand.addListener((command) => {
if (command !== "open-admin-wizard") return;
console.log("[vestibule] opening admin wizard");
browser.tabs.create({ url: browser.runtime.getURL("admin.html") });
});
// ─── Broadcasting helpers ─────────────────────────────────────────────
function broadcastToActiveTab(message) {
browser.tabs.query({ active: true, currentWindow: true }).then(
(tabs) => tabs[0] && browser.tabs.sendMessage(tabs[0].id, message).catch(() => {}),
() => {}
);
}
// ─── Message routing (table-driven dispatch) ──────────────────────────
const MESSAGE_HANDLERS = {
"ping-usher": (msg) => {
sendToNative({ type: "ping", echo: msg.echo || "vestibule" });
return { ok: true };
},
"unlock-attempt": (msg) => {
sendToNative({ type: "unlock", password: msg.password || "" });
return { ok: true, queued: true };
},
"set-unlock-password": (msg) => {
sendToNative({ type: "set-unlock", password: msg.password || "" });
return { ok: true, queued: true };
},
"open-unlock-popup": () => {
openUnlockPopup();
return { ok: true };
},
"get-policy": () => policy,
"set-policy": (msg) => {
policy = { ...policy, ...msg.policy };
browser.storage.local.set({ policy });
return { ok: true };
},
"navigate-home": (msg, sender) => {
const tabId = sender.tab ? sender.tab.id : null;
const target = { url: policy.homeUrl || "about:blank" };
if (tabId !== null) browser.tabs.update(tabId, target);
else browser.tabs.create(target);
return { ok: true };
},
"manual-reset": () => {
resetSession("manual");
return { ok: true };
},
"open-admin": () => {
browser.tabs.create({ url: browser.runtime.getURL("admin.html") });
return { ok: true };
},
};
browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
const handler = MESSAGE_HANDLERS[msg.type];
if (!handler) {
console.warn("[vestibule] unknown runtime message:", msg);
return false;
}
sendResponse(handler(msg, sender));
return false;
});
// ─── Boot ─────────────────────────────────────────────────────────────
connectNative();
console.log(
"[vestibule] background loaded, version",
browser.runtime.getManifest().version
);
console.log("[vestibule] admin wizard: Ctrl+Shift+V or gear icon");

83
extension/blocked.css Normal file
View File

@ -0,0 +1,83 @@
/* Vestibule blocked page — patient-facing styling.
* Matches the extension's design language (see admin.css, unlock.css):
* dark surface, centered card, system fonts, no decoration.
*/
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: #0f1116;
color: #e8eaed;
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
font-size: 15px;
line-height: 1.5;
min-height: 100vh;
}
body {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.block-card {
background: #1a1d24;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 40px 48px;
max-width: 460px;
width: 100%;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
text-align: center;
}
.block-icon {
font-size: 40px;
margin-bottom: 8px;
}
.block-card h1 {
margin: 0 0 8px;
font-size: 20px;
font-weight: 600;
color: #fff;
}
.block-sub {
margin: 0 0 28px;
color: #b8bcc4;
font-size: 13px;
}
.block-domain-label {
margin: 0 0 4px;
color: #b8bcc4;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.block-domain {
margin: 0 0 28px;
color: #ef4444;
font-family: "SF Mono", "Cascadia Mono", Consolas, monospace;
font-size: 14px;
word-break: break-all;
}
.block-home {
background: #2563eb;
color: #fff;
border: none;
border-radius: 8px;
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.block-home:hover { background: #1d4ed8; }
.block-home:active { background: #1e40af; }

24
extension/blocked.html Normal file
View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Blocked — Vestibule</title>
<link rel="stylesheet" href="blocked.css">
</head>
<body>
<main class="block-card">
<div class="block-icon" aria-hidden="true">&#128683;</div>
<h1>This site is not available</h1>
<p class="block-sub">
This kiosk only visits sites the operator has approved.
The rest of the web is blocked.
</p>
<p class="block-domain-label">Blocked address</p>
<p id="block-domain" class="block-domain"></p>
<button id="btn-home" class="block-home">Return to home page</button>
</main>
<script src="blocked.js"></script>
</body>
</html>

24
extension/blocked.js Normal file
View File

@ -0,0 +1,24 @@
// Vestibule blocked page — shown when a top-level navigation is
// denied by the URL policy (safelist mode).
//
// The attempted URL arrives as the ?u= query parameter. Only the
// hostname is displayed, and only via textContent — the parameter is
// never written into the DOM as markup.
(() => {
const params = new URLSearchParams(location.search);
const attempted = params.get("u") || "";
let host = attempted;
try {
host = new URL(attempted).hostname;
} catch (e) {
/* unparsable — display the raw value; textContent makes it inert */
}
document.getElementById("block-domain").textContent = host;
document.getElementById("btn-home").addEventListener("click", () => {
browser.runtime.sendMessage({ type: "navigate-home" }).catch(() => {});
});
})();

133
extension/content.css Executable file
View File

@ -0,0 +1,133 @@
/* Vestibule hidden menu — production styling.
* The menu is injected by content.js as a fixed-position overlay at the
* top-center of the viewport. It slides down when the mouse touches the
* top 3px of the screen, slides back up after 1.5s of mouse-leave.
*/
#vestibule-menu {
position: fixed;
top: 0;
left: 50%;
transform: translateX(-50%) translateY(-100%);
z-index: 2147483647; /* max int — always on top of host page */
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
background: rgba(15, 17, 22, 0.96);
border: 1px solid rgba(255, 255, 255, 0.12);
border-top: none;
border-radius: 0 0 10px 10px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
font-size: 14px;
color: #e8eaed;
opacity: 0;
transition: transform 180ms ease, opacity 180ms ease;
pointer-events: none;
user-select: none;
}
#vestibule-menu.vestibule-visible {
transform: translateX(-50%) translateY(0);
opacity: 1;
pointer-events: auto;
}
#vestibule-menu button {
background: transparent;
border: 1px solid transparent;
color: inherit;
font: inherit;
font-size: 16px;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
line-height: 1;
min-width: 28px;
}
#vestibule-menu button:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.15);
}
#vestibule-menu button:active {
background: rgba(255, 255, 255, 0.18);
}
#vestibule-menu button:focus-visible {
outline: 2px solid #6aa6ff;
outline-offset: 1px;
}
#vestibule-menu .vestibule-divider {
width: 1px;
height: 20px;
background: rgba(255, 255, 255, 0.15);
margin: 0 2px;
}
/* Lock overlay — shown after session reset (idle timeout or wake).
* Covers the entire viewport with a dark blur, presents a single
* "Unlock" CTA. The host page is still loaded behind it (so a tab
* refresh isn't required on unlock) but cannot be interacted with. */
#vestibule-lock-overlay {
position: fixed;
inset: 0;
z-index: 2147483646; /* one less than the hidden menu */
display: flex;
align-items: center;
justify-content: center;
background: rgba(8, 10, 14, 0.92);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
color: #e8eaed;
}
#vestibule-lock-overlay .vestibule-lock-card {
text-align: center;
max-width: 480px;
padding: 32px 48px;
background: rgba(28, 32, 40, 0.95);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
}
#vestibule-lock-overlay h1 {
margin: 0 0 16px;
font-size: 22px;
font-weight: 600;
color: #fff;
}
#vestibule-lock-overlay p {
margin: 0 0 12px;
font-size: 14px;
line-height: 1.5;
color: #b8bcc4;
}
#vestibule-lock-overlay button {
margin-top: 16px;
padding: 10px 24px;
background: #3b82f6;
border: none;
border-radius: 8px;
color: #fff;
font: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 120ms ease;
}
#vestibule-lock-overlay button:hover {
background: #2563eb;
}
#vestibule-lock-overlay button:active {
background: #1d4ed8;
}

127
extension/content.js Executable file
View File

@ -0,0 +1,127 @@
// Vestibule content script — production implementation.
//
// Single responsibility: inject and manage the hidden menu overlay
// and the lock overlay. The 3px-from-top gesture is the direct
// descendant of the 2001 VB6 Form_MouseMove handler (Y <= 3).
const EDGE_PX = 3;
const HIDE_DELAY_MS = 1500;
let menuEl = null;
let hideTimer = null;
// ─── Menu construction (data-driven) ──────────────────────────────────
const MENU_ACTIONS = [
{ action: "back", label: "Back", symbol: "\u25C0", handler: () => history.back() },
{ action: "forward", label: "Forward", symbol: "\u25B6", handler: () => history.forward() },
{ action: "refresh", label: "Refresh", symbol: "\u21BB", handler: () => location.reload() },
{ action: "home", label: "Home", symbol: "\u2302", handler: () => browser.runtime.sendMessage({ type: "navigate-home" }).catch(() => {}) },
{ divider: true },
{ action: "unlock", label: "Unlock", symbol: "\uD83D\uDD12", handler: openUnlockPopup },
{ action: "admin", label: "Admin", symbol: "\u2699", handler: () => browser.runtime.sendMessage({ type: "open-admin" }).catch(() => {}) },
];
function buildMenu() {
const menu = document.createElement("div");
menu.id = "vestibule-menu";
menu.setAttribute("role", "toolbar");
menu.setAttribute("aria-label", "Vestibule controls");
MENU_ACTIONS.forEach((item) => {
if (item.divider) {
const span = document.createElement("span");
span.className = "vestibule-divider";
menu.appendChild(span);
return;
}
const btn = document.createElement("button");
btn.dataset.action = item.action;
btn.title = item.label;
btn.setAttribute("aria-label", item.label);
btn.textContent = item.symbol;
btn.addEventListener("click", () => {
item.handler();
hideMenu();
});
menu.appendChild(btn);
});
menu.addEventListener("mouseenter", () => clearTimeout(hideTimer));
menu.addEventListener("mouseleave", scheduleHide);
return menu;
}
function ensureMenu() {
if (menuEl && document.body.contains(menuEl)) return menuEl;
menuEl = buildMenu();
document.documentElement.appendChild(menuEl);
return menuEl;
}
// ─── Show / hide (step-down) ──────────────────────────────────────────
function showMenu() {
clearTimeout(hideTimer);
ensureMenu().classList.add("vestibule-visible");
}
function hideMenu() {
if (menuEl) menuEl.classList.remove("vestibule-visible");
}
function scheduleHide() {
clearTimeout(hideTimer);
hideTimer = setTimeout(hideMenu, HIDE_DELAY_MS);
}
// ─── Unlock popup ─────────────────────────────────────────────────────
function openUnlockPopup() {
browser.runtime.sendMessage({ type: "open-unlock-popup" }).catch(() => {});
}
// ─── Lock overlay ─────────────────────────────────────────────────────
function showLockOverlay() {
if (document.getElementById("vestibule-lock-overlay")) return;
const overlay = document.createElement("div");
overlay.id = "vestibule-lock-overlay";
overlay.innerHTML = `
<div class="vestibule-lock-card">
<h1>Session Reset</h1>
<p>This kiosk has been idle. Your browsing data has been cleared.</p>
<p>Click Unlock to start a new session.</p>
<button data-action="unlock">Unlock</button>
</div>
`;
overlay.addEventListener("click", (e) => {
if (!e.target.closest("button[data-action='unlock']")) return;
openUnlockPopup();
});
document.documentElement.appendChild(overlay);
}
function hideLockOverlay() {
const overlay = document.getElementById("vestibule-lock-overlay");
if (overlay) overlay.remove();
}
// ─── Event wiring ─────────────────────────────────────────────────────
document.addEventListener("mousemove", (e) => {
if (e.clientY <= EDGE_PX) showMenu();
}, { passive: true });
const RUNTIME_MESSAGE_HANDLERS = {
"show-lock-overlay": showLockOverlay,
"hide-lock-overlay": hideLockOverlay,
};
browser.runtime.onMessage.addListener((msg) => {
const handler = RUNTIME_MESSAGE_HANDLERS[msg.type];
if (handler) handler(msg);
});
console.log("[vestibule] content script loaded on", location.href);

54
extension/manifest.json Executable file
View File

@ -0,0 +1,54 @@
{
"manifest_version": 3,
"name": "Vestibule",
"version": "1.2.2",
"description": "Kiosk lockdown browser extension for LibreWolf and Firefox — URL policy, session reset, admin wizard.",
"browser_specific_settings": {
"gecko": {
"id": "vestibule@vestibule.kiosk",
"strict_min_version": "115.0"
}
},
"permissions": [
"webRequest",
"webRequestBlocking",
"nativeMessaging",
"storage",
"tabs",
"activeTab",
"scripting",
"idle",
"commands"
],
"host_permissions": ["<all_urls>"],
"background": {
"scripts": ["url-policy.js", "background.js"]
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"css": ["content.css"],
"run_at": "document_idle"
}
],
"web_accessible_resources": [
{
"resources": [
"admin.html", "admin.css", "admin.js", "url-policy.js",
"unlock.html", "unlock.css", "unlock.js",
"blocked.html", "blocked.css", "blocked.js"
],
"matches": ["<all_urls>"]
}
],
"commands": {
"open-admin-wizard": {
"suggested_key": {
"default": "Ctrl+Shift+V",
"mac": "Command+Shift+V"
},
"description": "Open the Vestibule admin configuration wizard"
}
}
}

97
extension/unlock.css Executable file
View File

@ -0,0 +1,97 @@
/* Vestibule unlock popup — production styling.
* Centered card, dark theme, minimal chrome. This is a popup window
* (browser.windows.create type=popup), not a full tab.
*/
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: #0f1116;
color: #e8eaed;
font-family: -apple-system, "Segoe UI", system-ui, sans-serif;
font-size: 14px;
height: 100vh;
overflow: hidden;
}
body {
display: flex;
align-items: center;
justify-content: center;
}
.unlock-card {
text-align: center;
padding: 32px 40px;
width: 100%;
max-width: 320px;
}
.unlock-card h1 {
margin: 0 0 8px;
font-size: 24px;
font-weight: 600;
color: #fff;
letter-spacing: 0.02em;
}
.unlock-sub {
margin: 0 0 24px;
color: #b8bcc4;
font-size: 13px;
}
#unlock-form {
display: flex;
flex-direction: column;
gap: 12px;
}
#unlock-password {
width: 100%;
padding: 10px 14px;
background: #1a1d24;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
color: #e8eaed;
font: inherit;
font-size: 15px;
text-align: center;
letter-spacing: 0.1em;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
#unlock-password:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
button.primary {
padding: 10px 20px;
background: #3b82f6;
border: none;
border-radius: 8px;
color: #fff;
font: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 120ms ease;
}
button.primary:hover { background: #2563eb; }
button.primary:active { background: #1d4ed8; }
button.primary:disabled { background: #1e3a5f; cursor: not-allowed; }
.field-error {
margin: 12px 0 0;
padding: 8px 12px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 6px;
color: #ef4444;
font-size: 12px;
}

29
extension/unlock.html Executable file
View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Vestibule Unlock</title>
<link rel="stylesheet" href="unlock.css">
</head>
<body>
<div class="unlock-card">
<h1>Vestibule</h1>
<p class="unlock-sub">Enter the kiosk unlock code.</p>
<form id="unlock-form">
<input
type="password"
id="unlock-password"
placeholder="Unlock code"
autocomplete="off"
autofocus
>
<button type="submit" class="primary">Unlock</button>
</form>
<p id="unlock-error" class="field-error" hidden></p>
</div>
<script src="unlock.js"></script>
</body>
</html>

64
extension/unlock.js Executable file
View File

@ -0,0 +1,64 @@
// Vestibule unlock popup — production implementation.
//
// Opens as a browser.windows.create popup. Sends the password to the
// background script, which forwards to usher for Argon2id verification.
// On success: the popup closes and the background clears the lock
// overlay + enters admin grace mode. On failure: shows error, clears
// input, refocuses.
const REASON_MESSAGES = {
invalid: "Invalid unlock code.",
"not-configured": "No unlock password configured. Run the admin wizard (Ctrl+Shift+V).",
};
document.getElementById("unlock-form").addEventListener("submit", (e) => {
e.preventDefault();
const password = document.getElementById("unlock-password").value;
if (!password) return;
const submitBtn = e.target.querySelector("button[type=submit]");
submitBtn.disabled = true;
submitBtn.textContent = "Verifying...";
browser.runtime
.sendMessage({ type: "unlock-attempt", password })
.then(() => {
// Result arrives asynchronously via runtime.onMessage.
// The button re-enables when the result lands.
})
.catch(() => {
resetForm("Communication error. Try again.");
});
});
browser.runtime.onMessage.addListener((msg) => {
if (msg.type !== "unlock-result") return;
if (msg.granted) {
window.close();
return;
}
const message = REASON_MESSAGES[msg.reason] || "Unlock failed.";
resetForm(message);
});
function resetForm(errorMessage) {
const input = document.getElementById("unlock-password");
const submitBtn = document.querySelector("button[type=submit]");
const errorEl = document.getElementById("unlock-error");
input.value = "";
input.focus();
submitBtn.disabled = false;
submitBtn.textContent = "Unlock";
if (errorMessage) {
errorEl.textContent = errorMessage;
errorEl.hidden = false;
} else {
errorEl.hidden = true;
}
}
console.log("[vestibule-unlock] popup loaded");

156
extension/url-policy.js Normal file
View File

@ -0,0 +1,156 @@
// Vestibule URL policy engine — pure logic, no browser API calls.
//
// This module is the single source of truth for navigation decisions.
// It is loaded as a classic background script before background.js
// (which owns the webRequest wiring and policy state) and imported by
// scripts/test-url-policy.js under Node. The UMD-lite export keeps
// both worlds working from one file.
//
// Modes (one row per mode in MODE_TABLE):
// safelist — default-deny by domain. Every request whose hostname is
// not on the operator's safelist is blocked. Internal
// browser schemes and the configured home origin are
// always permitted. This is the default mode: a fresh
// install can load its home page and nothing else until
// the operator adds domains.
// open — no filtering.
// blocklist — substring block (legacy substring semantics).
// allowlist — substring allow (legacy substring semantics; an empty
// list allows everything — use safelist instead).
//
// Domain matching is hostname-based, never substring-based: the entry
// "example.org" permits example.org and any subdomain
// (portal.example.org), and nothing else. A URL whose query string
// merely contains "example.org" does not match — the failure mode of
// substring matching and the reason safelist mode exists.
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) {
module.exports = api; // Node (unit tests)
} else {
root.VestibuleUrlPolicy = api; // extension background / wizard page
}
})(typeof self !== "undefined" ? self : globalThis, function () {
// Schemes the browser itself needs. Blocking these breaks the admin
// wizard, the unlock popup, and about:blank — the kiosk would brick.
const INTERNAL_SCHEMES = ["about:", "moz-extension:", "chrome:", "resource:"];
// Same-document artifacts. Safe as subresources; blocked as
// top-level documents (a data: URL navigation is a known content-
// injection vector and has no legitimate kiosk use).
const DATA_LIKE_SCHEMES = ["data:", "blob:"];
const schemeOf = (url) => {
const idx = url.indexOf(":");
return idx === -1 ? "" : url.slice(0, idx + 1).toLowerCase();
};
const hostnameOf = (url) => {
try {
return new URL(url).hostname.toLowerCase();
} catch (e) {
return "";
}
};
// Normalize one operator-supplied safelist entry to a bare hostname.
// Accepts domains, wildcard-prefixed domains, and pasted URLs;
// returns null when nothing hostname-shaped can be extracted.
const normalizeDomainEntry = (entry) => {
const raw = String(entry || "").trim().toLowerCase();
if (!raw) return null;
const stripped = raw.startsWith("*.") ? raw.slice(2) : raw;
const candidate = /^[a-z][a-z0-9+.-]*:/.test(stripped) || stripped.includes("/")
? stripped // URL-ish — let the URL parser take it apart
: "http://" + stripped + "/"; // bare domain — synthesize a URL
const host = hostnameOf(candidate);
return host || null;
};
// Entry "example.org" matches hostname example.org and any
// subdomain of it. The leading-dot boundary is explicit: a sibling
// like evilexample.org must not match.
const hostMatchesEntry = (hostname, entry) =>
hostname === entry || hostname.endsWith("." + entry);
const domainAllowed = (url, safelist) => {
const hostname = hostnameOf(url);
if (!hostname) return false; // file:, malformed — nothing to match
const entries = (safelist || [])
.map(normalizeDomainEntry)
.filter((e) => e !== null);
return entries.some((entry) => hostMatchesEntry(hostname, entry));
};
// The home origin is always navigable regardless of safelist
// contents: session reset, wake, and idle all navigate home, and a
// block there would leave the kiosk showing its own block page
// forever. Exact hostname match — subdomains of home are not
// covered; the operator lists them explicitly.
const homeHostnameOf = (homeUrl) => {
const host = hostnameOf(homeUrl || "");
if (!host) return null;
const scheme = schemeOf(homeUrl);
return scheme === "http:" || scheme === "https:" ? host : null;
};
const substringMatches = (url, patterns) =>
(patterns || []).some((pat) =>
url.toLowerCase().includes(String(pat).toLowerCase())
);
const SAFELIST_PREDICATE = (url, policy, ctx) => {
const scheme = schemeOf(url);
if (INTERNAL_SCHEMES.includes(scheme)) return false; // always allow
if (DATA_LIKE_SCHEMES.includes(scheme)) return ctx.mainFrame; // subresource only
if (domainAllowed(url, policy.safelist)) return false;
return hostnameOf(url) !== ctx.homeHostname; // home origin passes
};
// One row per mode. Unknown modes resolve to the safelist row:
// a corrupted or hand-edited policy string fails closed — the kiosk
// locks to its home page instead of opening the perimeter.
const MODE_TABLE = {
safelist: SAFELIST_PREDICATE,
open: () => false,
blocklist: (url, policy) => substringMatches(url, policy.blocklist),
allowlist: (url, policy) =>
(policy.allowlist || []).length > 0 && !substringMatches(url, policy.allowlist),
};
const shouldBlockRequest = (url, policy, ctx) => {
const context = {
mainFrame: !!(ctx && ctx.mainFrame),
homeHostname: (ctx && ctx.homeHostname) || null,
};
const predicate = MODE_TABLE[policy.mode] || MODE_TABLE.safelist;
return predicate(url, policy, context);
};
// First-boot adoption: a provisioned kiosk launches with its home URL
// on the command line (kiosk.env → --kiosk URL), which the extension
// cannot learn any other way. When the policy is still the default
// (about:blank home, empty safelist), the browser's startup page is
// adopted as the home URL and its domain becomes the first safelist
// entry. Runs once against the tab list at background startup — only
// pages the browser was launched with qualify, never later
// operator-typed navigations. Returns the new policy, or null when
// nothing should change.
const adoptStartupPolicy = (startupUrls, policy) => {
if (!policy || policy.homeUrl !== "about:blank") return null;
if ((policy.safelist || []).length > 0) return null;
const url = (startupUrls || []).find((u) => homeHostnameOf(u) !== null);
if (!url) return null;
return { ...policy, homeUrl: url, safelist: [homeHostnameOf(url)] };
};
return {
INTERNAL_SCHEMES: INTERNAL_SCHEMES.slice(),
normalizeDomainEntry: normalizeDomainEntry,
hostnameOf: hostnameOf,
homeHostnameOf: homeHostnameOf,
shouldBlockRequest: shouldBlockRequest,
adoptStartupPolicy: adoptStartupPolicy,
};
});

1319
helper/Cargo.lock generated Executable file

File diff suppressed because it is too large Load Diff

32
helper/Cargo.toml Executable file
View File

@ -0,0 +1,32 @@
[package]
name = "usher"
version = "1.2.2"
edition = "2021"
description = "Native helper for Vestibule — kiosk unlock + power awareness"
license = "MIT"
authors = ["Jeremy Anderson <info@dcos.net>"]
publish = false
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
argon2 = "0.5"
dirs = "5"
[target.'cfg(unix)'.dependencies]
zbus = "4"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
"Win32_UI_WindowsAndMessaging",
"Win32_Foundation",
"Win32_System_Power",
"Win32_System_LibraryLoader",
] }
[profile.release]
opt-level = "z"
lto = true
strip = true
panic = "abort"
codegen-units = 1

46
helper/src/crypto.rs Executable file
View File

@ -0,0 +1,46 @@
//! Argon2id password hashing for kiosk unlock verification.
//!
//! PHC (Password Hashing Competition) string format encodes algorithm,
//! version, parameters, salt, and hash in one self-describing string.
//! Store the PHC string; verify against it later. No separate salt
//! management needed.
use argon2::{
password_hash::{rand_core::OsRng, SaltString},
Algorithm, Argon2, PasswordHash, PasswordHasher, PasswordVerifier, Params, Version,
};
/// Argon2id parameters — memory-hard, tuned for kiosk-class hardware.
/// 64 MiB memory cost, 3 iterations, 4 parallel lanes.
/// Tunable in Phase 2 via config file; hardcoded for Phase 1.
const M_COST: u32 = 65_536;
const T_COST: u32 = 3;
const P_COST: u32 = 4;
fn argon2_instance() -> Argon2<'static> {
let params = Params::new(M_COST, T_COST, P_COST, None)
.expect("valid Argon2id parameters");
Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
}
/// Hash a password and return the PHC string.
pub fn hash_password(password: &str) -> Result<String, String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = argon2_instance();
argon2
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| e.to_string())
}
/// Verify a password against a PHC string.
/// Returns false on any failure (invalid hash, wrong password, malformed PHC).
pub fn verify_password(password: &str, phc: &str) -> bool {
let parsed = match PasswordHash::new(phc) {
Ok(p) => p,
Err(_) => return false,
};
argon2_instance()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
}

214
helper/src/main.rs Executable file
View File

@ -0,0 +1,214 @@
// usher — native helper for Vestibule
//
// Production implementation. No stubs.
//
// Threaded architecture (one responsibility per thread):
// main — stdin read loop, message dispatch
// power — OS power event subscription
// writer — owns stdout lock, drains the shared channel
//
// Native Messaging protocol: 4-byte little-endian length prefix + JSON
// payload over stdin/stdout. stdout is the message channel; all
// diagnostics go to stderr.
use std::io::{self, Read, Write};
use std::sync::mpsc;
use std::thread;
use serde::{Deserialize, Serialize};
mod crypto;
mod power;
mod storage;
// ─── Protocol types ───────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
enum Incoming {
Hello { client: String, version: String },
Ping { echo: Option<String> },
Unlock { password: String },
SetUnlock { password: String },
/// Test affordance: triggers an outgoing Wake event without an actual
/// suspend. Exercises the real Wake emission path for CI. Harmless in
/// production — it only triggers a session reset, which is the same
/// behavior as an actual wake event.
SimulateWake,
}
#[derive(Debug, Serialize, Clone)]
#[serde(tag = "type", rename_all = "kebab-case")]
enum Outgoing {
Hello { server: String, version: String },
Pong { echo: String },
UnlockResult { granted: bool, reason: Option<String> },
UnlockSet { ok: bool, error: Option<String> },
Wake { reason: String },
}
// ─── IO primitives ────────────────────────────────────────────────────
const MAX_MESSAGE_LEN: usize = 1_000_000;
const SHUTDOWN_GRACE_MS: u64 = 100;
fn read_message<R: Read>(r: &mut R) -> io::Result<Option<Incoming>> {
let mut len_buf = [0u8; 4];
match r.read_exact(&mut len_buf) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e),
}
let len = u32::from_le_bytes(len_buf) as usize;
if len == 0 || len > MAX_MESSAGE_LEN {
eprintln!("[usher] rejecting message length {}", len);
return Ok(None);
}
let mut buf = vec![0u8; len];
r.read_exact(&mut buf)?;
Ok(serde_json::from_slice(&buf).map(Some).unwrap_or_else(|e| {
eprintln!("[usher] parse error: {}", e);
None
}))
}
fn write_message<W: Write>(w: &mut W, msg: &Outgoing) -> io::Result<()> {
let bytes = serde_json::to_vec(msg).expect("serialize outgoing");
let len = bytes.len() as u32;
w.write_all(&len.to_le_bytes())?;
w.write_all(&bytes)?;
w.flush()
}
// ─── Message dispatch (table-driven, step-down) ───────────────────────
fn handle_message(msg: Incoming, tx: &mpsc::Sender<Outgoing>) -> Option<Outgoing> {
eprintln!("[usher] recv: {:?}", msg);
match msg {
Incoming::Hello { client, version } => {
eprintln!("[usher] hello from {} v{}", client, version);
Some(Outgoing::Hello {
server: "usher".into(),
version: env!("CARGO_PKG_VERSION").into(),
})
}
Incoming::Ping { echo } => Some(Outgoing::Pong {
echo: echo.unwrap_or_default(),
}),
Incoming::Unlock { password } => Some(handle_unlock(&password)),
Incoming::SetUnlock { password } => Some(handle_set_unlock(&password)),
Incoming::SimulateWake => {
eprintln!("[usher] simulate-wake received, emitting Wake");
let _ = tx.send(Outgoing::Wake { reason: "simulated".into() });
None
}
}
}
/// Real unlock verification: load PHC from disk, verify with Argon2id.
fn handle_unlock(password: &str) -> Outgoing {
match storage::load_hash() {
Ok(Some(phc)) => {
let granted = crypto::verify_password(password, &phc);
let reason = if granted { None } else { Some("invalid".into()) };
Outgoing::UnlockResult { granted, reason }
}
Ok(None) => Outgoing::UnlockResult {
granted: false,
reason: Some("not-configured".into()),
},
Err(e) => Outgoing::UnlockResult {
granted: false,
reason: Some(format!("error: {}", e)),
},
}
}
/// Real password setting: hash with Argon2id, store to disk (0600).
fn handle_set_unlock(password: &str) -> Outgoing {
match crypto::hash_password(password) {
Ok(phc) => match storage::store_hash(&phc) {
Ok(()) => {
eprintln!("[usher] unlock hash stored");
Outgoing::UnlockSet { ok: true, error: None }
}
Err(e) => Outgoing::UnlockSet {
ok: false,
error: Some(format!("storage: {}", e)),
},
},
Err(e) => Outgoing::UnlockSet {
ok: false,
error: Some(format!("hash: {}", e)),
},
}
}
// ─── Main loop ────────────────────────────────────────────────────────
fn main() {
eprintln!("[usher] starting v{}", env!("CARGO_PKG_VERSION"));
let (tx, rx) = mpsc::channel::<Outgoing>();
spawn_writer(rx);
spawn_power_monitor(tx.clone());
run_stdin_loop(tx);
// Grace period for the writer to flush queued messages.
// The power thread is blocked on an OS-level receive call (D-Bus on
// Linux, GetMessage on Windows) and cannot be interrupted without an
// async runtime. It has no cleanup: no open files, no unflushed state,
// no connections beyond what the kernel reclaims on process exit.
// This is the correct shutdown strategy for this architecture.
thread::sleep(std::time::Duration::from_millis(SHUTDOWN_GRACE_MS));
eprintln!("[usher] shutdown complete");
std::process::exit(0);
}
fn spawn_writer(rx: mpsc::Receiver<Outgoing>) {
thread::spawn(move || {
let stdout = io::stdout();
let mut out = stdout.lock();
for msg in rx {
if write_message(&mut out, &msg).is_err() {
eprintln!("[usher] writer error, exiting");
break;
}
}
});
}
fn spawn_power_monitor(tx: mpsc::Sender<Outgoing>) {
thread::spawn(move || power::run_power_monitor(tx));
}
fn run_stdin_loop(tx: mpsc::Sender<Outgoing>) {
let stdin = io::stdin();
let mut stdin_lock = stdin.lock();
loop {
let msg = match read_message(&mut stdin_lock) {
Ok(Some(m)) => m,
Ok(None) => {
eprintln!("[usher] stdin closed, exiting");
return;
}
Err(e) => {
eprintln!("[usher] read error: {}, exiting", e);
return;
}
};
if let Some(reply) = handle_message(msg, &tx) {
if tx.send(reply).is_err() {
eprintln!("[usher] reply send failed, exiting");
return;
}
}
}
}

267
helper/src/power.rs Executable file
View File

@ -0,0 +1,267 @@
//! Cross-platform power event detection for usher.
//!
//! Linux: subscribes to `org.freedesktop.login1.Manager.PrepareForSleep`
//! via D-Bus. Emits `Outgoing::Wake` on resume (active=false).
//!
//! Windows: creates a message-only window, registers for suspend/resume
//! notifications via `RegisterSuspendResumeNotification`, and handles
//! `WM_POWERBROADCAST` in the window procedure. Emits `Outgoing::Wake`
//! on `PBT_APMRESUMEAUTOMATIC`.
//!
//! Design: the monitor is a long-running thread with no shared mutable
//! state beyond a static `Mutex<Option<Sender>>` used to pass events
//! from the Windows window procedure back to the channel. The thread
//! is kill-safe — process exit requires no cleanup on its side.
use std::sync::mpsc::Sender;
use crate::Outgoing;
// ─── Linux implementation ─────────────────────────────────────────────
#[cfg(unix)]
mod linux_impl {
use super::*;
use std::time::Duration;
use zbus::proxy;
#[proxy(
default_service = "org.freedesktop.login1",
default_path = "/org/freedesktop/login1",
interface = "org.freedesktop.login1.Manager"
)]
trait LoginManager {
#[zbus(signal)]
fn prepare_for_sleep(&self, active: bool) -> zbus::Result<()>;
}
const RECONNECT_DELAY_S: u64 = 5;
pub fn run_power_monitor(tx: Sender<Outgoing>) {
eprintln!("[usher] power: connecting to D-Bus system bus");
loop {
let conn = match connect_with_retry() {
Some(c) => c,
None => return,
};
let proxy = match LoginManagerProxyBlocking::new(&conn) {
Ok(p) => p,
Err(e) => {
eprintln!("[usher] power: login1 proxy failed: {}", e);
eprintln!("[usher] power: monitoring disabled (kiosk still works)");
return;
}
};
eprintln!("[usher] power: subscribed to PrepareForSleep (login1.Manager)");
drain_signals(&proxy, &tx);
eprintln!("[usher] power: signal stream ended, reconnecting");
std::thread::sleep(Duration::from_secs(RECONNECT_DELAY_S));
}
}
fn connect_with_retry() -> Option<zbus::blocking::Connection> {
loop {
match zbus::blocking::Connection::system() {
Ok(c) => return Some(c),
Err(e) => {
eprintln!(
"[usher] power: D-Bus connect failed: {}, retrying in {}s",
e, RECONNECT_DELAY_S
);
std::thread::sleep(Duration::from_secs(RECONNECT_DELAY_S));
}
}
}
}
fn drain_signals(proxy: &LoginManagerProxyBlocking, tx: &Sender<Outgoing>) {
let iterator = match proxy.receive_prepare_for_sleep() {
Ok(it) => it,
Err(e) => {
eprintln!("[usher] power: receive failed: {}", e);
return;
}
};
for signal in iterator {
match signal.args() {
Ok(args) => handle_prepare_for_sleep(args.active, tx),
Err(e) => eprintln!("[usher] power: deserialize error: {}", e),
}
}
}
fn handle_prepare_for_sleep(active: bool, tx: &Sender<Outgoing>) {
if active {
eprintln!("[usher] power: PrepareForSleep(true) — entering suspend");
return;
}
eprintln!("[usher] power: PrepareForSleep(false) — woke from suspend");
if let Err(e) = tx.send(Outgoing::Wake { reason: "suspend".into() }) {
eprintln!("[usher] power: channel send failed: {}", e);
}
}
}
// ─── Windows implementation ───────────────────────────────────────────
#[cfg(windows)]
mod windows_impl {
use super::*;
use std::sync::Mutex;
use windows::core::w;
use windows::Win32::Foundation::{DefWindowProcW, HWND, LPARAM, LRESULT, WPARAM};
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::System::Power::{
RegisterSuspendResumeNotification, UnregisterSuspendResumeNotification,
DEVICE_NOTIFY_WINDOW_HANDLE,
};
use windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DispatchMessageW, GetMessageW, RegisterClassExW, TranslateMessage,
MSG, WINDOW_EX_STYLE, WINDOW_STYLE, WNDCLASSEXW, WM_DESTROY, WM_POWERBROADCAST,
WM_QUIT,
};
// Power broadcast event codes (from WinUser.h).
const PBT_APMRESUMEAUTOMATIC: u32 = 0x0012;
const PBT_APMRESUMESUSPEND: u32 = 0x0022;
const PBT_APMSUSPEND: u32 = 0x0006;
// Channel for passing Wake events from the window procedure to the
// writer thread. Set once at startup; read on each power broadcast.
static TX: Mutex<Option<Sender<Outgoing>>> = Mutex::new(None);
pub fn run_power_monitor(tx: Sender<Outgoing>) {
*TX.lock().unwrap() = Some(tx);
eprintln!("[usher] power: creating message-only window for WM_POWERBROADCAST");
unsafe {
let hinst = GetModuleHandleW(None).unwrap_or_default();
let class_name = w!("VestibuleUsherPowerWnd");
let wc = WNDCLASSEXW {
cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
lpfnWndProc: Some(wnd_proc),
hInstance: hinst.into(),
lpszClassName: class_name,
..Default::default()
};
if RegisterClassExW(&wc) == 0 {
eprintln!("[usher] power: RegisterClassExW failed");
return;
}
// HWND_MESSAGE creates a message-only window — invisible,
// no taskbar entry, receives only directly-post messages and
// messages from registered notifications.
let hwnd = CreateWindowExW(
WINDOW_EX_STYLE::default(),
class_name,
w!("Vestibule"),
WINDOW_STYLE::default(),
0, 0, 0, 0,
HWND_MESSAGE,
None,
hinst,
None,
);
let hwnd = match hwnd {
Ok(h) => h,
Err(e) => {
eprintln!("[usher] power: CreateWindowExW failed: {}", e);
return;
}
};
// Register for suspend/resume notifications. Windows 8+.
// This ensures the message-only window receives WM_POWERBROADCAST
// even though it's not a top-level visible window.
let notify_handle = match RegisterSuspendResumeNotification(
hwnd,
DEVICE_NOTIFY_WINDOW_HANDLE,
) {
Ok(h) => {
eprintln!("[usher] power: registered for suspend/resume notifications");
h
}
Err(e) => {
eprintln!(
"[usher] power: RegisterSuspendResumeNotification failed: {}",
e
);
eprintln!("[usher] power: monitoring disabled (kiosk still works)");
return;
}
};
eprintln!("[usher] power: message loop running");
let mut msg = MSG::default();
while GetMessageW(&mut msg, None, 0, 0).into() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
let _ = UnregisterSuspendResumeNotification(notify_handle);
eprintln!("[usher] power: message loop exited");
}
}
extern "system" fn wnd_proc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT {
// Table-driven message dispatch — one arm per handled message.
match msg {
WM_POWERBROADCAST => handle_power_broadcast(wp.0 as u32),
WM_DESTROY => {
// Signal the message loop to exit (not expected in normal operation).
unsafe {
windows::Win32::UI::WindowsAndMessaging::PostQuitMessage(0);
}
LRESULT(0)
}
_ => unsafe { DefWindowProcW(hwnd, msg, wp, lp) },
}
}
fn handle_power_broadcast(event: u32) -> LRESULT {
match event {
PBT_APMRESUMEAUTOMATIC | PBT_APMRESUMESUSPEND => {
eprintln!("[usher] power: WM_POWERBROADCAST resume (event {})", event);
emit_wake("suspend");
}
PBT_APMSUSPEND => {
eprintln!("[usher] power: WM_POWERBROADCAST suspend");
}
_ => {
eprintln!("[usher] power: WM_POWERBROADCAST event {}", event);
}
}
LRESULT(1) // TRUE — acknowledge receipt
}
fn emit_wake(reason: &str) {
if let Some(tx) = TX.lock().unwrap().as_ref() {
if let Err(e) = tx.send(Outgoing::Wake { reason: reason.into() }) {
eprintln!("[usher] power: channel send failed: {}", e);
}
}
}
}
// ─── Platform dispatch ────────────────────────────────────────────────
#[cfg(unix)]
pub fn run_power_monitor(tx: Sender<Outgoing>) {
linux_impl::run_power_monitor(tx)
}
#[cfg(windows)]
pub fn run_power_monitor(tx: Sender<Outgoing>) {
windows_impl::run_power_monitor(tx)
}

81
helper/src/storage.rs Executable file
View File

@ -0,0 +1,81 @@
//! File-based hash storage with restrictive permissions.
//!
//! Stores the Argon2id PHC string at:
//! Linux: ~/.config/vestibule/unlock.hash (mode 0600)
//! Windows: %APPDATA%\Vestibule\unlock.hash (default user ACL)
//!
//! File-based storage is the pragmatic choice for kiosk deployments.
//! OS keyring daemons (gnome-keyring, kwallet) are unavailable on
//! minimal kiosk compositors like cage. File permissions enforce
//! owner-only access; the kiosk user account is dedicated and
//! non-privileged.
use std::fs;
use std::io;
use std::path::PathBuf;
const HASH_FILE_NAME: &str = "unlock.hash";
const CONFIG_SUBDIR: &str = "vestibule";
fn hash_file_path() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join(CONFIG_SUBDIR).join(HASH_FILE_NAME))
}
/// Store the PHC string to disk. Creates the config directory if needed.
/// On Unix, sets file permissions to 0600 (owner read/write only).
pub fn store_hash(phc: &str) -> io::Result<()> {
let path = hash_file_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no config directory available"))?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)?;
file.write_all(phc.as_bytes())?;
}
#[cfg(windows)]
{
fs::write(&path, phc)?;
}
Ok(())
}
/// Load the PHC string from disk. Returns None if no hash is stored.
pub fn load_hash() -> io::Result<Option<String>> {
let path = match hash_file_path() {
Some(p) => p,
None => return Ok(None),
};
match fs::read_to_string(&path) {
Ok(s) => Ok(Some(s.trim().to_string())),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
/// Remove the hash file. Used for testing and full reset.
pub fn clear_hash() -> io::Result<()> {
let path = match hash_file_path() {
Some(p) => p,
None => return Ok(()),
};
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}

86
history/README.md Normal file
View File

@ -0,0 +1,86 @@
# History — the 2001 VB6 original
This directory is the source code of the kiosk browser Jeremy Anderson
wrote in Visual Basic 6 in December 2001, at age eighteen, while on
co-op from school for his first employer — a Boston-area MSP and
programming company that serviced medical offices. It solved one
problem: patients waiting in a lobby would misuse the computer, so the
computer needed to become a single-purpose, escape-proof browser.
It ran in that lobby for years. Twenty-five years later it became
[Vestibule](../README.md). Nothing in Vestibule's build, runtime, or
packaging depends on this directory — it is a non-shipping historic
artifact, kept for provenance.
## What is here
Three snapshots of the same project, in the directory arrangement they
arrived in:
| Directory | What it is | Date |
|---|---|---|
| `vb6-2001/original/` | The earliest iteration. The unlock code is read from `C:\windows\system\smt.txt`; a first-run setup screen lets the operator set it. | Dec 11–17, 2001 |
| `vb6-2001/` (top level) | The lobby build. The unlock code is hardcoded in the form source; home page `www.msn.com`. The `f`-suffixed files are a later development line with the browser-disable step commented out for testing. | Dec 12–18, 2001 |
| `vb6-2001/Final/` | The shipped build: the IEXPLORE.EXE step is active again, wrapped in an error-53 retry loop for machines where the file was already gone. Includes `frmBrowserfFloppy.frm`, a self-demo variant that navigates to `a:\description.html` with a blank unlock code, and `frmMain.frm` ("Reloader"), a leftover test stub. | Dec 18, 2001 – Jan 3, 2002 |
`description.html` is the 2001-era readme, typos intact.
## How it worked
- A maximized, chromeless VB6 form hosting the Internet Explorer COM
control (`SHDOCVW.DLL`), `ControlBox = False`, `WindowState = 2`.
- `SystemParametersInfo(97, True)` — the undocumented Win9x
screensaver flag — disabled Ctrl+Alt+Del, Alt+Tab, and the Windows
key.
- `SetWindowPos` with `HWND_TOPMOST` kept the window on top.
- The menu hid by default and appeared when the mouse touched the top
edge of the screen (`picAddress_MouseMove`, `Y <= 10` twips) — the
gesture Vestibule still ships.
- On startup it renamed `IEXPLORE.EXE` to `IEXPLORE.bak` and deleted
the original: an escaped user found no browser to escape to. On
unlock it copied the file back.
- The unlock delay was a `GoTo` counting loop burning the CPU for half
a second. The lock screen used Comic Sans.
It refused to run on NT, 2000, or XP — the screensaver trick does
nothing on NT-family kernels, and the About box says so.
## The "System Security 1.5" strings
The lock screen and About box carry the name and description of a
sibling application — a standalone system-lockdown tool the author
wrote alongside this one. The browser reused that app's lock-screen
UI wholesale, branding included. The `SmtSysMan` variable names and
the `smt.txt` unlock-code file belong to that lineage as well. The
strings are left in place: they are what the shipped binary displayed.
## Scrub log (2026-08-24)
The code is preserved byte-for-byte except for the following, applied
so the artifact carries no employer, school, or client identifiers:
| Change | Where |
|---|---|
| Employer-branded project filenames renamed to the neutral `LobbyBrowser*`; project name and output executable inside each project file renamed to match; SourceSafe section headers follow the filenames | all `.vbp`, `.vbw`, `MSSCCPRJ.SCC` |
| Company version string (the school's initials) → empty | every `.vbp` |
| Hardcoded unlock code (the employer's initials) → `"REDACTED"` | `frmBrowser.frm`, `frmBrowserf.frm`, `Final/frmBrowserf.frm` |
| Employer name in the 2001 readme → "my co-op employer, a Boston-area MSP and programming company" | `description.html` |
| The floppy self-demo form's filename (which named a school) → `frmBrowserfFloppy.frm` | `Final/` |
Three files were excluded and are not in this tree:
- The compiled `.exe` — it embeds the original project name, the
school company string, and the unlock code in its version resource;
a binary cannot be scrubbed without a rebuild, and the artifact is
the source.
- `IEXPLORE.bak` — a copy of Microsoft's Internet Explorer executable
captured by the rename step. It is Microsoft's binary, not authored
code, and does not ship here.
- `test.txt` — an empty scratch file.
## Viewing it
The `.frm`/`.bas`/`.vbp` files are plain text and read fine anywhere.
Opening the project requires the Visual Basic 6 IDE, and running the
result requires Windows 95, 98, 98 SE, or ME — by design, it does
nothing on NT-family kernels, which includes every Windows since.

View File

@ -0,0 +1 @@
Line 185: Class SHDocVwCtl.WebBrowser of control brwWebBrowser was not a loaded control class.

View File

@ -0,0 +1,38 @@
Type=Exe
Reference=*\G{00020430-0000-0000-C000-000000000046}#2.0#0#C:\WINDOWS\SYSTEM\stdole2.tlb#OLE Automation
Object={6B7E6392-850A-101B-AFC0-4210102A8DA7}#1.3#0; COMCTL32.OCX
Object={EAB22AC0-30C1-11CF-A7EB-0000C05BAE0B}#1.1#0; SHDOCVW.DLL
Form=frmBrowserf.frm
Module=modMain; modMainf.bas
Startup="frmWebB"
HelpFile=""
ExeName32="LobbyBrowser.exe"
Path32=".."
Command32=""
Name="LobbyBrowser"
HelpContextID="0"
CompatibleMode="0"
MajorVer=1
MinorVer=0
RevisionVer=0
AutoIncrementVer=0
ServerSupportFiles=0
VersionCompanyName=""
CompilationType=-1
OptimizationType=0
FavorPentiumPro(tm)=0
CodeViewDebugInfo=0
NoAliasing=0
BoundsCheck=0
OverflowCheck=0
FlPointCheck=0
FDIVCheck=0
UnroundedFP=0
StartMode=0
Unattended=0
Retained=0
ThreadPerObject=0
MaxNumberOfThreads=1
[MS Transaction Server]
AutoRefresh=1

View File

@ -0,0 +1,2 @@
frmWebB = 88, 87, 499, 427, C, 24, 28, 479, 412, C
modMain = 66, 66, 424, 418, C

View File

@ -0,0 +1,6 @@
[SCC]
SCC=This is a source code control file
[LobbyBrowserf.vbp]
SCC_Project_Name=this project is not under source code control
SCC_Aux_Path=<This is an empty string for the mssccprj.scc file>

View File

@ -0,0 +1,586 @@
VERSION 5.00
Object = "{EAB22AC0-30C1-11CF-A7EB-0000C05BAE0B}#1.1#0"; "SHDOCVW.DLL"
Begin VB.Form frmWebB
BackColor = &H00000000&
ClientHeight = 8595
ClientLeft = 255
ClientTop = 150
ClientWidth = 11415
ControlBox = 0 'False
LinkTopic = "Form1"
ScaleHeight = 8595
ScaleWidth = 11415
WindowState = 2 'Maximized
Begin VB.Frame fraAbout
BackColor = &H00000000&
Caption = "About System Security 1.5"
BeginProperty Font
Name = "MS Sans Serif"
Size = 12
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 3495
Left = 3960
TabIndex = 6
Top = 2520
Visible = 0 'False
Width = 3735
Begin VB.Label Label6
BackColor = &H00000000&
Caption = "Web Browser Version 1.5"
BeginProperty Font
Name = "MS Sans Serif"
Size = 12
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 255
Left = 480
TabIndex = 16
Top = 480
Width = 2775
End
Begin VB.Label Label1
Alignment = 2 'Center
BackColor = &H00000000&
Caption = "Author: Jeremy Anderson"
ForeColor = &H00FFFFFF&
Height = 255
Left = 360
TabIndex = 12
Top = 2760
Width = 2895
End
Begin VB.Label Label2
BackColor = &H00000000&
Caption = "For Windows 95 - 98 - 98 SE && ME"
ForeColor = &H00FFFFFF&
Height = 255
Left = 600
TabIndex = 11
Top = 960
Width = 2535
End
Begin VB.Label Label3
Alignment = 2 'Center
BackColor = &H00000000&
Caption = $"frmBrowserf.frx":0000
ForeColor = &H00FFFFFF&
Height = 855
Left = 360
TabIndex = 10
Top = 1320
Width = 3015
End
Begin VB.Label Label4
BackColor = &H00000000&
Caption = "NOTE: Will NOT work on windows 2000, NT, or XP."
ForeColor = &H00FFFFFF&
Height = 375
Left = 360
TabIndex = 9
Top = 2280
Width = 3015
End
Begin VB.Label lblClose
BackColor = &H00000000&
Caption = "Close"
BeginProperty Font
Name = "MS Sans Serif"
Size = 12
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 375
Left = 2880
TabIndex = 8
Top = 3000
Width = 735
End
Begin VB.Label Label5
BackColor = &H00000000&
Caption = "Revision: 1"
ForeColor = &H00FFFFFF&
Height = 255
Left = 240
TabIndex = 7
Top = 3120
Width = 1095
End
End
Begin VB.Frame FraUnlock
BackColor = &H00000000&
Caption = "Unlock Code:"
BeginProperty Font
Name = "Comic Sans MS"
Size = 9.75
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 735
Left = 3960
TabIndex = 3
Top = 2520
Visible = 0 'False
Width = 3735
Begin VB.TextBox SmtSysManTech2
BeginProperty Font
Name = "Comic Sans MS"
Size = 12
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
Height = 375
IMEMode = 3 'DISABLE
Left = 120
PasswordChar = "*"
TabIndex = 5
Top = 240
Width = 2055
End
Begin VB.CommandButton CmdOK
BackColor = &H00000000&
Caption = "OK"
BeginProperty Font
Name = "Comic Sans MS"
Size = 8.25
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
Height = 375
Left = 2280
MaskColor = &H00000000&
TabIndex = 4
Top = 240
Width = 1335
End
End
Begin VB.Timer timTimer
Enabled = 0 'False
Interval = 5
Left = 5760
Top = 1440
End
Begin SHDocVwCtl.WebBrowser brwWebBrowser
Height = 4350
Left = 120
TabIndex = 0
Top = 600
Width = 5400
ExtentX = 9525
ExtentY = 7673
ViewMode = 1
Offline = 0
Silent = 0
RegisterAsBrowser= 0
RegisterAsDropTarget= 0
AutoArrange = -1 'True
NoClientEdge = -1 'True
AlignLeft = 0 'False
NoWebView = 0 'False
HideFileNames = 0 'False
SingleClick = 0 'False
SingleSelection = 0 'False
NoFolders = 0 'False
Transparent = 0 'False
ViewID = "{0057D0E0-3573-11CF-AE69-08002B2E1262}"
Location = "http:///"
End
Begin VB.PictureBox picAddress
Align = 1 'Align Top
BackColor = &H00000000&
BorderStyle = 0 'None
Height = 435
Left = 0
ScaleHeight = 435
ScaleWidth = 11415
TabIndex = 1
TabStop = 0 'False
Top = 0
Width = 11415
Begin VB.ComboBox cboAddress
Height = 315
Left = 120
TabIndex = 2
Top = 120
Width = 4515
End
End
Begin VB.Label lblTitle1
BackColor = &H00000000&
Caption = "System Security 1.5"
BeginProperty Font
Name = "Times New Roman"
Size = 24
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 615
Left = 3960
TabIndex = 14
Top = 600
Visible = 0 'False
Width = 3975
End
Begin VB.Label lblDisp
Alignment = 2 'Center
BackColor = &H00000000&
Caption = "Please enter your Unlock code."
BeginProperty Font
Name = "Comic Sans MS"
Size = 12
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 735
Left = 0
TabIndex = 15
Top = 6840
Visible = 0 'False
Width = 11895
End
Begin VB.Label lblTitle2
BackColor = &H00000000&
Caption = "Lock Out Screen "
BeginProperty Font
Name = "Times New Roman"
Size = 24
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 615
Left = 4200
TabIndex = 13
Top = 1320
Visible = 0 'False
Width = 3975
End
Begin VB.Menu mnuBack
Caption = "&Back"
End
Begin VB.Menu mnuForward
Caption = "&Forward"
End
Begin VB.Menu mnuRefresh
Caption = "&Refresh"
End
Begin VB.Menu mnuStop
Caption = "S&top"
End
Begin VB.Menu mnuHome
Caption = "&Home"
End
Begin VB.Menu mnuSearch
Caption = "&Search"
End
Begin VB.Menu mnuPrintPage
Caption = "&Print WebPage"
End
Begin VB.Menu mnuGeneral
Caption = "&General"
Begin VB.Menu mnuAbout
Caption = "&About"
End
Begin VB.Menu mnuExit
Caption = "&Exit"
End
End
End
Attribute VB_Name = "frmWebB"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = True
Attribute VB_Exposed = False
'fix about to exit display problem
Option Explicit
Public StartingAddress As String
Dim mbDontNavigateNow As Boolean
Private Sub Form_Load()
BeforeError:
commons True
On Error GoTo myer
FileCopy "C:\Program Files\Internet Explorer\IEXPLORE.EXE", "C:\Program Files\Internet Explorer\IEXPLORE.bak"
Kill "C:\Program Files\Internet Explorer\IEXPLORE.EXE"
mnuBack.Visible = False
mnuForward.Visible = False
mnuRefresh.Visible = False
mnuPrintPage.Visible = False
mnuStop.Visible = False
mnuHome.Visible = False
mnuGeneral.Visible = False
mnuExit.Visible = False
mnuSearch.Visible = False
On Error Resume Next
Me.Show
Form_Resize
cboAddress.Move 50
cboAddress.Text = "www.msn.com"
cboAddress.AddItem cboAddress.Text
timTimer.Enabled = True
brwWebBrowser.Navigate "www.msn.com"
Exit Sub
myer:
'MsgBox "Error is " & Err.Description
If Err.Number = 53 Then
'MsgBox "equals 53"
FileCopy "C:\Program Files\Internet Explorer\IEXPLORE.bak", "C:\Program Files\Internet Explorer\IEXPLORE.exe"
On Error GoTo error2
GoTo enderror2
error2:
MsgBox "error 2 " & Err.Description
enderror2:
Kill "C:\Program Files\Internet Explorer\IEXPLORE.bak"
On Error GoTo error3
GoTo enderror3
error3:
MsgBox "error 3 " & Err.Description
enderror3:
GoTo BeforeError
End If
End Sub
Private Sub brwWebBrowser_DownloadComplete()
On Error Resume Next
End Sub
Private Sub brwWebBrowser_NavigateComplete(ByVal URL As String)
Dim i As Integer
Dim bFound As Boolean
For i = 0 To cboAddress.ListCount - 1
If cboAddress.List(i) = brwWebBrowser.LocationURL Then
bFound = True
Exit For
End If
Next i
mbDontNavigateNow = True
If bFound Then
cboAddress.RemoveItem i
End If
cboAddress.AddItem brwWebBrowser.LocationURL, 0
cboAddress.ListIndex = 0
mbDontNavigateNow = False
End Sub
Private Sub cboAddress_Click()
If mbDontNavigateNow Then Exit Sub
timTimer.Enabled = True
brwWebBrowser.Navigate cboAddress.Text
End Sub
Private Sub cboAddress_KeyPress(KeyAscii As Integer)
On Error Resume Next
If KeyAscii = vbKeyReturn Then
cboAddress_Click
End If
End Sub
Private Sub Form_Resize()
If frmWebB.WindowState = 1 Then GoTo new1
cboAddress.Width = Me.ScaleWidth - 200
brwWebBrowser.Left = 200
brwWebBrowser.Width = Me.ScaleWidth - 400
brwWebBrowser.Height = Me.ScaleHeight - (picAddress.Top + picAddress.Height) - 200
new1:
End Sub
Private Sub mnuBack_Click()
On Error Resume Next
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.GoBack
Else
brwWebBrowser.GoBack
End If
End Sub
Private Sub mnuExit_Click()
FraUnlock.Visible = True
lblTitle1.Visible = True
lblTitle2.Visible = True
lblDisp.Visible = True
fraAbout.Visible = False
brwWebBrowser.Visible = False
cboAddress.Visible = False
End Sub
Private Sub mnuForward_Click()
On Error Resume Next
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.GoForward
Else
brwWebBrowser.GoForward
End If
End Sub
Private Sub mnuHome_Click()
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.GoHome
Else
brwWebBrowser.GoHome
End If
End Sub
Private Sub mnuPrintPage_Click()
MsgBox " To print right click on the webpage its self, go down the list and click print."
End Sub
Private Sub mnuRefresh_Click()
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.Refresh
Else
brwWebBrowser.Refresh
End If
End Sub
Private Sub mnuSearch_Click()
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.GoSearch
Else
brwWebBrowser.GoSearch
End If
End Sub
Private Sub mnuStop_Click()
timTimer.Enabled = False
brwWebBrowser.Stop
End Sub
Private Sub picAddress_MouseMove(Button As Integer, Shift As Integer, X As Single, Y As Single)
If Not Y <= 10 Then
mnuBack.Visible = False
mnuForward.Visible = False
mnuRefresh.Visible = False
mnuStop.Visible = False
mnuHome.Visible = False
mnuGeneral.Visible = False
mnuExit.Visible = False
mnuPrintPage.Visible = False
mnuSearch.Visible = False
Else
mnuBack.Visible = True
mnuPrintPage.Visible = True
mnuForward.Visible = True
mnuRefresh.Visible = True
mnuStop.Visible = True
mnuHome.Visible = True
mnuGeneral.Visible = True
mnuExit.Visible = True
mnuSearch.Visible = True
End If
End Sub
Private Sub timTimer_Timer()
If brwWebBrowser.Busy = False Then
timTimer.Enabled = False
Else
End If
End Sub
Private Sub cmdOK_Click()
If SmtSysManTech2.Text = "REDACTED" Then
CmdOK.Enabled = False
SmtSysManTech2.Enabled = False
FraUnlock.Enabled = False
lblDisp.Enabled = True
lblDisp.Caption = "Unlocked!"
frmWebB.Refresh
Dim time As Double
beginn:
time = time + 1
If Val(time) = 20000 Then GoTo endd
GoTo beginn
endd:
FileCopy "C:\Program Files\Internet Explorer\IEXPLORE.bak", "C:\Program Files\Internet Explorer\IEXPLORE.exe"
Kill "C:\Program Files\Internet Explorer\IEXPLORE.bak"
commons False
End
Else
FraUnlock.Visible = False
lblTitle1.Visible = False
lblTitle2.Visible = False
lblDisp.Visible = False
brwWebBrowser.Visible = True
cboAddress.Visible = True
End If
End Sub
Private Sub fraAbout_MouseMove(Button As Integer, Shift As Integer, X As Single, Y As Single)
lblClose.ForeColor = vbWhite
End Sub
Private Sub lblClose_Click()
fraAbout.Visible = False
lblTitle1.Visible = False
lblTitle2.Visible = False
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.Visible = True
cboAddress.Visible = True
End Sub
Private Sub lblClose_MouseMove(Button As Integer, Shift As Integer, X As Single, Y As Single)
lblClose.ForeColor = vbRed
End Sub
Private Sub mnuAbout_Click()
fraAbout.Visible = True
cboAddress.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.Visible = False
cboAddress.Visible = False
lblClose.ForeColor = vbWhite
End Sub
Private Sub SmtSysManTech2_KeyPress(KeyAscii As Integer)
If KeyAscii = 13 Then
cmdOK_Click
End If
End Sub

Binary file not shown.

View File

@ -0,0 +1,577 @@
VERSION 5.00
Object = "{EAB22AC0-30C1-11CF-A7EB-0000C05BAE0B}#1.1#0"; "SHDOCVW.DLL"
Begin VB.Form frmWebB
BackColor = &H00000000&
ClientHeight = 8595
ClientLeft = 255
ClientTop = 150
ClientWidth = 11415
ControlBox = 0 'False
LinkTopic = "Form1"
ScaleHeight = 8595
ScaleWidth = 11415
WindowState = 2 'Maximized
Begin VB.Frame fraAbout
BackColor = &H00000000&
Caption = "About System Security 1.5"
BeginProperty Font
Name = "MS Sans Serif"
Size = 12
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 3495
Left = 3960
TabIndex = 6
Top = 2520
Visible = 0 'False
Width = 3735
Begin VB.Label Label6
BackColor = &H00000000&
Caption = "Web Browser Version 1.5"
BeginProperty Font
Name = "MS Sans Serif"
Size = 12
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 255
Left = 480
TabIndex = 16
Top = 480
Width = 2775
End
Begin VB.Label Label1
Alignment = 2 'Center
BackColor = &H00000000&
Caption = "Author: Jeremy Anderson"
ForeColor = &H00FFFFFF&
Height = 255
Left = 360
TabIndex = 12
Top = 2760
Width = 2895
End
Begin VB.Label Label2
BackColor = &H00000000&
Caption = "For Windows 95 - 98 - 98 SE && ME"
ForeColor = &H00FFFFFF&
Height = 255
Left = 600
TabIndex = 11
Top = 960
Width = 2535
End
Begin VB.Label Label3
Alignment = 2 'Center
BackColor = &H00000000&
Caption = $"frmBrowserfFloppy.frx":0000
ForeColor = &H00FFFFFF&
Height = 855
Left = 360
TabIndex = 10
Top = 1320
Width = 3015
End
Begin VB.Label Label4
BackColor = &H00000000&
Caption = "NOTE: Will NOT work on windows 2000, NT, or XP."
ForeColor = &H00FFFFFF&
Height = 375
Left = 360
TabIndex = 9
Top = 2280
Width = 3015
End
Begin VB.Label lblClose
BackColor = &H00000000&
Caption = "Close"
BeginProperty Font
Name = "MS Sans Serif"
Size = 12
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 375
Left = 2880
TabIndex = 8
Top = 3000
Width = 735
End
Begin VB.Label Label5
BackColor = &H00000000&
Caption = "Revision: 1"
ForeColor = &H00FFFFFF&
Height = 255
Left = 240
TabIndex = 7
Top = 3120
Width = 1095
End
End
Begin VB.Frame FraUnlock
BackColor = &H00000000&
Caption = "Unlock Code:"
BeginProperty Font
Name = "Comic Sans MS"
Size = 9.75
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 735
Left = 3960
TabIndex = 3
Top = 2520
Visible = 0 'False
Width = 3735
Begin VB.TextBox SmtSysManTech2
BeginProperty Font
Name = "Comic Sans MS"
Size = 12
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
Height = 375
IMEMode = 3 'DISABLE
Left = 120
PasswordChar = "*"
TabIndex = 5
Top = 240
Width = 2055
End
Begin VB.CommandButton CmdOK
BackColor = &H00000000&
Caption = "OK"
BeginProperty Font
Name = "Comic Sans MS"
Size = 8.25
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
Height = 375
Left = 2280
MaskColor = &H00000000&
TabIndex = 4
Top = 240
Width = 1335
End
End
Begin VB.Timer timTimer
Enabled = 0 'False
Interval = 5
Left = 5760
Top = 1440
End
Begin SHDocVwCtl.WebBrowser brwWebBrowser
Height = 4350
Left = 120
TabIndex = 0
Top = 600
Width = 5400
ExtentX = 9525
ExtentY = 7673
ViewMode = 1
Offline = 0
Silent = 0
RegisterAsBrowser= 0
RegisterAsDropTarget= 0
AutoArrange = -1 'True
NoClientEdge = -1 'True
AlignLeft = 0 'False
ViewID = "{0057D0E0-3573-11CF-AE69-08002B2E1262}"
Location = ""
End
Begin VB.PictureBox picAddress
Align = 1 'Align Top
BackColor = &H00000000&
BorderStyle = 0 'None
Height = 435
Left = 0
ScaleHeight = 435
ScaleWidth = 11415
TabIndex = 1
TabStop = 0 'False
Top = 0
Width = 11415
Begin VB.ComboBox cboAddress
Height = 315
Left = 120
TabIndex = 2
Top = 120
Width = 4515
End
End
Begin VB.Label lblTitle1
BackColor = &H00000000&
Caption = "System Security 1.5"
BeginProperty Font
Name = "Times New Roman"
Size = 24
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 615
Left = 3960
TabIndex = 14
Top = 600
Visible = 0 'False
Width = 3975
End
Begin VB.Label lblDisp
Alignment = 2 'Center
BackColor = &H00000000&
Caption = "Please enter your Unlock code."
BeginProperty Font
Name = "Comic Sans MS"
Size = 12
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 735
Left = 0
TabIndex = 15
Top = 6840
Visible = 0 'False
Width = 11895
End
Begin VB.Label lblTitle2
BackColor = &H00000000&
Caption = "Lock Out Screen "
BeginProperty Font
Name = "Times New Roman"
Size = 24
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 615
Left = 4200
TabIndex = 13
Top = 1320
Visible = 0 'False
Width = 3975
End
Begin VB.Menu mnuBack
Caption = "&Back"
End
Begin VB.Menu mnuForward
Caption = "&Forward"
End
Begin VB.Menu mnuRefresh
Caption = "&Refresh"
End
Begin VB.Menu mnuStop
Caption = "S&top"
End
Begin VB.Menu mnuHome
Caption = "&Home"
End
Begin VB.Menu mnuSearch
Caption = "&Search"
End
Begin VB.Menu mnuPrintPage
Caption = "&Print WebPage"
End
Begin VB.Menu mnuGeneral
Caption = "&General"
Begin VB.Menu mnuAbout
Caption = "&About"
End
Begin VB.Menu mnuExit
Caption = "&Exit"
End
End
End
Attribute VB_Name = "frmWebB"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = True
Attribute VB_Exposed = False
Option Explicit
Public StartingAddress As String
Dim mbDontNavigateNow As Boolean
Private Sub Form_Load()
BeforeError:
commons True
On Error GoTo myer
FileCopy "C:\Program Files\Internet Explorer\IEXPLORE.EXE", "C:\Program Files\Internet Explorer\IEXPLORE.bak"
Kill "C:\Program Files\Internet Explorer\IEXPLORE.EXE"
mnuBack.Visible = False
mnuForward.Visible = False
mnuRefresh.Visible = False
mnuPrintPage.Visible = False
mnuStop.Visible = False
mnuHome.Visible = False
mnuGeneral.Visible = False
mnuExit.Visible = False
mnuSearch.Visible = False
On Error Resume Next
Me.Show
Form_Resize
cboAddress.Move 50
cboAddress.Text = "www.msn.com"
cboAddress.AddItem cboAddress.Text
timTimer.Enabled = True
brwWebBrowser.Navigate "a:\description.html"
Exit Sub
myer:
'MsgBox "Error is " & Err.Description
If Err.Number = 53 Then
'MsgBox "equals 53"
FileCopy "C:\Program Files\Internet Explorer\IEXPLORE.bak", "C:\Program Files\Internet Explorer\IEXPLORE.exe"
On Error GoTo error2
GoTo enderror2
error2:
MsgBox "error 2 " & Err.Description
enderror2:
Kill "C:\Program Files\Internet Explorer\IEXPLORE.bak"
On Error GoTo error3
GoTo enderror3
error3:
MsgBox "error 3 " & Err.Description
enderror3:
GoTo BeforeError
End If
End Sub
Private Sub brwWebBrowser_DownloadComplete()
On Error Resume Next
End Sub
Private Sub brwWebBrowser_NavigateComplete(ByVal URL As String)
Dim i As Integer
Dim bFound As Boolean
For i = 0 To cboAddress.ListCount - 1
If cboAddress.List(i) = brwWebBrowser.LocationURL Then
bFound = True
Exit For
End If
Next i
mbDontNavigateNow = True
If bFound Then
cboAddress.RemoveItem i
End If
cboAddress.AddItem brwWebBrowser.LocationURL, 0
cboAddress.ListIndex = 0
mbDontNavigateNow = False
End Sub
Private Sub cboAddress_Click()
If mbDontNavigateNow Then Exit Sub
timTimer.Enabled = True
brwWebBrowser.Navigate cboAddress.Text
End Sub
Private Sub cboAddress_KeyPress(KeyAscii As Integer)
On Error Resume Next
If KeyAscii = vbKeyReturn Then
cboAddress_Click
End If
End Sub
Private Sub Form_Resize()
If frmWebB.WindowState = 1 Then GoTo new1
cboAddress.Width = Me.ScaleWidth - 200
brwWebBrowser.Left = 200
brwWebBrowser.Width = Me.ScaleWidth - 400
brwWebBrowser.Height = Me.ScaleHeight - (picAddress.Top + picAddress.Height) - 200
new1:
End Sub
Private Sub mnuBack_Click()
On Error Resume Next
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.GoBack
Else
brwWebBrowser.GoBack
End If
End Sub
Private Sub mnuExit_Click()
FraUnlock.Visible = True
lblTitle1.Visible = True
lblTitle2.Visible = True
lblDisp.Visible = True
brwWebBrowser.Visible = False
cboAddress.Visible = False
End Sub
Private Sub mnuForward_Click()
On Error Resume Next
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.GoForward
Else
brwWebBrowser.GoForward
End If
End Sub
Private Sub mnuHome_Click()
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.GoHome
Else
brwWebBrowser.GoHome
End If
End Sub
Private Sub mnuPrintPage_Click()
MsgBox " To print right click on the webpage its self, go down the list and click print."
End Sub
Private Sub mnuRefresh_Click()
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.Refresh
Else
brwWebBrowser.Refresh
End If
End Sub
Private Sub mnuSearch_Click()
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.GoSearch
Else
brwWebBrowser.GoSearch
End If
End Sub
Private Sub mnuStop_Click()
timTimer.Enabled = False
brwWebBrowser.Stop
End Sub
Private Sub picAddress_MouseMove(Button As Integer, Shift As Integer, X As Single, Y As Single)
If Not Y <= 10 Then
mnuBack.Visible = False
mnuForward.Visible = False
mnuRefresh.Visible = False
mnuStop.Visible = False
mnuHome.Visible = False
mnuGeneral.Visible = False
mnuExit.Visible = False
mnuPrintPage.Visible = False
mnuSearch.Visible = False
Else
mnuBack.Visible = True
mnuPrintPage.Visible = True
mnuForward.Visible = True
mnuRefresh.Visible = True
mnuStop.Visible = True
mnuHome.Visible = True
mnuGeneral.Visible = True
mnuExit.Visible = True
mnuSearch.Visible = True
End If
End Sub
Private Sub timTimer_Timer()
If brwWebBrowser.Busy = False Then
timTimer.Enabled = False
Else
End If
End Sub
Private Sub cmdOK_Click()
If SmtSysManTech2.Text = "" Then 'password
CmdOK.Enabled = False
SmtSysManTech2.Enabled = False
FraUnlock.Enabled = False
lblDisp.Enabled = True
lblDisp.Caption = "Unlocked!"
frmWebB.Refresh
Dim time As Double
beginn:
time = time + 1
If Val(time) = 20000 Then GoTo endd
GoTo beginn
endd:
FileCopy "C:\Program Files\Internet Explorer\IEXPLORE.bak", "C:\Program Files\Internet Explorer\IEXPLORE.exe"
Kill "C:\Program Files\Internet Explorer\IEXPLORE.bak"
commons False
End
Else
FraUnlock.Visible = False
lblTitle1.Visible = False
lblTitle2.Visible = False
lblDisp.Visible = False
brwWebBrowser.Visible = True
cboAddress.Visible = True
End If
End Sub
Private Sub fraAbout_MouseMove(Button As Integer, Shift As Integer, X As Single, Y As Single)
lblClose.ForeColor = vbWhite
End Sub
Private Sub lblClose_Click()
fraAbout.Visible = False
lblTitle1.Visible = False
lblTitle2.Visible = False
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.Visible = True
cboAddress.Visible = True
End Sub
Private Sub lblClose_MouseMove(Button As Integer, Shift As Integer, X As Single, Y As Single)
lblClose.ForeColor = vbRed
End Sub
Private Sub mnuAbout_Click()
fraAbout.Visible = True
cboAddress.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.Visible = False
cboAddress.Visible = False
lblClose.ForeColor = vbWhite
End Sub
Private Sub SmtSysManTech2_KeyPress(KeyAscii As Integer)
If KeyAscii = 13 Then
cmdOK_Click
End If
End Sub

Binary file not shown.

View File

@ -0,0 +1,39 @@
VERSION 5.00
Begin VB.Form frmMain
Caption = "Reloader"
ClientHeight = 480
ClientLeft = 60
ClientTop = 345
ClientWidth = 2055
LinkTopic = "Form1"
ScaleHeight = 480
ScaleWidth = 2055
StartUpPosition = 3 'Windows Default
Begin VB.CommandButton cmdTest
Caption = "Test"
Height = 255
Left = 600
TabIndex = 0
Top = 120
Width = 1335
End
Begin VB.Timer Timer1
Interval = 1
Left = 0
Top = 0
End
End
Attribute VB_Name = "frmMain"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = True
Attribute VB_Exposed = False
Private Sub cmdTest_Click()
Open "C:\test.txt" For Output As #1
Print #1, "this is a test"
Close #1
End Sub
Private Sub Form_Load()
End Sub

View File

@ -0,0 +1,23 @@
Attribute VB_Name = "modMain"
Public X As Long
Option Explicit
Private Declare Function SystemParametersInfo Lib _
"user32" Alias "SystemParametersInfoA" (ByVal uAction _
As Long, ByVal uParam As Long, ByVal lpvParam As Any, _
ByVal fuWinIni As Long) As Long
Const FLAGS = 3
Const HWND_TOPMOST = -1
Const HWND_NOTOPMOST = -2
Public SetTop As Boolean
Private Declare Function SetWindowPos Lib "user32" (ByVal h%, ByVal hb%, ByVal X%, ByVal Y%, ByVal cx%, ByVal cy%, ByVal f%) As Integer
Sub commons(Disablem As Boolean)
X = SystemParametersInfo(97, Disablem, CStr(1), 0)
End Sub
Sub AlwaysOnTop(FormName As Form, bOnTop As Boolean)
Dim wind As Integer
If bOnTop = False Then
wind% = SetWindowPos(FormName.Wnd, HWND_TOPMOST, 0, 0, 0, 0, FLAGS)
Else
wind% = SetWindowPos(FormName.hWnd, HWND_NOTOPMOST, 0, 0, 0, 0, FLAGS)
End If
End Sub

View File

@ -0,0 +1,37 @@
Type=Exe
Reference=*\G{00020430-0000-0000-C000-000000000046}#2.0#0#C:\WINDOWS\SYSTEM\StdOle2.Tlb#OLE Automation
Object={6B7E6392-850A-101B-AFC0-4210102A8DA7}#1.3#0; COMCTL32.OCX
Object={EAB22AC0-30C1-11CF-A7EB-0000C05BAE0B}#1.1#0; SHDOCVW.DLL
Form=frmBrowser.frm
Module=modMain; modMain.bas
Startup="frmWebB"
HelpFile=""
ExeName32="LobbyBrowser.exe"
Command32=""
Name="LobbyBrowser"
HelpContextID="0"
CompatibleMode="0"
MajorVer=1
MinorVer=0
RevisionVer=0
AutoIncrementVer=0
ServerSupportFiles=0
VersionCompanyName=""
CompilationType=-1
OptimizationType=0
FavorPentiumPro(tm)=0
CodeViewDebugInfo=0
NoAliasing=0
BoundsCheck=0
OverflowCheck=0
FlPointCheck=0
FDIVCheck=0
UnroundedFP=0
StartMode=0
Unattended=0
Retained=0
ThreadPerObject=0
MaxNumberOfThreads=1
[MS Transaction Server]
AutoRefresh=1

View File

@ -0,0 +1,2 @@
frmWebB = 19, 113, 430, 453, , 22, 29, 477, 413, C
modMain = 66, 66, 424, 418, C

View File

@ -0,0 +1,37 @@
Type=Exe
Reference=*\G{00020430-0000-0000-C000-000000000046}#2.0#0#D:\WIN98\System32\stdole2.tlb#OLE Automation
Object={6B7E6392-850A-101B-AFC0-4210102A8DA7}#1.3#0; COMCTL32.OCX
Object={EAB22AC0-30C1-11CF-A7EB-0000C05BAE0B}#1.1#0; shdocvw.dll
Form=frmBrowserf.frm
Module=modMain; modMainf.bas
Startup="frmWebB"
HelpFile=""
ExeName32="LobbyBrowser.exe"
Command32=""
Name="LobbyBrowser"
HelpContextID="0"
CompatibleMode="0"
MajorVer=1
MinorVer=0
RevisionVer=0
AutoIncrementVer=0
ServerSupportFiles=0
VersionCompanyName=""
CompilationType=-1
OptimizationType=0
FavorPentiumPro(tm)=0
CodeViewDebugInfo=0
NoAliasing=0
BoundsCheck=0
OverflowCheck=0
FlPointCheck=0
FDIVCheck=0
UnroundedFP=0
StartMode=0
Unattended=0
Retained=0
ThreadPerObject=0
MaxNumberOfThreads=1
[MS Transaction Server]
AutoRefresh=1

View File

@ -0,0 +1,2 @@
frmWebB = 19, 113, 430, 453, , 22, 29, 477, 413, C
modMain = 66, 66, 424, 418, C

View File

@ -0,0 +1,6 @@
[SCC]
SCC=This is a source code control file
[LobbyBrowser.vbp]
SCC_Project_Name=this project is not under source code control
SCC_Aux_Path=<This is an empty string for the mssccprj.scc file>

View File

@ -0,0 +1,11 @@
<html>
<body>
<H2> This is a Full screen Web Browser, Written and designed by Jeremy Anderson </h2>
<h2> It was designed for my co-op employer, a Boston-area MSP and programming company</h2>
<h2> It purpose is to keep the Patients waiting in the lobby using the computer to only use online</H2>
<h2> They didn't want them to use anything else so I put in API calls to keep them from
using the Ctrl-Alt-Del, or any other shortcut keys. And Disabled the Internet Explorer</h2>
<h2>When you move the mouse three pixels away from the top of the screen a menu apears.
When You move it down again it disapears.</h2>
</body>
</html>

View File

@ -0,0 +1,573 @@
VERSION 5.00
Object = "{EAB22AC0-30C1-11CF-A7EB-0000C05BAE0B}#1.1#0"; "SHDOCVW.DLL"
Begin VB.Form frmWebB
BackColor = &H00000000&
ClientHeight = 8595
ClientLeft = 255
ClientTop = 150
ClientWidth = 11415
ControlBox = 0 'False
LinkTopic = "Form1"
ScaleHeight = 8595
ScaleWidth = 11415
WindowState = 2 'Maximized
Begin VB.Frame fraAbout
BackColor = &H00000000&
Caption = "About System Security 1.5"
BeginProperty Font
Name = "MS Sans Serif"
Size = 12
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 3495
Left = 4080
TabIndex = 6
Top = 2520
Visible = 0 'False
Width = 3495
Begin VB.Label Label6
BackColor = &H00000000&
Caption = "Web Browser Version 1.5"
BeginProperty Font
Name = "MS Sans Serif"
Size = 12
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 255
Left = 360
TabIndex = 16
Top = 480
Width = 2775
End
Begin VB.Label Label1
BackColor = &H00000000&
Caption = "Author: Jeremy Anderson"
ForeColor = &H00FFFFFF&
Height = 255
Left = 840
TabIndex = 12
Top = 2760
Width = 1935
End
Begin VB.Label Label2
BackColor = &H00000000&
Caption = "For Windows 95 - 98 - 98 SE && ME"
ForeColor = &H00FFFFFF&
Height = 255
Left = 480
TabIndex = 11
Top = 960
Width = 2535
End
Begin VB.Label Label3
BackColor = &H00000000&
Caption = $"frmBrowser.frx":0000
ForeColor = &H00FFFFFF&
Height = 855
Left = 240
TabIndex = 10
Top = 1320
Width = 3015
End
Begin VB.Label Label4
BackColor = &H00000000&
Caption = "NOTE: Will NOT work on windows 2000, NT, or XP."
ForeColor = &H00FFFFFF&
Height = 375
Left = 240
TabIndex = 9
Top = 2280
Width = 3015
End
Begin VB.Label lblClose
BackColor = &H00000000&
Caption = "Close"
BeginProperty Font
Name = "MS Sans Serif"
Size = 12
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 375
Left = 2640
TabIndex = 8
Top = 3000
Width = 735
End
Begin VB.Label Label5
BackColor = &H00000000&
Caption = "Revision: 1"
ForeColor = &H00FFFFFF&
Height = 255
Left = 240
TabIndex = 7
Top = 3120
Width = 855
End
End
Begin VB.Frame FraUnlock
BackColor = &H00000000&
Caption = "Unlock Code:"
BeginProperty Font
Name = "Comic Sans MS"
Size = 9.75
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 735
Left = 3960
TabIndex = 3
Top = 2520
Visible = 0 'False
Width = 3735
Begin VB.TextBox SmtSysManTech2
BeginProperty Font
Name = "Comic Sans MS"
Size = 12
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
Height = 375
IMEMode = 3 'DISABLE
Left = 120
PasswordChar = "*"
TabIndex = 5
Top = 240
Width = 2055
End
Begin VB.CommandButton CmdOK
BackColor = &H00000000&
Caption = "OK"
BeginProperty Font
Name = "Comic Sans MS"
Size = 8.25
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
Height = 375
Left = 2280
MaskColor = &H00000000&
TabIndex = 4
Top = 240
Width = 1335
End
End
Begin VB.Timer timTimer
Enabled = 0 'False
Interval = 5
Left = 5760
Top = 1440
End
Begin SHDocVwCtl.WebBrowser brwWebBrowser
Height = 4350
Left = 120
TabIndex = 0
Top = 600
Width = 5400
ExtentX = 9525
ExtentY = 7673
ViewMode = 1
Offline = 0
Silent = 0
RegisterAsBrowser= 0
RegisterAsDropTarget= 0
AutoArrange = -1 'True
NoClientEdge = -1 'True
AlignLeft = 0 'False
NoWebView = 0 'False
HideFileNames = 0 'False
SingleClick = 0 'False
SingleSelection = 0 'False
NoFolders = 0 'False
Transparent = 0 'False
ViewID = "{0057D0E0-3573-11CF-AE69-08002B2E1262}"
Location = "http:///"
End
Begin VB.PictureBox picAddress
Align = 1 'Align Top
BackColor = &H00000000&
BorderStyle = 0 'None
Height = 435
Left = 0
ScaleHeight = 435
ScaleWidth = 11415
TabIndex = 1
TabStop = 0 'False
Top = 0
Width = 11415
Begin VB.ComboBox cboAddress
Height = 315
Left = 120
TabIndex = 2
Top = 120
Width = 4515
End
End
Begin VB.Label lblTitle1
BackColor = &H00000000&
Caption = "System Security 1.5"
BeginProperty Font
Name = "Times New Roman"
Size = 24
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 615
Left = 3960
TabIndex = 14
Top = 600
Visible = 0 'False
Width = 3975
End
Begin VB.Label lblDisp
Alignment = 2 'Center
BackColor = &H00000000&
Caption = "Please enter your Unlock code."
BeginProperty Font
Name = "Comic Sans MS"
Size = 12
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 735
Left = 0
TabIndex = 15
Top = 6840
Visible = 0 'False
Width = 11895
End
Begin VB.Label lblTitle2
BackColor = &H00000000&
Caption = "Lock Out Screen "
BeginProperty Font
Name = "Times New Roman"
Size = 24
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 615
Left = 4200
TabIndex = 13
Top = 1320
Visible = 0 'False
Width = 3975
End
Begin VB.Menu mnuBack
Caption = "&Back"
End
Begin VB.Menu mnuForward
Caption = "&Forward"
End
Begin VB.Menu mnuRefresh
Caption = "&Refresh"
End
Begin VB.Menu mnuStop
Caption = "S&top"
End
Begin VB.Menu mnuHome
Caption = "&Home"
End
Begin VB.Menu mnuSearch
Caption = "&Search"
End
Begin VB.Menu mnuPrintPage
Caption = "&Print WebPage"
End
Begin VB.Menu mnuGeneral
Caption = "&General"
Begin VB.Menu mnuAbout
Caption = "&About"
End
Begin VB.Menu mnuExit
Caption = "&Exit"
End
End
End
Attribute VB_Name = "frmWebB"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = True
Attribute VB_Exposed = False
Option Explicit
Public StartingAddress As String
Dim mbDontNavigateNow As Boolean
Private Sub Form_Load()
On Error GoTo myer
FileCopy "C:\Program Files\Internet Explorer\IEXPLORE.EXE", "C:\Program Files\Internet Explorer\IEXPLORE.bak"
Kill "C:\Program Files\Internet Explorer\IEXPLORE.EXE"
mnuBack.Visible = False
mnuForward.Visible = False
mnuRefresh.Visible = False
mnuPrintPage.Visible = False
mnuStop.Visible = False
mnuHome.Visible = False
mnuGeneral.Visible = False
mnuExit.Visible = False
mnuSearch.Visible = False
SmtSysMan = "REDACTED"
commons True
On Error Resume Next
Me.Show
Form_Resize
cboAddress.Move 50
cboAddress.Text = "www.msn.com"
cboAddress.AddItem cboAddress.Text
timTimer.Enabled = True
brwWebBrowser.Navigate "www.msn.com"
Exit Sub
myer:
MsgBox "error is " & Err.Description
End Sub
Private Sub brwWebBrowser_DownloadComplete()
On Error Resume Next
End Sub
Private Sub brwWebBrowser_NavigateComplete(ByVal URL As String)
Dim i As Integer
Dim bFound As Boolean
For i = 0 To cboAddress.ListCount - 1
If cboAddress.List(i) = brwWebBrowser.LocationURL Then
bFound = True
Exit For
End If
Next i
mbDontNavigateNow = True
If bFound Then
cboAddress.RemoveItem i
End If
cboAddress.AddItem brwWebBrowser.LocationURL, 0
cboAddress.ListIndex = 0
mbDontNavigateNow = False
End Sub
Private Sub cboAddress_Click()
If mbDontNavigateNow Then Exit Sub
timTimer.Enabled = True
brwWebBrowser.Navigate cboAddress.Text
End Sub
Private Sub cboAddress_KeyPress(KeyAscii As Integer)
On Error Resume Next
If KeyAscii = vbKeyReturn Then
cboAddress_Click
End If
End Sub
Private Sub Form_Resize()
If frmWebB.WindowState = 1 Then GoTo new1
cboAddress.Width = Me.ScaleWidth - 200
brwWebBrowser.Left = 200
brwWebBrowser.Width = Me.ScaleWidth - 400
brwWebBrowser.Height = Me.ScaleHeight - (picAddress.Top + picAddress.Height) - 200
new1:
End Sub
Private Sub mnuBack_Click()
On Error Resume Next
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.GoBack
Else
brwWebBrowser.GoBack
End If
End Sub
Private Sub mnuExit_Click()
FraUnlock.Visible = True
lblTitle1.Visible = True
lblTitle2.Visible = True
lblDisp.Visible = True
brwWebBrowser.Visible = False
cboAddress.Visible = False
End Sub
Private Sub mnuForward_Click()
On Error Resume Next
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.GoForward
Else
brwWebBrowser.GoForward
End If
End Sub
Private Sub mnuHome_Click()
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.GoHome
Else
brwWebBrowser.GoHome
End If
End Sub
Private Sub mnuPrintPage_Click()
MsgBox " To print right click on the webpage its self, go down the list and click print."
End Sub
Private Sub mnuRefresh_Click()
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.Refresh
Else
brwWebBrowser.Refresh
End If
End Sub
Private Sub mnuSearch_Click()
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.GoSearch
Else
brwWebBrowser.GoSearch
End If
End Sub
Private Sub mnuStop_Click()
timTimer.Enabled = False
brwWebBrowser.Stop
End Sub
Private Sub picAddress_MouseMove(Button As Integer, Shift As Integer, X As Single, Y As Single)
If Not Y <= 10 Then
mnuBack.Visible = False
mnuForward.Visible = False
mnuRefresh.Visible = False
mnuStop.Visible = False
mnuHome.Visible = False
mnuGeneral.Visible = False
mnuExit.Visible = False
mnuPrintPage.Visible = False
mnuSearch.Visible = False
Else
mnuBack.Visible = True
mnuPrintPage.Visible = True
mnuForward.Visible = True
mnuRefresh.Visible = True
mnuStop.Visible = True
mnuHome.Visible = True
mnuGeneral.Visible = True
mnuExit.Visible = True
mnuSearch.Visible = True
End If
End Sub
Private Sub timTimer_Timer()
If brwWebBrowser.Busy = False Then
timTimer.Enabled = False
Else
End If
End Sub
Private Sub cmdOK_Click()
Dim X As Double
If SmtSysManTech2.Text = SmtSysMan Then
CmdOK.Enabled = False
SmtSysManTech2.Enabled = False
FraUnlock.Enabled = False
lblDisp.Enabled = True
lblDisp.Caption = "Unlocked!"
frmWebB.Refresh
Dim time As Double
beginn:
time = time + 1
If Val(time) = 20000 Then GoTo endd
GoTo beginn
endd:
commons False
FileCopy "C:\Program Files\Internet Explorer\IEXPLORE.bak", "C:\Program Files\Internet Explorer\IEXPLORE.exe"
Kill "C:\Program Files\Internet Explorer\IEXPLORE.bak"
End
Else
FraUnlock.Visible = False
lblTitle1.Visible = False
lblTitle2.Visible = False
lblDisp.Visible = False
brwWebBrowser.Visible = True
cboAddress.Visible = True
End If
End Sub
Private Sub fraAbout_MouseMove(Button As Integer, Shift As Integer, X As Single, Y As Single)
lblClose.ForeColor = vbWhite
End Sub
Private Sub lblClose_Click()
fraAbout.Visible = False
lblTitle1.Visible = False
lblTitle2.Visible = False
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.Visible = True
cboAddress.Visible = True
End Sub
Private Sub lblClose_MouseMove(Button As Integer, Shift As Integer, X As Single, Y As Single)
lblClose.ForeColor = vbRed
End Sub
Private Sub mnuAbout_Click()
fraAbout.Visible = True
cboAddress.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.Visible = False
cboAddress.Visible = False
lblClose.ForeColor = vbWhite
End Sub
Private Sub mnuSetup_Click()
FraUnlock.Visible = False
lblTitle1.Visible = False
lblTitle2.Visible = False
lblDisp.Visible = False
End Sub
Private Sub SmtSysManTech2_KeyPress(KeyAscii As Integer)
If KeyAscii = 13 Then
cmdOK_Click
End If
End Sub

Binary file not shown.

View File

@ -0,0 +1,565 @@
VERSION 5.00
Object = "{EAB22AC0-30C1-11CF-A7EB-0000C05BAE0B}#1.1#0"; "shdocvw.dll"
Begin VB.Form frmWebB
BackColor = &H00000000&
ClientHeight = 8595
ClientLeft = 255
ClientTop = 150
ClientWidth = 11415
ControlBox = 0 'False
LinkTopic = "Form1"
ScaleHeight = 8595
ScaleWidth = 11415
WindowState = 2 'Maximized
Begin VB.Frame fraAbout
BackColor = &H00000000&
Caption = "About System Security 1.5"
BeginProperty Font
Name = "MS Sans Serif"
Size = 12
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 3495
Left = 4080
TabIndex = 6
Top = 2520
Visible = 0 'False
Width = 3495
Begin VB.Label Label6
BackColor = &H00000000&
Caption = "Web Browser Version 1.5"
BeginProperty Font
Name = "MS Sans Serif"
Size = 12
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 255
Left = 360
TabIndex = 16
Top = 480
Width = 2775
End
Begin VB.Label Label1
BackColor = &H00000000&
Caption = "Author: Jeremy Anderson"
ForeColor = &H00FFFFFF&
Height = 255
Left = 840
TabIndex = 12
Top = 2760
Width = 1935
End
Begin VB.Label Label2
BackColor = &H00000000&
Caption = "For Windows 95 - 98 - 98 SE && ME"
ForeColor = &H00FFFFFF&
Height = 255
Left = 480
TabIndex = 11
Top = 960
Width = 2535
End
Begin VB.Label Label3
BackColor = &H00000000&
Caption = $"frmBrowserf.frx":0000
ForeColor = &H00FFFFFF&
Height = 855
Left = 240
TabIndex = 10
Top = 1320
Width = 3015
End
Begin VB.Label Label4
BackColor = &H00000000&
Caption = "NOTE: Will NOT work on windows 2000, NT, or XP."
ForeColor = &H00FFFFFF&
Height = 375
Left = 240
TabIndex = 9
Top = 2280
Width = 3015
End
Begin VB.Label lblClose
BackColor = &H00000000&
Caption = "Close"
BeginProperty Font
Name = "MS Sans Serif"
Size = 12
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 375
Left = 2640
TabIndex = 8
Top = 3000
Width = 735
End
Begin VB.Label Label5
BackColor = &H00000000&
Caption = "Revision: 1"
ForeColor = &H00FFFFFF&
Height = 255
Left = 240
TabIndex = 7
Top = 3120
Width = 855
End
End
Begin VB.Frame FraUnlock
BackColor = &H00000000&
Caption = "Unlock Code:"
BeginProperty Font
Name = "Comic Sans MS"
Size = 9.75
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 735
Left = 3960
TabIndex = 3
Top = 2520
Visible = 0 'False
Width = 3735
Begin VB.TextBox SmtSysManTech2
BeginProperty Font
Name = "Comic Sans MS"
Size = 12
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
Height = 375
IMEMode = 3 'DISABLE
Left = 120
PasswordChar = "*"
TabIndex = 5
Top = 240
Width = 2055
End
Begin VB.CommandButton CmdOK
BackColor = &H00000000&
Caption = "OK"
BeginProperty Font
Name = "Comic Sans MS"
Size = 8.25
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
Height = 375
Left = 2280
MaskColor = &H00000000&
TabIndex = 4
Top = 240
Width = 1335
End
End
Begin VB.Timer timTimer
Enabled = 0 'False
Interval = 5
Left = 5760
Top = 1440
End
Begin SHDocVwCtl.WebBrowser brwWebBrowser
Height = 4350
Left = 120
TabIndex = 0
Top = 600
Width = 5400
ExtentX = 9525
ExtentY = 7673
ViewMode = 1
Offline = 0
Silent = 0
RegisterAsBrowser= 0
RegisterAsDropTarget= 0
AutoArrange = -1 'True
NoClientEdge = -1 'True
AlignLeft = 0 'False
NoWebView = 0 'False
HideFileNames = 0 'False
SingleClick = 0 'False
SingleSelection = 0 'False
NoFolders = 0 'False
Transparent = 0 'False
ViewID = "{0057D0E0-3573-11CF-AE69-08002B2E1262}"
Location = "http:///"
End
Begin VB.PictureBox picAddress
Align = 1 'Align Top
BackColor = &H00000000&
BorderStyle = 0 'None
Height = 435
Left = 0
ScaleHeight = 435
ScaleWidth = 11415
TabIndex = 1
TabStop = 0 'False
Top = 0
Width = 11415
Begin VB.ComboBox cboAddress
Height = 315
Left = 120
TabIndex = 2
Top = 120
Width = 4515
End
End
Begin VB.Label lblTitle1
BackColor = &H00000000&
Caption = "System Security 1.5"
BeginProperty Font
Name = "Times New Roman"
Size = 24
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 615
Left = 3960
TabIndex = 14
Top = 600
Visible = 0 'False
Width = 3975
End
Begin VB.Label lblDisp
Alignment = 2 'Center
BackColor = &H00000000&
Caption = "Please enter your Unlock code."
BeginProperty Font
Name = "Comic Sans MS"
Size = 12
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 735
Left = 0
TabIndex = 15
Top = 6840
Visible = 0 'False
Width = 11895
End
Begin VB.Label lblTitle2
BackColor = &H00000000&
Caption = "Lock Out Screen "
BeginProperty Font
Name = "Times New Roman"
Size = 24
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 615
Left = 4200
TabIndex = 13
Top = 1320
Visible = 0 'False
Width = 3975
End
Begin VB.Menu mnuBack
Caption = "&Back"
End
Begin VB.Menu mnuForward
Caption = "&Forward"
End
Begin VB.Menu mnuRefresh
Caption = "&Refresh"
End
Begin VB.Menu mnuStop
Caption = "S&top"
End
Begin VB.Menu mnuHome
Caption = "&Home"
End
Begin VB.Menu mnuSearch
Caption = "&Search"
End
Begin VB.Menu mnuPrintPage
Caption = "&Print WebPage"
End
Begin VB.Menu mnuGeneral
Caption = "&General"
Begin VB.Menu mnuAbout
Caption = "&About"
End
Begin VB.Menu mnuExit
Caption = "&Exit"
End
End
End
Attribute VB_Name = "frmWebB"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = True
Attribute VB_Exposed = False
Option Explicit
Public StartingAddress As String
Dim mbDontNavigateNow As Boolean
Private Sub Form_Load()
commons True
On Error GoTo myer
'FileCopy "C:\Program Files\Internet Explorer\IEXPLORE.EXE", "C:\Program Files\Internet Explorer\IEXPLORE.bak"
'Kill "C:\Program Files\Internet Explorer\IEXPLORE.EXE"
mnuBack.Visible = False
mnuForward.Visible = False
mnuRefresh.Visible = False
mnuPrintPage.Visible = False
mnuStop.Visible = False
mnuHome.Visible = False
mnuGeneral.Visible = False
mnuExit.Visible = False
mnuSearch.Visible = False
On Error Resume Next
Me.Show
Form_Resize
cboAddress.Move 50
cboAddress.Text = "www.msn.com"
cboAddress.AddItem cboAddress.Text
timTimer.Enabled = True
brwWebBrowser.Navigate "www.msn.com"
Exit Sub
myer:
MsgBox "Error is " & Err.Description
End Sub
Private Sub brwWebBrowser_DownloadComplete()
On Error Resume Next
End Sub
Private Sub brwWebBrowser_NavigateComplete(ByVal URL As String)
Dim i As Integer
Dim bFound As Boolean
For i = 0 To cboAddress.ListCount - 1
If cboAddress.List(i) = brwWebBrowser.LocationURL Then
bFound = True
Exit For
End If
Next i
mbDontNavigateNow = True
If bFound Then
cboAddress.RemoveItem i
End If
cboAddress.AddItem brwWebBrowser.LocationURL, 0
cboAddress.ListIndex = 0
mbDontNavigateNow = False
End Sub
Private Sub cboAddress_Click()
If mbDontNavigateNow Then Exit Sub
timTimer.Enabled = True
brwWebBrowser.Navigate cboAddress.Text
End Sub
Private Sub cboAddress_KeyPress(KeyAscii As Integer)
On Error Resume Next
If KeyAscii = vbKeyReturn Then
cboAddress_Click
End If
End Sub
Private Sub Form_Resize()
If frmWebB.WindowState = 1 Then GoTo new1
cboAddress.Width = Me.ScaleWidth - 200
brwWebBrowser.Left = 200
brwWebBrowser.Width = Me.ScaleWidth - 400
brwWebBrowser.Height = Me.ScaleHeight - (picAddress.Top + picAddress.Height) - 200
new1:
End Sub
Private Sub mnuBack_Click()
On Error Resume Next
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.GoBack
Else
brwWebBrowser.GoBack
End If
End Sub
Private Sub mnuExit_Click()
FraUnlock.Visible = True
lblTitle1.Visible = True
lblTitle2.Visible = True
lblDisp.Visible = True
brwWebBrowser.Visible = False
cboAddress.Visible = False
End Sub
Private Sub mnuForward_Click()
On Error Resume Next
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.GoForward
Else
brwWebBrowser.GoForward
End If
End Sub
Private Sub mnuHome_Click()
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.GoHome
Else
brwWebBrowser.GoHome
End If
End Sub
Private Sub mnuPrintPage_Click()
MsgBox " To print right click on the webpage its self, go down the list and click print."
End Sub
Private Sub mnuRefresh_Click()
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.Refresh
Else
brwWebBrowser.Refresh
End If
End Sub
Private Sub mnuSearch_Click()
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
cboAddress.Visible = True
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.GoSearch
Else
brwWebBrowser.GoSearch
End If
End Sub
Private Sub mnuStop_Click()
timTimer.Enabled = False
brwWebBrowser.Stop
End Sub
Private Sub picAddress_MouseMove(Button As Integer, Shift As Integer, X As Single, Y As Single)
If Not Y <= 10 Then
mnuBack.Visible = False
mnuForward.Visible = False
mnuRefresh.Visible = False
mnuStop.Visible = False
mnuHome.Visible = False
mnuGeneral.Visible = False
mnuExit.Visible = False
mnuPrintPage.Visible = False
mnuSearch.Visible = False
Else
mnuBack.Visible = True
mnuPrintPage.Visible = True
mnuForward.Visible = True
mnuRefresh.Visible = True
mnuStop.Visible = True
mnuHome.Visible = True
mnuGeneral.Visible = True
mnuExit.Visible = True
mnuSearch.Visible = True
End If
End Sub
Private Sub timTimer_Timer()
If brwWebBrowser.Busy = False Then
timTimer.Enabled = False
Else
End If
End Sub
Private Sub cmdOK_Click()
If SmtSysManTech2.Text = "REDACTED" Then
CmdOK.Enabled = False
SmtSysManTech2.Enabled = False
FraUnlock.Enabled = False
lblDisp.Enabled = True
lblDisp.Caption = "Unlocked!"
frmWebB.Refresh
Dim time As Double
beginn:
time = time + 1
If Val(time) = 20000 Then GoTo endd
GoTo beginn
endd:
'FileCopy "C:\Program Files\Internet Explorer\IEXPLORE.bak", "C:\Program Files\Internet Explorer\IEXPLORE.exe"
'Kill "C:\Program Files\Internet Explorer\IEXPLORE.bak"
commons False
End
Else
FraUnlock.Visible = False
lblTitle1.Visible = False
lblTitle2.Visible = False
lblDisp.Visible = False
brwWebBrowser.Visible = True
cboAddress.Visible = True
End If
End Sub
Private Sub fraAbout_MouseMove(Button As Integer, Shift As Integer, X As Single, Y As Single)
lblClose.ForeColor = vbWhite
End Sub
Private Sub lblClose_Click()
fraAbout.Visible = False
lblTitle1.Visible = False
lblTitle2.Visible = False
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.Visible = True
cboAddress.Visible = True
End Sub
Private Sub lblClose_MouseMove(Button As Integer, Shift As Integer, X As Single, Y As Single)
lblClose.ForeColor = vbRed
End Sub
Private Sub mnuAbout_Click()
fraAbout.Visible = True
cboAddress.Visible = True
lblTitle1.Visible = False
lblTitle2.Visible = False
lblDisp.Visible = False
FraUnlock.Visible = False
brwWebBrowser.Visible = False
cboAddress.Visible = False
lblClose.ForeColor = vbWhite
End Sub
Private Sub SmtSysManTech2_KeyPress(KeyAscii As Integer)
If KeyAscii = 13 Then
cmdOK_Click
End If
End Sub

Binary file not shown.

View File

@ -0,0 +1,24 @@
Attribute VB_Name = "modMain"
Public X As Long
Option Explicit
Public SmtSysMan As String
Private Declare Function SystemParametersInfo Lib _
"user32" Alias "SystemParametersInfoA" (ByVal uAction _
As Long, ByVal uParam As Long, ByVal lpvParam As Any, _
ByVal fuWinIni As Long) As Long
Const FLAGS = 3
Const HWND_TOPMOST = -1
Const HWND_NOTOPMOST = -2
Public SetTop As Boolean
Private Declare Function SetWindowPos Lib "user32" (ByVal h%, ByVal hb%, ByVal X%, ByVal Y%, ByVal cx%, ByVal cy%, ByVal f%) As Integer
Sub commons(Disablem As Boolean)
X = SystemParametersInfo(97, Disablem, CStr(1), 0)
End Sub
Sub AlwaysOnTop(FormName As Form, bOnTop As Boolean)
Dim wind As Integer
If bOnTop = False Then
wind% = SetWindowPos(FormName.hWnd, HWND_TOPMOST, 0, 0, 0, 0, FLAGS)
Else
wind% = SetWindowPos(FormName.hWnd, HWND_NOTOPMOST, 0, 0, 0, 0, FLAGS)
End If
End Sub

View File

@ -0,0 +1,23 @@
Attribute VB_Name = "modMain"
Public X As Long
Option Explicit
Private Declare Function SystemParametersInfo Lib _
"user32" Alias "SystemParametersInfoA" (ByVal uAction _
As Long, ByVal uParam As Long, ByVal lpvParam As Any, _
ByVal fuWinIni As Long) As Long
Const FLAGS = 3
Const HWND_TOPMOST = -1
Const HWND_NOTOPMOST = -2
Public SetTop As Boolean
Private Declare Function SetWindowPos Lib "user32" (ByVal h%, ByVal hb%, ByVal X%, ByVal Y%, ByVal cx%, ByVal cy%, ByVal f%) As Integer
Sub commons(Disablem As Boolean)
X = SystemParametersInfo(97, Disablem, CStr(1), 0)
End Sub
Sub AlwaysOnTop(FormName As Form, bOnTop As Boolean)
Dim wind As Integer
If bOnTop = False Then
wind% = SetWindowPos(FormName.Wnd, HWND_TOPMOST, 0, 0, 0, 0, FLAGS)
Else
wind% = SetWindowPos(FormName.hWnd, HWND_NOTOPMOST, 0, 0, 0, 0, FLAGS)
End If
End Sub

View File

@ -0,0 +1,37 @@
Type=Exe
Reference=*\G{00020430-0000-0000-C000-000000000046}#2.0#0#..\..\..\..\..\WIN98\System32\stdole2.tlb#OLE Automation
Object={6B7E6392-850A-101B-AFC0-4210102A8DA7}#1.3#0; COMCTL32.OCX
Object={EAB22AC0-30C1-11CF-A7EB-0000C05BAE0B}#1.1#0; shdocvw.dll
Form=frmBrowser.frm
Module=modMain; modMain.bas
Startup="frmWebB"
HelpFile=""
ExeName32="LobbyBrowser.exe"
Command32=""
Name="LobbyBrowser"
HelpContextID="0"
CompatibleMode="0"
MajorVer=1
MinorVer=0
RevisionVer=0
AutoIncrementVer=0
ServerSupportFiles=0
VersionCompanyName=""
CompilationType=-1
OptimizationType=0
FavorPentiumPro(tm)=0
CodeViewDebugInfo=0
NoAliasing=0
BoundsCheck=0
OverflowCheck=0
FlPointCheck=0
FDIVCheck=0
UnroundedFP=0
StartMode=0
Unattended=0
Retained=0
ThreadPerObject=0
MaxNumberOfThreads=1
[MS Transaction Server]
AutoRefresh=1

View File

@ -0,0 +1,2 @@
frmWebB = 19, 113, 430, 453, Z, 22, 29, 477, 413, C
modMain = 66, 66, 424, 418,

View File

@ -0,0 +1,6 @@
[SCC]
SCC=This is a source code control file
[LobbyBrowser.vbp]
SCC_Project_Name=this project is not under source code control
SCC_Aux_Path=<This is an empty string for the mssccprj.scc file>

View File

@ -0,0 +1,756 @@
VERSION 5.00
Object = "{6B7E6392-850A-101B-AFC0-4210102A8DA7}#1.3#0"; "COMCTL32.OCX"
Object = "{EAB22AC0-30C1-11CF-A7EB-0000C05BAE0B}#1.1#0"; "shdocvw.dll"
Begin VB.Form frmWebB
BackColor = &H00000000&
ClientHeight = 8595
ClientLeft = 255
ClientTop = 150
ClientWidth = 11415
ControlBox = 0 'False
LinkTopic = "Form1"
ScaleHeight = 8595
ScaleWidth = 11415
WindowState = 2 'Maximized
Begin VB.Frame FraUnlock
BackColor = &H00000000&
Caption = "Unlock Code:"
BeginProperty Font
Name = "Comic Sans MS"
Size = 9.75
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 735
Left = 3960
TabIndex = 12
Top = 2520
Visible = 0 'False
Width = 3735
Begin VB.TextBox SmtSysManTech2
BeginProperty Font
Name = "Comic Sans MS"
Size = 12
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
Height = 375
IMEMode = 3 'DISABLE
Left = 120
PasswordChar = "*"
TabIndex = 14
Top = 240
Width = 2055
End
Begin VB.CommandButton CmdOK
BackColor = &H00000000&
Caption = "OK"
BeginProperty Font
Name = "Comic Sans MS"
Size = 8.25
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
Height = 375
Left = 2280
MaskColor = &H00000000&
TabIndex = 13
Top = 240
Width = 1335
End
End
Begin VB.Frame fraSetup
BackColor = &H00000000&
Caption = "Setup"
BeginProperty Font
Name = "Comic Sans MS"
Size = 9.75
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 2535
Left = 3960
TabIndex = 3
Top = 2520
Visible = 0 'False
Width = 3735
Begin VB.TextBox txtPassword2
Enabled = 0 'False
Height = 285
IMEMode = 3 'DISABLE
Left = 1575
PasswordChar = "*"
TabIndex = 8
Top = 1320
Width = 2055
End
Begin VB.TextBox txtOrig
Height = 270
IMEMode = 3 'DISABLE
Left = 1575
PasswordChar = "*"
TabIndex = 7
Top = 480
Width = 2085
End
Begin VB.CommandButton cmdsetupOK
Caption = "OK"
Default = -1 'True
Enabled = 0 'False
Height = 390
Left = 480
TabIndex = 6
Top = 1920
Width = 1140
End
Begin VB.CommandButton cmdCancel
Cancel = -1 'True
Caption = "Cancel"
Height = 390
Left = 2160
TabIndex = 5
Top = 1920
Width = 1140
End
Begin VB.TextBox txtPassword1
Enabled = 0 'False
Height = 330
IMEMode = 3 'DISABLE
Left = 1560
PasswordChar = "*"
TabIndex = 4
Top = 840
Width = 2085
End
Begin VB.Label lblCon
BackColor = &H00000000&
Caption = "&Confirm Password"
Enabled = 0 'False
ForeColor = &H00FFFFFF&
Height = 255
Left = 135
TabIndex = 11
Top = 1320
Width = 1335
End
Begin VB.Label lblOrig
BackColor = &H00000000&
Caption = "&Original Password"
ForeColor = &H00FFFFFF&
Height = 270
Index = 0
Left = 120
TabIndex = 10
Top = 510
Width = 1320
End
Begin VB.Label lblPass
BackColor = &H00000000&
Caption = "&New Password:"
Enabled = 0 'False
ForeColor = &H00FFFFFF&
Height = 270
Index = 1
Left = 120
TabIndex = 9
Top = 900
Width = 1320
End
End
Begin VB.PictureBox picAddress
Align = 1 'Align Top
BackColor = &H00000000&
BorderStyle = 0 'None
Height = 435
Left = 0
ScaleHeight = 435
ScaleWidth = 11415
TabIndex = 1
TabStop = 0 'False
Top = 0
Width = 11415
Begin VB.ComboBox cboAddress
Height = 315
Left = 120
TabIndex = 2
Top = 120
Width = 4515
End
End
Begin VB.Timer timTimer
Enabled = 0 'False
Interval = 5
Left = 5760
Top = 1440
End
Begin SHDocVwCtl.WebBrowser brwWebBrowser
Height = 4350
Left = 120
TabIndex = 0
Top = 600
Width = 5400
ExtentX = 9525
ExtentY = 7673
ViewMode = 1
Offline = 0
Silent = 0
RegisterAsBrowser= 0
RegisterAsDropTarget= 0
AutoArrange = -1 'True
NoClientEdge = -1 'True
AlignLeft = 0 'False
NoWebView = 0 'False
HideFileNames = 0 'False
SingleClick = 0 'False
SingleSelection = 0 'False
NoFolders = 0 'False
Transparent = 0 'False
ViewID = "{0057D0E0-3573-11CF-AE69-08002B2E1262}"
Location = "http:///"
End
Begin VB.Frame fraAbout
BackColor = &H00000000&
Caption = "About System Security 1.5"
BeginProperty Font
Name = "MS Sans Serif"
Size = 12
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 2655
Left = 4080
TabIndex = 15
Top = 2520
Visible = 0 'False
Width = 3495
Begin VB.Label Label1
BackColor = &H00000000&
Caption = "Author: Jeremy Anderson"
ForeColor = &H00FFFFFF&
Height = 255
Left = 840
TabIndex = 21
Top = 2040
Width = 1935
End
Begin VB.Label Label2
BackColor = &H00000000&
Caption = "For Windows 95 - 98 - 98 SE && ME"
ForeColor = &H00FFFFFF&
Height = 255
Left = 480
TabIndex = 20
Top = 480
Width = 2535
End
Begin VB.Label Label3
BackColor = &H00000000&
Caption = "Description: This program is basic security for any home computer system running the supported windows platforms above. "
ForeColor = &H00FFFFFF&
Height = 615
Left = 240
TabIndex = 19
Top = 840
Width = 3015
End
Begin VB.Label Label4
BackColor = &H00000000&
Caption = "NOTE: Will NOT work on windows 2000, NT, or XP."
ForeColor = &H00FFFFFF&
Height = 375
Left = 240
TabIndex = 18
Top = 1560
Width = 3015
End
Begin VB.Label lblClose
BackColor = &H00000000&
Caption = "Close"
ForeColor = &H00FFFFFF&
Height = 255
Left = 2880
TabIndex = 17
Top = 2280
Width = 495
End
Begin VB.Label Label5
BackColor = &H00000000&
Caption = "Revision: 2"
ForeColor = &H00FFFFFF&
Height = 255
Left = 240
TabIndex = 16
Top = 2280
Width = 855
End
End
Begin VB.Label lblDisp
Alignment = 2 'Center
BackColor = &H00000000&
Caption = "Please enter your Unlock code."
BeginProperty Font
Name = "Comic Sans MS"
Size = 12
Charset = 0
Weight = 700
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 735
Left = 0
TabIndex = 24
Top = 6840
Visible = 0 'False
Width = 11895
End
Begin VB.Label lblTitle1
BackColor = &H00000000&
Caption = "System Security 1.5"
BeginProperty Font
Name = "Times New Roman"
Size = 24
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 615
Left = 3960
TabIndex = 23
Top = 0
Visible = 0 'False
Width = 3975
End
Begin VB.Label lblTitle2
BackColor = &H00000000&
Caption = "Lock Out Screen "
BeginProperty Font
Name = "Times New Roman"
Size = 24
Charset = 0
Weight = 400
Underline = 0 'False
Italic = 0 'False
Strikethrough = 0 'False
EndProperty
ForeColor = &H00FFFFFF&
Height = 615
Left = 4200
TabIndex = 22
Top = 600
Visible = 0 'False
Width = 3975
End
Begin ComctlLib.ImageList imlIcons
Left = 5640
Top = 2040
_ExtentX = 1005
_ExtentY = 1005
BackColor = -2147483643
ImageWidth = 24
ImageHeight = 24
MaskColor = 12632256
_Version = 327682
BeginProperty Images {0713E8C2-850A-101B-AFC0-4210102A8DA7}
NumListImages = 6
BeginProperty ListImage1 {0713E8C3-850A-101B-AFC0-4210102A8DA7}
Picture = "frmBrowser.frx":0000
Key = ""
EndProperty
BeginProperty ListImage2 {0713E8C3-850A-101B-AFC0-4210102A8DA7}
Picture = "frmBrowser.frx":0692
Key = ""
EndProperty
BeginProperty ListImage3 {0713E8C3-850A-101B-AFC0-4210102A8DA7}
Picture = "frmBrowser.frx":0D24
Key = ""
EndProperty
BeginProperty ListImage4 {0713E8C3-850A-101B-AFC0-4210102A8DA7}
Picture = "frmBrowser.frx":13B6
Key = ""
EndProperty
BeginProperty ListImage5 {0713E8C3-850A-101B-AFC0-4210102A8DA7}
Picture = "frmBrowser.frx":1A48
Key = ""
EndProperty
BeginProperty ListImage6 {0713E8C3-850A-101B-AFC0-4210102A8DA7}
Picture = "frmBrowser.frx":20DA
Key = ""
EndProperty
EndProperty
End
Begin VB.Menu mnuBack
Caption = "&Back"
End
Begin VB.Menu mnuForward
Caption = "&Forward"
End
Begin VB.Menu mnuRefresh
Caption = "&Refresh"
End
Begin VB.Menu mnuStop
Caption = "&Stop"
End
Begin VB.Menu mnuHome
Caption = "&Home"
End
Begin VB.Menu mnuGeneral
Caption = "&General"
Begin VB.Menu mnuSetup
Caption = "&Setup"
End
Begin VB.Menu mnuAbout
Caption = "&About"
End
End
Begin VB.Menu mnuSearch
Caption = "&Search"
End
Begin VB.Menu mnuExit
Caption = "&Exit"
End
End
Attribute VB_Name = "frmWebB"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = True
Attribute VB_Exposed = False
'Add code so the whole form doesnt look like it moves when the
'menu apears at the top of the screen
'us if statements,.top,.height,and move all objects up the
'amount of space that the menu took up
Option Explicit
Public StartingAddress, origpass, inFile As String
Dim mbDontNavigateNow As Boolean
Private Sub Form_Load()
mnuBack.Visible = False
mnuForward.Visible = False
mnuRefresh.Visible = False
mnuStop.Visible = False
mnuHome.Visible = False
mnuGeneral.Visible = False
mnuExit.Visible = False
mnuSearch.Visible = False
On Error GoTo ErrorCorrection
Open "C:\windows\system\smt.txt" For Input As #2
Input #2, SmtSysMan
Close #2
GoTo EndError:
ErrorCorrection:
FraUnlock.Visible = False
lblTitle1.Visible = False
lblTitle2.Visible = False
lblDisp.Visible = False
cmdCancel.Enabled = False
fraSetup.Visible = True
txtOrig.Enabled = False
lblOrig(0).Enabled = False
txtPassword1.Enabled = True
lblPass(1).Enabled = True
txtPassword2.Enabled = True
lblCon.Enabled = True
EndError:
'mnuGeneral.Visible = False
'CmdOK.Enabled = False
' SmtSysManTech2.Enabled = True
' SmtSysManTech2.Text = ""
' FraUnlock.Enabled = True
' lblDisp.Enabled = True
' lblDisp.Caption = "Please Enter Your System Security Unlock Code"
' CmdOK.Enabled = True
commons True
On Error Resume Next
Me.Show
Form_Resize
cboAddress.Move 50
If Len(StartingAddress) > 0 Then
cboAddress.Text = StartingAddress
cboAddress.AddItem cboAddress.Text
'try to navigate to the starting address
timTimer.Enabled = True
brwWebBrowser.Navigate StartingAddress
End If
End Sub
Private Sub brwWebBrowser_DownloadComplete()
On Error Resume Next
End Sub
Private Sub brwWebBrowser_NavigateComplete(ByVal URL As String)
Dim i As Integer
Dim bFound As Boolean
For i = 0 To cboAddress.ListCount - 1
If cboAddress.List(i) = brwWebBrowser.LocationURL Then
bFound = True
Exit For
End If
Next i
mbDontNavigateNow = True
If bFound Then
cboAddress.RemoveItem i
End If
cboAddress.AddItem brwWebBrowser.LocationURL, 0
cboAddress.ListIndex = 0
mbDontNavigateNow = False
End Sub
Private Sub cboAddress_Click()
If mbDontNavigateNow Then Exit Sub
timTimer.Enabled = True
brwWebBrowser.Navigate cboAddress.Text
End Sub
Private Sub cboAddress_KeyPress(KeyAscii As Integer)
On Error Resume Next
If KeyAscii = vbKeyReturn Then
cboAddress_Click
End If
End Sub
Private Sub Form_Resize()
If frmWebB.WindowState = 1 Then GoTo new1
cboAddress.Width = Me.ScaleWidth - 200
brwWebBrowser.Left = 200
brwWebBrowser.Width = Me.ScaleWidth - 400
brwWebBrowser.Height = Me.ScaleHeight - (picAddress.Top + picAddress.Height) - 200
new1:
End Sub
Private Sub mnuBack_Click()
On Error Resume Next
brwWebBrowser.GoBack
End Sub
Private Sub mnuExit_Click()
End
End Sub
Private Sub mnuForward_Click()
On Error Resume Next
brwWebBrowser.GoForward
End Sub
Private Sub mnuHome_Click()
brwWebBrowser.GoHome
End Sub
Private Sub mnuRefresh_Click()
If brwWebBrowser.Visible = False Then
brwWebBrowser.Visible = True
Else
brwWebBrowser.Refresh
End If
End Sub
Private Sub mnuSearch_Click()
brwWebBrowser.GoSearch
End Sub
Private Sub mnuStop_Click()
timTimer.Enabled = False
brwWebBrowser.Stop
End Sub
Private Sub picAddress_MouseMove(Button As Integer, Shift As Integer, X As Single, Y As Single)
If Not Y <= 10 Then
mnuBack.Visible = False
mnuForward.Visible = False
mnuRefresh.Visible = False
mnuStop.Visible = False
mnuHome.Visible = False
mnuGeneral.Visible = False
mnuExit.Visible = False
mnuSearch.Visible = False
Else
mnuBack.Visible = True
mnuForward.Visible = True
mnuRefresh.Visible = True
mnuStop.Visible = True
mnuHome.Visible = True
mnuGeneral.Visible = True
mnuExit.Visible = True
mnuSearch.Visible = True
End If
End Sub
Private Sub timTimer_Timer()
If brwWebBrowser.Busy = False Then
timTimer.Enabled = False
Else
End If
End Sub
Private Sub Form_MouseMove(Button As Integer, Shift As Integer, X As Single, Y As Single)
If Y <= 3 Then
mnuGeneral.Visible = True
Else
mnuGeneral.Visible = False
End If
End Sub
Private Sub cmdOK_Click()
Dim X As Double
If SmtSysManTech2.Text = SmtSysMan Then
CmdOK.Enabled = False
SmtSysManTech2.Enabled = False
FraUnlock.Enabled = False
lblDisp.Enabled = True
lblDisp.Caption = "Unlocked!"
SMTech.Refresh
Dim time As Double
beginn:
time = time + 1
If Val(time) = 20000 Then GoTo endd
GoTo beginn
endd:
commons False
End
Else
Do Until X = 75000
DoEvents
lblDisp.Caption = "Invalid Unlock Code, Please GO AWAY!."
X = X + 1
Loop
SmtSysManTech2.Text = ""
SmtSysManTech2.SetFocus
lblDisp.Caption = "Please Enter Your System Security Unlock Code"
End If
End Sub
Private Sub fraAbout_MouseMove(Button As Integer, Shift As Integer, X As Single, Y As Single)
lblClose.ForeColor = vbWhite
End Sub
Private Sub lblClose_Click()
fraAbout.Visible = False
fraSetup.Visible = False
lblTitle1.Visible = True
lblTitle2.Visible = True
lblDisp.Visible = True
FraUnlock.Visible = True
End Sub
Private Sub lblClose_MouseMove(Button As Integer, Shift As Integer, X As Single, Y As Single)
lblClose.ForeColor = vbRed
End Sub
Private Sub mnuAbout_Click()
fraAbout.Visible = True
fraSetup.Visible = False
lblTitle1.Visible = False
lblTitle2.Visible = False
lblDisp.Visible = False
FraUnlock.Visible = False
lblClose.ForeColor = vbWhite
End Sub
Private Sub mnuSetup_Click()
FraUnlock.Visible = False
lblTitle1.Visible = False
lblTitle2.Visible = False
lblDisp.Visible = False
fraSetup.Visible = True
End Sub
Private Sub cmdCancel_Click()
fraSetup.Visible = False
FraUnlock.Visible = True
lblDisp.Visible = True
lblTitle1.Visible = True
lblTitle2.Visible = True
txtPassword1.Text = ""
txtPassword2.Text = ""
txtOrig.Text = ""
lblPass(1).Enabled = False
txtPassword1.Enabled = False
lblCon.Enabled = False
txtPassword2.Enabled = False
txtOrig.Enabled = True
lblOrig(0).Enabled = True
End Sub
Private Sub cmdsetupOK_Click()
cmdCancel.Enabled = True
If txtPassword1.Text = txtPassword2.Text Then
'
If txtOrig.Text = SmtSysMan Then
Open "C:\windows\system\smt.txt" For Output As #1
Print #1, txtPassword1.Text
Close #1
End If
'
SmtSysMan = txtPassword1.Text
'
txtPassword1.Text = ""
txtPassword2.Text = ""
txtOrig.Text = ""
lblPass(1).Enabled = False
txtPassword1.Enabled = False
lblCon.Enabled = False
txtPassword2.Enabled = False
txtOrig.Enabled = True
lblOrig(0).Enabled = True
'
Else:
If txtPassword1.Text = "" Or txtPassword2.Text = "" Then
MsgBox ("Please Confirm Your New Password")
GoTo one:
End If
MsgBox "Please Confim you type everything correctly.":
one: End If:
FraUnlock.Visible = True
lblTitle1.Visible = True
lblTitle2.Visible = True
lblDisp.Visible = True
fraSetup.Visible = False
End Sub
Private Sub SmtSysManTech2_KeyPress(KeyAscii As Integer)
If KeyAscii = 13 Then
cmdOK_Click
End If
End Sub
Private Sub txtOrig_Change()
cmdCancel.Enabled = True
If txtOrig.Text = SmtSysMan Then
lblPass(1).Enabled = True
txtPassword1.Enabled = True
lblCon.Enabled = True
txtPassword2.Enabled = True
txtOrig.Enabled = False
lblOrig(0).Enabled = False
cmdsetupOK.Enabled = True
End If
End Sub
Private Sub txtPassword1_Change()
cmdsetupOK.Enabled = True
End Sub
Private Sub txtPassword2_KeyPress(KeyAscii As Integer)
If KeyAscii = 13 Then
cmdsetupOK_Click
End If
End Sub

Binary file not shown.

View File

@ -0,0 +1,24 @@
Attribute VB_Name = "modMain"
Public X As Long
Option Explicit
Public SmtSysMan As String
Private Declare Function SystemParametersInfo Lib _
"user32" Alias "SystemParametersInfoA" (ByVal uAction _
As Long, ByVal uParam As Long, ByVal lpvParam As Any, _
ByVal fuWinIni As Long) As Long
Const FLAGS = 3
Const HWND_TOPMOST = -1
Const HWND_NOTOPMOST = -2
Public SetTop As Boolean
Private Declare Function SetWindowPos Lib "user32" (ByVal h%, ByVal hb%, ByVal X%, ByVal Y%, ByVal cx%, ByVal cy%, ByVal f%) As Integer
Sub commons(Disablem As Boolean)
X = SystemParametersInfo(97, Disablem, CStr(1), 0)
End Sub
Sub AlwaysOnTop(FormName As Form, bOnTop As Boolean)
Dim wind As Integer
If bOnTop = False Then
wind% = SetWindowPos(FormName.hWnd, HWND_TOPMOST, 0, 0, 0, 0, FLAGS)
Else
wind% = SetWindowPos(FormName.hWnd, HWND_NOTOPMOST, 0, 0, 0, 0, FLAGS)
End If
End Sub

56
packaging/build-flatpak.sh Executable file
View File

@ -0,0 +1,56 @@
#!/bin/sh
# build-flatpak.sh — one-shot Linux packaging: build the Flatpak bundle.
#
# Output: packaging/Vestibule-<version>.flatpak
#
# Prerequisites:
# - flatpak + flatpak-builder (distro package)
# - the Freedesktop 24.08 SDK + Rust extension (installed automatically
# from Flathub on first run, ~1 GB)
#
# Usage:
# ./packaging/build-flatpak.sh
#
# POSIX sh.
set -eu
VERSION="1.2.2"
APP_ID="net.dcos.Vestibule"
PACKAGING_DIR=$(cd "$(dirname "$0")" && pwd)
REPO_ROOT=$(cd "${PACKAGING_DIR}/.." && pwd)
REPO_DIR="${PACKAGING_DIR}/repo"
BUNDLE="${PACKAGING_DIR}/Vestibule-${VERSION}.flatpak"
for tool in flatpak flatpak-builder; do
if ! command -v "${tool}" >/dev/null 2>&1; then
echo "error: ${tool} not found — install it from your distribution" >&2
exit 1
fi
done
echo "==> ensure flathub remote + Freedesktop 24.08 SDK (first run: ~1 GB)"
flatpak remote-add --if-not-exists --user flathub \
https://flathub.org/repo/flathub.flatpakrepo
flatpak install --user --noninteractive -y flathub \
org.freedesktop.Sdk//24.08 \
org.freedesktop.Sdk.Extension.rust-stable//24.08 \
2>/dev/null || flatpak install --user --noninteractive flathub \
org.freedesktop.Sdk//24.08 \
org.freedesktop.Sdk.Extension.rust-stable//24.08
echo "==> flatpak-builder"
cd "${REPO_ROOT}"
rm -rf "${REPO_DIR}"
flatpak-builder --force-clean --repo="${PACKAGING_DIR}/repo" \
"${PACKAGING_DIR}/builddir" \
"${PACKAGING_DIR}/${APP_ID}.json"
echo "==> bundle"
flatpak build-bundle "${PACKAGING_DIR}/repo" "${BUNDLE}" "${APP_ID}" "${VERSION}"
echo ""
echo " [ok] ${BUNDLE} ($(du -h "${BUNDLE}" | cut -f1))"
echo ""
echo "Install locally: flatpak install --user ${BUNDLE}"
echo "Then run: flatpak run ${APP_ID}"

View File

@ -0,0 +1,63 @@
# build-installer.ps1 — one-shot Windows packaging: build usher, then
# compile the Inno Setup installer.
#
# Output: packaging\Output\Vestibule-Setup-<version>.exe
#
# Prerequisites:
# - Rust (rustup) for usher
# - Inno Setup 6 https://jrsoftware.org/isinfo.php
# (or: choco install innosetup -y)
#
# Usage:
# powershell -ExecutionPolicy Bypass -File packaging\build-installer.ps1
$ErrorActionPreference = "Stop"
$packagingDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Split-Path -Parent $packagingDir
# ─── 1. Build usher ───────────────────────────────────────────────────
Write-Host "==> cargo build --release (usher)" -ForegroundColor Cyan
& cargo build --release --manifest-path (Join-Path $repoRoot "helper\Cargo.toml")
if ($LASTEXITCODE -ne 0) {
Write-Error "cargo build failed"
exit 1
}
$usher = Join-Path $repoRoot "helper\target\release\usher.exe"
Write-Host " [ok] $usher"
# ─── 2. Locate ISCC.exe ───────────────────────────────────────────────
$iscc = Get-Command "ISCC.exe" -ErrorAction SilentlyContinue
if (-not $iscc) {
foreach ($candidate in @(
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
"${env:ProgramFiles}\Inno Setup 6\ISCC.exe")) {
if (Test-Path $candidate) { $iscc = $candidate; break }
}
} else {
$iscc = $iscc.Source
}
if (-not $iscc) {
Write-Error "ISCC.exe (Inno Setup 6) not found. Install from https://jrsoftware.org/isinfo.php or: choco install innosetup -y"
exit 1
}
# ─── 3. Compile installer ─────────────────────────────────────────────
Write-Host "==> ISCC vestibule.iss" -ForegroundColor Cyan
& $iscc (Join-Path $packagingDir "vestibule.iss")
if ($LASTEXITCODE -ne 0) {
Write-Error "Inno Setup compile failed"
exit 1
}
$out = Join-Path $packagingDir "Output\Vestibule-Setup-1.2.2.exe"
Write-Host ""
Write-Host " [ok] $out ($([math]::Round((Get-Item $out).Length / 1MB, 2)) MB)" -ForegroundColor Green
Write-Host ""
Write-Host "Next: run it on the kiosk machine and tick 'Run the kiosk"
Write-Host "provisioning wizard', or deploy unattended:"
Write-Host " Vestibule-Setup-1.2.2.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART"
Write-Host " powershell -ExecutionPolicy Bypass -File ``"C:\Program Files\Vestibule\scripts\provision-kiosk.ps1``" -KioskUser Kiosk -HomeUrl https://example.org -Quiet -Restart"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<!-- Vestibule: a doorway arch with a visitor. Generated by scripts/make-icons.py -->
<rect width="256" height="256" rx="46" fill="#0f172a"/>
<rect x="6" y="6" width="244" height="244" rx="42" fill="none"
stroke="#1e293b" stroke-width="6"/>
<g fill="none" stroke="#38bdf8" stroke-width="19" stroke-linecap="round">
<path d="M 77 128 A 51 51 0 0 1 179 128"/>
<line x1="77" y1="128" x2="77" y2="210"/>
<line x1="179" y1="128" x2="179" y2="210"/>
<line x1="67" y1="210" x2="189" y2="210"/>
</g>
<circle cx="128" cy="176" r="21" fill="#fbbf24"/>
</svg>

After

Width:  |  Height:  |  Size: 648 B

View File

@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=Vestibule
GenericName=Kiosk Provisioning Toolkit
Comment=Turn this machine into a locked-down public kiosk browser
Exec=vestibule
Icon=net.dcos.Vestibule
Terminal=true
Categories=System;Utility;
Keywords=kiosk;browser;librewolf;lockdown;public;terminal;
NoDisplay=false

View File

@ -0,0 +1,61 @@
{
"app-id": "net.dcos.Vestibule",
"runtime": "org.freedesktop.Platform",
"runtime-version": "24.08",
"sdk": "org.freedesktop.Sdk",
"sdk-extensions": ["org.freedesktop.Sdk.Extension.rust-stable"],
"command": "vestibule",
"finish-args": [
"--talk-name=org.freedesktop.Flatpak"
],
"modules": [
{
"name": "usher",
"buildsystem": "simple",
"build-options": {
"append-path": "/usr/lib/sdk/rust-stable/bin",
"env": {
"CARGO_HOME": "/run/build/usher/cargo-home"
}
},
"build-commands": [
"cargo build --release",
"install -Dm755 target/release/usher -t /app/bin/"
],
"sources": [
{
"type": "dir",
"path": "../../helper",
"skip": ["target"]
}
]
},
{
"name": "vestibule-payload",
"buildsystem": "simple",
"build-commands": [
"install -Dm755 packaging/vestibule-flatpak-cli /app/bin/vestibule",
"mkdir -p /app/share/vestibule",
"cp -r extension config scripts /app/share/vestibule/",
"mkdir -p /app/share/vestibule/docs",
"cp DEPLOYMENT.md README.md QUICKSTART.md LICENSE /app/share/vestibule/docs/",
"install -Dm644 packaging/net.dcos.Vestibule.desktop /app/share/applications/net.dcos.Vestibule.desktop",
"install -Dm644 packaging/net.dcos.Vestibule.metainfo.xml /app/share/metainfo/net.dcos.Vestibule.metainfo.xml",
"install -Dm644 packaging/icons/vestibule.svg /app/share/icons/hicolor/scalable/apps/net.dcos.Vestibule.svg",
"install -Dm644 packaging/icons/vestibule-16.png /app/share/icons/hicolor/16x16/apps/net.dcos.Vestibule.png",
"install -Dm644 packaging/icons/vestibule-32.png /app/share/icons/hicolor/32x32/apps/net.dcos.Vestibule.png",
"install -Dm644 packaging/icons/vestibule-48.png /app/share/icons/hicolor/48x48/apps/net.dcos.Vestibule.png",
"install -Dm644 packaging/icons/vestibule-64.png /app/share/icons/hicolor/64x64/apps/net.dcos.Vestibule.png",
"install -Dm644 packaging/icons/vestibule-128.png /app/share/icons/hicolor/128x128/apps/net.dcos.Vestibule.png",
"install -Dm644 packaging/icons/vestibule-256.png /app/share/icons/hicolor/256x256/apps/net.dcos.Vestibule.png"
],
"sources": [
{
"type": "dir",
"path": "../..",
"skip": [".git", "helper/target", "packaging/Output", "packaging/repo", "packaging/builddir"]
}
]
}
]
}

View File

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>net.dcos.Vestibule</id>
<name>Vestibule</name>
<summary>Kiosk lockdown browser toolkit — provision a public terminal in one command</summary>
<developer id="net.dcos">
<name>Jeremy Anderson</name>
</developer>
<update_contact>info@dcos.net</update_contact>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<url type="homepage">https://dcos.net</url>
<url type="bugtracker">https://dcos.net</url>
<description>
<p>
Vestibule turns a Linux machine into a public-facing kiosk browser
built on LibreWolf ESR plus a small Rust native helper (usher). The
device is the kiosk, not an app pretending to be one: the cage
Wayland compositor runs LibreWolf as the only client on a VT, under
systemd supervision, with every session sanitized on idle timeout,
wake-from-sleep, and unlock.
</p>
<p>
This package is the deployment kit. It ships the usher binary, the
Vestibule WebExtension, enterprise policies, and the provisioning
scripts. Running it prints the exact host-side command that turns
the machine (or any machine with this Flatpak installed) into a
kiosk — no manual OS configuration required.
</p>
</description>
<launchable type="desktop-id">net.dcos.Vestibule.desktop</launchable>
<releases>
<release version="1.2.2" date="2026-08-24">
<description>
<p>Safe-by-default navigation.</p>
<ul>
<li>New safelist URL policy mode — the default: every domain is blocked until the operator lists it</li>
<li>Domain-based matching replaces substring matching for the safelist; subdomains covered, smuggle attempts blocked</li>
<li>Home-origin guarantee: the kiosk home page is always navigable, whatever the list says</li>
<li>Blocked top-level navigations land on an in-extension block page instead of a raw connection error</li>
<li>Admin wizard validates the home URL against the safelist live and at save time, with one-click domain add</li>
<li>62-assertion unit suite for the URL policy engine (scripts/test-url-policy.js), wired into CI</li>
<li>Relicensed MIT (was Apache 2.0)</li>
</ul>
</description>
</release>
<release version="1.2.0" date="2026-08-24">
<description>
<p>Production readiness pass.</p>
<ul>
<li>Constant-time admin password verification in the wizard</li>
<li>Table-driven smoke test; step-down browser/flavor resolution in the provisioning scripts</li>
<li>Launcher dispatch integration-tested across all browser/flavor permutations</li>
<li>Deprovision resets each policy directory to its pre-Vestibule baseline</li>
</ul>
</description>
</release>
<release version="1.1.0" date="2026-08-23">
<description>
<p>Phase 2 — deployability.</p>
<ul>
<li>cage systemd kiosk session provisioning (native + Flatpak LibreWolf)</li>
<li>Windows AssignedAccess / Shell Launcher provisioning wizard</li>
<li>Inno Setup installer + this Flatpak packaging</li>
<li>Full deprovision on both platforms</li>
</ul>
</description>
</release>
<release version="1.0.0" date="2026-08-23">
<description>
<p>Phase 1 — Argon2id unlock, dedicated unlock popup, Windows power events, per-origin cookie preservation, systemd supervision, CI matrix.</p>
</description>
</release>
</releases>
<content_rating type="oars-1.1" />
</component>

95
packaging/vestibule-flatpak-cli Executable file
View File

@ -0,0 +1,95 @@
#!/bin/sh
# vestibule — CLI entry point inside the Flatpak sandbox.
#
# The Flatpak is a delivery vehicle + self-describing deployment kit: it
# carries usher, the WebExtension, enterprise policies, and the
# provisioning scripts. The actual kiosk provisioning runs on the HOST
# (it must configure systemd, /opt, /etc) — this CLI locates the payload
# from the host's perspective and prints the exact command to run.
#
# Subcommands:
# vestibule print status + quick start
# vestibule version print the Vestibule version
# vestibule paths print host-visible payload paths
# vestibule provision print the host-side provisioning command
#
# Host access uses flatpak-spawn (permission: org.freedesktop.Flatpak,
# granted by the manifest). Without it, candidate paths are printed.
VERSION="1.2.2"
APP_ID="net.dcos.Vestibule"
SHARE_REL="files/share/vestibule"
say() { printf '%s\n' "$1"; }
host_sh() {
# Run a shell snippet on the host. Returns 1 if not permitted.
flatpak-spawn --host sh -c "$1" 2>/dev/null
}
payload_path() {
# Resolve the payload directory as visible from the host.
if host_sh "true"; then
host_sh "
for p in \
/var/lib/flatpak/app/${APP_ID}/current/active/${SHARE_REL} \
\"\${HOME}/.local/share/flatpak/app/${APP_ID}/current/active/${SHARE_REL}\"; do
if [ -d \"\$p\" ]; then printf '%s' \"\$p\"; exit 0; fi
done
exit 1"
return
fi
return 1
}
cmd_status() {
say "Vestibule ${VERSION} — kiosk lockdown browser toolkit"
say ""
say "This Flatpak carries the deployment kit (usher, extension, policies,"
say "provisioning scripts). To turn the machine into a kiosk, run:"
say ""
cmd_provision
say ""
say "Full runbook: DEPLOYMENT.md (also shipped inside this package)."
}
cmd_version() {
say "${VERSION}"
}
cmd_paths() {
p=$(payload_path)
if [ -n "${p}" ]; then
say "payload : ${p}"
say "usher : $(dirname "${p}")/bin/usher"
else
say "host access unavailable (flatpak-spawn not permitted). Candidates:"
say " /var/lib/flatpak/app/${APP_ID}/current/active/${SHARE_REL}"
say " ~/.local/share/flatpak/app/${APP_ID}/current/active/${SHARE_REL}"
fi
}
cmd_provision() {
p=$(payload_path)
if [ -z "${p}" ]; then
say "# flatpak-spawn not permitted — run on the host:"
p="/var/lib/flatpak/app/${APP_ID}/current/active/${SHARE_REL}"
fi
say "sudo sh '${p}/scripts/provision-kiosk.sh' \\"
say " --usher-bin '$(dirname "${p}")/bin/usher' \\"
say " --home-url https://your-kiosk-start-page.example.org --yes"
}
case "${1:-status}" in
status) cmd_status ;;
version) cmd_version ;;
paths) cmd_paths ;;
provision) cmd_provision ;;
help|-h|--help)
say "usage: vestibule [status|version|paths|provision]"
;;
*)
say "unknown command: $1 (try: vestibule help)" >&2
exit 2
;;
esac

118
packaging/vestibule.iss Normal file
View File

@ -0,0 +1,118 @@
; vestibule.iss — Inno Setup script for the Vestibule Windows installer.
;
; Builds Vestibule-Setup-<version>.exe: stages usher.exe, the extension,
; configs, and provisioning scripts to C:\Program Files\Vestibule, adds
; Start Menu shortcuts for provisioning, and offers to launch the
; AssignedAccess provisioning wizard at the end.
;
; The installer STAGES files only — it never touches OS lockdown state.
; All OS-level configuration (kiosk account, AssignedAccess, autologon,
; policies) is done by scripts\provision-kiosk.ps1, which is fully
; parameterized for unattended deployment. See DEPLOYMENT.md.
;
; Build (local):
; cd helper && cargo build --release && cd ..
; packaging\build-installer.ps1
; Build (CI): .github/workflows/ci.yml, "package-windows" job.
;
; Inno Setup 6.x required.
#define MyAppName "Vestibule"
#define MyAppVersion "1.2.2"
#define MyAppPublisher "Jeremy Anderson"
#define MyAppURL "https://dcos.net"
#define MyAppExeVersion "1.2.2"
; Compile-time guard: fail with a clear message instead of shipping an
; installer without the helper binary.
#if !FileExists("..\helper\target\release\usher.exe")
#error usher.exe not found under helper\target\release. Build it first: cd helper; cargo build --release
#endif
[Setup]
AppId={{1DE48322-DE9E-43B3-B86B-41ABCD8EB585}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes
LicenseFile=..\LICENSE
; Vestibule is a system-level kiosk product: install per-machine, elevated.
PrivilegesRequired=admin
ArchitecturesInstallIn64BitMode=x64compatible
OutputDir=Output
OutputBaseFilename=Vestibule-Setup-{#MyAppVersion}
SetupIconFile=icons\vestibule.ico
UninstallDisplayIcon={app}\bin\usher.exe
UninstallDisplayName={#MyAppName} {#MyAppVersion}
Compression=lzma2/max
SolidCompression=yes
WizardStyle=modern
; The installer is unsigned until a code-signing budget exists (see
; README "Honest limitations"). SmartScreen will warn; operators verify
; the hash from the release notes.
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "provision"; Description: "Run the kiosk provisioning wizard after setup (configure AssignedAccess, kiosk account, autologon)"; Flags: unchecked
Name: "stagenativehost"; Description: "Also register usher as Native Messaging host for THIS user (developer mode; kiosk users get it automatically at logon)"
[Files]
Source: "..\helper\target\release\usher.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
Source: "..\extension\*"; DestDir: "{app}\extension"; Flags: recursesubdirs createallsubdirs ignoreversion
Source: "..\config\*"; DestDir: "{app}\config"; Flags: ignoreversion
Source: "..\scripts\*.ps1"; DestDir: "{app}\scripts"; Flags: ignoreversion
Source: "..\scripts\*.py"; DestDir: "{app}\scripts"; Flags: ignoreversion
Source: "..\scripts\vestibule-kiosk.service.in"; DestDir: "{app}\scripts"; Flags: ignoreversion
Source: "..\docs\*"; DestDir: "{app}\docs"; Flags: recursesubdirs ignoreversion
Source: "..\DEPLOYMENT.md"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\README.md"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\QUICKSTART.md"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\LICENSE"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\Provision kiosk (run as admin)"; Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\provision-kiosk.ps1"""
Name: "{group}\Deprovision kiosk (run as admin)"; Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\deprovision-kiosk.ps1"""
Name: "{group}\Deployment guide"; Filename: "{app}\DEPLOYMENT.md"
Name: "{group}\README"; Filename: "{app}\README.md"
Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
[Run]
; Developer-mode native host registration for the installing user
; (mirrors scripts/install-native-host.ps1 but from the staged binary).
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""if (-not (Test-Path \"$env:LOCALAPPDATA\Vestibule\usher.exe\")) { New-Item -ItemType Directory -Force -Path \"$env:LOCALAPPDATA\Vestibule\" | Out-Null; Copy-Item \"{app}\bin\usher.exe\" \"$env:LOCALAPPDATA\Vestibule\usher.exe\" }"""; Tasks: stagenativehost; Flags: runhidden; Description: "Register usher for this user"
; Provisioning wizard (elevated — the installer process is elevated).
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\provision-kiosk.ps1"""; Tasks: provision; Flags: postinstall nowait skipifsilent; Description: "Run the kiosk provisioning wizard"
[UninstallDelete]
; The XPI is generated by provision-kiosk.ps1 after install.
Type: files; Name: "{app}\extension\vestibule.xpi"
Type: filesandordirs; Name: "{app}\Output"
[Code]
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
var
ResultCode: Integer;
begin
if CurUninstallStep = usUninstall then
begin
if MsgBox('Also remove kiosk lockdown configuration?' + #13#10 + #13#10 +
'This runs deprovision-kiosk.ps1: removes AssignedAccess/' +
'Shell Launcher config, autologon, the kiosk shortcut, and ' +
'Vestibule policies. Choose Yes on kiosk machines, No to ' +
'keep OS configuration (e.g. shared dev boxes).',
mbConfirmation, MB_YESNO) = IDYES then
begin
Exec(ExpandConstant('{cmd}'),
'/C powershell.exe -NoProfile -ExecutionPolicy Bypass -File "' +
ExpandConstant('{app}') + '\scripts\deprovision-kiosk.ps1" -Quiet -RemoveKioskAccount',
'', SW_SHOW, ewWaitUntilTerminated, ResultCode);
end;
end;
end;

View File

@ -0,0 +1,245 @@
# deprovision-kiosk.ps1 — remove the Vestibule kiosk session from this
# machine (Windows). The inverse of provision-kiosk.ps1, applied in
# reverse order:
#
# 1. AssignedAccess / Shell Launcher kiosk configuration
# 2. Automatic logon registry values (only if they point at the kiosk
# account — an unrelated autologon is never touched)
# 3. The Start Menu kiosk shortcut
# 4. Vestibule's merged policies.json — each directory is reset to its
# pre-Vestibule baseline (the backed-up operator file is reinstalled
# where one exists, Vestibule's generated file is deleted where one
# does not)
# 5. C:\ProgramData\Vestibule
# 6. Optionally the kiosk account and the Program Files install
#
# Exit codes:
# 0 success
# 10 success, reboot required to fully clear kiosk mode
# 2 unsupported platform / not elevated
# 5 removal failure
#
# Usage:
# powershell -ExecutionPolicy Bypass -File deprovision-kiosk.ps1 `
# -RemoveKioskAccount -RemoveInstall
param(
[string]$KioskUser = "VestibuleKiosk",
[string]$InstallRoot = "C:\Program Files\Vestibule",
[switch]$RemoveKioskAccount,
[switch]$RemoveInstall,
[switch]$Quiet,
[switch]$Check,
[switch]$Restart
)
$ErrorActionPreference = "Stop"
$AUMID = "Vestibule.Kiosk"
function Fail([int]$code, [string]$msg) {
Write-Host ""
Write-Host "ERROR: $msg" -ForegroundColor Red
Write-Host " exiting with code $code"
exit $code
}
function Info($msg) { Write-Host $msg }
function Ok($msg) { Write-Host " [ok] $msg" -ForegroundColor Green }
function Step($msg) { Write-Host ""; Write-Host "==> $msg" -ForegroundColor Cyan }
if ($env:OS -ne "Windows_NT") {
Fail 2 "Windows-only; on Linux use scripts/deprovision-kiosk.sh"
}
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
if (-not ([Security.Principal.WindowsPrincipal]$id).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
Fail 2 "must run elevated"
}
$rebootNeeded = $false
# ─── Discover current state ───────────────────────────────────────────
$lnkPath = Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\Vestibule Kiosk.lnk"
$wl = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
$autologonUser = (Get-ItemProperty $wl -ErrorAction SilentlyContinue).DefaultUserName
$autologonOn = (Get-ItemProperty $wl -ErrorAction SilentlyContinue).AutoAdminLogon -eq "1"
$weslPresent = [bool](Get-CimClass -Namespace "root\standardcimv2\embedded" `
-ClassName "WESL_UserSetting" -ErrorAction SilentlyContinue)
$accountPresent = [bool](Get-LocalUser -Name $KioskUser -ErrorAction SilentlyContinue)
Step "Current state"
Info "kiosk account : $(if ($accountPresent) {'present'} else {'absent'})"
Info "autologon : $(if ($autologonOn) {"on (user: $autologonUser)"} else {'off'})"
Info "Shell Launcher WMI : $(if ($weslPresent) {'present'} else {'absent'})"
Info "kiosk shortcut : $(if (Test-Path $lnkPath) {'present'} else {'absent'})"
if ($Check) {
Info "(check only — no changes made)"
exit 0
}
if (-not $Quiet) {
$answer = Read-Host "Proceed with removal? [y/N]"
if ($answer -notmatch '^[Yy]') { Info "aborted"; exit 0 }
}
# ─── 1. Lockdown removal ──────────────────────────────────────────────
Step "Remove kiosk lockdown"
if ($weslPresent) {
$wesl = [wmiclass]"\\.\root\standardcimv2\embedded:WESL_UserSetting"
$removed = $false
foreach ($call in @(
{ $wesl.RemoveCustomShell($KioskUser) },
{ $wesl.RemoveCustomShell() }
)) {
try {
$r = & $call
if ($r.ReturnValue -eq 0) { $removed = $true; break }
} catch { }
}
if ($removed) { Ok "Shell Launcher custom shell removed for '$KioskUser'" }
else { Info "Shell Launcher: no custom shell to remove (or already clean)" }
}
# AssignedAccess removal: call any Remove* method the bridge exposes on
# this build. Bridge method availability varies by build; on some builds
# the only clean removal is deleting the kiosk account (which orphans and
# neutralizes the config) — handled below, and reported honestly.
try {
$class = Get-CimClass -Namespace "root\cimv2\mdm\dmmap" -ClassName "MDM_AssignedAccess" `
-ErrorAction Stop
$removeMethods = @($class.CimClassMethods | Where-Object { $_.Name -like "Remove*" })
foreach ($m in $removeMethods) {
try {
$instances = @(Get-CimInstance -Namespace "root\cimv2\mdm\dmmap" `
-ClassName "MDM_AssignedAccess" -ErrorAction Stop)
foreach ($inst in $instances) {
$res = Invoke-CimMethod -InputObject $inst -MethodName $m.Name -ErrorAction Stop
if ($res.ReturnValue -eq 0) {
Ok "AssignedAccess cleared via $($m.Name)"
}
}
$rebootNeeded = $true
} catch {
Info "bridge method $($m.Name) present but call failed: $($_.Exception.Message)"
}
}
} catch {
Info "MDM bridge not reachable — AssignedAccess config (if any) is cleared by removing the kiosk account below"
if ($accountPresent) { $rebootNeeded = $true }
}
# ─── 2. Automatic logon ───────────────────────────────────────────────
Step "Remove automatic logon"
if ($autologonOn -and $autologonUser -eq $KioskUser) {
Set-ItemProperty $wl -Name "AutoAdminLogon" -Value "0" -Type String
Remove-ItemProperty $wl -Name "DefaultUserName" -ErrorAction SilentlyContinue
Remove-ItemProperty $wl -Name "DefaultDomainName" -ErrorAction SilentlyContinue
Remove-ItemProperty $wl -Name "DefaultPassword" -ErrorAction SilentlyContinue
Ok "autologon disabled and credentials cleared"
} elseif ($autologonOn) {
Info "autologon is configured for '$autologonUser' (not ours) — left untouched"
} else {
Info "autologon already off"
}
# ─── 3. Shortcut ──────────────────────────────────────────────────────
Step "Remove kiosk shortcut"
if (Test-Path $lnkPath) {
Remove-Item $lnkPath -Force
Ok "removed $lnkPath"
} else {
Info "already absent"
}
# ─── 4. Policies ──────────────────────────────────────────────────────
Step "Reset browser policies to baseline"
# LibreWolf and Firefox share the distribution/policies.json mechanism;
# cover every install location for both browsers. Step-down per
# directory: backup present -> reinstall it; ours -> delete; else leave.
foreach ($browserExe in @(
"${env:ProgramFiles}\LibreWolf\librewolf.exe",
"${env:ProgramFiles(x86)}\LibreWolf\librewolf.exe",
"${env:LOCALAPPDATA}\Programs\LibreWolf\librewolf.exe",
"${env:ProgramFiles}\Mozilla Firefox\firefox.exe",
"${env:ProgramFiles}\Mozilla Firefox ESR\firefox.exe",
"${env:ProgramFiles(x86)}\Mozilla Firefox\firefox.exe",
"${env:ProgramFiles(x86)}\Mozilla Firefox ESR\firefox.exe",
"${env:LOCALAPPDATA}\Mozilla Firefox\firefox.exe")) {
if (-not (Test-Path $browserExe)) { continue }
$distDir = Join-Path (Split-Path -Parent $browserExe) "distribution"
$policiesPath = Join-Path $distDir "policies.json"
$bak = "$policiesPath.vestibule-bak"
if (Test-Path $bak) {
Move-Item $bak $policiesPath -Force
Ok "baseline policies.json reinstalled in $distDir (from vestibule-bak)"
} elseif (Test-Path $policiesPath) {
$content = Get-Content $policiesPath -Raw
if ($content -match "vestibule@vestibule\.kiosk") {
Remove-Item $policiesPath -Force
Ok "Vestibule-generated policies.json deleted in $distDir (no baseline on record)"
} else {
Info "policies.json in $distDir exists but is not ours — left untouched"
}
}
# librewolf.overrides.cfg is LibreWolf-only; harmless no-op for Firefox.
$cfg = Join-Path (Split-Path -Parent $browserExe) "librewolf.overrides.cfg"
if (Test-Path $cfg) { Remove-Item $cfg -Force; Ok "removed librewolf.overrides.cfg" }
}
# ─── 5. ProgramData ───────────────────────────────────────────────────
Step "Remove deployment config"
$pdDir = Join-Path $env:ProgramData "Vestibule"
if (Test-Path $pdDir) {
Remove-Item $pdDir -Recurse -Force
Ok "removed $pdDir"
} else {
Info "already absent"
}
# ─── 6. Optional: account + install tree ──────────────────────────────
if ($RemoveKioskAccount) {
Step "Remove kiosk account"
if ($accountPresent) {
# Ensure the account is not mid-logon (fast user switching) first.
try {
Remove-LocalUser -Name $KioskUser -ErrorAction Stop
Ok "removed local account '$KioskUser'"
$rebootNeeded = $true
} catch {
Fail 5 "could not remove account: $($_.Exception.Message)"
}
} else {
Info "account '$KioskUser' already absent"
}
}
if ($RemoveInstall) {
Step "Remove install tree"
if (Test-Path $InstallRoot) {
Remove-Item $InstallRoot -Recurse -Force
Ok "removed $InstallRoot"
} else {
Info "already absent"
}
}
# ─── Summary ──────────────────────────────────────────────────────────
Step "Deprovisioning complete"
Info "verify in Settings > Accounts > Other users that no kiosk entry remains"
if ($rebootNeeded) {
Info "reboot recommended to fully clear kiosk mode."
if ($Restart) {
shutdown.exe /r /t 10 /c "Vestibule kiosk removal"
exit 0
}
exit 10
}
exit 0

237
scripts/deprovision-kiosk.sh Executable file
View File

@ -0,0 +1,237 @@
#!/bin/sh
# deprovision-kiosk.sh — remove the Vestibule kiosk session from this
# machine (Linux). The inverse of provision-kiosk.sh, applied in reverse
# order:
#
# 1. The vestibule-kiosk systemd unit (stop, disable, delete)
# 2. /usr/local/bin/vestibule-kiosk-launch and /etc/vestibule
# 3. Vestibule's merged policies.json everywhere it was deployed —
# LibreWolf native/Flatpak, Firefox native (/etc/firefox), and the
# kiosk-home locations for Firefox Flatpak/snap. Each directory is
# reset to its pre-Vestibule baseline: the backed-up operator file
# is reinstalled where one exists, Vestibule's generated file is
# deleted where one does not.
# 4. The usher Native Messaging manifests from the kiosk user's home
# (all five locations: LibreWolf + Firefox, native + Flatpak + snap)
# 5. The sandbox-visible XPI copies (Flatpak/snap flavors)
# 6. Optionally: the kiosk account, /opt/vestibule, /usr/local/bin/usher
#
# The regular getty/console login is never modified, so removing the
# unit is all it takes to return the machine to a stock boot.
#
# Exit codes:
# 0 success
# 2 not root / no systemd
# 5 removal failure
#
# Usage:
# sudo ./deprovision-kiosk.sh # keep account + /opt
# sudo ./deprovision-kiosk.sh --remove-user --remove-opt --remove-usher
#
# POSIX sh — no bashisms.
set -eu
KIOSK_USER="vestibule-kiosk"
LW_FLATPAK_APP="io.gitlab.librewolf-community"
FF_FLATPAK_APP="org.mozilla.firefox"
OPT_ROOT="/opt/vestibule"
UNIT_DST="/etc/systemd/system/vestibule-kiosk.service"
LAUNCH_DST="/usr/local/bin/vestibule-kiosk-launch"
ENV_DIR="/etc/vestibule"
USHER_DST="/usr/local/bin/usher"
REMOVE_USER=0
REMOVE_OPT=0
REMOVE_USHER=0
ASSUME_YES=0
CHECK=0
usage() {
cat <<'EOF'
deprovision-kiosk.sh — remove the Vestibule kiosk session from this machine
Options:
--kiosk-user NAME Kiosk account name (default: vestibule-kiosk)
--remove-user Also delete the kiosk account and its home directory
--remove-opt Also delete /opt/vestibule (staged files, XPI, docs)
--remove-usher Also delete /usr/local/bin/usher
--yes Skip the confirmation prompt
--check Show what would be removed; change nothing
-h, --help This text
EOF
}
die() {
code="$1"; msg="$2"
echo ""
echo "ERROR: ${msg}" >&2
echo " exiting with code ${code}" >&2
exit "${code}"
}
info() { echo "$1"; }
ok() { echo " [ok] $1"; }
step() { echo ""; echo "==> $1"; }
while [ $# -gt 0 ]; do
case "$1" in
--kiosk-user) KIOSK_USER="$2"; shift 2 ;;
--remove-user) REMOVE_USER=1; shift ;;
--remove-opt) REMOVE_OPT=1; shift ;;
--remove-usher) REMOVE_USHER=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
--check) CHECK=1; shift ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; die 5 "unknown option: $1" ;;
esac
done
if [ ! -d /run/systemd/system ]; then
die 2 "systemd is not the running init system"
fi
# ─── Discover current state ───────────────────────────────────────────
UNIT_ACTIVE=0
if systemctl is-active vestibule-kiosk.service >/dev/null 2>&1; then
UNIT_ACTIVE=1
fi
UNIT_ENABLED=0
if systemctl is-enabled vestibule-kiosk.service >/dev/null 2>&1; then
UNIT_ENABLED=1
fi
KIOSK_HOME=""
if id -u "${KIOSK_USER}" >/dev/null 2>&1; then
KIOSK_HOME=$(getent passwd "${KIOSK_USER}" | cut -d: -f6)
fi
step "Current state"
info "unit : $(if [ -f "${UNIT_DST}" ]; then echo present; else echo absent; fi) (active: ${UNIT_ACTIVE}, enabled: ${UNIT_ENABLED})"
info "launcher : $(if [ -x "${LAUNCH_DST}" ]; then echo present; else echo absent; fi)"
info "kiosk.env : $(if [ -f "${ENV_DIR}/kiosk.env" ]; then echo present; else echo absent; fi)"
info "kiosk account : $(if [ -n "${KIOSK_HOME}" ]; then echo "present (home: ${KIOSK_HOME})"; else echo absent; fi)"
info "opt root : $(if [ -d "${OPT_ROOT}" ]; then echo present; else echo absent; fi)"
if [ "${CHECK}" -eq 1 ]; then
info "(check only — no changes made)"
exit 0
fi
[ "$(id -u)" -eq 0 ] || die 2 "must run as root (sudo)"
if [ "${ASSUME_YES}" -eq 0 ]; then
echo ""
printf "Proceed with removal? [y/N] "
read -r answer
case "${answer}" in
y|Y|yes|YES) ;;
*) info "aborted"; exit 0 ;;
esac
fi
# ─── 1. systemd unit ──────────────────────────────────────────────────
step "Remove kiosk session"
if [ -f "${UNIT_DST}" ]; then
systemctl stop vestibule-kiosk.service 2>/dev/null || true
systemctl disable vestibule-kiosk.service 2>/dev/null || true
rm -f "${UNIT_DST}"
systemctl daemon-reload
ok "unit stopped, disabled, and removed"
else
info "unit already absent"
fi
# ─── 2. Launcher + env ────────────────────────────────────────────────
step "Remove launcher + session config"
if [ -x "${LAUNCH_DST}" ]; then
rm -f "${LAUNCH_DST}"
ok "removed ${LAUNCH_DST}"
fi
if [ -d "${ENV_DIR}" ]; then
rm -rf "${ENV_DIR}"
ok "removed ${ENV_DIR}"
fi
# ─── 3. Policies ──────────────────────────────────────────────────────
step "Reset browser policies to baseline"
reset_policy_dir() {
# Step-down, one decision per line:
# absent directory -> nothing to do
# baseline backup present -> reinstall it over Vestibule's merge
# Vestibule-generated file -> delete it (no pre-Vestibule baseline)
_dir="$1"
if [ ! -d "${_dir}" ]; then
return
fi
_dst="${_dir}/policies.json"
_bak="${_dst}.vestibule-bak"
if [ -f "${_bak}" ]; then
mv "${_bak}" "${_dst}"
ok "baseline policies.json reinstalled in ${_dir}"
elif [ -f "${_dst}" ] && grep -q "vestibule@vestibule\.kiosk" "${_dst}" 2>/dev/null; then
rm -f "${_dst}"
ok "Vestibule-generated policies.json deleted in ${_dir} (no baseline on record)"
fi
}
reset_policy_dir /usr/lib/librewolf/distribution
reset_policy_dir /usr/share/librewolf/distribution
reset_policy_dir /opt/librewolf/distribution
reset_policy_dir "/var/lib/flatpak/app/${LW_FLATPAK_APP}/current/active/files/librewolf/distribution"
reset_policy_dir /etc/firefox/policies
if [ -n "${KIOSK_HOME}" ]; then
reset_policy_dir "${KIOSK_HOME}/.var/app/${FF_FLATPAK_APP}/.mozilla/policies"
reset_policy_dir "${KIOSK_HOME}/snap/firefox/common/.mozilla/policies"
fi
# ─── 4. Native Messaging manifests ────────────────────────────────────
step "Remove Native Messaging manifests"
if [ -n "${KIOSK_HOME}" ]; then
rm -f "${KIOSK_HOME}/.librewolf/native-messaging-hosts/com.vestibule.usher.json"
rm -f "${KIOSK_HOME}/.mozilla/native-messaging-hosts/com.vestibule.usher.json"
rm -f "${KIOSK_HOME}/.var/app/${LW_FLATPAK_APP}/.librewolf/native-messaging-hosts/com.vestibule.usher.json"
rm -f "${KIOSK_HOME}/.var/app/${FF_FLATPAK_APP}/.mozilla/native-messaging-hosts/com.vestibule.usher.json"
rm -f "${KIOSK_HOME}/snap/firefox/common/.mozilla/native-messaging-hosts/com.vestibule.usher.json"
# XPI copies staged for sandbox-visible installs (flatpak/snap).
rm -f "${KIOSK_HOME}/.var/app/${LW_FLATPAK_APP}/vestibule.xpi"
rm -f "${KIOSK_HOME}/.var/app/${FF_FLATPAK_APP}/vestibule.xpi"
rm -f "${KIOSK_HOME}/snap/firefox/common/vestibule.xpi"
ok "manifests + sandbox XPI copies removed from ${KIOSK_HOME}"
else
info "kiosk account already absent — nothing to clean"
fi
# ─── 5. Optional removals ─────────────────────────────────────────────
if [ "${REMOVE_USHER}" -eq 1 ]; then
step "Remove usher binary"
if [ -f "${USHER_DST}" ]; then
rm -f "${USHER_DST}"
ok "removed ${USHER_DST}"
fi
fi
if [ "${REMOVE_OPT}" -eq 1 ]; then
step "Remove ${OPT_ROOT}"
if [ -d "${OPT_ROOT}" ]; then
rm -rf "${OPT_ROOT}"
ok "removed ${OPT_ROOT}"
fi
fi
if [ "${REMOVE_USER}" -eq 1 ]; then
step "Remove kiosk account"
if [ -n "${KIOSK_HOME}" ]; then
userdel -r "${KIOSK_USER}" || die 5 "userdel failed"
ok "removed account '${KIOSK_USER}' and its home"
else
info "account already absent"
fi
fi
step "Deprovisioning complete"
info "next boot returns to the normal login prompt"
exit 0

50
scripts/install-native-host.ps1 Executable file
View File

@ -0,0 +1,50 @@
# Registers usher as a Firefox/LibreWolf native messaging host on Windows
# for the current user. No admin elevation required — installs to
# $env:LOCALAPPDATA\Vestibule and registers under HKCU.
#
# After running this script, restart LibreWolf and load the extension from
# about:debugging to verify usher connects (check the Browser Console:
# Ctrl+Shift+J).
$ErrorActionPreference = "Stop"
$hostName = "com.vestibule.usher"
$extId = "vestibule@vestibule.kiosk"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$projectRoot = Split-Path -Parent $scriptDir
$helperBin = Join-Path $projectRoot "helper\target\release\usher.exe"
if (-not (Test-Path $helperBin)) {
Write-Error "usher.exe not found at $helperBin"
Write-Host " Build it first: cd helper; cargo build --release"
exit 1
}
# Install location: %LOCALAPPDATA%\Vestibule (no admin needed)
$installDir = Join-Path $env:LOCALAPPDATA "Vestibule"
New-Item -ItemType Directory -Force -Path $installDir | Out-Null
$destExe = Join-Path $installDir "usher.exe"
Copy-Item $helperBin $destExe -Force
Write-Host "installed binary: $destExe"
# Generate manifest JSON with the correct path
$manifest = @{
name = $hostName
description = "Vestibule native helper"
path = $destExe
type = "stdio"
allowed_extensions = @($extId)
}
$manifestPath = Join-Path $installDir "$hostName.json"
$manifest | ConvertTo-Json -Depth 5 | Set-Content $manifestPath -Encoding UTF8
Write-Host "manifest: $manifestPath"
# Register in registry under HKCU (no admin needed)
# LibreWolf reads from HKCU\Software\Mozilla\NativeMessagingHosts\<name>
$regKey = "HKCU:\Software\Mozilla\NativeMessagingHosts\$hostName"
if (-not (Test-Path $regKey)) { New-Item -Path $regKey -Force | Out-Null }
Set-ItemProperty -Path $regKey -Name "(default)" -Value $manifestPath
Write-Host "registry: $regKey -> $manifestPath"
Write-Host ""
Write-Host "Done. Restart LibreWolf, load the extension from about:debugging,"
Write-Host "and check the Browser Console (Ctrl+Shift+J) for '[vestibule] usher hello'."

68
scripts/install-native-host.sh Executable file
View File

@ -0,0 +1,68 @@
#!/bin/sh
# Registers usher as a Firefox/LibreWolf native messaging host for the
# current user on Linux. Installs to ~/.local/bin and registers the
# manifest in ~/.librewolf/native-messaging-hosts and
# ~/.mozilla/native-messaging-hosts. No sudo required.
#
# POSIX sh — no bashisms. Runs on any minimal Linux base.
# After running, restart LibreWolf and load the extension from
# about:debugging to verify usher connects (check the Browser Console:
# Ctrl+Shift+J).
set -eu
HOST_NAME="com.vestibule.usher"
EXT_ID="vestibule@vestibule.kiosk"
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
PROJECT_ROOT=$(cd "${SCRIPT_DIR}/.." && pwd)
HELPER_BIN="${PROJECT_ROOT}/helper/target/release/usher"
if [ ! -x "${HELPER_BIN}" ]; then
echo "error: usher binary not found at ${HELPER_BIN}" >&2
echo " build it first: (cd ${PROJECT_ROOT}/helper && cargo build --release)" >&2
exit 1
fi
# Install binary to ~/.local/bin (no sudo required)
TARGET_BIN="${HOME}/.local/bin/usher"
BIN_DIR=$(dirname "${TARGET_BIN}")
mkdir -p "${BIN_DIR}"
install -m 0755 "${HELPER_BIN}" "${TARGET_BIN}"
echo "installed binary: ${TARGET_BIN}"
# Substitute the absolute install path into the manifest template.
# Paths travel as argv, never interpolated into source (SEI CERT
# IDS03-ENUM; a path containing a quote must not alter the program).
MANIFEST_TEMPLATE="${PROJECT_ROOT}/config/com.vestibule.usher.linux.json"
GENERATED_MANIFEST=$(mktemp)
trap 'rm -f "${GENERATED_MANIFEST}"' EXIT INT TERM
python3 - "${MANIFEST_TEMPLATE}" "${GENERATED_MANIFEST}" "${TARGET_BIN}" <<'PYEOF'
import json
import sys
template, generated, target_bin = sys.argv[1:4]
with open(template) as f:
manifest = json.load(f)
manifest["path"] = target_bin
with open(generated, "w") as f:
json.dump(manifest, f, indent=2)
PYEOF
# Install manifest into both LibreWolf and Mozilla native-messaging-hosts
# directories. LibreWolf reads from ~/.librewolf, vanilla Firefox from
# ~/.mozilla. Covering both is harmless and future-proof.
for DIR in \
"${HOME}/.librewolf/native-messaging-hosts" \
"${HOME}/.mozilla/native-messaging-hosts"; do
mkdir -p "${DIR}"
install -m 0644 "${GENERATED_MANIFEST}" "${DIR}/${HOST_NAME}.json"
echo "installed manifest: ${DIR}/${HOST_NAME}.json"
done
echo
echo "Done. Restart LibreWolf, load the extension from about:debugging,"
echo "and check the Browser Console (Ctrl+Shift+J) for '[vestibule] usher hello'."

View File

@ -0,0 +1,36 @@
#!/bin/sh
# Installs the usher systemd user service for crash recovery.
# This is optional — in normal operation, LibreWolf manages usher's
# lifecycle via Native Messaging. This service ensures usher is
# available for pre-LibreWolf power monitoring and restarts on crash.
#
# POSIX sh. No sudo required (user-level systemd unit).
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
PROJECT_ROOT=$(cd "${SCRIPT_DIR}/.." && pwd)
SERVICE_SRC="${PROJECT_ROOT}/scripts/vestibule-usher.service"
SERVICE_DST="${HOME}/.config/systemd/user/vestibule-usher.service"
if [ ! -x "${HOME}/.local/bin/usher" ]; then
echo "error: usher not installed at ~/.local/bin/usher" >&2
echo " run install-native-host.sh first" >&2
exit 1
fi
mkdir -p "$(dirname "${SERVICE_DST}")"
# Substitute %h with the actual home directory (systemd user units
# support %h natively, but some older versions don't — do it explicitly)
sed "s|%h|${HOME}|g" "${SERVICE_SRC}" > "${SERVICE_DST}"
echo "installed: ${SERVICE_DST}"
systemctl --user daemon-reload
systemctl --user enable vestibule-usher.service
echo "enabled: vestibule-usher.service"
echo
echo "To start now: systemctl --user start vestibule-usher.service"
echo "To check: systemctl --user status vestibule-usher.service"
echo "To view logs: journalctl --user -u vestibule-usher.service -f"

44
scripts/install-usher-task.ps1 Executable file
View File

@ -0,0 +1,44 @@
# Installs a Windows Scheduled Task that starts usher at logon and
# restarts on failure. This is optional — in normal operation, LibreWolf
# manages usher's lifecycle via Native Messaging. This task ensures
# usher is available for pre-LibreWolf power monitoring.
$ErrorActionPreference = "Stop"
$taskName = "VestibuleUsher"
$usherPath = Join-Path $env:LOCALAPPDATA "Vestibule\usher.exe"
if (-not (Test-Path $usherPath)) {
Write-Error "usher.exe not found at $usherPath"
Write-Host " Run install-native-host.ps1 first"
exit 1
}
# Remove existing task if present (idempotent)
$existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
if ($existing) {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
Write-Host "removed existing task: $taskName"
}
$action = New-ScheduledTaskAction -Execute $usherPath
$trigger = New-ScheduledTaskTrigger -AtLogOn
$settings = New-ScheduledTaskSettingsSet `
-RestartCount 3 `
-RestartInterval (New-TimeSpan -Minutes 1) `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries
Register-ScheduledTask `
-TaskName $taskName `
-Action $action `
-Trigger $trigger `
-Settings $settings `
-Description "Vestibule usher — kiosk unlock helper + power monitor" `
-RunLevel Limited
Write-Host "installed scheduled task: $taskName"
Write-Host " starts at logon, restarts up to 3x on failure"
Write-Host ""
Write-Host "To start now: Start-ScheduledTask -TaskName $taskName"
Write-Host "To check: Get-ScheduledTask -TaskName $taskName | Get-ScheduledTaskInfo"
Write-Host "To view logs: Event Viewer > Windows Logs > Application"

225
scripts/kiosk-launch.ps1 Normal file
View File

@ -0,0 +1,225 @@
# kiosk-launch.ps1 — per-user bootstrap + kiosk browser supervisor (Windows)
#
# This is the process Windows launches in AssignedAccess single-app kiosk
# mode (the "Vestibule Kiosk" Start Menu shortcut points here). It runs as
# the kiosk user, NOT as an administrator, and must never prompt.
#
# Responsibilities, in order:
# 1. Read deployment config (C:\ProgramData\Vestibule\kiosk.env)
# 2. Locate the browser (LibreWolf or Firefox, per kiosk.env)
# 3. Ensure usher.exe is installed per-user (%LOCALAPPDATA%\Vestibule)
# 4. Ensure the Native Messaging host is registered under HKCU
# 5. Ensure the dedicated vestibule-profile exists
# 6. Launch the browser --kiosk, wait, relaunch on exit (crash recovery)
#
# Everything is logged to %LOCALAPPDATA%\Vestibule\kiosk-launch.log so a
# headless kiosk can be diagnosed after the fact. The log rotates at
# 512 KiB so a crash-loop cannot fill the disk.
#
# Why per-user bootstrap instead of provisioning-time HKU writes: the
# kiosk account's profile (and HKCU hive) does not exist until first
# logon, and AssignedAccess kiosk sessions never run RunOnce entries.
# Registering from inside the kiosk session is the only path that needs
# no admin rights — it matches the project's no-elevation philosophy.
#
# Params:
# -InstallRoot Vestibule install dir (default: C:\Program Files\Vestibule)
# -MaxRestarts Browser relaunches before this launcher exits and lets
# AssignedAccess restart it (default: 50)
param(
[string]$InstallRoot = "C:\Program Files\Vestibule",
[int]$MaxRestarts = 50
)
$ErrorActionPreference = "Continue" # kiosk must never die on a soft error
$hostName = "com.vestibule.usher"
$extId = "vestibule@vestibule.kiosk"
$profileName = "vestibule-profile"
$logMaxBytes = 524288
# ─── Logging ──────────────────────────────────────────────────────────
function Get-LogPath {
$dir = Join-Path $env:LOCALAPPDATA "Vestibule"
New-Item -ItemType Directory -Force -Path $dir | Out-Null
return (Join-Path $dir "kiosk-launch.log")
}
function Log($msg) {
$line = "{0} {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $msg
Write-Host $line
try {
$path = Get-LogPath
if ((Get-Item $path -ErrorAction SilentlyContinue).Length -gt $logMaxBytes) {
Move-Item $path "$path.old" -Force
}
Add-Content -Path $path -Value $line -Encoding UTF8
} catch { }
}
# ─── Config ───────────────────────────────────────────────────────────
function Read-KioskEnv {
# KEY=VALUE lines from C:\ProgramData\Vestibule\kiosk.env, written by
# provision-kiosk.ps1. Operators may edit it to change the home URL
# without re-running provisioning.
$env_ = @{}
$envFile = Join-Path $env:ProgramData "Vestibule\kiosk.env"
if (Test-Path $envFile) {
foreach ($line in Get-Content $envFile) {
if ($line -match '^\s*([A-Za-z0-9_]+)\s*=\s*(.*)\s*$') {
$env_[$matches[1]] = $matches[2]
}
}
}
return $env_
}
# ─── Browser discovery (LibreWolf or Firefox) ───────────────────────
$librewolfSearchPaths = @(
"${env:ProgramFiles}\LibreWolf\librewolf.exe",
"${env:ProgramFiles(x86)}\LibreWolf\librewolf.exe",
"${env:LOCALAPPDATA}\Programs\LibreWolf\librewolf.exe"
)
$firefoxSearchPaths = @(
"${env:ProgramFiles}\Mozilla Firefox\firefox.exe",
"${env:ProgramFiles}\Mozilla Firefox ESR\firefox.exe",
"${env:ProgramFiles(x86)}\Mozilla Firefox\firefox.exe",
"${env:ProgramFiles(x86)}\Mozilla Firefox ESR\firefox.exe",
"${env:LOCALAPPDATA}\Mozilla Firefox\firefox.exe"
)
function Find-InPaths($paths, $exeName) {
$hit = $paths | Where-Object { Test-Path $_ } | Select-Object -First 1
if ($hit) { return $hit }
# Registry App Paths step-down: per-machine first, then per-user.
$regRoots = @("HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths",
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths")
return $regRoots |
ForEach-Object { (Get-ItemProperty (Join-Path $_ $exeName) -ErrorAction SilentlyContinue)."(default)" } |
Where-Object { $_ -and (Test-Path $_) } |
Select-Object -First 1
}
function Find-LibreWolf { return Find-InPaths $librewolfSearchPaths "librewolf.exe" }
function Find-Firefox { return Find-InPaths $firefoxSearchPaths "firefox.exe" }
function Resolve-Browser([string]$preference) {
# Returns @{ Kind = ...; Exe = ... } or $null. The preference comes
# from kiosk.env (VESTIBULE_BROWSER); "auto" steps down LibreWolf ->
# Firefox — same order as provision-kiosk.ps1.
$lw = Find-LibreWolf
$ff = Find-Firefox
switch ($preference) {
"firefox" { if ($ff) { return @{ Kind = "firefox"; Exe = $ff } } }
"librewolf" { if ($lw) { return @{ Kind = "librewolf"; Exe = $lw } } }
default {
if ($lw) { return @{ Kind = "librewolf"; Exe = $lw } }
if ($ff) { return @{ Kind = "firefox"; Exe = $ff } }
}
}
# Preference not satisfiable: step down to whatever exists.
if ($lw) { return @{ Kind = "librewolf"; Exe = $lw } }
if ($ff) { return @{ Kind = "firefox"; Exe = $ff } }
return $null
}
# ─── Per-user bootstrap steps ─────────────────────────────────────────
function Ensure-Usher {
$src = Join-Path $InstallRoot "bin\usher.exe"
$dir = Join-Path $env:LOCALAPPDATA "Vestibule"
$dst = Join-Path $dir "usher.exe"
if (-not (Test-Path $src)) {
Log "usher source missing: $src — native messaging will not work"
return $false
}
if (-not (Test-Path $dst)) {
New-Item -ItemType Directory -Force -Path $dir | Out-Null
Copy-Item $src $dst -Force
Log "installed usher per-user: $dst"
}
return $true
}
function Ensure-NativeHostRegistration([string]$usherPath) {
$manifestPath = Join-Path $env:LOCALAPPDATA "Vestibule\$hostName.json"
$dir = Split-Path -Parent $manifestPath
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Force -Path $dir | Out-Null
}
if (-not (Test-Path $manifestPath)) {
$manifest = @{
name = $hostName
description = "Vestibule native helper"
path = $usherPath
type = "stdio"
allowed_extensions = @($extId)
}
$manifest | ConvertTo-Json -Depth 5 | Set-Content $manifestPath -Encoding UTF8
Log "wrote native host manifest: $manifestPath"
}
$regKey = "HKCU:\Software\Mozilla\NativeMessagingHosts\$hostName"
if (-not (Test-Path $regKey)) {
New-Item -Path $regKey -Force | Out-Null
}
$current = (Get-ItemProperty $regKey -ErrorAction SilentlyContinue)."(default)"
if ($current -ne $manifestPath) {
Set-ItemProperty -Path $regKey -Name "(default)" -Value $manifestPath
Log "registered native host: $regKey -> $manifestPath"
}
}
function Ensure-Profile([string]$browserExe) {
# -CreateProfile is idempotent: an existing profile is left untouched.
# It writes to profiles.ini in the kiosk user's own profile dir.
& $browserExe -CreateProfile $profileName 2>$null | Out-Null
Log "ensured profile: $profileName"
}
# ─── Main ─────────────────────────────────────────────────────────────
Log "=== vestibule kiosk-launch starting (pid $PID) ==="
$config = Read-KioskEnv
$homeUrl = $config["VESTIBULE_HOME_URL"]
if (-not $homeUrl) { $homeUrl = "about:blank" }
$browserPref = $config["VESTIBULE_BROWSER"]
if (-not $browserPref) { $browserPref = "auto" }
$browser = Resolve-Browser $browserPref
if (-not $browser) {
Log "FATAL: no LibreWolf or Firefox found in any known location"
Start-Sleep -Seconds 30 # let AssignedAccess's watchdog see us exit
exit 3
}
$browserExe = $browser.Exe
Log "browser ($($browser.Kind)): $browserExe"
if (Ensure-Usher) {
Ensure-NativeHostRegistration (Join-Path $env:LOCALAPPDATA "Vestibule\usher.exe")
}
Ensure-Profile $browserExe
Log "home url: $homeUrl"
$restarts = 0
while ($true) {
# Supervision loop: relaunch the browser until the restart budget is
# spent, then hand the job to AssignedAccess's own watchdog.
Log "launching $($browser.Kind) (kiosk mode, restart #$restarts)"
try {
$proc = Start-Process -FilePath $browserExe `
-ArgumentList @("--kiosk", "-P", $profileName, "-no-remote", $homeUrl) `
-PassThru -Wait
Log "$($browser.Kind) exited with code $($proc.ExitCode)"
} catch {
Log "launch failed: $($_.Exception.Message)"
}
$restarts++
if ($restarts -gt $MaxRestarts) {
Log "restart budget exhausted — exiting so AssignedAccess takes over"
exit 0
}
Start-Sleep -Seconds 5
}

151
scripts/make-icons.py Normal file
View File

@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Generate the Vestibule icon set (SVG + PNG sizes + Windows .ico).
Design: a doorway arch (a vestibule is an entrance hall) in cyan on a
deep-slate rounded square, with an amber visitor dot — the kiosk is the
doorway, the public is the visitor.
Outputs (into <repo>/packaging/icons/):
vestibule.svg scalable source (Flatpak/scalable)
vestibule-<N>.png 16..256 px hicolor sizes (Flatpak)
vestibule.ico multi-size Windows icon (Inno Setup)
Usage: python3 scripts/make-icons.py
"""
import os
from PIL import Image, ImageDraw
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(REPO, "packaging", "icons")
BG = (15, 23, 42, 255) # slate-900
BG_EDGE = (30, 41, 59, 255) # slate-800 rim
CYAN = (56, 189, 248, 255) # sky-400 arch
AMBER = (251, 191, 36, 255) # amber-400 visitor
SIZES = [16, 32, 48, 64, 128, 256]
SIMPLE_SIZE_MAX = 32 # at or below: legibility beats detail
def geometry(size: int) -> dict:
"""Scale table: every dimension derives from the canvas size."""
s = size
inset_x = int(s * 0.30)
arch_w = s - 2 * inset_x
return {
"s": s,
"radius": max(2, int(s * 0.18)),
"rim_w": max(1, int(s * 0.023)),
"stroke": max(2, int(s * 0.075)),
"inset_x": inset_x,
"arch_top": int(s * 0.18),
"leg_bottom": int(s * 0.82),
"arch_w": arch_w,
"arch_cx": s // 2,
"arch_r": arch_w // 2,
"dot_r": max(2, int(s * 0.085)),
}
def draw_arch(d, g, stroke, with_threshold=True):
"""Doorway arch (dome + legs) + visitor dot; threshold optional
(small canvases drop it for legibility)."""
s, x, w = g["s"], g["inset_x"], g["arch_w"]
dome_bottom = g["arch_top"] + g["arch_r"]
# Dome (top half circle outline).
d.arc([x, g["arch_top"], x + w, g["arch_top"] + w],
start=180, end=360, fill=CYAN, width=stroke)
# Legs.
d.line([x, dome_bottom, x, g["leg_bottom"]], fill=CYAN, width=stroke)
d.line([s - x, dome_bottom, s - x, g["leg_bottom"]], fill=CYAN, width=stroke)
if with_threshold:
d.line([x - int(s * 0.04), g["leg_bottom"], s - x + int(s * 0.04), g["leg_bottom"]],
fill=CYAN, width=stroke)
# Visitor: amber dot on the threshold, centered.
dot_cy = g["leg_bottom"] - int(s * 0.02) - g["dot_r"]
d.ellipse([g["arch_cx"] - g["dot_r"], dot_cy - g["dot_r"],
g["arch_cx"] + g["dot_r"], dot_cy + g["dot_r"]], fill=AMBER)
def draw_full_icon(size: int) -> Image.Image:
"""Full-detail icon: rimmed background + arch."""
g = geometry(size)
s = g["s"]
img = Image.new("RGBA", (s, s), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
# Background: rounded square with a subtle rim.
d.rounded_rectangle([0, 0, s - 1, s - 1], radius=g["radius"], fill=BG)
d.rounded_rectangle([g["rim_w"], g["rim_w"], s - 1 - g["rim_w"], s - 1 - g["rim_w"]],
radius=max(1, g["radius"] - g["rim_w"]), outline=BG_EDGE,
width=g["rim_w"])
draw_arch(d, g, g["stroke"])
return img
def draw_simple_icon(size: int) -> Image.Image:
"""Legibility variant for small canvases: rim and threshold dropped,
minimum stroke widths."""
g = geometry(size)
s = g["s"]
img = Image.new("RGBA", (s, s), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.rounded_rectangle([0, 0, s - 1, s - 1], radius=g["radius"], fill=BG)
draw_arch(d, g, max(2, g["stroke"]), with_threshold=False)
return img
def draw_icon(size: int) -> Image.Image:
"""Step-down: small canvases use the simple variant, all others full."""
if size <= SIMPLE_SIZE_MAX:
return draw_simple_icon(size)
return draw_full_icon(size)
SVG = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<!-- Vestibule: a doorway arch with a visitor. Generated by scripts/make-icons.py -->
<rect width="256" height="256" rx="46" fill="#0f172a"/>
<rect x="6" y="6" width="244" height="244" rx="42" fill="none"
stroke="#1e293b" stroke-width="6"/>
<g fill="none" stroke="#38bdf8" stroke-width="19" stroke-linecap="round">
<path d="M 77 128 A 51 51 0 0 1 179 128"/>
<line x1="77" y1="128" x2="77" y2="210"/>
<line x1="179" y1="128" x2="179" y2="210"/>
<line x1="67" y1="210" x2="189" y2="210"/>
</g>
<circle cx="128" cy="176" r="21" fill="#fbbf24"/>
</svg>
"""
def main():
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "vestibule.svg"), "w") as f:
f.write(SVG)
rendered = {size: render_and_save(size) for size in SIZES}
# Windows .ico: multi-size, PNG-compressed entries for large sizes.
rendered[256].save(
os.path.join(OUT, "vestibule.ico"),
format="ICO",
sizes=[(s, s) for s in (16, 32, 48, 64, 128, 256)],
append_images=[rendered[s] for s in (128, 64, 48, 32, 16)],
)
print(f"wrote icon set to {OUT}")
print("\n".join(
f" {name} ({os.path.getsize(os.path.join(OUT, name))} bytes)"
for name in sorted(os.listdir(OUT))
))
def render_and_save(size: int) -> Image.Image:
img = draw_icon(size)
img.save(os.path.join(OUT, f"vestibule-{size}.png"))
return img
if __name__ == "__main__":
main()

683
scripts/provision-kiosk.ps1 Normal file
View File

@ -0,0 +1,683 @@
# provision-kiosk.ps1 — Windows kiosk provisioning wizard
#
# Turns a Windows Pro/Enterprise/Education machine into a Vestibule
# kiosk without manual OS configuration:
#
# 1. Stage install files to C:\Program Files\Vestibule (if not already
# installed there by the Inno Setup installer)
# 2. Create the dedicated kiosk local account
# 3. Build the extension XPI and deploy a merged policies.json to the
# browser's distribution directory (policy force-installs the
# extension, so no about:debugging step on the kiosk) — works for
# LibreWolf and Firefox alike
# 4. Write C:\ProgramData\Vestibule\kiosk.env (home URL + browser)
# 5. Create the Start Menu shortcut with a stable AppUserModelID
# 6. Apply AssignedAccess single-app kiosk config via the MDM WMI
# bridge; on Enterprise/Education, step down to Shell Launcher if
# the bridge rejects the XML
# 7. Configure automatic logon for the kiosk account
#
# Exit codes:
# 0 success (no reboot needed)
# 10 success, reboot required to activate
# 2 unsupported platform (not Windows / Home edition / not elevated)
# 3 prerequisite missing (browser, usher binary, install files)
# 4 kiosk account error
# 5 lockdown apply failed (AssignedAccess AND Shell Launcher)
# 6 invalid parameters
#
# Unattended example:
# powershell -ExecutionPolicy Bypass -File provision-kiosk.ps1 `
# -KioskUser Kiosk -KioskPassword 'S3cure!' `
# -HomeUrl https://checkin.example.org -Quiet -Restart
#
# Interactive (wizard prompts):
# powershell -ExecutionPolicy Bypass -File provision-kiosk.ps1
param(
[ValidateSet("auto", "librewolf", "firefox")]
[string]$Browser = "auto", # auto: step-down order LibreWolf -> Firefox
[string]$KioskUser = "VestibuleKiosk",
[string]$KioskPassword, # generated + printed if omitted
[string]$HomeUrl, # default about:blank
[string]$InstallRoot, # default: detected below
[switch]$Quiet, # no prompts (unattended)
[switch]$Restart, # auto-reboot when required
[switch]$Check, # validate only, change nothing
[switch]$ShellLauncher, # force Shell Launcher (skip bridge)
[switch]$NoAutoLogon, # skip automatic logon config
[switch]$InstallOverridesCfg # also deploy librewolf.overrides.cfg
)
$ErrorActionPreference = "Stop"
$AUMID = "Vestibule.Kiosk"
$ExtId = "vestibule@vestibule.kiosk"
$ProgramDataDir = Join-Path $env:ProgramData "Vestibule"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# ─── Small helpers ────────────────────────────────────────────────────
function Fail([int]$code, [string]$msg) {
Write-Host ""
Write-Host "ERROR: $msg" -ForegroundColor Red
Write-Host " exiting with code $code"
exit $code
}
function Info($msg) { Write-Host $msg }
function Ok($msg) { Write-Host " [ok] $msg" -ForegroundColor Green }
function Step($msg) { Write-Host ""; Write-Host "==> $msg" -ForegroundColor Cyan }
function Test-Elevated {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
return ([Security.Principal.WindowsPrincipal]$id).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Get-WindowsEdition {
return (Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion").EditionID
}
# ─── Browser discovery (pipeline, first hit wins) ───────────────────
$librewolfSearchPaths = @(
"${env:ProgramFiles}\LibreWolf\librewolf.exe",
"${env:ProgramFiles(x86)}\LibreWolf\librewolf.exe",
"${env:LOCALAPPDATA}\Programs\LibreWolf\librewolf.exe"
)
$firefoxSearchPaths = @(
"${env:ProgramFiles}\Mozilla Firefox\firefox.exe",
"${env:ProgramFiles}\Mozilla Firefox ESR\firefox.exe",
"${env:ProgramFiles(x86)}\Mozilla Firefox\firefox.exe",
"${env:ProgramFiles(x86)}\Mozilla Firefox ESR\firefox.exe",
"${env:LOCALAPPDATA}\Mozilla Firefox\firefox.exe"
)
function Find-InPaths($paths, $exeName) {
$hit = $paths | Where-Object { Test-Path $_ } | Select-Object -First 1
if ($hit) { return $hit }
# Registry App Paths step-down: per-machine first, then per-user.
$regRoots = @("HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths",
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths")
return $regRoots |
ForEach-Object { (Get-ItemProperty (Join-Path $_ $exeName) -ErrorAction SilentlyContinue)."(default)" } |
Where-Object { $_ -and (Test-Path $_) } |
Select-Object -First 1
}
function Find-LibreWolf { Find-InPaths $librewolfSearchPaths "librewolf.exe" }
function Find-Firefox {
# Firefox and Firefox ESR. Both read the same distribution/policies
# mechanism; ESR is recommended for kiosks (slower release cadence).
Find-InPaths $firefoxSearchPaths "firefox.exe"
}
function Resolve-Browser {
# Returns @{ Kind = 'librewolf'|'firefox'; Exe = path } or $null.
# Explicit -Browser wins; auto steps down LibreWolf -> Firefox
# (privacy defaults + trademark-safe, then fully supported Firefox).
$lw = Find-LibreWolf
$ff = Find-Firefox
switch ($Browser) {
"librewolf" {
if ($lw) { return @{ Kind = "librewolf"; Exe = $lw } }
return $null
}
"firefox" {
if ($ff) { return @{ Kind = "firefox"; Exe = $ff } }
return $null
}
default {
if ($lw) { return @{ Kind = "librewolf"; Exe = $lw } }
if ($ff) { return @{ Kind = "firefox"; Exe = $ff } }
return $null
}
}
}
function New-RandomPassword {
# 20 chars, unambiguous classes — strong enough for a locked-down
# kiosk account that is never typed by a human.
$chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!#%+"
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
$bytes = New-Object byte[] 20
$rng.GetBytes($bytes)
return (-join ($bytes | ForEach-Object { $chars[$_ % $chars.Length] }))
}
# ─── Pre-flight checks ────────────────────────────────────────────────
Step "Pre-flight checks"
if ($env:OS -ne "Windows_NT") {
Fail 2 "this script configures Windows kiosk lockdown; on Linux use scripts/provision-kiosk.sh"
}
if (-not (Test-Elevated)) {
Fail 2 "must run elevated (right-click PowerShell -> Run as administrator)"
}
$edition = Get-WindowsEdition
Info "Windows edition: $edition"
if ($edition -like "Core*") {
Fail 2 ("Windows Home (edition '$edition') does not support AssignedAccess or " +
"Shell Launcher. Use Windows Pro, Enterprise, or Education, or deploy " +
"the Linux (cage) variant.")
}
$shellLauncherCapable = ($edition -like "Enterprise*" -or
$edition -like "Education*" -or
$edition -like "IoTEnterprise*")
# Install root: explicit param > already-staged install > repo checkout.
if (-not $InstallRoot) {
if (Test-Path (Join-Path $scriptDir "..\bin\usher.exe")) {
$InstallRoot = (Resolve-Path (Join-Path $scriptDir "..")).Path
} else {
$InstallRoot = "C:\Program Files\Vestibule"
}
}
Info "install root: $InstallRoot"
$browser = Resolve-Browser
if ($browser) {
Ok "browser ($($browser.Kind)): $($browser.Exe)"
$browserExe = $browser.Exe
} else {
$browserExe = $null
Write-Host " [!!] No LibreWolf or Firefox found in standard locations" -ForegroundColor Yellow
}
$repoRoot = if (Test-Path (Join-Path $scriptDir "..\extension\manifest.json")) {
(Resolve-Path (Join-Path $scriptDir "..")).Path
} else { $null }
$usherStaged = Test-Path (Join-Path $InstallRoot "bin\usher.exe")
# ─── Interactive prompts (skipped with -Quiet) ────────────────────────
if (-not $Quiet -and -not $Check) {
if (-not $HomeUrl) {
$HomeUrl = Read-Host "Kiosk home URL [about:blank]"
if (-not $HomeUrl) { $HomeUrl = "about:blank" }
}
if (-not $KioskPassword) {
$KioskPassword = New-RandomPassword
Write-Host ""
Write-Host "Generated kiosk account password (needed for auto-logon; save it now):" -ForegroundColor Yellow
Write-Host " $KioskUser / $KioskPassword" -ForegroundColor Yellow
Write-Host ""
}
}
if (-not $HomeUrl) { $HomeUrl = "about:blank" }
if ($Check) {
Step "Check complete (no changes made)"
Info "edition : $edition ($(
if ($shellLauncherCapable) {'AssignedAccess + Shell Launcher step-down'} else {'AssignedAccess only'}))"
Info "browser : $(if ($browser) {"$($browser.Kind) ($($browser.Exe))"} else {'MISSING -> would exit 3'})"
Info "install root : $InstallRoot (usher staged: $usherStaged)"
Info "kiosk user : $KioskUser"
Info "home URL : $HomeUrl"
Info "auto-logon : $(if ($NoAutoLogon) {'disabled'} else {'enabled'})"
if (-not $browser) { Fail 3 "LibreWolf/Firefox not found" }
if (-not $usherStaged -and -not $repoRoot) { Fail 3 "no staged install and no repo checkout with a built usher" }
Ok "all prerequisites satisfied — re-run without -Check to provision"
exit 0
}
if (-not $KioskPassword) {
$KioskPassword = New-RandomPassword
Write-Host "Generated kiosk account password (save it now): $KioskUser / $KioskPassword" -ForegroundColor Yellow
}
if (-not $browser) { Fail 3 "No supported browser found — install LibreWolf (librewolf.net) or Firefox (mozilla.org), then re-run" }
# ─── 1. Stage install files ───────────────────────────────────────────
Step "Stage install files ($InstallRoot)"
if (-not $usherStaged) {
if (-not $repoRoot) {
Fail 3 ("usher.exe not found at '$InstallRoot\bin'. Install via the " +
"Vestibule Setup .exe, or build from source: cd helper; cargo build --release")
}
New-Item -ItemType Directory -Force -Path "$InstallRoot\bin" | Out-Null
New-Item -ItemType Directory -Force -Path "$InstallRoot\scripts" | Out-Null
Copy-Item (Join-Path $repoRoot "helper\target\release\usher.exe") "$InstallRoot\bin\usher.exe" -Force
Copy-Item (Join-Path $repoRoot "scripts\*.ps1") "$InstallRoot\scripts\" -Force
Ok "staged usher.exe + scripts"
}
if (-not (Test-Path "$InstallRoot\extension\manifest.json") -and $repoRoot) {
New-Item -ItemType Directory -Force -Path "$InstallRoot\extension" | Out-Null
Copy-Item (Join-Path $repoRoot "extension\*") "$InstallRoot\extension\" -Recurse -Force
Ok "staged extension source"
}
if (-not (Test-Path "$InstallRoot\extension\manifest.json")) {
Fail 3 "extension files missing under '$InstallRoot\extension'"
}
# ─── 2. Kiosk account ─────────────────────────────────────────────────
Step "Kiosk account '$KioskUser'"
$secure = ConvertTo-SecureString $KioskPassword -AsPlainText -Force
if (Get-LocalUser -Name $KioskUser -ErrorAction SilentlyContinue) {
Set-LocalUser -Name $KioskUser -Password $secure -PasswordNeverExpires $true
Ok "account exists — password reset, never expires"
} else {
try {
New-LocalUser -Name $KioskUser -Password $secure `
-AccountNeverExpires -PasswordNeverExpires `
-PasswordChangeNotAllowed `
-Description "Vestibule kiosk account (auto-provisioned)" | Out-Null
Ok "created"
} catch {
Fail 4 "could not create kiosk account: $($_.Exception.Message)"
}
}
# ─── 3. XPI + policies.json ───────────────────────────────────────────
Step "Extension XPI + $($browser.Kind) policies"
$xpiPath = Join-Path $InstallRoot "extension\vestibule.xpi"
$xpiTmp = Join-Path $env:TEMP "vestibule-xpi.zip"
if (Test-Path $xpiTmp) { Remove-Item $xpiTmp -Force }
Compress-Archive -Path (Join-Path $InstallRoot "extension\*") `
-DestinationPath $xpiTmp -Force
Move-Item $xpiTmp $xpiPath -Force
Ok "built $(Split-Path -Leaf $xpiPath)"
# Distribution dir sits next to the browser executable (LibreWolf and
# Firefox share the mechanism).
$browserDir = Split-Path -Parent $browserExe
$distDir = Join-Path $browserDir "distribution"
New-Item -ItemType Directory -Force -Path $distDir | Out-Null
$policiesPath = Join-Path $distDir "policies.json"
# The shipped distribution/policies.json is backed up once, then ours
# is deep-merged on top so the browser's own hardening survives.
if (Test-Path $policiesPath) {
$bak = "$policiesPath.vestibule-bak"
if (-not (Test-Path $bak)) { Copy-Item $policiesPath $bak -Force }
Ok "backed up existing policies.json -> vestibule-bak"
}
# ConvertFrom-Json in Windows PowerShell 5.1 yields PSCustomObjects and
# has no -AsHashtable; walk the tree into real hashtables so the deep
# merge below can mutate in place.
function ConvertTo-HashtableDeep($node) {
if ($node -is [System.Management.Automation.PSCustomObject]) {
$h = @{}
foreach ($p in $node.PSObject.Properties) { $h[$p.Name] = ConvertTo-HashtableDeep $p.Value }
return $h
}
if ($node -is [System.Collections.IEnumerable] -and $node -isnot [string]) {
$arr = @()
foreach ($item in $node) { $arr += ,(ConvertTo-HashtableDeep $item) }
return $arr
}
return $node
}
function Merge-Policy([hashtable]$base, [hashtable]$overlay) {
foreach ($k in $overlay.Keys) {
if ($base.ContainsKey($k) -and $base[$k] -is [hashtable] -and $overlay[$k] -is [hashtable]) {
Merge-Policy $base[$k] $overlay[$k]
} else {
$base[$k] = $overlay[$k]
}
}
}
$policies = @{ policies = @{} }
if (Test-Path $policiesPath) {
try {
$existing = ConvertTo-HashtableDeep (Get-Content $policiesPath -Raw | ConvertFrom-Json)
if ($existing -is [hashtable] -and $existing.Count -gt 0) { $policies = $existing }
} catch { $policies = @{ policies = @{} } }
}
$canonical = ConvertTo-HashtableDeep (Get-Content (Join-Path $scriptDir "..\config\policies.json") -Raw | ConvertFrom-Json)
Merge-Policy $policies $canonical
# Policy-install the extension: force_installed survives the "*" blocked
# wildcard in ExtensionSettings and re-installs itself on every startup.
$xpiUrl = ([uri]$xpiPath).AbsoluteUri
$policies.policies.ExtensionSettings = @{
"*" = @{
blocked_install_message = "Extensions are not allowed on this kiosk."
install_sources = @()
installation_mode = "blocked"
}
$ExtId = @{
installation_mode = "force_installed"
install_url = $xpiUrl
}
}
# WriteAllText = UTF-8 without BOM. Set-Content -Encoding UTF8 in
# Windows PowerShell 5.1 emits a BOM, which Gecko's policy loader is not
# guaranteed to tolerate.
[System.IO.File]::WriteAllText($policiesPath, ($policies | ConvertTo-Json -Depth 10))
Ok "deployed policies.json (extension force-installed from $xpiUrl)"
if ($InstallOverridesCfg) {
# librewolf.overrides.cfg is a LibreWolf-specific autoconfig file; it
# has no effect on Firefox (Firefox ignores it safely).
if ($browser.Kind -eq "librewolf") {
$src = Join-Path $scriptDir "..\config\librewolf.overrides.cfg"
if (Test-Path $src) {
Copy-Item $src (Join-Path $browserDir "librewolf.overrides.cfg") -Force
Ok "deployed librewolf.overrides.cfg (SSO/telehealth compat)"
}
} else {
Info "skipped librewolf.overrides.cfg (LibreWolf-only; browser is $($browser.Kind))"
}
}
# ─── 4. ProgramData config ────────────────────────────────────────────
Step "Deployment config"
New-Item -ItemType Directory -Force -Path $ProgramDataDir | Out-Null
@"
VESTIBULE_HOME_URL=$HomeUrl
VESTIBULE_BROWSER=$($browser.Kind)
"@ | Set-Content (Join-Path $ProgramDataDir "kiosk.env") -Encoding UTF8
Ok "kiosk.env written (home URL: $HomeUrl, browser: $($browser.Kind))"
# ─── 5. Start Menu shortcut with AUMID ────────────────────────────────
Step "Kiosk shortcut (AUMID: $AUMID)"
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
// Sets the System.AppUserModel.ID property on a .lnk file. AssignedAccess
// single-app kiosk mode launches desktop apps by AUMID, so the shortcut
// must carry a stable explicit AUMID.
public static class ShortcutAumid {
[ComImport, Guid("00021401-0000-0000-C000-000000000046")]
private class ShellLinkCoClass {}
[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown),
Guid("0000010B-0000-0000-C000-000000000046")]
private interface IPersistFile {
void GetClassID(out Guid pClassID);
[PreserveSig] int IsDirty();
void Load([MarshalAs(UnmanagedType.LPWStr)] string pszFileName, uint dwMode);
void Save([MarshalAs(UnmanagedType.LPWStr)] string pszFileName,
[MarshalAs(UnmanagedType.Bool)] bool fRemember);
void SaveCompleted([MarshalAs(UnmanagedType.LPWStr)] string pszFileName);
void GetCurFile([MarshalAs(UnmanagedType.LPWStr)] out string ppszFileName);
}
[ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IPropertyStore {
int GetCount(out uint cProps);
int GetAt(uint iProp, out PropertyKey pkey);
int GetValue(ref PropertyKey key, out PropVariant pv);
int SetValue(ref PropertyKey key, ref PropVariant pv);
int Commit();
}
[StructLayout(LayoutKind.Sequential)]
private struct PropertyKey { public Guid fmtid; public uint pid; }
[StructLayout(LayoutKind.Explicit)]
private struct PropVariant {
[FieldOffset(0)] public ushort vt;
[FieldOffset(8)] public IntPtr pointerValue;
}
[DllImport("ole32.dll")]
private static extern int CoInitialize(IntPtr reserved);
[DllImport("ole32.dll")]
private static extern void CoUninitialize();
[DllImport("ole32.dll")]
private static extern int CoCreateInstance(ref Guid clsid, IntPtr outer,
uint context, ref Guid iid, [MarshalAs(UnmanagedType.IUnknown)] out object obj);
[DllImport("ole32.dll")]
private static extern IntPtr CoTaskMemAlloc(uint bytes);
[DllImport("ole32.dll")]
private static extern void CoTaskMemFree(IntPtr p);
private const ushort VT_LPWSTR = 31;
private const uint STGM_READWRITE = 2;
private static readonly Guid ClsidShellLink =
new Guid("00021401-0000-0000-C000-000000000046");
private static readonly Guid IidPersistFile =
new Guid("0000010B-0000-0000-C000-000000000046");
private static readonly Guid IidPropertyStore =
new Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99");
private static readonly PropertyKey PkeyAppUserModelId =
new PropertyKey {
fmtid = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"),
pid = 5
};
public static int SetLnkAumid(string lnkPath, string aumid) {
int initHr = CoInitialize(IntPtr.Zero); // S_OK (0) or S_FALSE (1)
try {
Guid clsid = ClsidShellLink, iidPf = IidPersistFile;
object pfObj;
int hr = CoCreateInstance(ref clsid, IntPtr.Zero, 1 /*CLSCTX_INPROC_SERVER*/,
ref iidPf, out pfObj);
if (hr != 0) return hr;
IPersistFile persist = (IPersistFile)pfObj;
persist.Load(lnkPath, STGM_READWRITE);
IPropertyStore store = (IPropertyStore)pfObj;
PropVariant pv = new PropVariant();
pv.vt = VT_LPWSTR;
pv.pointerValue = Marshal.StringToCoTaskMemUni(aumid);
try {
hr = store.SetValue(ref PkeyAppUserModelId, ref pv);
if (hr != 0) return hr;
hr = store.Commit();
if (hr != 0) return hr;
} finally {
CoTaskMemFree(pv.pointerValue);
}
persist.Save(lnkPath, true);
return 0;
} finally {
if (initHr == 0) CoUninitialize();
}
}
}
"@
$startMenuDir = Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs"
$lnkPath = Join-Path $startMenuDir "Vestibule Kiosk.lnk"
$launcher = Join-Path $InstallRoot "scripts\kiosk-launch.ps1"
$ws = New-Object -ComObject WScript.Shell
$sc = $ws.CreateShortcut($lnkPath)
$sc.TargetPath = "powershell.exe"
$sc.Arguments = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$launcher`""
$sc.WorkingDirectory = $InstallRoot
$sc.IconLocation = "$browserExe,0"
$sc.Description = "Vestibule kiosk (AssignedAccess shell)"
$sc.Save()
$hr = [ShortcutAumid]::SetLnkAumid($lnkPath, $AUMID)
if ($hr -ne 0) { Fail 5 "could not set AUMID on shortcut (HRESULT 0x$($hr.ToString('X8')))" }
Ok "shortcut: $lnkPath"
# Verify the shell can resolve the AUMID (Get-StartApps indexes the
# Start Menu; retry briefly because indexing is asynchronous).
$aumidFound = $false
for ($i = 0; $i -lt 3 -and -not $aumidFound; $i++) {
Start-Sleep -Seconds 2
$aumidFound = [bool](Get-StartApps | Where-Object { $_.AppID -eq $AUMID })
}
if (-not $aumidFound) {
Fail 5 "AUMID '$AUMID' not visible to Get-StartApps — AssignedAccess would reject it. Reboot and re-run."
}
Ok "AUMID resolves via Get-StartApps"
# ─── 6. Lockdown: AssignedAccess (Shell Launcher step-down) ────────
$shellLauncherArgs = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$launcher`""
$lockdownApplied = $false
$rebootNeeded = $false
function Test-ShellLauncherClass {
return [bool](Get-CimClass -Namespace "root\standardcimv2\embedded" `
-ClassName "WESL_UserSetting" -ErrorAction SilentlyContinue)
}
function Enable-ShellLauncherFeature {
try {
Enable-WindowsOptionalFeature -Online `
-FeatureName "Client-DeviceLockdown" -All -NoRestart -ErrorAction Stop | Out-Null
return $true
} catch {
try {
Enable-WindowsOptionalFeature -Online `
-FeatureName "Client-EmbeddedShellLauncher" -All -NoRestart -ErrorAction Stop | Out-Null
return $true
} catch { return $false }
}
}
function Invoke-ShellLauncher {
if (-not (Test-ShellLauncherClass)) {
if (-not (Enable-ShellLauncherFeature)) { return $false }
if (-not (Test-ShellLauncherClass)) {
# Feature enabled but class appears after reboot.
$script:rebootNeeded = $true
return $false
}
}
$wesl = [wmiclass]"\\.\root\standardcimv2\embedded:WESL_UserSetting"
# SetCustomShell's parameter list has drifted across Windows builds
# (4-arg v1 and 5-arg variants with a custom return-code map). Try each
# known shape; the first that returns 0 wins.
$r = $null
foreach ($call in @(
{ $wesl.SetCustomShell($KioskUser, "powershell.exe", $shellLauncherArgs, $null, 0) },
{ $wesl.SetCustomShell($KioskUser, "powershell.exe", $shellLauncherArgs, 0) },
{ $wesl.SetCustomShell($KioskUser, "powershell.exe", $shellLauncherArgs) }
)) {
try {
$r = & $call
if ($r.ReturnValue -eq 0) { break }
} catch { $r = $null }
}
if ($r -and $r.ReturnValue -eq 0) {
$script:lockdownApplied = $true
$script:shellLauncherMode = $true
Ok "Shell Launcher custom shell set for '$KioskUser'"
return $true
}
Write-Host " [!!] SetCustomShell failed (last result: $(if ($r) {$r.ReturnValue} else {'exception'}))" -ForegroundColor Yellow
return $false
}
function Invoke-AssignedAccess {
$profileId = "{$([guid]::NewGuid().ToString())}"
$xml = @"
<?xml version="1.0" encoding="utf-8"?>
<AssignedAccessConfiguration xmlns="http://schemas.microsoft.com/AssignedAccess/2017/config">
<Profiles>
<Profile Id="$profileId">
<KioskModeApp AppUserModelId="$AUMID"/>
</Profile>
</Profiles>
<Configs>
<Config>
<Account>$KioskUser</Account>
<DefaultProfile Id="$profileId"/>
</Config>
</Configs>
</AssignedAccessConfiguration>
"@
try {
$instances = @(Get-CimInstance -Namespace "root\cimv2\mdm\dmmap" `
-ClassName "MDM_AssignedAccess" -ErrorAction Stop)
} catch {
Write-Host " [!!] MDM WMI bridge unavailable: $($_.Exception.Message)" -ForegroundColor Yellow
return $false
}
foreach ($inst in $instances) {
try {
$res = Invoke-CimMethod -InputObject $inst `
-MethodName "SetSingleAppKiosk" `
-Arguments @{ AssignedAccessConfiguration = $xml } -ErrorAction Stop
if ($res.ReturnValue -eq 0) {
$script:lockdownApplied = $true
$script:shellLauncherMode = $false
Ok "AssignedAccess single-app kiosk configured via MDM bridge"
return $true
}
Write-Host " [!!] SetSingleAppKiosk returned $($res.ReturnValue) on one enrollment" -ForegroundColor Yellow
} catch {
Write-Host " [!!] bridge call failed: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
return $false
}
Step "OS-level lockdown"
if ($ShellLauncher) {
Info "mode: Shell Launcher (forced by parameter)"
[void](Invoke-ShellLauncher)
} else {
Info "mode: AssignedAccess via MDM WMI bridge"
if (-not (Invoke-AssignedAccess)) {
if ($shellLauncherCapable) {
Info "bridge failed — stepping down to Shell Launcher (supported on $edition)"
[void](Invoke-ShellLauncher)
}
}
}
if (-not $lockdownApplied) {
Fail 5 ("could not apply AssignedAccess or Shell Launcher. Apply manually: " +
"Settings > Accounts > Other users > Set up kiosk, or use -ShellLauncher on Enterprise/Education.")
}
# ─── 7. Automatic logon ───────────────────────────────────────────────
Step "Automatic logon"
if ($NoAutoLogon) {
Info "skipped (-NoAutoLogon) — kiosk starts after manual logon as '$KioskUser'"
} else {
$wl = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
Set-ItemProperty $wl -Name "AutoAdminLogon" -Value "1" -Type String
Set-ItemProperty $wl -Name "DefaultUserName" -Value $KioskUser -Type String
Set-ItemProperty $wl -Name "DefaultDomainName" -Value $env:COMPUTERNAME -Type String
# NOTE: DefaultPassword is stored in plaintext in the registry. On a
# locked-down kiosk appliance this is an accepted trade-off (documented
# in DEPLOYMENT.md); hold Shift during boot to bypass auto-logon.
Set-ItemProperty $wl -Name "DefaultPassword" -Value $KioskPassword -Type String
Ok "auto-logon configured for '$KioskUser' (Shift at logon bypasses it)"
}
# ─── Summary ──────────────────────────────────────────────────────────
Step "Provisioning complete"
Info "kiosk user : $KioskUser"
Info "home URL : $HomeUrl"
Info "lockdown : $(if ($shellLauncherMode) {'Shell Launcher'} else {'AssignedAccess'})"
Info "browser : $($browser.Kind) ($($browser.Exe))"
Info "policies : $policiesPath"
$rebootNeeded = $rebootNeeded -or (-not $NoAutoLogon)
if ($rebootNeeded) {
Info "reboot required to activate the kiosk."
if ($Restart) {
Info "restarting in 10 seconds (-Restart)..."
shutdown.exe /r /t 10 /c "Vestibule kiosk activation"
exit 0
}
exit 10
}
Ok "kiosk will start the next time '$KioskUser' logs on"
exit 0

761
scripts/provision-kiosk.sh Executable file
View File

@ -0,0 +1,761 @@
#!/bin/sh
# provision-kiosk.sh — Linux kiosk provisioning wizard
#
# Turns a systemd Linux machine into a Vestibule kiosk without manual OS
# configuration:
#
# 1. Verify prerequisites: systemd, cage, a Gecko browser (LibreWolf
# or Firefox — native package, Flatpak, or snap), usher, python3
# 2. Create the dedicated kiosk user (password stays locked)
# 3. Stage files under /opt/vestibule (usher, extension XPI, configs)
# 4. Install /usr/local/bin/vestibule-kiosk-launch
# 5. Write /etc/vestibule/kiosk.env (home URL, browser + flavor)
# 6. Register the usher Native Messaging host for the kiosk user
# 7. Deploy a merged policies.json (policy force-installs the
# extension) where the chosen browser reads it
# 8. Generate + enable vestibule-kiosk.service (cage on a VT)
#
# The unit IS the graphical session: no display manager, no autologin
# config. cage takes the VT and runs the browser as the kiosk user,
# restarted by systemd if anything dies.
#
# Browser support (auto-detect picks the first available, LibreWolf
# preferred): librewolf native, librewolf flatpak, firefox native,
# firefox flatpak, firefox snap.
#
# Exit codes:
# 0 success
# 2 not root / not supported (no systemd)
# 3 prerequisite missing (cage, browser, usher, python3, dbus)
# 4 kiosk user error
# 5 unit install / enable failure
# 6 invalid parameters
#
# Unattended examples:
# sudo ./provision-kiosk.sh --home-url https://checkin.example.org \
# --kiosk-user kiosk --librewolf flatpak --yes --start
# sudo ./provision-kiosk.sh --firefox native \
# --home-url https://lobby.example.org --yes
#
# Interactive:
# sudo ./provision-kiosk.sh
#
# POSIX sh — no bashisms. Runs on any minimal Linux base.
set -eu
HOST_NAME="com.vestibule.usher"
EXT_ID="vestibule@vestibule.kiosk"
LW_FLATPAK_APP="io.gitlab.librewolf-community"
FF_FLATPAK_APP="org.mozilla.firefox"
OPT_ROOT="/opt/vestibule"
ENV_DIR="/etc/vestibule"
UNIT_SRC_DIR=$(cd "$(dirname "$0")" && pwd)
PROJECT_ROOT=$(cd "${UNIT_SRC_DIR}/.." && pwd)
UNIT_DST="/etc/systemd/system/vestibule-kiosk.service"
LAUNCH_DST="/usr/local/bin/vestibule-kiosk-launch"
USHER_DST="/usr/local/bin/usher"
KIOSK_USER="vestibule-kiosk"
TTY_NUM="2"
BROWSER_REQ="auto"
FLAVOR_REQ="auto"
BROWSER_FLAGS_SEEN=""
HOME_URL="about:blank"
USHER_SRC=""
QUIET=0
CHECK=0
ASSUME_YES=0
START_NOW=0
usage() {
sed -n '2,48p' "$0" | sed 's/^# \{0,1\}//'
cat <<'EOF'
Options:
--home-url URL Kiosk home URL (default: about:blank)
--kiosk-user NAME Kiosk account name (default: vestibule-kiosk)
--tty N VT for the cage session, 1-12 (default: 2)
--librewolf FLAVOR Use LibreWolf: native | flatpak | auto
--firefox FLAVOR Use Firefox: native | flatpak | snap | auto
(default: auto-detect, LibreWolf preferred;
--librewolf and --firefox are mutually exclusive)
--usher-bin PATH Explicit usher binary to install
--start Start the kiosk session immediately
--yes Skip the confirmation prompt (unattended)
--quiet Minimal output
--check Validate prerequisites only; change nothing
-h, --help This text
Firefox notes: Firefox ESR is recommended for kiosk stability. The
extension, usher, and lockdown policies are identical for both browsers.
Vestibule configures an existing Firefox install; it never downloads or
redistributes Firefox (Mozilla trademark policy).
EOF
}
die() {
code="$1"; msg="$2"
echo ""
echo "ERROR: ${msg}" >&2
echo " exiting with code ${code}" >&2
exit "${code}"
}
info() { echo "$1"; }
ok() { echo " [ok] $1"; }
step() { echo ""; echo "==> $1"; }
# First executable in the argument list wins; empty arguments are skipped.
# Single home for every ordered-path probe in this script.
first_executable() {
for cand in "$@"; do
if [ -n "${cand}" ] && [ -x "${cand}" ]; then
printf '%s\n' "${cand}"
return 0
fi
done
return 1
}
# ─── Argument parsing ─────────────────────────────────────────────────
while [ $# -gt 0 ]; do
case "$1" in
--home-url) HOME_URL="$2"; shift 2 ;;
--kiosk-user) KIOSK_USER="$2"; shift 2 ;;
--tty) TTY_NUM="$2"; shift 2 ;;
--librewolf) BROWSER_REQ="librewolf"; FLAVOR_REQ="$2"
BROWSER_FLAGS_SEEN="${BROWSER_FLAGS_SEEN} librewolf"; shift 2 ;;
--firefox) BROWSER_REQ="firefox"; FLAVOR_REQ="$2"
BROWSER_FLAGS_SEEN="${BROWSER_FLAGS_SEEN} firefox"; shift 2 ;;
--usher-bin) USHER_SRC="$2"; shift 2 ;;
--start) START_NOW=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
--quiet) QUIET=1; shift ;;
--check) CHECK=1; shift ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; die 6 "unknown option: $1" ;;
esac
done
if [ "$(printf '%s' "${BROWSER_FLAGS_SEEN}" | wc -w)" -gt 1 ]; then
die 6 "--librewolf and --firefox are mutually exclusive (saw:${BROWSER_FLAGS_SEEN})"
fi
if [ "${BROWSER_REQ}" = "librewolf" ]; then
case "${FLAVOR_REQ}" in
native|flatpak|auto) ;;
*) die 6 "--librewolf must be native, flatpak, or auto (got: ${FLAVOR_REQ})" ;;
esac
fi
if [ "${BROWSER_REQ}" = "firefox" ]; then
case "${FLAVOR_REQ}" in
native|flatpak|snap|auto) ;;
*) die 6 "--firefox must be native, flatpak, snap, or auto (got: ${FLAVOR_REQ})" ;;
esac
fi
case "${TTY_NUM}" in
''|*[!0-9]*) die 6 "--tty must be a number (got: ${TTY_NUM})" ;;
esac
[ "${TTY_NUM}" -ge 1 ] && [ "${TTY_NUM}" -le 12 ] || die 6 "--tty must be 1-12"
case "${HOME_URL}" in
http://*|https://*|about:*) ;;
*) die 6 "--home-url must start with http://, https://, or about: (got: ${HOME_URL})" ;;
esac
# ─── Detection ────────────────────────────────────────────────────────
step "Pre-flight detection"
if [ ! -d /run/systemd/system ]; then
die 2 "systemd is not the running init system — this provisioning path targets systemd"
fi
ok "systemd"
# Distro + package manager (informational: we print install commands,
# we never auto-install — operator stays in control of the base image).
# NOTE: os-release is sourced in a SUBSHELL — its standard fields
# (HOME_URL, SUPPORT_URL, ...) would otherwise clobber our variables.
DISTRO_ID="unknown"
PKG_MGR="none"
if [ -r /etc/os-release ]; then
DISTRO_ID=$(
# shellcheck disable=SC1091
. /etc/os-release
printf '%s' "${ID:-unknown}"
)
fi
for pm in apt-get dnf pacman zypper; do
if command -v "${pm}" >/dev/null 2>&1; then PKG_MGR="${pm}"; break; fi
done
[ "${QUIET}" -eq 1 ] || info "distro: ${DISTRO_ID} (pkg mgr: ${PKG_MGR})"
CAGE_BIN=""
if command -v cage >/dev/null 2>&1; then
CAGE_BIN=$(command -v cage)
elif [ -x /usr/bin/cage ]; then
CAGE_BIN="/usr/bin/cage"
fi
if [ -n "${CAGE_BIN}" ]; then
ok "cage: ${CAGE_BIN}"
fi
# ── LibreWolf detection ──────────────────────────────────────────────
# Native LibreWolf: binary + its distribution directory.
LW_NATIVE_BIN=$(first_executable \
"$(command -v librewolf 2>/dev/null || true)" \
/usr/lib/librewolf/librewolf \
/usr/local/lib/librewolf/librewolf \
/opt/librewolf/librewolf \
/usr/local/bin/librewolf || true)
LW_DIST=""
if [ -n "${LW_NATIVE_BIN}" ]; then
LW_REAL=$(readlink -f "${LW_NATIVE_BIN}" 2>/dev/null || echo "${LW_NATIVE_BIN}")
LW_REAL_DIR=$(dirname "${LW_REAL}")
# A real LibreWolf install dir carries application.ini + browser/;
# /usr/bin/librewolf may be a wrapper script or symlink — don't trust
# its directory unless those markers are there.
if [ -d "${LW_REAL_DIR}/distribution" ] || [ -f "${LW_REAL_DIR}/application.ini" ] \
|| [ -d "${LW_REAL_DIR}/browser" ]; then
LW_DIST="${LW_REAL_DIR}/distribution"
else
# Wrapper-script install: pick the first candidate whose parent dir
# exists — provisioning creates distribution/ inside it.
for dist in \
/usr/lib/librewolf/distribution \
/usr/share/librewolf/distribution \
/opt/librewolf/distribution; do
if [ -d "$(dirname "${dist}")" ]; then LW_DIST="${dist}"; break; fi
done
fi
ok "librewolf (native): ${LW_NATIVE_BIN} (policies: ${LW_DIST})"
fi
# Flatpak LibreWolf: system installation only — a per-user install would
# be invisible to the kiosk account.
LW_FLATPAK=0
if command -v flatpak >/dev/null 2>&1; then
if flatpak info --system "${LW_FLATPAK_APP}" >/dev/null 2>&1; then
LW_FLATPAK=1
ok "librewolf (flatpak, system): ${LW_FLATPAK_APP}"
elif flatpak info --user "${LW_FLATPAK_APP}" >/dev/null 2>&1; then
info " [!!] ${LW_FLATPAK_APP} is installed per-USER — the kiosk account cannot see it."
info " reinstall system-wide: flatpak install --system flathub ${LW_FLATPAK_APP}"
fi
fi
# ── Firefox detection ────────────────────────────────────────────────
# Native Firefox. Canonical distro paths first (Debian/Ubuntu:
# /usr/lib/firefox, Fedora: /usr/lib64/firefox, Arch: /usr/bin symlink),
# then tarball-style installs (/opt, /usr/local). command -v results
# that resolve into /snap or a flatpak export are routed to their own
# flavors below.
FF_NATIVE_BIN=""
FF_NATIVE_REAL=""
FF_DISTRO=0
FF_DIST=""
for cand in \
/usr/lib/firefox/firefox \
/usr/lib64/firefox/firefox \
/usr/bin/firefox \
/opt/firefox/firefox \
/usr/local/firefox/firefox \
/usr/local/bin/firefox \
"$(command -v firefox 2>/dev/null || true)"; do
if [ -z "${cand}" ] || [ ! -x "${cand}" ]; then
continue
fi
real=$(readlink -f "${cand}" 2>/dev/null || echo "${cand}")
case "${real}" in
/snap/*) continue ;; # snap firefox, handled below
*/flatpak/*|*/.local/share/flatpak/*) continue ;; # flatpak export
esac
case "${cand}" in
/usr/lib/firefox/*|/usr/lib64/firefox/*|/usr/bin/*|/usr/share/*)
FF_DISTRO=1 ;;
*) FF_DISTRO=0 ;;
esac
FF_NATIVE_BIN="${cand}"
FF_NATIVE_REAL="${real}"
break
done
if [ -n "${FF_NATIVE_BIN}" ]; then
if [ "${FF_DISTRO}" -eq 1 ]; then
# Distro package: /etc/firefox/policies is the canonical,
# update-safe location (Mozilla's documented Linux path).
FF_DIST="/etc/firefox/policies"
else
# Tarball-style install: policies live beside the binary, in the
# distribution/ directory — same mechanism as Windows.
FF_DIST="$(dirname "${FF_NATIVE_REAL}")/distribution"
fi
ok "firefox (native): ${FF_NATIVE_BIN} (policies: ${FF_DIST})"
fi
# Firefox snap (Ubuntu's default): the sandbox home is
# ~/snap/firefox/common — policies and NM manifests for the kiosk user
# go there. Detected via snap list or a /usr/bin/firefox wrapper that
# resolves into /snap.
FF_SNAP=0
if command -v snap >/dev/null 2>&1; then
if snap list firefox >/dev/null 2>&1; then
FF_SNAP=1
fi
fi
if [ "${FF_SNAP}" -eq 0 ]; then
ff_probe=""
if [ -e /usr/bin/firefox ]; then
ff_probe=$(readlink -f /usr/bin/firefox 2>/dev/null || true)
elif [ -e /snap/bin/firefox ]; then
ff_probe=$(readlink -f /snap/bin/firefox 2>/dev/null || true)
fi
case "${ff_probe}" in
/snap/*) FF_SNAP=1 ;;
esac
fi
if [ "${FF_SNAP}" -eq 1 ]; then
ok "firefox (snap): /snap/bin/firefox (policies: ~kiosk/snap/firefox/common/.mozilla/policies)"
fi
# Firefox Flatpak: system installation only.
FF_FLATPAK=0
if command -v flatpak >/dev/null 2>&1; then
if flatpak info --system "${FF_FLATPAK_APP}" >/dev/null 2>&1; then
FF_FLATPAK=1
ok "firefox (flatpak, system): ${FF_FLATPAK_APP}"
elif flatpak info --user "${FF_FLATPAK_APP}" >/dev/null 2>&1; then
info " [!!] ${FF_FLATPAK_APP} is installed per-USER — the kiosk account cannot see it."
info " reinstall system-wide: flatpak install --system flathub ${FF_FLATPAK_APP}"
fi
fi
# ── Resolve browser + flavor ─────────────────────────────────────────
#
# Step-down resolution: each probe names one flavor; the first available
# wins. An explicit --librewolf/--firefox flavor request pins the probe to
# that flavor alone; "auto" walks the whole ladder.
BROWSER=""
FLAVOR=""
flavor_available() {
# Lookup table: browser:flavor -> availability test.
case "$1:$2" in
librewolf:native) [ -n "${LW_NATIVE_BIN}" ] ;;
librewolf:flatpak) [ "${LW_FLATPAK}" -eq 1 ] ;;
firefox:native) [ -n "${FF_NATIVE_BIN}" ] ;;
firefox:flatpak) [ "${FF_FLATPAK}" -eq 1 ] ;;
firefox:snap) [ "${FF_SNAP}" -eq 1 ] ;;
*) return 1 ;;
esac
}
resolve_flavor() {
# $1 = browser, $2 = flavor
if [ "${FLAVOR_REQ}" = "auto" ] || [ "${FLAVOR_REQ}" = "$2" ]; then
if flavor_available "$1" "$2"; then
BROWSER="$1"; FLAVOR="$2"; return 0
fi
fi
return 1
}
resolve_librewolf() {
resolve_flavor librewolf native && return 0
resolve_flavor librewolf flatpak && return 0
return 1
}
resolve_firefox() {
resolve_flavor firefox native && return 0
resolve_flavor firefox flatpak && return 0
resolve_flavor firefox snap && return 0
return 1
}
if [ "${BROWSER_REQ}" = "librewolf" ]; then
resolve_librewolf
elif [ "${BROWSER_REQ}" = "firefox" ]; then
resolve_firefox
else
# Auto step-down: LibreWolf (privacy defaults + trademark-safe), then
# Firefox (fully supported alternative).
FLAVOR_REQ="auto"
resolve_librewolf || resolve_firefox
fi
BROWSER_MISSING=""
if [ -z "${BROWSER}" ]; then
if [ "${BROWSER_REQ}" = "librewolf" ]; then
BROWSER_MISSING="librewolf"
elif [ "${BROWSER_REQ}" = "firefox" ]; then
BROWSER_MISSING="firefox"
else
BROWSER_MISSING="browser (librewolf or firefox)"
fi
fi
# usher source: explicit flag > existing install > repo build > staged.
if [ -z "${USHER_SRC}" ]; then
if [ -x "${USHER_DST}" ]; then
USHER_SRC="${USHER_DST}"
elif [ -x "${PROJECT_ROOT}/helper/target/release/usher" ]; then
USHER_SRC="${PROJECT_ROOT}/helper/target/release/usher"
elif [ -x "${OPT_ROOT}/bin/usher" ]; then
USHER_SRC="${OPT_ROOT}/bin/usher"
fi
fi
PYTHON_BIN=""
if command -v python3 >/dev/null 2>&1; then
PYTHON_BIN=$(command -v python3)
fi
DBUS_RUN=""
if command -v dbus-run-session >/dev/null 2>&1; then
DBUS_RUN=$(command -v dbus-run-session)
fi
# ─── Missing-prerequisite report ──────────────────────────────────────
MISSING=""
if [ -z "${CAGE_BIN}" ]; then MISSING="${MISSING} cage"; fi
if [ -n "${BROWSER_MISSING}" ]; then MISSING="${MISSING} ${BROWSER_MISSING}"; fi
if [ -z "${USHER_SRC}" ]; then MISSING="${MISSING} usher"; fi
if [ -z "${PYTHON_BIN}" ]; then MISSING="${MISSING} python3"; fi
if [ -z "${DBUS_RUN}" ]; then MISSING="${MISSING} dbus"; fi
if [ -n "${MISSING}" ]; then
echo ""
info "Missing prerequisites:${MISSING}"
info "Install them, then re-run this script. Per-distro commands:"
echo ""
case "${PKG_MGR}" in
apt-get)
echo " sudo apt-get install -y cage dbus python3 firefox-esr"
echo " # LibreWolf: deb repo — https://librewolf.net/installation/linux/"
echo " # or Flatpak: flatpak install --system flathub ${LW_FLATPAK_APP}"
echo " # or Firefox Flatpak: flatpak install --system flathub ${FF_FLATPAK_APP}" ;;
dnf)
echo " sudo dnf install -y cage dbus python3 firefox"
echo " # LibreWolf: https://librewolf.net/installation/linux/"
echo " # or Flatpak: flatpak install --system flathub ${LW_FLATPAK_APP}" ;;
pacman)
echo " sudo pacman -S --needed cage dbus python3 firefox"
echo " # LibreWolf: AUR (librewolf / librewolf-bin)"
echo " # or Flatpak: flatpak install --system flathub ${LW_FLATPAK_APP}" ;;
zypper)
echo " sudo zypper install cage dbus python3 MozillaFirefox"
echo " # LibreWolf: https://librewolf.net/installation/linux/"
echo " # or Flatpak: flatpak install --system flathub ${LW_FLATPAK_APP}" ;;
*)
echo " # Install cage, dbus, python3, and a browser (firefox or"
echo " # librewolf) from your distribution."
echo " # cage from source: https://github.com/cage-kiosk/cage"
echo " # Flatpak browsers: flatpak install --system flathub ${FF_FLATPAK_APP}" ;;
esac
echo ""
echo " usher: build from source — cd helper && cargo build --release"
die 3 "prerequisites not met"
fi
ok "usher: ${USHER_SRC}"
ok "python3: ${PYTHON_BIN}"
ok "browser: ${BROWSER} (${FLAVOR})"
# ─── Policy destination preview (needs KIOSK_HOME for sandboxed flavors)
policy_dir_for_kiosk_home() {
# $1 = kiosk home
case "${BROWSER}:${FLAVOR}" in
librewolf:native) printf '%s' "${LW_DIST}" ;;
librewolf:flatpak) printf '%s' "/var/lib/flatpak/app/${LW_FLATPAK_APP}/current/active/files/librewolf/distribution" ;;
firefox:native) printf '%s' "${FF_DIST}" ;;
firefox:flatpak) printf '%s' "$1/.var/app/${FF_FLATPAK_APP}/.mozilla/policies" ;;
firefox:snap) printf '%s' "$1/snap/firefox/common/.mozilla/policies" ;;
esac
}
if [ "${CHECK}" -eq 1 ]; then
step "Check complete (no changes made)"
info "kiosk user : ${KIOSK_USER} (created if absent)"
info "tty : /dev/tty${TTY_NUM}"
info "browser : ${BROWSER} (${FLAVOR})"
info "home URL : ${HOME_URL}"
info "policies dir : $(policy_dir_for_kiosk_home "/home/${KIOSK_USER}")"
info "opt root : ${OPT_ROOT}"
ok "all prerequisites satisfied — re-run without --check to provision"
exit 0
fi
[ "$(id -u)" -eq 0 ] || die 2 "must run as root (sudo)"
# ─── Confirmation ─────────────────────────────────────────────────────
if [ "${ASSUME_YES}" -eq 0 ] && [ "${QUIET}" -eq 0 ]; then
echo ""
printf "Provision kiosk (user=%s, tty=%s, browser=%s/%s, url=%s)? [y/N] " \
"${KIOSK_USER}" "${TTY_NUM}" "${BROWSER}" "${FLAVOR}" "${HOME_URL}"
read -r answer
case "${answer}" in
y|Y|yes|YES) ;;
*) info "aborted"; exit 0 ;;
esac
fi
# ─── 1. Kiosk user ────────────────────────────────────────────────────
step "Kiosk user '${KIOSK_USER}'"
KIOSK_HOME=""
if id -u "${KIOSK_USER}" >/dev/null 2>&1; then
KIOSK_HOME=$(getent passwd "${KIOSK_USER}" | cut -d: -f6)
ok "exists (home: ${KIOSK_HOME})"
else
useradd -m -s /bin/sh "${KIOSK_USER}" || die 4 "useradd failed"
KIOSK_HOME=$(getent passwd "${KIOSK_USER}" | cut -d: -f6)
ok "created with locked password (no password login is possible)"
fi
[ -n "${KIOSK_HOME}" ] || die 4 "could not resolve home directory"
# ─── 2. Stage /opt/vestibule ──────────────────────────────────────────
step "Stage ${OPT_ROOT}"
mkdir -p "${OPT_ROOT}/bin" "${OPT_ROOT}/extension" "${OPT_ROOT}/config" "${OPT_ROOT}/docs"
install -m 0755 "${USHER_SRC}" "${OPT_ROOT}/bin/usher"
install -m 0755 "${USHER_SRC}" "${USHER_DST}"
ok "usher installed: ${USHER_DST}"
# Build the XPI (a zip of extension/) — python3 zipfile, no zip binary
# dependency. If an XPI is already staged and no source tree is present,
# keep the staged one.
XPI_SRC_DIR="${PROJECT_ROOT}/extension"
XPI_DST="${OPT_ROOT}/extension/vestibule.xpi"
if [ -f "${XPI_SRC_DIR}/manifest.json" ]; then
"${PYTHON_BIN}" - "${XPI_SRC_DIR}" "${XPI_DST}" <<'PYEOF'
import os, sys, zipfile
src_dir, dst = sys.argv[1], sys.argv[2]
with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as z:
for root, dirs, files in os.walk(src_dir):
dirs.sort()
for f in sorted(files):
full = os.path.join(root, f)
z.write(full, os.path.relpath(full, src_dir))
PYEOF
chmod 0644 "${XPI_DST}"
ok "built extension XPI: ${XPI_DST}"
elif [ -f "${XPI_DST}" ]; then
ok "using staged XPI: ${XPI_DST}"
else
die 3 "no extension source at ${XPI_SRC_DIR} and no staged XPI"
fi
for f in config/policies.json config/librewolf.overrides.cfg \
config/vestibule.toml.example; do
if [ -f "${PROJECT_ROOT}/${f}" ]; then
install -m 0644 "${PROJECT_ROOT}/${f}" "${OPT_ROOT}/${f}"
fi
done
if [ -f "${PROJECT_ROOT}/LICENSE" ]; then install -m 0644 "${PROJECT_ROOT}/LICENSE" "${OPT_ROOT}/LICENSE"; fi
if [ -f "${PROJECT_ROOT}/DEPLOYMENT.md" ]; then install -m 0644 "${PROJECT_ROOT}/DEPLOYMENT.md" "${OPT_ROOT}/docs/DEPLOYMENT.md"; fi
if [ -f "${PROJECT_ROOT}/README.md" ]; then install -m 0644 "${PROJECT_ROOT}/README.md" "${OPT_ROOT}/docs/README.md"; fi
ok "staged config + docs"
# ─── 3. Launcher + kiosk.env ──────────────────────────────────────────
step "Launcher + session config"
install -m 0755 "${UNIT_SRC_DIR}/vestibule-kiosk-launch" "${LAUNCH_DST}"
ok "installed ${LAUNCH_DST}"
mkdir -p "${ENV_DIR}"
cat > "${ENV_DIR}/kiosk.env" <<EOF
# /etc/vestibule/kiosk.env — Vestibule kiosk session configuration.
# Edit this file to reconfigure; the unit re-reads it on every start.
VESTIBULE_HOME_URL="${HOME_URL}"
VESTIBULE_BROWSER="${BROWSER}"
VESTIBULE_BROWSER_FLAVOR="${FLAVOR}"
# cage flags: "-d" allows VT switching (admin escape hatch, cage >= 0.1.2).
# In a VM without GPU: add WLR_LIBINPUT_NO_DEVICES=1 and WLR_RENDERER=pixman
# as separate Environment entries in the systemd unit, not here.
VESTIBULE_CAGE_ARGS="-d"
EOF
chmod 0644 "${ENV_DIR}/kiosk.env"
ok "wrote ${ENV_DIR}/kiosk.env (browser: ${BROWSER}/${FLAVOR})"
# ─── 4. Native Messaging host for the kiosk user ──────────────────────
step "Native Messaging host (kiosk user)"
NM_MANIFEST_BODY=$(cat <<EOF
{
"name": "${HOST_NAME}",
"description": "Vestibule native helper",
"path": "${USHER_DST}",
"type": "stdio",
"allowed_extensions": ["${EXT_ID}"]
}
EOF
)
NM_DIR_REL="native-messaging-hosts"
# Every location a Gecko variant can read manifests from, relative to
# the kiosk home: LibreWolf native, Firefox native (~/.mozilla), the two
# Flatpaks (sandbox home remap), and the Firefox snap. Writing all five
# costs nothing and makes the flavor choice future-proof.
for base in \
".librewolf" \
".mozilla" \
".var/app/${LW_FLATPAK_APP}/.librewolf" \
".var/app/${FF_FLATPAK_APP}/.mozilla" \
"snap/firefox/common/.mozilla"; do
nm_dir="${KIOSK_HOME}/${base}/${NM_DIR_REL}"
mkdir -p "${nm_dir}"
printf '%s\n' "${NM_MANIFEST_BODY}" > "${nm_dir}/${HOST_NAME}.json"
chmod 0644 "${nm_dir}/${HOST_NAME}.json"
done
chown -R "${KIOSK_USER}:${KIOSK_USER}" "${KIOSK_HOME}/.librewolf" \
"${KIOSK_HOME}/.mozilla" "${KIOSK_HOME}/.var" "${KIOSK_HOME}/snap" 2>/dev/null || \
chown -R "${KIOSK_USER}" "${KIOSK_HOME}/.librewolf" "${KIOSK_HOME}/.mozilla"
ok "manifests installed for ${KIOSK_USER} (librewolf + firefox, native + flatpak + snap)"
# ─── 5. policies.json ─────────────────────────────────────────────────
step "${BROWSER} policies"
# For sandboxed flavors (flatpak/snap) the browser cannot read /opt —
# its filesystem is the kiosk home remap. Copy the XPI to a
# sandbox-visible path and point install_url there. Native flavors read
# /opt/vestibule directly.
XPI_INSTALL_URL="file://${XPI_DST}"
if [ "${FLAVOR}" = "flatpak" ]; then
if [ "${BROWSER}" = "firefox" ]; then
SANDBOX_APP_DIR="${KIOSK_HOME}/.var/app/${FF_FLATPAK_APP}"
else
SANDBOX_APP_DIR="${KIOSK_HOME}/.var/app/${LW_FLATPAK_APP}"
fi
mkdir -p "${SANDBOX_APP_DIR}"
install -m 0644 "${XPI_DST}" "${SANDBOX_APP_DIR}/vestibule.xpi"
chown -R "${KIOSK_USER}:${KIOSK_USER}" "${SANDBOX_APP_DIR}" 2>/dev/null || \
chown -R "${KIOSK_USER}" "${SANDBOX_APP_DIR}"
# Inside the sandbox $HOME is the kiosk home path — that is where the
# browser must find the XPI.
XPI_INSTALL_URL="file://${KIOSK_HOME}/vestibule.xpi"
ok "XPI copied to sandbox-visible ${SANDBOX_APP_DIR}/vestibule.xpi"
elif [ "${FLAVOR}" = "snap" ]; then
SANDBOX_APP_DIR="${KIOSK_HOME}/snap/firefox/common"
mkdir -p "${SANDBOX_APP_DIR}"
install -m 0644 "${XPI_DST}" "${SANDBOX_APP_DIR}/vestibule.xpi"
chown -R "${KIOSK_USER}:${KIOSK_USER}" "${SANDBOX_APP_DIR}" 2>/dev/null || \
chown -R "${KIOSK_USER}" "${SANDBOX_APP_DIR}"
XPI_INSTALL_URL="file://${KIOSK_HOME}/vestibule.xpi"
ok "XPI copied to snap-visible ${SANDBOX_APP_DIR}/vestibule.xpi"
fi
POLICY_DIR=$(policy_dir_for_kiosk_home "${KIOSK_HOME}")
mkdir -p "${POLICY_DIR}"
POLICY_DST="${POLICY_DIR}/policies.json"
if [ -f "${POLICY_DST}" ] && [ ! -f "${POLICY_DST}.vestibule-bak" ]; then
cp "${POLICY_DST}" "${POLICY_DST}.vestibule-bak"
ok "backed up existing policies.json -> vestibule-bak"
fi
"${PYTHON_BIN}" - "${PROJECT_ROOT}/config/policies.json" "${POLICY_DST}" \
"${XPI_INSTALL_URL}" "${EXT_ID}" <<'PYEOF'
import json, sys
canonical_path, target, xpi_url, ext_id = sys.argv[1:5]
policies = {"policies": {}}
try:
with open(target) as f:
existing = json.load(f)
if isinstance(existing, dict):
policies = existing
except (OSError, ValueError):
pass
with open(canonical_path) as f:
canonical = json.load(f)
def deep_merge(base, overlay):
for k, v in overlay.items():
if k in base and isinstance(base[k], dict) and isinstance(v, dict):
deep_merge(base[k], v)
else:
base[k] = v
deep_merge(policies, canonical)
# Policy-install the extension: force_installed survives the "*" blocked
# wildcard and re-installs itself on every browser start.
policies.setdefault("policies", {})
policies["policies"]["ExtensionSettings"] = {
"*": {
"blocked_install_message": "Extensions are not allowed on this kiosk.",
"install_sources": [],
"installation_mode": "blocked",
},
ext_id: {
"installation_mode": "force_installed",
"install_url": xpi_url,
},
}
with open(target, "w") as f:
json.dump(policies, f, indent=2)
f.write("\n")
PYEOF
if [ -n "${POLICY_DIR##${KIOSK_HOME}*}" ]; then
# System-level policy file — root-owned is correct.
:
else
# Policy file inside the kiosk home (flatpak/snap flavors) — the
# browser reads it as the kiosk user.
chown "${KIOSK_USER}:${KIOSK_USER}" "${POLICY_DST}" 2>/dev/null || \
chown "${KIOSK_USER}" "${POLICY_DST}"
fi
ok "deployed ${POLICY_DST} (extension force-installed from ${XPI_INSTALL_URL})"
if [ "${FLAVOR}" = "flatpak" ] && [ "${BROWSER}" = "librewolf" ]; then
info "NOTE: the Flatpak app dir is replaced on every LibreWolf update."
info " re-run this script after updates (the extension's own session"
info " sanitization is unaffected — this file is layer 1 of 3)."
fi
if [ "${FLAVOR}" = "flatpak" ] || [ "${FLAVOR}" = "snap" ]; then
info "NOTE: for ${BROWSER} ${FLAVOR}, policies and the XPI live in the"
info " kiosk user's home — they survive browser updates (unlike a"
info " LibreWolf-Flatpak system-dir deploy)."
fi
# ─── 6. systemd unit ──────────────────────────────────────────────────
step "systemd unit"
sed -e "s|__KIOSK_USER__|${KIOSK_USER}|g" -e "s|__TTY__|${TTY_NUM}|g" \
"${UNIT_SRC_DIR}/vestibule-kiosk.service.in" > "${UNIT_DST}"
chmod 0644 "${UNIT_DST}"
ok "generated ${UNIT_DST} (tty${TTY_NUM}, user ${KIOSK_USER})"
systemctl daemon-reload || die 5 "systemctl daemon-reload failed"
systemctl enable vestibule-kiosk.service >/dev/null 2>&1 || die 5 "enable failed"
ok "enabled vestibule-kiosk.service"
if [ "${START_NOW}" -eq 1 ]; then
systemctl start vestibule-kiosk.service || die 5 "start failed — check: journalctl -u vestibule-kiosk.service"
ok "started — the kiosk should be running on tty${TTY_NUM}"
else
info "start now with: sudo systemctl start vestibule-kiosk.service"
info "or reboot — the unit starts automatically at boot."
fi
# ─── Summary ──────────────────────────────────────────────────────────
step "Provisioning complete"
info "kiosk user : ${KIOSK_USER} (locked password)"
info "session : cage on tty${TTY_NUM} via systemd"
info "browser : ${BROWSER} (${FLAVOR})"
info "home URL : ${HOME_URL}"
info "policies : ${POLICY_DST}"
info "logs : journalctl -u vestibule-kiosk.service -f"
info "escape hatch : Ctrl+Alt+F3 (VT switching; requires VESTIBULE_CAGE_ARGS=-d)"
exit 0

182
scripts/test-native-messaging.py Executable file
View File

@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Smoke test for usher native messaging — production protocol.
Tests the full Argon2id unlock round-trip without LibreWolf. The
protocol steps are a table: each step declares its label, request,
timeout, and pass criterion. The driver runs the table in one pass and
collects failure messages — adding a protocol step is one table entry.
Steps:
1. hello handshake
2. ping/pong
3. set-unlock "test-password" -> unlock-set ok=true (hash stored to disk)
4. unlock "wrong-password" -> granted=false, reason="invalid"
5. unlock "test-password" -> granted=true
6. simulate-wake -> Wake event
Cleans up the hash file before and after so the test is idempotent.
Usage:
python3 scripts/test-native-messaging.py [path/to/usher]
"""
import contextlib
import json
import os
import select
import struct
import subprocess
import sys
DEFAULT_USHER = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"helper", "target", "release", "usher",
)
USHER = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_USHER
# Hash file path — must match helper/src/storage.rs
HASH_FILE = os.path.join(
os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")),
"vestibule", "unlock.hash"
)
STANDARD_TIMEOUT_S = 10
ARGON2_TIMEOUT_S = 15 # Argon2id at 64 MiB takes ~1-3 s
WAKE_TIMEOUT_S = 5
EXIT_TIMEOUT_S = 5
def cleanup_hash():
with contextlib.suppress(FileNotFoundError):
os.remove(HASH_FILE)
def send(proc, obj):
data = json.dumps(obj).encode("utf-8")
proc.stdin.write(struct.pack("<I", len(data)))
proc.stdin.write(data)
proc.stdin.flush()
def recv(proc, timeout):
ready, _, _ = select.select([proc.stdout], [], [], timeout)
if not ready:
return None
header = proc.stdout.read(4)
if len(header) < 4:
return None
(n,) = struct.unpack("<I", header)
body = proc.stdout.read(n)
if len(body) < n:
return None
return json.loads(body.decode("utf-8"))
def exchange(proc, request, timeout):
send(proc, request)
return recv(proc, timeout)
# ─── Step criteria ─────────────────────────────────────────────────────
#
# A criterion maps a decoded response (or None on timeout/truncation) to
# a failure message, or None when the step passes.
def matches(**expected):
"""Criterion factory: every expected key/value pair must be present."""
def criterion(response):
if response is None:
return "no response within timeout"
mismatched = [
f"{key}: expected {value!r}, got {response.get(key)!r}"
for key, value in expected.items()
if response.get(key) != value
]
return "; ".join(mismatched) or None
return criterion
def stores_argon2_hash(response):
"""set-unlock must succeed AND create the hash file on disk."""
failure = matches(type="unlock-set", ok=True)(response)
if failure:
return failure
return None if os.path.exists(HASH_FILE) else f"hash file not created at {HASH_FILE}"
STEPS = [
("hello", {"type": "hello", "client": "smoke-test", "version": "0.0.0"},
STANDARD_TIMEOUT_S, matches(type="hello", server="usher")),
("ping", {"type": "ping", "echo": "production-123"},
STANDARD_TIMEOUT_S, matches(type="pong", echo="production-123")),
("set-unlock", {"type": "set-unlock", "password": "test-password-123"},
ARGON2_TIMEOUT_S, stores_argon2_hash),
("unlock(wrong)", {"type": "unlock", "password": "wrong-password"},
ARGON2_TIMEOUT_S, matches(type="unlock-result", granted=False)),
("unlock(correct)", {"type": "unlock", "password": "test-password-123"},
ARGON2_TIMEOUT_S, matches(type="unlock-result", granted=True)),
("wake", {"type": "simulate-wake"},
WAKE_TIMEOUT_S, matches(type="wake")),
]
def run_steps(proc):
"""Run every table step; return the failure messages, in order."""
def run(step):
label, request, timeout, criterion = step
response = exchange(proc, request, timeout)
print(f" {label:<15} -> {response}")
return criterion(response)
return [message for message in map(run, STEPS) if message]
def shutdown(proc):
"""Close stdin; usher must exit promptly. Returns failure messages."""
proc.stdin.close()
try:
proc.wait(timeout=EXIT_TIMEOUT_S)
except subprocess.TimeoutExpired:
proc.kill()
return [f"usher did not exit within {EXIT_TIMEOUT_S}s of stdin close"]
return []
def main():
if not os.path.exists(USHER):
print(f"error: usher binary not found at {USHER}", file=sys.stderr)
print(" build it first: (cd helper && cargo build --release)", file=sys.stderr)
sys.exit(1)
# Remove any hash left over from a previous run.
cleanup_hash()
print(f"launching {USHER}")
proc = subprocess.Popen(
[USHER],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
)
failures = run_steps(proc)
failures.extend(shutdown(proc))
stderr = proc.stderr.read().decode("utf-8", errors="replace").strip()
if stderr:
print("\n--- stderr ---")
print(stderr)
print("--- end stderr ---\n")
# Leave no hash behind.
cleanup_hash()
if failures:
print("FAIL:")
print("\n".join(f" - {failure}" for failure in failures))
sys.exit(1)
print("\nOK — usher production protocol works: Argon2id unlock + wake events.")
if __name__ == "__main__":
main()

443
scripts/test-provision-linux.sh Executable file
View File

@ -0,0 +1,443 @@
#!/bin/sh
# test-provision-linux.sh — rootless integration test for the Linux
# kiosk provisioning pipeline (LibreWolf AND Firefox).
#
# Runs provision-kiosk.sh and deprovision-kiosk.sh against a sandbox:
# all privileged destinations (/opt, /etc, /usr/local/bin, systemd) are
# rewritten to a temp directory, and privileged commands (useradd,
# systemctl, chown, id) are replaced with shims. No root required, no
# real system mutation — this is what CI runs on every push.
#
# Scenarios:
# 1. --librewolf native full provision + deprovision cycle
# 2. --firefox native /etc/firefox/policies path (distro Firefox)
# 3. --firefox flatpak kiosk-home policy path + sandbox XPI copy
#
# What it verifies per scenario:
# provision: kiosk user creation, /opt staging, XPI build (valid zip
# with manifest.json), launcher install, kiosk.env browser
# + flavor, Native Messaging manifests (all five Gecko
# locations, valid JSON, correct path), policies.json
# deep-merge (the browser's own keys survive, ours added,
# extension force-installed with the right install_url),
# unit generation + enable.
# deprovision: unit removed, policies.json equal to the pre-provision
# baseline byte-for-byte, manifests + sandbox XPI copies
# removed, staged files removed.
#
# After the scenarios, the launcher dispatch is exercised directly: all
# four env permutations (firefox/flatpak, firefox/native,
# librewolf/flatpak, defaults) run against browser shims and the exec'd
# command line is asserted.
#
# Usage: sh scripts/test-provision-linux.sh (KEEP_SANDBOX=1 to inspect)
# Exit: 0 pass, 1 fail
#
# POSIX sh — no bashisms.
set -u
REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd)
SANDBOX=$(mktemp -d)
PASS=0
FAIL=0
KIOSK_USER="vestibule-kiosk"
cleanup() {
if [ "${KEEP_SANDBOX:-0}" = "1" ]; then
say "sandbox kept at: ${SANDBOX}"
else
rm -rf "${SANDBOX}"
fi
}
trap cleanup EXIT INT TERM
say() { printf '%s\n' "$1"; }
pass() { PASS=$((PASS+1)); say " [pass] $1"; }
fail() { FAIL=$((FAIL+1)); say " [FAIL] $1"; }
check() { # check <description> <command...>
desc="$1"; shift
if "$@" >/dev/null 2>&1; then pass "${desc}"; else fail "${desc}"; fi
}
# ─── Sandbox layout ───────────────────────────────────────────────────
#
# repo/ copy of the real repo (scripts/, config/, extension/)
# root/opt fake /opt/vestibule
# root/etc fake /etc/vestibule + /etc/systemd/system
# root/etc/firefox fake /etc/firefox/policies (distro Firefox)
# root/usrlocal fake /usr/local/bin
# root/usr/lib/firefox fake distro Firefox install
# librewolf/ fake native LibreWolf install (wrapper in PATH)
# home/ fake kiosk user home
# bin/ PATH shims (privileged + browser + usher)
mkdir -p "${SANDBOX}/repo" "${SANDBOX}/root/opt" "${SANDBOX}/root/etc/systemd/system" \
"${SANDBOX}/root/etc/firefox/policies" "${SANDBOX}/root/usrlocal" \
"${SANDBOX}/root/usr/lib/firefox" "${SANDBOX}/bin" \
"${SANDBOX}/librewolf/browser" "${SANDBOX}/librewolf/distribution" \
"${SANDBOX}/home"
cp -r "${REPO_ROOT}/scripts" "${REPO_ROOT}/config" "${REPO_ROOT}/extension" "${SANDBOX}/repo/"
mkdir -p "${SANDBOX}/repo/helper/target/release"
printf '#!/bin/sh\nexit 0\n' > "${SANDBOX}/repo/helper/target/release/usher"
chmod +x "${SANDBOX}/repo/helper/target/release/usher"
# Fake native LibreWolf: wrapper in PATH + real install dir with markers.
printf '#!/bin/sh\nexec "%s/librewolf/librewolf" "$@"\n' "${SANDBOX}" > "${SANDBOX}/bin/librewolf"
chmod +x "${SANDBOX}/bin/librewolf"
# Fake LibreWolf binary: logs its argv (launcher dispatch assertions).
printf '#!/bin/sh\nprintf "%%s\\n" "librewolf $*" >> "%s/browser.log"\n' "${SANDBOX}" \
> "${SANDBOX}/librewolf/librewolf"
chmod +x "${SANDBOX}/librewolf/librewolf"
touch "${SANDBOX}/librewolf/application.ini"
# Fake Firefox in PATH for the launcher dispatch assertions. Provision
# detection never consults it: the canonical /usr/lib/firefox path wins.
printf '#!/bin/sh\nprintf "%%s\\n" "firefox $*" >> "%s/browser.log"\n' "${SANDBOX}" \
> "${SANDBOX}/bin/firefox"
chmod +x "${SANDBOX}/bin/firefox"
# Pre-seed LibreWolf's own shipped policies — the merge must preserve
# them and the deprovision must return this baseline byte-for-byte.
cat > "${SANDBOX}/librewolf/distribution/policies.json" <<'EOF'
{
"policies": {
"DisableAppUpdate": true,
"LibreWolfOwnSetting": "must-survive"
}
}
EOF
cp "${SANDBOX}/librewolf/distribution/policies.json" "${SANDBOX}/original-lw-policies.json"
# Fake distro Firefox at /usr/lib/firefox (canonical Debian/Arch layout;
# Fedora uses /usr/lib64 — same code path). No PATH wrapper: detection
# must find it via the canonical path.
printf '#!/bin/sh\nexit 0\n' > "${SANDBOX}/root/usr/lib/firefox/firefox"
chmod +x "${SANDBOX}/root/usr/lib/firefox/firefox"
touch "${SANDBOX}/root/usr/lib/firefox/application.ini"
# Pre-seed Firefox's own policies in /etc/firefox/policies.
cat > "${SANDBOX}/root/etc/firefox/policies/policies.json" <<'EOF'
{
"policies": {
"DisableTelemetry": true,
"FirefoxOwnSetting": "must-survive"
}
}
EOF
cp "${SANDBOX}/root/etc/firefox/policies/policies.json" "${SANDBOX}/original-ff-policies.json"
# ─── PATH shims ───────────────────────────────────────────────────────
printf '#!/bin/sh\n# id shim: "id -u" -> 0 (root); "id -u NAME" -> uid or fail if absent\nif [ "$1" = "-u" ] && [ $# -eq 1 ]; then echo 0; exit 0; fi\nname="$2"\nif grep -q "^${name}:" "%s/passwd" 2>/dev/null; then echo 1500; exit 0; fi\nexit 1\n' \
"${SANDBOX}" > "${SANDBOX}/bin/id"
printf '#!/bin/sh\necho useradd "$@" >> "%s/priv.log"\nmkdir -p "%s/home/vestibule-kiosk"\nprintf "vestibule-kiosk:x:1500:1500::%s/home/vestibule-kiosk:/bin/sh\\n" >> "%s/passwd"\n' \
"${SANDBOX}" "${SANDBOX}" "${SANDBOX}" "${SANDBOX}" > "${SANDBOX}/bin/useradd"
printf '#!/bin/sh\nif [ "$1" = "passwd" ] && [ "$2" = "vestibule-kiosk" ]; then grep "^vestibule-kiosk:" "%s/passwd"; fi\nexit 0\n' \
"${SANDBOX}" > "${SANDBOX}/bin/getent"
printf '#!/bin/sh\necho systemctl "$@" >> "%s/priv.log"\nexit 0\n' "${SANDBOX}" > "${SANDBOX}/bin/systemctl"
printf '#!/bin/sh\nexit 0\n' > "${SANDBOX}/bin/chown"
printf '#!/bin/sh\necho userdel "$@" >> "%s/priv.log"\ngrep -v "^vestibule-kiosk:" "%s/passwd" > "%s/passwd.tmp" && mv "%s/passwd.tmp" "%s/passwd"\nexit 0\n' \
"${SANDBOX}" "${SANDBOX}" "${SANDBOX}" "${SANDBOX}" "${SANDBOX}" > "${SANDBOX}/bin/userdel"
# cage shim: log the invocation, then exec the client after "--". The
# harness pins VESTIBULE_CAGE_ARGS to a single flag (-d) in every
# launcher test, so exactly two arguments precede the client.
printf '#!/bin/sh\nprintf "%%s\\n" "cage $*" >> "%s/browser.log"\nshift 2\nexec "$@"\n' "${SANDBOX}" > "${SANDBOX}/bin/cage"
# dbus-run-session shim: pass straight through to the compositor.
printf '#!/bin/sh\nshift\nexec "$@"\n' > "${SANDBOX}/bin/dbus-run-session"
# flatpak shim: system installs "exist" (info --system succeeds), user
# installs do not, and "run" is a logged browser launch.
printf '#!/bin/sh\nif [ "$1" = "info" ]; then [ "$2" = "--system" ] && exit 0; exit 1; fi\nif [ "$1" = "run" ]; then printf "%%s\\n" "flatpak $*" >> "%s/browser.log"; exit 0; fi\nexit 1\n' \
"${SANDBOX}" > "${SANDBOX}/bin/flatpak"
for f in "${SANDBOX}/bin/"*; do chmod +x "$f"; done
# ─── Rewrite privileged paths in the scripts under test ───────────────
rewrite() {
sed -e "s|/run/systemd/system|${SANDBOX}/run-systemd|g" \
-e "s|/opt/vestibule|${SANDBOX}/root/opt/vestibule|g" \
-e "s|/etc/vestibule|${SANDBOX}/root/etc/vestibule|g" \
-e "s|/etc/systemd/system|${SANDBOX}/root/etc/systemd/system|g" \
-e "s|/etc/firefox|${SANDBOX}/root/etc/firefox|g" \
-e "s|/usr/lib/firefox|${SANDBOX}/root/usr/lib/firefox|g" \
-e "s|/usr/local/bin|${SANDBOX}/root/usrlocal|g" \
-e "s|/usr/lib/librewolf|${SANDBOX}/librewolf|g" \
"$1" > "$2"
}
mkdir -p "${SANDBOX}/run-systemd"
rewrite "${REPO_ROOT}/scripts/provision-kiosk.sh" "${SANDBOX}/repo/scripts/provision-kiosk.sh"
rewrite "${REPO_ROOT}/scripts/deprovision-kiosk.sh" "${SANDBOX}/repo/scripts/deprovision-kiosk.sh"
rewrite "${REPO_ROOT}/scripts/vestibule-kiosk-launch" "${SANDBOX}/repo/scripts/vestibule-kiosk-launch"
export PATH="${SANDBOX}/bin:${PATH}"
NM="${SANDBOX}/home/vestibule-kiosk"
UNIT="${SANDBOX}/root/etc/systemd/system/vestibule-kiosk.service"
run_provision() {
sh "${SANDBOX}/repo/scripts/provision-kiosk.sh" "$@" \
--kiosk-user "${KIOSK_USER}" --tty 2 --yes \
> "${SANDBOX}/provision.out" 2>&1
}
run_deprovision() {
sh "${SANDBOX}/repo/scripts/deprovision-kiosk.sh" \
--kiosk-user "${KIOSK_USER}" \
--remove-user --remove-opt --remove-usher --yes \
> "${SANDBOX}/deprovision.out" 2>&1
}
report_on_fail() {
if [ "$1" -ne 0 ]; then
sed -n '1,60p' "${SANDBOX}/provision.out" 2>/dev/null
sed -n '1,60p' "${SANDBOX}/deprovision.out" 2>/dev/null
fi
}
assert_common_provision() {
# Everything browser-independent: staging, launcher, unit, NM.
check "usher installed" test -x "${SANDBOX}/root/usrlocal/usher"
check "usher staged under /opt" test -x "${SANDBOX}/root/opt/vestibule/bin/usher"
check "launcher installed" test -x "${SANDBOX}/root/usrlocal/vestibule-kiosk-launch"
check "XPI built" test -f "${SANDBOX}/root/opt/vestibule/extension/vestibule.xpi"
if python3 -c "
import zipfile, json
z = zipfile.ZipFile('${SANDBOX}/root/opt/vestibule/extension/vestibule.xpi')
m = json.loads(z.read('manifest.json'))
assert m['version'] == '1.2.2', m['version']
"; then pass "XPI valid zip with manifest.json v1.2.2"; else fail "XPI valid zip with manifest.json v1.2.2"; fi
check "unit generated" test -f "${UNIT}"
check "unit User substituted" grep -q '^User=vestibule-kiosk$' "${UNIT}"
check "unit TTYPath substituted" grep -q '^TTYPath=/dev/tty2$' "${UNIT}"
check "unit conflicts getty" grep -q 'Conflicts=getty@tty2.service' "${UNIT}"
check "unit execs launcher" grep -q 'ExecStart=/usr/local/bin/vestibule-kiosk-launch' "${UNIT}"
check "systemctl daemon-reload" grep -q "daemon-reload" "${SANDBOX}/priv.log"
check "systemctl enable" grep -q "enable vestibule-kiosk.service" "${SANDBOX}/priv.log"
# Native Messaging manifests: all five Gecko locations, valid JSON.
for loc in ".librewolf" ".mozilla" \
".var/app/io.gitlab.librewolf-community/.librewolf" \
".var/app/org.mozilla.firefox/.mozilla" \
"snap/firefox/common/.mozilla"; do
f="${NM}/${loc}/native-messaging-hosts/com.vestibule.usher.json"
check "NM manifest ${loc}" test -f "${f}"
if [ -f "${f}" ] && python3 -c "
import json
m = json.load(open('${f}'))
assert m['path'] == '${SANDBOX}/root/usrlocal/usher', m['path']
assert m['allowed_extensions'] == ['vestibule@vestibule.kiosk']
assert m['type'] == 'stdio'
"; then pass "NM manifest ${loc} valid"; else fail "NM manifest ${loc} valid"; fi
done
}
assert_common_deprovision() {
check "unit removed" test ! -f "${UNIT}"
check "launcher removed" test ! -e "${SANDBOX}/root/usrlocal/vestibule-kiosk-launch"
check "kiosk.env removed" test ! -e "${SANDBOX}/root/etc/vestibule"
check "/opt/vestibule removed" test ! -e "${SANDBOX}/root/opt/vestibule"
check "usher removed" test ! -e "${SANDBOX}/root/usrlocal/usher"
check "userdel called" grep -q "userdel" "${SANDBOX}/priv.log"
check "NM manifests removed" test ! -e "${NM}/.librewolf/native-messaging-hosts/com.vestibule.usher.json"
check "NM manifests removed (firefox flatpak)" test ! -e "${NM}/.var/app/org.mozilla.firefox/.mozilla/native-messaging-hosts/com.vestibule.usher.json"
check "NM manifests removed (firefox snap)" test ! -e "${NM}/snap/firefox/common/.mozilla/native-messaging-hosts/com.vestibule.usher.json"
check "sandbox XPI copy removed" test ! -e "${NM}/.var/app/org.mozilla.firefox/vestibule.xpi"
}
# ══════════════════════════════════════════════════════════════════════
# Scenario 1: LibreWolf, native
# ══════════════════════════════════════════════════════════════════════
say ""
say "==> scenario 1: provision --librewolf native"
if run_provision --librewolf native --home-url https://checkin.example.org; then
pass "provision-kiosk.sh exited 0"
else
fail "provision-kiosk.sh exited nonzero"
report_on_fail 1
fi
assert_common_provision
check "kiosk.env written" test -f "${SANDBOX}/root/etc/vestibule/kiosk.env"
check "kiosk.env home URL" grep -q 'VESTIBULE_HOME_URL="https://checkin.example.org"' "${SANDBOX}/root/etc/vestibule/kiosk.env"
check "kiosk.env browser librewolf" grep -q 'VESTIBULE_BROWSER="librewolf"' "${SANDBOX}/root/etc/vestibule/kiosk.env"
check "kiosk.env flavor native" grep -q 'VESTIBULE_BROWSER_FLAVOR="native"' "${SANDBOX}/root/etc/vestibule/kiosk.env"
POL="${SANDBOX}/librewolf/distribution/policies.json"
if python3 -c "
import json
p = json.load(open('${POL}'))['policies']
assert p['LibreWolfOwnSetting'] == 'must-survive', 'pre-existing key lost'
assert p['DisablePrivateBrowsing'] is True, 'canonical key missing'
assert p['SanitizeOnShutdown']['Cookies'] is True, 'nested key missing'
es = p['ExtensionSettings']
assert es['*']['installation_mode'] == 'blocked'
assert es['vestibule@vestibule.kiosk']['installation_mode'] == 'force_installed'
assert es['vestibule@vestibule.kiosk']['install_url'] == 'file://${SANDBOX}/root/opt/vestibule/extension/vestibule.xpi', es['vestibule@vestibule.kiosk']['install_url']
"; then pass "policies.json deep-merge correct (librewolf)"; else fail "policies.json deep-merge correct (librewolf)"; fi
check "policies backup created" test -f "${POL}.vestibule-bak"
say ""
say "==> scenario 1: deprovision"
if run_deprovision; then
pass "deprovision-kiosk.sh exited 0"
else
fail "deprovision-kiosk.sh exited nonzero"
report_on_fail 1
fi
assert_common_deprovision
if cmp -s "${POL}" "${SANDBOX}/original-lw-policies.json"; then
pass "librewolf policies.json equals the pre-provision baseline (byte-for-byte)"
else
fail "librewolf policies.json equals the pre-provision baseline (byte-for-byte)"
fi
# ══════════════════════════════════════════════════════════════════════
# Scenario 2: Firefox, native (distro package -> /etc/firefox/policies)
# ══════════════════════════════════════════════════════════════════════
say ""
say "==> scenario 2: provision --firefox native"
if run_provision --firefox native --home-url https://portal.example.org; then
pass "provision-kiosk.sh exited 0"
else
fail "provision-kiosk.sh exited nonzero"
report_on_fail 1
fi
assert_common_provision
FFPOL="${SANDBOX}/root/etc/firefox/policies/policies.json"
check "kiosk.env browser firefox" grep -q 'VESTIBULE_BROWSER="firefox"' "${SANDBOX}/root/etc/vestibule/kiosk.env"
check "kiosk.env flavor native (ff)" grep -q 'VESTIBULE_BROWSER_FLAVOR="native"' "${SANDBOX}/root/etc/vestibule/kiosk.env"
check "firefox policies file deployed" test -f "${FFPOL}"
if python3 -c "
import json
p = json.load(open('${FFPOL}'))['policies']
assert p['FirefoxOwnSetting'] == 'must-survive', 'pre-existing Firefox key lost'
assert p['DisablePrivateBrowsing'] is True, 'canonical key missing'
assert p['SanitizeOnShutdown']['Cookies'] is True, 'nested key missing'
es = p['ExtensionSettings']
assert es['vestibule@vestibule.kiosk']['installation_mode'] == 'force_installed'
assert es['vestibule@vestibule.kiosk']['install_url'] == 'file://${SANDBOX}/root/opt/vestibule/extension/vestibule.xpi', es['vestibule@vestibule.kiosk']['install_url']
"; then pass "firefox /etc/firefox policies deep-merge correct"; else fail "firefox /etc/firefox policies deep-merge correct"; fi
check "firefox policies backup created" test -f "${FFPOL}.vestibule-bak"
# Firefox provisioning must not touch the LibreWolf install.
if cmp -s "${POL}" "${SANDBOX}/original-lw-policies.json"; then
pass "librewolf policies untouched by firefox provision"
else
fail "librewolf policies untouched by firefox provision"
fi
say ""
say "==> scenario 2: deprovision"
if run_deprovision; then
pass "deprovision-kiosk.sh exited 0"
else
fail "deprovision-kiosk.sh exited nonzero"
report_on_fail 1
fi
assert_common_deprovision
if cmp -s "${FFPOL}" "${SANDBOX}/original-ff-policies.json"; then
pass "firefox /etc/firefox policies.json equals the pre-provision baseline (byte-for-byte)"
else
fail "firefox /etc/firefox policies.json equals the pre-provision baseline (byte-for-byte)"
fi
# ══════════════════════════════════════════════════════════════════════
# Scenario 3: Firefox, Flatpak (kiosk-home policy path + XPI copy)
# ══════════════════════════════════════════════════════════════════════
say ""
say "==> scenario 3: provision --firefox flatpak"
if run_provision --firefox flatpak --home-url https://lobby.example.org; then
pass "provision-kiosk.sh exited 0"
else
fail "provision-kiosk.sh exited nonzero"
report_on_fail 1
fi
assert_common_provision
FFHOME_POL="${NM}/.var/app/org.mozilla.firefox/.mozilla/policies/policies.json"
check "kiosk.env browser firefox (fp)" grep -q 'VESTIBULE_BROWSER="firefox"' "${SANDBOX}/root/etc/vestibule/kiosk.env"
check "kiosk.env flavor flatpak (fp)" grep -q 'VESTIBULE_BROWSER_FLAVOR="flatpak"' "${SANDBOX}/root/etc/vestibule/kiosk.env"
check "XPI copied to flatpak home" test -f "${NM}/.var/app/org.mozilla.firefox/vestibule.xpi"
check "flatpak policies file deployed" test -f "${FFHOME_POL}"
if python3 -c "
import json
p = json.load(open('${FFHOME_POL}'))['policies']
es = p['ExtensionSettings']
assert es['vestibule@vestibule.kiosk']['installation_mode'] == 'force_installed'
# install_url must point INSIDE the sandbox-visible home, not /opt.
assert es['vestibule@vestibule.kiosk']['install_url'] == 'file://${NM}/vestibule.xpi', es['vestibule@vestibule.kiosk']['install_url']
"; then pass "flatpak install_url points at sandbox-visible XPI"; else fail "flatpak install_url points at sandbox-visible XPI"; fi
say ""
say "==> scenario 3: deprovision"
if run_deprovision; then
pass "deprovision-kiosk.sh exited 0"
else
fail "deprovision-kiosk.sh exited nonzero"
report_on_fail 1
fi
assert_common_deprovision
check "flatpak-home policies removed" test ! -e "${FFHOME_POL}"
# ════════════════════════════════════════════════════════════════════
# Launcher dispatch: all four env permutations run against browser
# shims; the exec'd command line is asserted, not just parsed.
# ════════════════════════════════════════════════════════════════════
say ""
say "==> launcher dispatch"
LAUNCHER="${SANDBOX}/repo/scripts/vestibule-kiosk-launch"
KIOSK_ENV="${SANDBOX}/root/etc/vestibule/kiosk.env"
URL="https://launch.example.org"
write_kiosk_env() {
mkdir -p "${SANDBOX}/root/etc/vestibule"
{
printf 'VESTIBULE_HOME_URL="%s"\n' "${URL}"
printf 'VESTIBULE_CAGE_ARGS="-d"\n'
[ -n "${1:-}" ] && printf 'VESTIBULE_BROWSER="%s"\n' "$1"
[ -n "${2:-}" ] && printf 'VESTIBULE_BROWSER_FLAVOR="%s"\n' "$2"
} > "${KIOSK_ENV}"
}
launcher_check() {
# $1 = description, $2 = expected command line (fixed string)
desc="$1"; expect="$2"
rm -f "${SANDBOX}/browser.log"
sh "${LAUNCHER}" > "${SANDBOX}/launcher.out" 2>&1
if grep -qF "${expect}" "${SANDBOX}/browser.log" 2>/dev/null; then
pass "launcher dispatches ${desc}"
else
fail "launcher dispatches ${desc} (log: $(cat "${SANDBOX}/browser.log" 2>/dev/null || echo empty))"
fi
}
write_kiosk_env firefox flatpak
launcher_check "firefox/flatpak -> flatpak run" \
"flatpak run org.mozilla.firefox --kiosk -P vestibule-profile -no-remote ${URL}"
write_kiosk_env firefox native
launcher_check "firefox/native -> firefox" \
"firefox --kiosk -P vestibule-profile -no-remote ${URL}"
write_kiosk_env librewolf flatpak
launcher_check "librewolf/flatpak -> flatpak run" \
"flatpak run io.gitlab.librewolf-community --kiosk -P vestibule-profile -no-remote ${URL}"
write_kiosk_env "" ""
launcher_check "defaults -> librewolf native" \
"librewolf --kiosk -P vestibule-profile -no-remote ${URL}"
# ─── Summary ──────────────────────────────────────────────────────────
say ""
if [ "${FAIL}" -eq 0 ]; then
say "OK — ${PASS} assertions passed: kiosk provision/deprovision works for LibreWolf native, Firefox native, and Firefox Flatpak."
exit 0
else
say "FAIL: ${FAIL} failed of $((PASS+FAIL))"
exit 1
fi

175
scripts/test-url-policy.js Normal file
View File

@ -0,0 +1,175 @@
#!/usr/bin/env node
// test-url-policy.js — unit tests for the Vestibule URL policy engine.
//
// The engine (extension/url-policy.js) is pure logic with no browser
// dependencies, so Node loads it directly through the module.exports
// arm of its UMD-lite export. Run: node scripts/test-url-policy.js
//
// Structure follows the project's table-driven test convention:
// every case is a row; the driver is one loop; the failure report
// names the case, the input, and both expectation and result.
"use strict";
const P = require("../extension/url-policy.js");
let passed = 0;
let failed = 0;
function check(label, actual, expected) {
const ok = actual === expected;
if (ok) {
passed += 1;
} else {
failed += 1;
console.error(` [FAIL] ${label}`);
console.error(` expected: ${JSON.stringify(expected)}`);
console.error(` actual: ${JSON.stringify(actual)}`);
}
}
// ─── Entry normalization ────────────────────────────────────────────
console.log("==> normalizeDomainEntry");
const NORMALIZE_CASES = [
// [input, expected hostname]
["example.org", "example.org"],
[" Example.ORG ", "example.org"], // surrounding whitespace + case
["*.example.org", "example.org"], // wildcard prefix stripped
["https://portal.example.org/welcome", "portal.example.org"], // pasted URL
["http://example.org:8080/path?q=1", "example.org"], // port and path dropped
["https://Example.Org/", "example.org"],
["not a domain", null], // spaces inside — no hostname
["", null],
[null, null],
[" ", null],
];
NORMALIZE_CASES.forEach(([input, expected]) => {
check(`normalize(${JSON.stringify(input)})`, P.normalizeDomainEntry(input), expected);
});
// ─── Home hostname extraction ───────────────────────────────────────
console.log("==> homeHostnameOf");
check("http home", P.homeHostnameOf("http://kiosk.example.org/start"), "kiosk.example.org");
check("https home", P.homeHostnameOf("https://portal.example.org/"), "portal.example.org");
check("about:blank is not a home origin", P.homeHostnameOf("about:blank"), null);
check("empty", P.homeHostnameOf(""), null);
check("missing", P.homeHostnameOf(undefined), null);
// ─── Safelist mode decisions ────────────────────────────────────────
console.log("==> safelist mode");
const SAFE = { mode: "safelist", safelist: ["example.org", "cdn.example.net"] };
const sub = { mainFrame: true }; // subresource context would be {mainFrame:false}
check("listed domain allowed", P.shouldBlockRequest("https://example.org/welcome", SAFE, sub), false);
check("subdomain of listed domain allowed", P.shouldBlockRequest("https://portal.example.org/", SAFE, sub), false);
check("deep subdomain allowed", P.shouldBlockRequest("https://a.b.example.org/x", SAFE, sub), false);
check("second entry allowed", P.shouldBlockRequest("https://cdn.example.net/lib.js", SAFE, sub), false);
check("unlisted domain blocked", P.shouldBlockRequest("https://evil.com/", SAFE, sub), true);
check("sibling domain blocked", P.shouldBlockRequest("https://evilexample.org/", SAFE, sub), true);
check("query-string smuggle blocked", P.shouldBlockRequest("https://evil.com/?q=example.org", SAFE, sub), true);
check("path smuggle blocked", P.shouldBlockRequest("https://evil.com/example.org", SAFE, sub), true);
check("userinfo smuggle blocked", P.shouldBlockRequest("https://example.org@evil.com/", SAFE, sub), true);
check("empty hostname (file:) blocked", P.shouldBlockRequest("file:///etc/passwd", SAFE, sub), true);
// Internal schemes — the browser machinery must keep working.
check("about:blank always allowed", P.shouldBlockRequest("about:blank", SAFE, sub), false);
check("moz-extension always allowed", P.shouldBlockRequest("moz-extension://abc/blocked.html?u=x", SAFE, sub), false);
check("chrome: always allowed", P.shouldBlockRequest("chrome://global/skin/", SAFE, sub), false);
check("resource: always allowed", P.shouldBlockRequest("resource://gre/modules/", SAFE, sub), false);
// data:/blob: — subresource yes, top-level no.
check("data: subresource allowed", P.shouldBlockRequest("data:image/png;base64,AAA", SAFE, { mainFrame: false }), false);
check("blob: subresource allowed", P.shouldBlockRequest("blob:https://example.org/uuid", SAFE, { mainFrame: false }), false);
check("data: top-level blocked", P.shouldBlockRequest("data:text/html,<script>1</script>", SAFE, { mainFrame: true }), true);
check("blob: top-level blocked", P.shouldBlockRequest("blob:https://example.org/uuid", SAFE, { mainFrame: true }), true);
// Empty safelist — the safe-by-default posture: nothing external loads.
const EMPTY = { mode: "safelist", safelist: [] };
check("empty safelist blocks http", P.shouldBlockRequest("https://example.org/", EMPTY, sub), true);
check("empty safelist allows about:blank", P.shouldBlockRequest("about:blank", EMPTY, sub), false);
// Unnormalized entries in the list still match (storage written by
// hand, older tools, etc. — the engine normalizes on read).
const RAW = { mode: "safelist", safelist: ["https://Example.ORG/path"] };
check("raw URL entry normalizes on read", P.shouldBlockRequest("https://sub.example.org/", RAW, sub), false);
// ─── Home-origin guarantee ──────────────────────────────────────────
console.log("==> home-origin guarantee");
const HOME_CTX = { mainFrame: true, homeHostname: "lobby.example.org" };
check("home origin passes empty safelist", P.shouldBlockRequest("https://lobby.example.org/start", EMPTY, HOME_CTX), false);
check("home origin passes with safelist active", P.shouldBlockRequest("https://lobby.example.org/", SAFE, HOME_CTX), false);
check("subdomain of home is NOT auto-covered", P.shouldBlockRequest("https://www.lobby.example.org/", EMPTY, HOME_CTX), true);
check("sibling of home blocked", P.shouldBlockRequest("https://lobby.example.org.evil.com/", EMPTY, HOME_CTX), true);
check("home exemption applies to subresources too", P.shouldBlockRequest("https://lobby.example.org/app.js", EMPTY, { mainFrame: false, homeHostname: "lobby.example.org" }), false);
// ─── Legacy modes (behavior unchanged from 1.2.0) ──────────────────
console.log("==> legacy modes");
check("open never blocks", P.shouldBlockRequest("https://anything.anywhere/", { mode: "open" }, sub), false);
check("blocklist blocks substring", P.shouldBlockRequest("https://example.org/blocked/x", { mode: "blocklist", blocklist: ["example.org/blocked"] }, sub), true);
check("blocklist allows the rest", P.shouldBlockRequest("https://example.org/ok", { mode: "blocklist", blocklist: ["example.org/blocked"] }, sub), false);
check("allowlist allows listed substring", P.shouldBlockRequest("https://example.org/x", { mode: "allowlist", allowlist: ["example.org"] }, sub), false);
check("allowlist blocks unlisted", P.shouldBlockRequest("https://other.org/", { mode: "allowlist", allowlist: ["example.org"] }, sub), true);
check("allowlist with empty list allows everything (documented legacy quirk)", P.shouldBlockRequest("https://anything.org/", { mode: "allowlist", allowlist: [] }, sub), false);
check("substring allowlist passes query-string smuggle (the weakness safelist fixes)", P.shouldBlockRequest("https://evil.com/?q=example.org", { mode: "allowlist", allowlist: ["example.org"] }, sub), false);
// ─── Fail-closed on unknown mode ────────────────────────────────────
console.log("==> unknown mode fails closed");
const GARBAGE = { mode: "alllowlist", safelist: [] }; // corrupted policy
check("unknown mode blocks external", P.shouldBlockRequest("https://evil.com/", GARBAGE, sub), true);
check("unknown mode keeps internal pages working", P.shouldBlockRequest("about:blank", GARBAGE, sub), false);
check("unknown mode keeps home working", P.shouldBlockRequest("https://home.org/", GARBAGE, { mainFrame: true, homeHostname: "home.org" }), false);
// ─── First-boot startup adoption ────────────────────────────────────
console.log("==> startup adoption");
const DEFAULTS = { mode: "safelist", safelist: [], homeUrl: "about:blank" };
let adopted = P.adoptStartupPolicy(
["about:blank", "https://checkin.example.org/welcome"], DEFAULTS);
check("provisioned startup page adopted", adopted !== null, true);
check("adopted home URL is the startup page",
!!(adopted && adopted.homeUrl === "https://checkin.example.org/welcome"), true);
check("adopted safelist is the page's domain",
!!(adopted && adopted.safelist.length === 1 && adopted.safelist[0] === "checkin.example.org"), true);
check("no http startup tabs → no adoption",
P.adoptStartupPolicy(["about:blank"], DEFAULTS), null);
check("no tabs at all → no adoption", P.adoptStartupPolicy([], DEFAULTS), null);
check("configured home → no adoption",
P.adoptStartupPolicy(["https://checkin.example.org/"], { ...DEFAULTS, homeUrl: "https://other.example.org/" }), null);
check("non-empty safelist → no adoption",
P.adoptStartupPolicy(["https://checkin.example.org/"], { ...DEFAULTS, safelist: ["existing.example.org"] }), null);
check("null policy → no adoption", P.adoptStartupPolicy(["https://x.example.org/"], null), null);
// The adopted policy must actually admit the startup page through the
// engine (home guarantee + safelist entry).
check("adopted policy admits the startup page",
!!(adopted && !P.shouldBlockRequest(adopted.homeUrl, adopted, { mainFrame: true })), true);
check("adopted policy still blocks other domains",
!!(adopted && P.shouldBlockRequest("https://evil.com/", adopted, { mainFrame: true })), true);
check("adopted policy admits subdomains of the home domain",
!!(adopted && !P.shouldBlockRequest("https://portal.checkin.example.org/", adopted, { mainFrame: true })), true);
// ─── Report ─────────────────────────────────────────────────────────
console.log("");
if (failed === 0) {
console.log(`OK — ${passed}/${passed + failed} URL policy assertions pass.`);
process.exit(0);
}
console.log(`FAIL: ${failed} of ${passed + failed} assertions failed.`);
process.exit(1);

203
scripts/validate.sh Executable file
View File

@ -0,0 +1,203 @@
#!/bin/sh
# validate.sh — local pre-ship validation for the Vestibule release.
# Mirrors the CI checks that can run without Windows/flatpak/cargo.
#
# Usage: sh scripts/validate.sh (from anywhere inside the repo copy)
# Exit: 0 all pass, 1 failure
REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd)
cd "${REPO_ROOT}"
FAILURES=0
say() { printf '%s\n' "$1"; }
pass() { say " [pass] $1"; }
fail() { FAILURES=$((FAILURES+1)); say " [FAIL] $1"; }
say "==> POSIX sh syntax"
for f in scripts/*.sh scripts/vestibule-kiosk-launch packaging/vestibule-flatpak-cli packaging/build-flatpak.sh; do
if sh -n "$f" 2>/dev/null; then pass "$f"; else fail "$f"; fi
done
say "==> Python syntax"
for f in scripts/*.py; do
if python3 -m py_compile "$f" 2>/dev/null; then pass "$f"; else fail "$f"; fi
done
say "==> JSON validity"
for f in extension/manifest.json config/policies.json config/com.vestibule.usher.linux.json \
config/com.vestibule.usher.windows.json packaging/net.dcos.Vestibule.json; do
if python3 -c "import json; json.load(open('$f'))" 2>/dev/null; then pass "$f"; else fail "$f"; fi
done
say "==> XML validity"
if python3 -c "import xml.dom.minidom; xml.dom.minidom.parse('packaging/net.dcos.Vestibule.metainfo.xml')" 2>/dev/null; then
pass "metainfo.xml"
else
fail "metainfo.xml"
fi
say "==> YAML validity (ci.yml)"
if python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))" 2>/dev/null; then
pass "ci.yml"
else
fail "ci.yml"
fi
say "==> Version consistency (1.2.2)"
v_cargo=$(sed -n 's/^version = "\(.*\)"/\1/p' helper/Cargo.toml | head -1)
v_manifest=$(python3 -c "import json; print(json.load(open('extension/manifest.json'))['version'])")
v_metainfo=$(python3 -c "import re; print(re.search(r'release version=\"([^\"]+)\"', open('packaging/net.dcos.Vestibule.metainfo.xml').read()).group(1))")
v_iss=$(sed -n 's/^#define MyAppVersion "\(.*\)"/\1/p' packaging/vestibule.iss | head -1)
v_cli=$(sed -n 's/^VERSION="\(.*\)"/\1/p' packaging/vestibule-flatpak-cli | head -1)
for pair in "Cargo.toml:$v_cargo" "manifest.json:$v_manifest" "metainfo:$v_metainfo" "vestibule.iss:$v_iss" "flatpak-cli:$v_cli"; do
name=${pair%%:*}; val=${pair#*:}
if [ "$val" = "1.2.2" ]; then pass "$name = $val"; else fail "$name = $val (expected 1.2.2)"; fi
done
say "==> PowerShell sanity (structure checks)"
if python3 - <<'PYEOF'
import sys, re
files = [
"scripts/provision-kiosk.ps1",
"scripts/deprovision-kiosk.ps1",
"scripts/kiosk-launch.ps1",
"packaging/build-installer.ps1",
]
problems = []
for path in files:
src = open(path, encoding="utf-8").read()
lines = src.splitlines()
# Here-string terminators must start at column 0.
for i, ln in enumerate(lines, 1):
if re.match(r'^\s+@"|^\s+@\'', ln):
problems.append(f"{path}:{i} here-string opener must be at column 0")
# Brace/paren/bracket balance outside strings and comments (heuristic).
# Order matters: strip quoted strings FIRST (strings may contain '#'),
# then strip trailing comments (comments may contain quotes).
cleaned = []
in_herestring = False
for ln in lines:
if in_herestring:
if ln.startswith('"@') or ln.startswith("'@"):
in_herestring = False
continue
if ln.lstrip().startswith('@"') or ln.lstrip().startswith("@'"):
in_herestring = True
continue
no_strings = re.sub(r'"[^"]*"', '""', ln)
no_strings = re.sub(r"'[^']*'", "''", no_strings)
no_comment = re.sub(r'#.*$', '', no_strings)
cleaned.append(no_comment)
blob = "\n".join(cleaned)
for open_c, close_c in [("{", "}"), ("(", ")"), ("[", "]")]:
if blob.count(open_c) != blob.count(close_c):
problems.append(
f"{path}: unbalanced {open_c}{close_c} "
f"({blob.count(open_c)} vs {blob.count(close_c)})")
if problems:
print("\n".join(problems))
sys.exit(1)
print(f"{len(files)} files structurally consistent")
PYEOF
then
pass "PowerShell structural checks"
else
fail "PowerShell structural checks"
fi
say "==> URL policy unit tests (node)"
# The engine is pure JavaScript with no browser dependencies; Node runs
# the same file the background script loads. CI runs this gate on every
# push; on a node-less machine it is reported, not silently skipped.
if command -v node >/dev/null 2>&1; then
if node scripts/test-url-policy.js >/tmp/vestibule-urlpolicy.log 2>&1; then
pass "scripts/test-url-policy.js"
else
fail "scripts/test-url-policy.js"
tail -20 /tmp/vestibule-urlpolicy.log
fi
else
say " [warn] node not found — URL policy unit tests skipped (CI runs them)"
fi
say "==> XPI build smoke test"
tmp_xpi=$(mktemp -u).xpi
if python3 - "$REPO_ROOT/extension" "$tmp_xpi" <<'PYEOF'
import os, sys, zipfile
src_dir, dst = sys.argv[1], sys.argv[2]
with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as z:
for root, dirs, files in os.walk(src_dir):
dirs.sort()
for f in sorted(files):
full = os.path.join(root, f)
z.write(full, os.path.relpath(full, src_dir))
with zipfile.ZipFile(dst) as z:
m = __import__("json").loads(z.read("manifest.json"))
assert m["version"] == "1.2.2"
assert m["browser_specific_settings"]["gecko"]["id"] == "vestibule@vestibule.kiosk"
assert m["background"]["scripts"][0] == "url-policy.js", "policy engine must load first"
for name in ["background.js", "url-policy.js", "content.js", "admin.html", "unlock.html", "blocked.html"]:
assert name in z.namelist(), name
PYEOF
then
pass "XPI builds; manifest + all entry points present"
else
fail "XPI build"
fi
rm -f "$tmp_xpi"
say "==> Icon assets"
for f in packaging/icons/vestibule.ico packaging/icons/vestibule.svg packaging/icons/vestibule-256.png; do
if [ -s "$f" ]; then pass "$f"; else fail "$f"; fi
done
say "==> Historic artifact scrub (history/)"
# The 2001 VB6 artifact and every other file in the tree must stay free
# of the scrubbed identifiers: the school initials, the employer names,
# the original unlock code, and the employer-branded project name.
if python3 - <<'PYEOF'
import os
import sys
FORBIDDEN = ("gl" "ths", # school initials
"advanced technical " "solutions", # employer name
"ats" "inc", # original unlock code
"ats" "browser", # employer-branded project name
"school" "version") # renamed form's old filename
hits = []
for root, dirs, files in os.walk("."):
dirs[:] = [d for d in dirs if d not in (".git", "__pycache__")]
for name in files:
path = os.path.join(root, name)
try:
blob = open(path, "rb").read().decode("utf-8", "ignore").lower()
except OSError:
continue
hits.extend(f"{path}: {token}" for token in FORBIDDEN if token in blob)
if hits:
print("\n".join(hits))
sys.exit(1)
PYEOF
then
pass "no scrub-list identifiers anywhere in the tree"
else
fail "scrubbed identifiers present (see list above)"
fi
say "==> Rootless provision/deprovision integration test"
if sh scripts/test-provision-linux.sh > /tmp/vestibule-inttest.log 2>&1; then
pass "3-scenario integration test (LibreWolf native + Firefox native + Firefox Flatpak)"
else
fail "integration test"
tail -40 /tmp/vestibule-inttest.log
fi
say ""
if [ "${FAILURES}" -eq 0 ]; then
say "OK — all local validation passed."
exit 0
else
say "FAIL: ${FAILURES} check(s) failed"
exit 1
fi

73
scripts/vestibule-kiosk-launch Executable file
View File

@ -0,0 +1,73 @@
#!/bin/sh
# vestibule-kiosk-launch — the process the kiosk systemd unit execs.
#
# Reads /etc/vestibule/kiosk.env, then starts the cage compositor with
# the configured Gecko browser (LibreWolf or Firefox; native, Flatpak,
# or snap) as its only client. dbus-run-session provides the session
# bus that both cage children and sandboxed browsers need.
#
# Installed to /usr/local/bin by provision-kiosk.sh. Edit
# /etc/vestibule/kiosk.env to change behavior — never this file.
#
# kiosk.env keys:
# VESTIBULE_HOME_URL page the kiosk opens (default: about:blank)
# VESTIBULE_BROWSER librewolf (default) | firefox
# VESTIBULE_BROWSER_FLAVOR native (default) | flatpak | snap
# VESTIBULE_CAGE_ARGS extra cage flags (default: -d)
#
# Dispatch is step-down: flavor first (flatpak runs the sandbox app),
# then browser name (native and snap flavors share the plain exec).
#
# POSIX sh — no bashisms. Runs on any minimal Linux base.
set -eu
ENV_FILE="/etc/vestibule/kiosk.env"
if [ -r "${ENV_FILE}" ]; then
. "${ENV_FILE}"
fi
: "${VESTIBULE_HOME_URL:=about:blank}"
: "${VESTIBULE_BROWSER:=librewolf}"
: "${VESTIBULE_BROWSER_FLAVOR:=native}"
# cage flags: "-d" allows VT switching (admin escape hatch — switch away
# from the kiosk with Ctrl+Alt+F3 etc. on cage >= 0.1.2). Set to "" on
# older cage builds that reject it.
: "${VESTIBULE_CAGE_ARGS:=-d}"
LW_FLATPAK_APP="io.gitlab.librewolf-community"
FF_FLATPAK_APP="org.mozilla.firefox"
# Gecko defaults to X11 and cage ships no Xwayland: force native Wayland
# or the browser exits with "cannot open display". Applies to every
# Gecko flavor — LibreWolf and Firefox alike.
MOZ_ENABLE_WAYLAND=1
GDK_BACKEND=wayland
XDG_SESSION_TYPE=wayland
export MOZ_ENABLE_WAYLAND GDK_BACKEND XDG_SESSION_TYPE
CAGE="cage"
command -v cage >/dev/null 2>&1 || CAGE="/usr/bin/cage"
URL="${VESTIBULE_HOME_URL}"
if [ "${VESTIBULE_BROWSER_FLAVOR}" = "flatpak" ]; then
FLATPAK_APP="${LW_FLATPAK_APP}"
case "${VESTIBULE_BROWSER}" in
firefox) FLATPAK_APP="${FF_FLATPAK_APP}" ;;
esac
# shellcheck disable=SC2086 # VESTIBULE_CAGE_ARGS is intentionally word-split
exec dbus-run-session -- "${CAGE}" ${VESTIBULE_CAGE_ARGS} -- \
flatpak run "${FLATPAK_APP}" \
--kiosk -P vestibule-profile -no-remote "${URL}"
fi
# native and snap flavors both exec the browser by name — the snap
# wrapper ships the same CLI.
BROWSER_BIN="librewolf"
case "${VESTIBULE_BROWSER}" in
firefox) BROWSER_BIN="firefox" ;;
esac
# shellcheck disable=SC2086 # VESTIBULE_CAGE_ARGS is intentionally word-split
exec dbus-run-session -- "${CAGE}" ${VESTIBULE_CAGE_ARGS} -- \
"${BROWSER_BIN}" --kiosk -P vestibule-profile -no-remote "${URL}"

View File

@ -0,0 +1,29 @@
[Unit]
Description=Vestibule kiosk (cage + LibreWolf)
Documentation=file:/opt/vestibule/docs/DEPLOYMENT.md
# The cage compositor takes a VT and runs LibreWolf as the kiosk user in
# a full-screen, no-chrome Wayland session. No display manager is needed:
# this unit IS the graphical session, started directly at boot.
After=systemd-user-sessions.service dbus.service
Conflicts=getty@tty__TTY__.service
[Service]
Type=simple
User=__KIOSK_USER__
# PAMName=login gives the service login-session semantics: pam_systemd
# creates the runtime directory (XDG_RUNTIME_DIR) that Wayland needs.
# The account's password stays locked — nothing authenticates to get in.
PAMName=login
TTYPath=/dev/tty__TTY__
StandardInput=tty
StandardOutput=journal
StandardError=journal
UtmpIdentifier=tty__TTY__
# The launcher reads /etc/vestibule/kiosk.env (home URL, LibreWolf
# flavor, cage args) and execs: dbus-run-session -- cage -- librewolf.
ExecStart=/usr/local/bin/vestibule-kiosk-launch
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

24
scripts/vestibule-usher.service Executable file
View File

@ -0,0 +1,24 @@
[Unit]
Description=Vestibule usher — kiosk unlock helper + power monitor
After=graphical-session.target
PartOf=graphical-session.target
[Service]
Type=simple
# usher is launched by LibreWolf via Native Messaging when the extension
# connects. This systemd unit is a watchdog that ensures usher is
# available even before LibreWolf starts, and restarts it if it crashes
# outside of a Native Messaging session.
#
# In typical kiosk operation, LibreWolf manages usher's lifecycle.
# This unit is the step-down supervisor for bare-metal deployments
# where usher must run ahead of the browser for unlock and power
# monitoring.
ExecStart=%h/.local/bin/usher
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=graphical-session.target