Skip to content

amuleapi: make it own its own credentials, and let the Web UI change them #656

Description

@ngosang

The Web UI exposes an amuleapi admin password field, but changing it does
not do what a user expects: the new password never reaches the running daemon,
and in a standalone deployment it is silently discarded forever. The
guest password has no API surface at all — it cannot be set, changed or
disabled from the Web UI.

The root cause is that amuleapi's credentials live in two stores with a
load-time-only precedence rule between them. amuleapi has never shipped in a
release — this is its first one — so there is no installed base to stay
compatible with and no migration to write: the proposal is simply to delete
the duplication
rather than synchronise it. amuleapi-passwords becomes the
single source of truth, the amule.conf keys and EC tags for these two secrets
are removed, and amuleapi changes its own credentials in-process — no restart,
no sync, no precedence. That deletes more code than it adds.

Goal: a user with an admin session can, from the Web UI, change the admin
password
, set/change the guest password and disable the guest role,
with the change persisted and effective immediately, in every
deployment mode.

amuleweb is out of scope. It has the same architecture and the same
defects (credentials read once at startup, from amule.conf in
WebInterface.cpp:420-430 or from its own remote.conf in
WebInterface.cpp:455-461, compared per request in WebServer.cpp:2012-2030,
with no reload path). It is deprecated and slated for removal, so it is
referenced below only as an example of the pattern we are moving away from.
No amuleweb changes are requested.

Part 1 — Current implementation (as of 765d332)

1.1 Two sources of truth, both load-time only

Mode How amuleapi is started Credential source
A — aMule-launched amule/amuled spawns it (src/amule.cpp:1126) with --amule-config-file=<amule.conf> amule.conf keys /AmuleApi/Password, /AmuleApi/GuestPassword (MD5 hex), applied in memory onlysrc/webapi/App.cpp:305-317
B — standalone systemd unit, container, manual run — no --amule-config-file ${config_dir}/amuleapi-passwords (admin=<md5> / guest=<md5>, mode 0600) — src/webapi/AmuleApiConfig.cpp:407

In mode A amule.conf wins and amuleapi-passwords is deliberately left
untouched (src/webapi/App.cpp:305-310). In mode B amule.conf is never read.
Both paths run once, at startup: there is no reload — SIGHUP is mapped to a
soft shutdown (src/webapi/App.cpp:374-379).

1.2 What each entry point does today

Entry point Persisted where Reaches the running daemon?
Web UI → PATCH /preferences remote_controls.amuleapi_password amule.conf:/AmuleApi/Password — via EC → thePrefs::SetAmuleApiPass() (ECSpecialMuleTags.cpp:687) → glob_prefs->Save() (ExternalConn.cpp:2830) Never. Mode A: only after amuleapi restarts. Mode B: never at all — that key is never read, and amuleapi-passwords is never written
Web UI → remote_controls.amuleapi_guest_password The key does not exist. Silently ignored (Api.cpp:6225-6330 only handles webserver_guest_password)
amule prefs dialog (local) amule.conf Only after aMule restarts and respawns the child. Prompted (PrefsUnifiedDlg.cpp:1128-1140)
amulegui prefs dialog (remote) pushed over EC → the daemon saves amule.conf Only after the daemon restarts. The prompt says "aMule must be restarted", which in amulegui reads as "restart amulegui" — restarting the GUI changes nothing
amulegui, field emptied to disable a role not propagated The hash tags are only emitted when non-empty (ECSpecialMuleTags.cpp:244-253) and Apply() only writes when the tag is present (ECSpecialMuleTags.cpp:687-692). So the guest role cannot be disabled from a remote amulegui. Locally in amule emptying does work (Cfg_Str_Encrypted::TransferFromWindow, Preferences.cpp:430-444) — a third, different behaviour
amuleapi --set-admin-pass= / --set-guest-pass= amuleapi-passwords (atomic, 0600) Needs a restart; and in mode A the value is then overridden by amule.conf

Three entry points, three behaviours, none of which applies the change to the
live daemon. That is the "shouldn't it behave the same?" question answered: it
should, and the way to get there is to have exactly one owner for these secrets.

1.3 Additional problems found

  • The Web UI field is hidden from exactly the users who need it.
    amuleapi_password is gated by gatedBy: "amuleapi_enabled"
    (preferences.js:217). In a standalone deployment /AmuleApi/Enabled is
    false in amule.conf (aMule didn't launch it), so GET /preferences
    returns "amuleapi_enabled": false and the field is hidden entirely
    verified against a live standalone instance.
  • No way to express "disable the guest role." The amuleapi guest role is
    implicit ("enabled" == "digest non-empty"), and PrefTakePassword
    (Api.cpp:5602), WritePasswordsFile (AmuleApiConfig.cpp:511) and the EC
    tags all treat empty as "leave unchanged".
  • Password changes don't invalidate sessions. JWTs stay valid for their full
    24 h; revocation is per-jti via /auth/logout only (Auth.h:53-68).
    Whoever holds a stolen session keeps it across a rotation.
  • No current-password confirmation anywhere — an admin session (or an
    XSS) is enough to lock the owner out.
  • Empty string becomes a valid password. PrefTakePassword hashes whatever
    it is given, including ""MD5("") = d41d8cd98f00b204e9800998ecf8486e,
    a valid-looking digest. PATCH {"remote_controls":{"amuleapi_password":""}}
    therefore makes POST /auth/login {"password":""} succeed. The Web UI skips
    empty password inputs client-side (preferences.js:354-356, "leave empty to
    keep current"
    ), so it is API-only — but reachable, and webserver_password,
    webserver_guest_password and proxy_password share the helper.
  • The daemon never reports its own live state. GET /version returns only
    names and versions — nothing about the effective bind address, port,
    deployment mode, or whether an admin/guest password exists. The Web UI has no
    way to tell truth from amule.conf fiction (Part 2).

Part 2 — Does amuleapi configuration belong in the Web UI?

The credentials: yes — they are amuleapi's own, and the Web UI is its
primary interface.

The rest (amuleapi_enabled, amuleapi_port, amuleapi_bind): not as it
stands.
Those keys describe the very process serving the page, and in a
standalone deployment they are fiction — they describe a process aMule would
spawn, not the one answering the request (which took its bind/port from
amuleapi.conf or the CLI). A user who edits amuleapi_bind in the Web UI
reasonably expects the API to move; nothing happens, and in standalone mode
nothing ever will. That is worse than not offering the control.

Proposed split in the Web UI:

  1. "This server" — a new section backed by a read-only endpoint (3.3):
    effective bind address, port, deployment mode (amule-launched |
    standalone), config dir, and the booleans admin_password_set /
    guest_password_set. The credential form (3.2) lives here, not gated by
    amuleapi_enabled.
  2. Preferences → Remote controls — keeps amuleapi_enabled / _port /
    _bind as what they really are: the parameters aMule uses when it launches
    amuleapi
    . Relabelled accordingly, and hidden (or explicitly marked inert)
    in standalone mode. amuleapi_password is removed from this tab.

Part 3 — Proposed solution

3.1 amuleapi owns its credentials: delete the second store

amuleapi-passwords becomes the single source of truth in both modes.
Remove — not synchronise — the amule.conf half:

Remove Where
/AmuleApi/Password, /AmuleApi/GuestPassword config items Preferences.cpp:1392-1394
thePrefs::{Get,Set}AmuleApiPass / …GuestPass + their statics Preferences.h:554-557, Preferences.h:1052-1053, Preferences.cpp:189-190
Emitting and applying EC_TAG_AMULEAPI_PASSWD / EC_TAG_AMULEAPI_GUEST_PASSWD ECSpecialMuleTags.cpp:244-253, ECSpecialMuleTags.cpp:687-692
The two password fields in the prefs dialog + their change checks muuli_wdr.cpp:2102,2107, muuli_wdr.h:340, PrefsUnifiedDlg.cpp:1136-1137
The --amule-config-file credential handoff App.cpp:305-317
remote_controls.amuleapi_password (API + UI + i18n key) Api.cpp:6281-6286, preferences.js:217, i18n/*.json
The "amule.conf wins / in-memory override" contract AmuleApiConfig.h:116-126, docs/QUICKSTART-AMULEAPI.md:140-158

Delete the tag codes 0x1509 / 0x150B outright from ECCodes.h and
ECCodes.abstract (including their GetTagName() cases) and leave the
numbering gap. Nothing has ever shipped that sends them, so there is no client
to stay compatible with and no reason to renumber EC_TAG_AMULEAPI_BIND just to
close a hole.

No migration is needed either — these keys have never existed in a released
amule.conf. A user upgrading from 3.0.x has no /AmuleApi/* section at all,
and a developer running an unreleased build sets the password once through the
first-run flow below.

What this deletes along with the duplication: the precedence rule, the
mode A/B asymmetry, the need for a "clear this credential" encoding in EC, the
need for live reload of amule.conf, and the whole class of
"persisted-but-inert" outcomes in 1.2.

3.2 A dedicated credential endpoint

admin-only, separate from the bulk preferences PATCH — rotation needs
re-authentication, session handling and per-field errors that don't fit a batch
update:

PATCH /api/v0/auth/passwords
{
  "current_password": "<plain>",        // required, re-auth
  "admin_password":   "<plain>",        // optional, omitted = unchanged
  "guest_password":   "<plain>" | null  // optional; null = disable guest role
}
  • omitted key → unchanged; null → clear that digest (disable the role);
  • empty string → 400 bad_request (never "hash of the empty string");
  • clearing the admin digest is refused while the bind address is
    non-loopback, mirroring the startup guard at App.cpp:451;
  • current_password mismatch → 401, counted by the same login rate limiter
    /auth/login uses (m_rateLimiter), so this is not a brute-force bypass.

The handler does exactly two things — that is the point of 3.1:

  1. Persist via CAmuleApiConfig::WritePasswordsFile(), which is already an
    atomic write-temp/fsync/rename at mode 0600 (AmuleApiConfig.cpp:146). It
    needs a "keep vs clear" distinction (e.g. std::optional<std::string> per
    role) — same for Set{Admin,Guest}PasswordMd5().
  2. Apply in memory, so the change is effective immediately and the HTTP
    response still gets delivered (a self-respawn could not guarantee either).

Response:

{ "admin_password_set": true, "guest_password_set": false }

First-run claim. Loopback + no password is an explicitly supported state
(App.cpp:449-451), and in it the whole authenticated surface is unusable
(login returns 503 login_disabled). So a desktop user whose amuleapi was
launched by aMule needs a way in without a terminal. Allow unauthenticated
POST /api/v0/auth/passwords only when no admin digest is configured and
the peer is loopback; it sets the initial admin password (and optionally the
guest one) and returns a session. Any other combination → 403. Recovery from
a forgotten password stays amuleapi --set-admin-pass=…, where filesystem
access is the proof of ownership. This replaces what the aMule prefs dialog
used to provide, and it works identically in both modes.

3.3 Report the live state

New read-only endpoint (or an additive section on GET /version):

{ "mode": "standalone" | "amule-launched",
  "bind_address": "127.0.0.1", "port": 4713,
  "config_dir": "/home/u/.aMule",
  "admin_password_set": true, "guest_password_set": false,
  "setup_required": false }

Digests are never exposed. setup_required drives the first-run claim screen;
mode lets the Web UI hide the autostart fields that are inert in standalone
mode (Part 2). Decide whether it is public or admin-gated — setup_required
has to be readable before any session exists, so either the endpoint is public
with a minimal field set pre-setup, or setup_required is surfaced on the
already-public /version.

3.4 Invalidate other sessions on a password change

A rotation that leaves old JWTs valid is not a rotation. Cheapest correct
mechanism: a per-role min_iat epoch in CJwt — reject any verified token
whose iat predates the epoch for its role. Bump the epoch for the role whose
password changed, then re-issue the caller's own token and return it via
Set-Cookie (same shape as /auth/login) so the initiator is not logged out.

Trade-off for the implementer: an in-memory epoch means revoked sessions become
valid again after an amuleapi restart (the JWT secret is persistent).
Persisting it as one key in amuleapi.conf ([Auth]/MinIssuedAt) closes that
hole for one config line.

3.5 Web UI (src/webapi/static)

  • New "This server" section: live values from 3.3 plus the credential form
    current password, new admin password + confirm, guest password +
    confirm, and a "Disable guest access" toggle that sends
    "guest_password": null. Guest inputs hidden while the toggle is on. Not
    gated by amuleapi_enabled.
  • First-run screen when setup_required is true: ask for the admin
    password (+ confirm) and call the claim endpoint, instead of showing a login
    form that can only return 503.
  • Remove amuleapi_password from Preferences → Remote controls; relabel the
    remaining amuleapi fields as aMule's autostart parameters and hide them in
    standalone mode.
  • New i18n keys in en.json / es.json; success state mentions that sessions
    on other devices were signed out.

3.6 Docs

  • docs/api/REFERENCE.md — the new endpoints; update Role model, which
    currently says the two passwords are configured via --set-admin-pass /
    --set-guest-pass only.
  • docs/QUICKSTART-AMULEAPI.md — rewrite Auto-starting from aMule: aMule no
    longer carries amuleapi credentials; the first-run flow is loopback + claim
    (or the CLI); passwords are changed at runtime from the Web UI with no
    restart.
  • docs/CHANGELOG.md — no compatibility note needed; this is part of the
    initial amuleapi feature, which has not shipped yet.

Open decisions for the implementer

  • amuleapi_port / amuleapi_bind duplication. The same single-owner
    argument applies: in mode A aMule passes --bind / --http-port, which
    override amuleapi.conf, so the live values can disagree with both stores.
    Cleanest would be to let amuleapi.conf own the HTTP binding and reduce
    aMule's role to "launch it or not". The counter-argument is the desktop
    escape hatch: a user who cannot reach the Web UI (wrong port) can currently
    fix it from the GUI. Suggested for now: leave the mechanism alone, make the
    UI honest (Part 2), and revisit separately.
  • Public vs gated /server/info — see 3.3.
  • min_iat persistence — see 3.4.

Acceptance criteria

  • amuleapi-passwords is the only credential store; /AmuleApi/Password
    and /AmuleApi/GuestPassword no longer exist in amule.conf, in
    thePrefs, in the EC prefs packet or in the preferences dialog.
  • PATCH /api/v0/auth/passwords exists, is admin-only, requires
    current_password, and is covered by the login rate limiter.
  • Changing the admin password takes effect immediately — old password
    rejected, new one accepted, no restart — in both modes, and survives a
    restart of amuleapi and of the core.
  • The guest password can be set, changed and disabled from the Web UI;
    once disabled, login with the old guest password returns 401.
  • Clearing the admin digest is refused when the bind address is not
    loopback.
  • First run on loopback with no admin password: the Web UI offers a setup
    screen and the claim endpoint sets the password. The claim endpoint
    returns 403 once an admin digest exists, and 403 from a non-loopback
    peer.
  • Empty plaintext is rejected with 400 on every remaining password field
    (webserver, webserver_guest, proxy) — no more MD5("") credentials.
  • After an admin password change, previously issued admin tokens are
    rejected while the caller's own session keeps working.
  • A read-only endpoint reports the live bind/port/mode and
    admin_password_set / guest_password_set; the Web UI no longer infers
    amuleapi's state from amule.conf.
  • Passwords are never returned by any GET; plaintext is never logged nor
    written to disk. amuleapi-passwords keeps mode 0600 and its atomic
    write.
  • Docs and changelog updated.

Files involved

File Why
src/webapi/Api.cpp new credential + server-info handlers and routes; remove amuleapi_password; PrefTakePassword empty-string guard
src/webapi/AmuleApiConfig.{h,cpp} "clear digest" support; drop the in-memory-override contract
src/webapi/App.cpp remove the amule.conf credential handoff; expose mode + effective bind/port to the dispatcher
src/webapi/Auth.{h,cpp} per-role min_iat epoch; unauthenticated loopback claim path
src/Preferences.{h,cpp}, src/ECSpecialMuleTags.cpp, src/muuli_wdr.{h,cpp}, src/PrefsUnifiedDlg.cpp remove the amuleapi password prefs, EC tags and dialog fields
src/webapi/static/js/views/preferences.js, src/webapi/static/i18n/*.json "This server" section, credential form, first-run screen, new strings
docs/api/REFERENCE.md, docs/QUICKSTART-AMULEAPI.md documentation

Reproduction

Standalone amuleapi (mode B), admin password admin. Step 1 was run against a
live instance; steps 2-4 follow from the code paths cited above.

T=$(curl -s -X POST http://127.0.0.1:4713/api/v0/auth/login \
      -H 'Accept: application/jwt' -d '{"password":"admin"}' \
    | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')

# 1. amuleapi_enabled is false in a standalone deployment → the Web UI hides
#    the admin password field entirely.
curl -s http://127.0.0.1:4713/api/v0/preferences -H "Authorization: Bearer $T" \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["remote_controls"])'
# {... 'amuleapi_enabled': False, 'amuleapi_port': 4713, 'amuleapi_bind': '127.0.0.1'}

# 2. Change the password through the documented path.
curl -s -X PATCH http://127.0.0.1:4713/api/v0/preferences \
  -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
  -d '{"remote_controls":{"amuleapi_password":"newSecret123"}}'

# 3. The new password does not work — and never will, in this mode:
curl -s -X POST http://127.0.0.1:4713/api/v0/auth/login -d '{"password":"newSecret123"}'
# 401 invalid_credentials  (before AND after restarting amuleapi)
# The old password still works. `amuleapi-passwords` is unchanged; the hash
# went into amule.conf:/AmuleApi/Password, which a standalone amuleapi
# never reads.

# 4. There is no amuleapi guest password key at all — silently ignored:
curl -s -X PATCH http://127.0.0.1:4713/api/v0/preferences \
  -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
  -d '{"remote_controls":{"amuleapi_guest_password":"guestpw"}}'

From amulegui connected to a remote amuled: open Preferences → Remote
Controls
, clear the guest password field and apply. The daemon keeps
accepting the old guest password — the cleared value is never sent (1.2, last
row).

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions