Skip to content

Add expiring user support to web panel - #101

Merged
wiresock merged 12 commits into
mainfrom
codex/add-user-expiration
Aug 12, 2026
Merged

Add expiring user support to web panel#101
wiresock merged 12 commits into
mainfrom
codex/add-user-expiration

Conversation

@wiresock

Copy link
Copy Markdown
Owner

Adds optional user lifetimes while keeping existing and explicitly permanent users non-expiring.

Stores expiration metadata, displays and edits expiration status in the web panel, and performs startup/periodic cleanup through the existing user-deletion path so VPN configuration, keys, and related state are removed consistently.

Includes concurrency safeguards, retry-safe cleanup, migration coverage, and architecture documentation updates.

Verification: cargo test --all-targets (414 passed); cargo check --all-targets; git diff --check.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds optional user expiration with web/API controls, persistent metadata, and automated lifecycle cleanup.

Changes:

  • Adds expiration fields, validation, UI, and API support.
  • Adds migration, cleanup scheduling, concurrency controls, and retry-safe removal.
  • Extends tests and architecture documentation.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/web/mod.rs Expiration API, UI, validation, and tests
src/poller/mod.rs Periodic expired-user cleanup
src/db/peers.rs Expiration persistence and queries
src/admin/mod.rs Lifecycle orchestration and cleanup
src/admin/client_manager.rs Resumable identity-safe removal
migrations/0008_add_peer_expiration.sql Expiration schema
docs/ARCHITECTURE.md Architecture updates
Suppressed comments (1)

amneziawg-web/src/web/mod.rs:2553

  • The HTML edit path has the same clear/remap race as the API path: an existing migrated managed user can temporarily have has_config = 0 while config discovery rebuilds mappings, causing a valid expiration edit to fail as unmanaged. Hold the config-mapping lock across the peer read, eligibility check, and expiration write, or make mapping persist the stable managed identity.
    let managed_client_name = managed_client_name_for_expiration(&existing);
    if expiration_update.as_ref().is_some_and(Option::is_some)
        && managed_client_name.is_none()

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread amneziawg-web/src/web/mod.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Comment thread amneziawg-web/src/admin/client_manager.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

amneziawg-web/src/admin/mod.rs:246

  • The lifecycle lock held here does not serialize creation persistence with execute_update_peer_expiration. The newly synced peer can be inserted/mapped by the poller while persist_created_peer waits for CONFIG_MAPPING_LOCK; a concurrent PATCH can then successfully set a deadline, after which upsert_created_peer unconditionally overwrites expires_at with the create request's older value (including NULL). Acquire EXPIRATION_STATE_LOCK before the lifecycle lock and retain it through persistence so the lock order remains consistent with cleanup and successful expiration edits cannot be lost.
    // Keep a single lifecycle lock held across native creation and expiration
    // persistence. If the database write fails, rollback runs under the same
    // lock, so no concurrent add/remove can strand or replace this client.

Comment thread amneziawg-web/src/admin/client_manager.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

amneziawg-web/src/db/peers.rs:701

  • The stale-peer pass can discard the retry state created by remove_client_resumable. For example, a manual removal of a permanent or future-expiring user can remove the server block/live peer and then fail while deleting the client config; execute_remove_user_inner deliberately keeps the row so the operation can retry, but the next poll sees the key absent and this predicate allows the row to be deleted as stale. The client config is then orphaned and no later lifecycle retry can find the peer. Persist and exclude an explicit removal-in-progress state (covering manual and expiration removals), rather than inferring retry eligibility only from whether the deadline is currently due.
           AND (expires_at IS NULL
                OR julianday(expires_at) IS NULL
                OR julianday(expires_at) > julianday(?))

Comment thread amneziawg-web/src/admin/client_manager.rs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 4 comments.

Comment thread amneziawg-web/src/db/peers.rs Outdated
Comment thread amneziawg-web/src/db/peers.rs
Comment thread amneziawg-install.sh Outdated
Comment thread amneziawg-web/scripts/amneziawg-web-install.sh Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

amneziawg-web/src/web/mod.rs:2585

  • On an expiration conflict, this returns an error page after update_peer_metadata has already committed the submitted name/comment. Thus a rejected form submission silently applies part of the edit and skips its audit record. Persist metadata and expiration in one guarded transaction so the conflict rolls back the full form update.
    if let Some(ref expires_at) = expiration_update {
        if crate::admin::execute_update_peer_expiration(
            &state.db,
            id,
            expires_at.as_deref(),
            expires_at.as_deref().and(managed_client_name),
        )

amneziawg-web/src/web/mod.rs:2185

  • If removal has already set removal_pending, this call returns None and the handler responds 409, but the preceding update_peer_metadata has already committed any requested display-name/comment changes. The failed PATCH is therefore partially applied without an audit event. Apply metadata and expiration atomically under the expiration/removal guard so a conflict leaves every requested field unchanged.

This issue also appears on line 2579 of the same file.

    if let Some(ref expires_at) = expiration_update {
        if crate::admin::execute_update_peer_expiration(
            &state.db,
            id,
            expires_at.as_deref(),
            expires_at.as_deref().and(managed_client_name),
        )

Comment thread amneziawg-install.sh Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

amneziawg-web/src/admin/mod.rs:523

  • Opening the config directory as the lock now fails before native removal when AWG_CONFIG_DIR is absent. The binary explicitly permits a missing directory at startup (main.rs:147-149), and remove_client_inner treats it as “no config files to remove,” but this call makes that branch unreachable. If the directory is deleted while the service is stopped, startup expiration cleanup can never revoke the still-active server peer and every periodic retry fails with ENOENT. Establish a securely validated lock directory before this point, or use a stable lock inode that does not depend on the client directory existing.
    let lock_result = acquire_lifecycle_lock(config_dir).map_err(map_lock_error);

amneziawg-install.sh:5799

  • When the panel's configured directory is missing, this recreates it as root with mode 0700. The web installer deliberately makes AWG_CONFIG_DIR service-owned (amneziawg-web/scripts/amneziawg-web-install.sh:1081-1104), so after a root CLI add/remove recreates a missing directory, the running panel cannot open the shared lock or manage configs. Preserve the configured service ownership/access when recreating the directory, or avoid creating the panel directory solely to obtain the lock.
	mkdir -p "${lock_dir}" || {
		umask "${old_umask}"
		echo "ERROR: could not create client lifecycle directory '${lock_dir}'" >&2
		return 1
	}

Comment thread amneziawg-web/src/admin/mod.rs
@wiresock

Copy link
Copy Markdown
Owner Author

Also addressed both suppressed findings from the latest review in be8f94e:

  • Lifecycle locking now uses the persistent database directory, derived from AWG_WEB_DB, rather than AWG_CONFIG_DIR. Startup expiration cleanup can therefore remove an active peer even when the client-config directory is absent.
  • The root CLI discovers the same database directory from the installed service EnvironmentFile. It never creates a missing installed-panel state/config directory, so it cannot recreate service-owned paths as root:root 0700; standalone use without a panel retains the prior config-directory fallback.

Shell coverage verifies custom database-path contention, missing client-directory behavior, and refusal to recreate a missing panel state directory. Both shell files pass bash -n, git diff --check passes, component versions validate, and 423 Rust tests pass.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.

Suppressed comments (4)

amneziawg-install.sh:1741

  • If the installed unit uses its supported inline Environment=AWG_WEB_DB=... settings and has no EnvironmentFile, this fallback locks the client-config directory. The shipped packaging/amneziawg-web.service:29-32 explicitly supports that setup, while the web process always locks the database parent, so installer and web mutations are no longer serialized. Resolve inline unit settings too, or fail instead of selecting a different lock inode.
	fi

	resolveWebPanelConfigDir

amneziawg-install.sh:1734

  • This rejects a documented database configuration: AWG_WEB_DB=sqlite:///var/lib/amneziawg-web/awg-web.db is accepted by the web process (db/mod.rs) but does not begin with /, so installer --add-client/--remove-client now fail before acquiring the shared lock. Normalize supported SQLite URLs (and relative paths using the service working directory) to the same filesystem directory as main.rs.

This issue also appears on line 1739 of the same file.

		if [[ -n "${database_path}" ]]; then
			if [[ "${database_path}" != /* || "${database_path}" =~ [[:space:][:cntrl:]] ]]; then
				echo "ERROR: refusing unsafe AWG_WEB_DB '${database_path}'" >&2
				return 1
			fi
			dirname -- "${database_path}"

amneziawg-web/docs/ARCHITECTURE.md:85

  • This architecture description is now incorrect: main.rs derives lifecycle_lock_dir from the database parent, and the installer resolver does the same. Saying the client-config directory is locked obscures the persistence invariant and conflicts with the implementation.
The web panel holds an advisory lock on the open client-config directory across
the complete managed-client lifecycle, including database persistence and
client-config cleanup. Supported installer `--add-client` and `--remove-client`
operations lock the same directory descriptor, preventing an out-of-band
same-name replacement from appearing inside a web lifecycle operation without
introducing a mutable lock pathname in the service-writable directory.

amneziawg-web/docs/MVP.md:30

  • The implementation now shares the database state directory descriptor, not the client-config directory. Update this summary so it matches main.rs::lifecycle_lock_dir and resolveClientLifecycleLockDir.
|24 | Lifecycle locking + validation             | Cross-process add/remove lock on the client-config directory shared with installer CLI operations, plus installer-name validation for managed user actions |

Comment thread amneziawg-web/src/db/peers.rs Outdated
@wiresock

Copy link
Copy Markdown
Owner Author

Addressed all four suppressed findings from the latest review in 7c25e66:

  • The installer resolver now reads supported inline Environment= settings and honors systemd precedence when a referenced EnvironmentFile overrides them. Unreferenced env files are ignored.
  • AWG_WEB_DB accepts the same plain paths and sqlite: / sqlite:// forms as the web process, strips query parameters, and resolves relative paths against the unit WorkingDirectory.
  • Shell coverage now exercises EnvironmentFile-over-inline precedence, sqlite:/// absolute URLs, inline relative SQLite paths, WorkingDirectory resolution, config-path discovery, and lock contention.
  • ARCHITECTURE.md and MVP.md now correctly document locking the persistent database state directory rather than the client-config directory.

Verification: 424 Rust tests pass; the changed shell files pass bash -n; the shipped inline-only service fixture resolves to /var/lib/amneziawg-web; git diff --check and component-version validation pass; Clippy has no new warnings.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

amneziawg-web/src/admin/client_manager.rs:1123

  • create_client now tries to open config_dir as the lock directory before prepare_client_config_dir runs. For the documented case where the client directory does not exist yet, this returns ENOENT and never recreates the directory, regressing the previous behavior. Prepare/validate the directory before using it as the standalone lock inode; the production path can still acquire its independent persistent lock first.
    script_bridge::validate_client_name(name)?;
    let _lock_file = acquire_creation_lifecycle_lock(config_dir, config_dir)?;
    create_client_with_lifecycle_lock(config_dir, name, disabled_keys, ip_override)

Comment thread amneziawg-install.sh Outdated
@wiresock

Copy link
Copy Markdown
Owner Author

Also addressed the latest suppressed finding in 3f101f0: standalone create_client now prepares and validates a missing client directory before using that directory as its lock inode, restoring create-on-first-use behavior. The production web path still acquires its independent persistent state-directory lock before preparing the client directory. A Unix regression exercises the standalone ordering. All PR checks are green.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

amneziawg-install.sh:1846

  • The binary's public --config-dir option takes precedence over AWG_CONFIG_DIR, but this resolver only reads Environment=/EnvironmentFile=. If an operator supplies that option in the effective ExecStart, installer add/remove operations still copy or delete configs in the environment/default directory rather than the directory the panel scans. Account for effective command-line overrides, or configure one authoritative path that both processes consume.
	configured_dir="$(readWebPanelSetting AWG_CONFIG_DIR)" || return 1

Comment thread amneziawg-install.sh
@wiresock

Copy link
Copy Markdown
Owner Author

Also addressed the latest suppressed finding in f3c8bed: resolveWebPanelConfigDir now applies the effective ExecStart --config-dir value after AWG_CONFIG_DIR, matching the binary's Clap precedence. The same strict argv parser handles both separated and = option forms and fails closed on ambiguous text. The shell regression verifies the CLI config override wins over the ordered effective EnvironmentFile values. All PR checks are green.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.

Suppressed comments (2)

amneziawg-install.sh:1917

  • This rejects valid zero-arity CLI options. Config defines auth_enabled and auth_secure_cookie as boolean Clap flags, so an effective command such as amneziawg-web --auth-enabled --database-url=... is valid, but this branch sees the following --... token and returns status 2. Both lifecycle-lock and config-directory resolution then fail, disabling installer add/remove whenever those supported flags are configured in ExecStart. Skip known boolean flags (or parse option arity from the application's CLI contract) while continuing to reject actual positional arguments.
		else
			option_name="${token}"
			index=$((index + 1))
			if [[ "${index}" -ge "${#exec_args[@]}" || "${exec_args[index]}" == --* ]]; then
				echo "ERROR: missing value for '${option_name}' in web panel ExecStart" >&2
				return 2

amneziawg-install.sh:1718

  • systemctl show --value EnvironmentFiles serializes the a(sb) list on one property line, with entries such as /etc/first.env (ignore_errors=no) /etc/last.env (ignore_errors=no). This loop treats that whole line as one entry; the greedy match makes configured_env contain both files, and validateWebPanelEnvFile rejects it for whitespace. A normal unit with multiple effective EnvironmentFile= directives therefore makes installer add/remove fail instead of resolving the ordered settings. Parse and iterate each path (ignore_errors=...) tuple from the property value (while still rejecting unsupported escaping).
		while IFS= read -r configured_env; do
			[[ -n "${configured_env}" ]] || continue
			if [[ ! "${configured_env}" =~ ^(.*)[[:space:]]+\(ignore_errors=(yes|no)\)$ ]]; then
				echo "ERROR: unsupported systemd EnvironmentFiles value '${configured_env}'" >&2
				return 1
			fi
			configured_env="${BASH_REMATCH[1]}"

@wiresock

Copy link
Copy Markdown
Owner Author

Addressed both suppressed findings from the latest review in d205f45:

  • Effective EnvironmentFiles parsing now consumes every ordered path (ignore_errors=...) tuple whether systemctl serializes the list on one line or multiple lines. Unsupported escaping still fails closed. The regression explicitly verifies two tuples survive the one-line representation in order and that the later file overrides the earlier one.
  • Effective ExecStart parsing now follows the binary CLI contract: --auth-enabled and --auth-secure-cookie are accepted as zero-arity flags, while all other supported options require values and unknown/positional arguments remain rejected. The precedence regression includes both boolean flags around the database/config overrides.

All PR checks are green, including shellcheck, syntax, every distro integration, the privileged Ubuntu smoke job, MSRV, and the full unit suite.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.

@wiresock
wiresock merged commit b727d2a into main Aug 12, 2026
13 checks passed
github-actions Bot added a commit that referenced this pull request Aug 12, 2026
@wiresock
wiresock deleted the codex/add-user-expiration branch August 12, 2026 06:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants