Note
AI usage disclosure. The project itself is hand-written; AI was not used in its design or original implementation. AI assistance was used only for two narrowly scoped tasks: a recent security audit and the follow-up fixes that came out of it, and the first draft of this README.
A small, customizable password-protection layer that sits in front of an HTTP service or a local directory. Think "put a login wall on the thing" for self-hosted setups where you don't want to wire up a full identity provider.
pathguard ships with:
- An admin dashboard for managing users, groups, and access rules.
- A rule engine that controls which URL paths each group can reach.
- An activity log of every authenticated and anonymous access decision.
- A NixOS module for the impatient.
It's a one-binary, one-SQLite-file deployment with no external dependencies at runtime.
pathguard runs in one of two modes, picked as a subcommand:
pathguard [options] proxy <port> # reverse-proxy to 127.0.0.1:<port>
pathguard [options] files <root> # serve files from <root> with a browser UI
In proxy mode, authenticated requests are forwarded to a backend
listening on the given local port; the backend sees an added
X-Pathguard-User header identifying the caller.
In files mode, pathguard itself serves the directory tree, hides files that the caller's rules don't permit, and renders a minimal file-browser UI.
In both modes, the admin dashboard is mounted at /pathguard (or
wherever --dashboard points).
You'll need a nightly Rust toolchain.
# Serve ./public behind a login wall.
cargo run -p pathguard -- files ./public
# Or reverse-proxy a service already running on port 3000.
cargo run -p pathguard -- proxy 3000Visit http://localhost:8000/pathguard. Log in as admin / password
(change this immediately — see Security). From the
dashboard you can create groups, add rules, and create additional
users.
- A user has a name and password and belongs to one or more groups.
- A group is a named, ordered collection of access rules.
- The seeded
adminuser bypasses all rules and has access to the dashboard. - The seeded
defaultgroup governs anonymous (not-logged-in) access. Add allow rules here to expose specific paths publicly.
A rule is (path_prefix, allow | block | unset). When a request comes
in, pathguard walks all rules for all groups the caller belongs to in
sort order; the last matching rule's verdict wins. If nothing
matches, the request is blocked.
Prefix matching is on path-component boundaries: a rule for /public
matches /public and /public/anything but not /publicfile.
unset rules are placeholders — useful when you want to keep a rule's
position in the sort order but neither allow nor block.
Every authenticated and anonymous access decision is written to the
activities table and surfaced at /pathguard/activity, with
filtering by user and path. Admin actions are not logged (you can see
who the admin is by definition).
pathguard [OPTIONS] <COMMAND>
Options:
--db <PATH> Database file (default: database.db)
-k, --key <PATH> Session-key file (default: session.key)
-p, --port <PORT> Listen port on 127.0.0.1 (default: 8000)
-d, --dashboard <PATH> Dashboard mount path (default: /pathguard)
-m, --min-password-strength <SCORE> Reject passwords below this score, 0–100
(default: 60.0)
--trust-forwarded-for Trust Forwarded / X-Forwarded-For when
recording the client IP in the activity
log. Only enable when behind a reverse
proxy that strips client-supplied copies.
Default: off.
Commands:
proxy <PORT> Reverse-proxy authenticated requests to 127.0.0.1:<PORT>
files <ROOT> Serve files from <ROOT>
The database and session-key files are created with mode 0600 on
first run.
Every proxied request carries an X-Pathguard-User header:
X-Pathguard-User: alice # authenticated user "alice"
X-Pathguard-User: # anonymous-but-allowed caller
The header is always present on requests pathguard forwards — its
presence is the signal "this came through pathguard." Any client-supplied
copy of the header is stripped before pathguard injects its own, so a
backend can trust the value as long as the only path to it is through
pathguard (enforce that with a firewall or by binding the backend to
127.0.0.1).
pathguard's own session cookie (pathguard_id) is stripped from the
forwarded Cookie header. Other cookies pass through unchanged.
The flake exposes both a package and a NixOS module:
{
inputs.pathguard.url = "github:ElnuDev/pathguard";
outputs = { self, nixpkgs, pathguard, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
pathguard.nixosModules.default
({ ... }: {
services.pathguard = {
enable = true;
port = 8000;
dashboard = "/pathguard";
minPasswordStrength = 60;
mode = {
kind = "files";
root = "/srv/public";
};
# Set to true ONLY if you're behind a proxy that
# strips client-supplied X-Forwarded-For. The default
# is false; the activity log uses the peer address.
trustForwardedFor = false;
};
})
];
};
};
}The systemd unit uses DynamicUser=true and a StateDirectory, so the
database and session key live in /var/lib/pathguard with restrictive
permissions out of the box.
Running ad hoc against this flake:
nix run github:ElnuDev/pathguard -- files ./publicpathguard is designed for a specific threat model: keeping casual internet traffic and untrusted LAN peers out of a self-hosted service. It is not designed to replace SSO, MFA, or any system where the passwords being protected are high-value credentials in their own right.
Some specifics worth knowing up front:
-
Passwords are stored in plaintext in the database. This is intentional: pathguard's intended use is informal access passwords — the kind you'd share with a friend in chat to let them see your photo gallery. The integrity of who can change passwords (admin re-authentication is required to rotate the admin password) matters more than the confidentiality of the password values themselves. If you have a credential that you wouldn't want leaked from a database backup, don't use it as a pathguard password.
-
The default admin password is
password. Change it on first login. There is currently no forced-rotation gate. -
Logins are rate-limited per source IP with exponential backoff, capped at 60 seconds, so brute-force is not practical even with the plaintext storage.
-
Sessions are encrypted cookies (the actix-session default). The key is persisted to
session.key(mode0600) and survives restarts. -
Audit log records every protected access. The IP recorded is the socket peer by default; enable
--trust-forwarded-foronly when pathguard is behind a proxy that overwrites the header. -
Defense-in-depth headers (
X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer) are set globally and only on responses where the backend hasn't set its own value.
The project is a Cargo workspace with a single member crate in
pathguard/. Nightly Rust is required (#![feature(impl_trait_in_assoc_type)]).
Common commands, run from the repo root:
cargo run -p pathguard -- files ./public # run with debug build
cargo test # unit tests (robots_txt + proxy helpers)
cargo fmt # rustfmt is configured to use hard tabsDiesel migrations live in pathguard/migrations/ and are compiled
into the binary, so a deployed binary self-bootstraps its schema on
first run. To work on the schema:
cd pathguard
diesel migration generate <name>
diesel migration run
diesel print-schema > src/schema.rsThe Nix dev shell (nix develop) ships the nightly toolchain plus
bacon, cargo-edit, cargo-shear, and diesel-cli.
Per CLAUDE.md, the proc macros that minify script.js and
override.css don't trigger recompiles when their source files
change; touch main.rs (or cargo clean -p pathguard) to pick up
CSS/JS edits in release builds.
GPL-3.0. See LICENSE.md.