Skip to content

netatalk: add unprivileged single-user mode - #3277

Draft
rdmark wants to merge 1 commit into
mainfrom
1914-allow-netatalk-to-run-as-non-root
Draft

netatalk: add unprivileged single-user mode#3277
rdmark wants to merge 1 commit into
mainfrom
1914-allow-netatalk-to-run-as-non-root

Conversation

@rdmark

@rdmark rdmark commented Aug 27, 2026

Copy link
Copy Markdown
Member

Allow a non-root user to run the controller with a safe, explicitly configured single-user AFP service. Restrict the mode to a user-owned SRP verifier and static SQLite volumes with explicit UUIDs and signatures, pass the mode through to afpd, and authenticate only the invoking account.

Require a private absolute PID path, reject privilege-dependent features, and disable configuration reloads. Keep the SQLite CNID database, WAL, and shared-memory files private to the serving user.

Add non-root SRP verifier initialization and robust password-input error handling in afppasswd, update the manuals, and cover command-line parsing.

@rdmark
rdmark requested a review from a team August 27, 2026 20:16
@rdmark
rdmark requested a review from andylemin as a code owner August 27, 2026 20:16
@rdmark rdmark linked an issue Aug 27, 2026 that may be closed by this pull request
@rdmark
rdmark force-pushed the 1914-allow-netatalk-to-run-as-non-root branch from a473a06 to e221d8d Compare August 27, 2026 20:48
@rdmark

rdmark commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

@andylemin something that's been on my mind for a few years, now finally possible! this involved quite a lot of scaffolding so hardly a plug'n'play thing for beginners to run locally, but perhaps it could be the foundation of a variant of the containerized netatalk that runs non-privileged?

@andylemin andylemin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for this — the restriction set is well chosen and validate_unprivileged_config() is thorough. A few things I'd like to talk through, mostly around the setuid-root afppasswd path and the config-parse/daemonize() reordering. Comments inline.

Two I think need resolving before this lands:

  • the new non-root afppasswd -c path reaching open(O_CREAT|O_TRUNC) with euid 0 on a caller-supplied -p target
  • afp_config_parse() moving ahead of daemonize(), which silently drops file logging for the default root service

One more that didn't fall on a changed line, so I couldn't anchor it: update_srp_passwd() dereferences getpass() without a NULL check (lines 562, 617, 646), which segfaults when there's no controlling terminal. That's pre-existing, but the manual now shows a script-shaped afppasswd -c -p <path>, so it's newly easy to reach — worth a cheap guard while we're here?

Comment thread bin/afppasswd/afppasswd.c
return -1;
}

if ((fd = open(path, O_CREAT | O_TRUNC | O_WRONLY, 0600)) < 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

afppasswd is installed rwsr-xr-x (bin/afppasswd/meson.build:28) and nothing in the program drops euid, so this open() runs as root against whatever -p supplies — no O_EXCL, no O_NOFOLLOW, no path restriction.

How do we feel about afppasswd -c -f -w x -p /etc/shadow from an unprivileged account? It truncates the target and fchmods it 0600. Non-root -c was refused outright before, so this is the first path that gets there.

Would dropping to the real uid before creating, or requiring the path to resolve under the caller's own home plus O_EXCL|O_NOFOLLOW, be enough to close it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the get_password() wrapper — that's the getpass() NULL path cleanly closed across all seven call sites.

This one still stands, though. create_srp_file_for_user() opens the caller-supplied -p path with O_CREAT | O_TRUNC while euid is still 0: the binary installs rwsr-xr-x (bin/afppasswd/meson.build:28) and the only credential call in the file is getuid() at line 1064, so nothing drops privilege first. afppasswd -c -f -p /etc/shadow from any account truncates it as root.

Cheapest shape is probably to drop to the real uid before touching anything a caller named — the non-root create path needs no root at all, since it only writes a file the user owns. Alternatively refuse -p outright when geteuid() != getuid(). Happy to look at either.

Comment thread bin/afppasswd/afppasswd.c
return -1;
}

if (!i && ((flags & OPT_FORCE) == 0)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With -p omitted, path is still _PATH_AFPDSRPPWFILE, so a plain afppasswd -c -f from a regular user lands on the shared system verifier and create_srp_file_for_user() truncates it — everyone else's entries included.

Should non-root -c require an explicit -p, and refuse a target it doesn't already own?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still open as well, and it compounds the setuid issue above: with -p omitted, path is set to _PATH_AFPDSRPPWFILE (line ~1128) before the new non-root branch runs, so a plain afppasswd -c -f from a regular account calls create_srp_file_for_user() on the shared system verifier and replaces it with one entry for the calling user.

Requiring an explicit -p in the non-root create path would close it, or defaulting that path to something under $HOME when the caller is not root. Combined with the privilege drop, the blast radius goes away entirely.

Comment thread bin/afppasswd/afppasswd.c

/* Verify old password for non-root users */
if ((flags & OPT_ISROOT) == 0) {
if ((flags & OPT_ISROOT) == 0 && !(flags & OPT_INITIAL)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

OPT_INITIAL is only ever set immediately after create_srp_file_for_user(), so skipping the old-password proof is self-consistent in isolation. But the file it just wrote over may have held a real verifier, so combined with the default -p above, does this let someone set a new AFP credential without knowing the current one?

Would deriving this from "we created this file in this run and it was genuinely new" be tighter than a caller-supplied flag?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

OPT_INITIAL itself reads fine now that it is only ever set immediately after the create — thanks for tightening that.

The residual is inherited from the default-path thread rather than from this flag: because a non-root -c -f can still land on the shared verifier, OPT_INITIAL then skips the old-password proof on an entry the caller never owned. Fixing the path and privilege side closes this without any change here.

Comment thread bin/afppasswd/afppasswd.c
p++;

if (!(flags & OPT_ISROOT) && (*p == PASSWD_ILLEGAL)) {
if (!(flags & OPT_ISROOT) && !(flags & OPT_INITIAL)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same thought on the * marker: OPT_INITIAL skips it, and the truncate upstream means an admin-disabled entry is gone by the time we reach here anyway. Is re-enabling a disabled account meant to be reachable without root?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same relationship as the old-password thread: the * skip is reasonable for a genuinely fresh file, and only becomes a re-enable-without-root path while a non-root -c can still target the shared verifier. No change needed here once that is fixed.

Comment thread bin/afppasswd/afppasswd.c
return -1;
}

return update_srp_passwd(path, pwd->pw_name, flags | OPT_INITIAL, pass);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

By this point create_srp_file_for_user() has already truncated the file and written the * placeholder, so a password mismatch, a cracklib rejection, or Ctrl-C leaves the previous verifier destroyed and the account disabled.

Recovery looks awkward too: -c without -f is refused above, and a plain update takes the non-OPT_INITIAL branch and prints "Your password is disabled. Please see your administrator." — in a mode whose premise is that there's no administrator.

Could we collect and validate the password first, then create and write once?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Still create-then-prompt: create_srp_file_for_user() writes the placeholder record before update_srp_passwd() asks for the password, so a typo at the confirmation prompt or a Ctrl-C leaves a disabled verifier behind — and on an existing file, the previous contents are already gone.

Collecting and validating the password first and writing once would fix it. If you would rather keep the two functions separate, writing to a temp file in the same directory and rename()ing on success gives the same atomicity.

Comment thread etc/netatalk/netatalk.c
Comment thread etc/netatalk/netatalk.c
exit(EXITERR_CONF);
}

volumes_loaded = (load_afp_conf_vols(&obj, LV_ALL) == 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One side effect of parsing before check_lockfile(): a second netatalk invocation now loads all volumes first, so get_vol_uuid() can generate and append a UUID to afp_voluuid.conf and every volume path gets EA-probed, before we notice another instance is already running. Previously a duplicate start exited before touching anything.

Could the lockfile check move back ahead of this?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Order is still parse (1056) then load_afp_conf_vols() (1060) then check_lockfile() (1066), so a second netatalk invocation loads every volume — appending UUIDs to afp_voluuid.conf, running the EA probes — before it discovers the lock and exits.

Moving check_lockfile() above the volume load keeps the duplicate-instance case free of side effects.

Comment thread etc/netatalk/netatalk.c
return count == 1;
}

static bool dbpath_parent_is_writable(const char *path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The trailing-slash strip and strrchr parent derivation here is identical to pidfile_path_is_private() just below — the two differ only in what they check afterwards. Since both feed security decisions, would a single shared helper be safer than keeping two copies in sync? I couldn't find a dirname-style primitive in libatalk to reuse, so a small static one here might be the way.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The trailing-slash strip and strrchr parent derivation is still duplicated between the two helpers. Since both are security checks, a single helper taking the path plus a "needs write" flag would keep them from drifting apart later.

Comment thread etc/afpd/afp_options.c
static void show_usage(void)
{
fprintf(stderr, "Usage:\tafpd [-d] [-F configfile]\n");
fprintf(stderr, "Usage:\tafpd [-du] [-F configfile]\n");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

doc/manpages/man8/afpd.8.md still lists only -d, -v, -V, -h and -F — should -u be documented there to match this? (netatalk.8.md covers -u and -P nicely.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

doc/manpages/man8/afpd.8.md still documents only -d, -v, -V, -h and -F, so -u remains accepted and advertised in the usage string but absent from the man page. The netatalk.8.md wording you added would carry over nicely.

Comment thread test/afpd/test.c
char *unprivileged_args[] = { "afpd", "-u" };
TEST(afp_options_parse_cmdline(&unprivileged_obj, 2, unprivileged_args),
"parse afpd rootless command-line option");
TEST_expr(reti = 0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This asserts the flag bit gets set, which would stay green even if the pw_uid != getuid() check in auth.c were removed — and that's the only runtime enforcement point for the whole mode.

Worth adding a case asserting a uid mismatch is rejected and a match accepted, plus something over validate_unprivileged_config()? It carries most of the security contract and currently has no coverage.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The new case is a good start — parsing -u and asserting the flag lands is worth having.

It does still pass if the behaviour behind the flag is removed: deleting the pw_uid != getuid() check in auth.c keeps this green, because nothing here exercises the refusal. A case that logs in with a non-matching uid and expects rejection would pin the part that matters.

@rdmark

rdmark commented Aug 30, 2026

Copy link
Copy Markdown
Member Author

@andylemin just wanted to leave a note to say that I have this on my radar -- a lot of great feedback here that I want to work through properly

do you think it's okay to push this into the 4.6.0 release when all the feedback is addressed, or better to postpone?

@andylemin

Copy link
Copy Markdown
Contributor

@andylemin just wanted to leave a note to say that I have this on my radar -- a lot of great feedback here that I want to work through properly

do you think it's okay to push this into the 4.6.0 release when all the feedback is addressed, or better to postpone?

We are getting close to the release date now, but at the same time, this is an "it either works or it doesn't change", so I don't mind.

Allow a non-root user to run the controller with a safe, explicitly configured single-user AFP service. Restrict the mode to a user-owned SRP verifier and static SQLite volumes with explicit UUIDs and signatures, pass the mode through to afpd, and authenticate only the invoking account.

Require a private absolute PID path, reject privilege-dependent features, and disable configuration reloads. Keep the SQLite CNID database, WAL, and shared-memory files private to the serving user.

Add non-root SRP verifier initialization and robust password-input error handling in afppasswd, update the manuals, and cover command-line parsing.
@rdmark
rdmark force-pushed the 1914-allow-netatalk-to-run-as-non-root branch from e221d8d to 5444e7c Compare September 4, 2026 18:49
@rdmark
rdmark requested a review from andylemin September 4, 2026 18:51
@rdmark

rdmark commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

@andylemin I basically concur with all feedback given :)

can you please take a closer look at the SQLite hacks to make the database files only owner accessible in unprivileged mode?

@sonarqubecloud

sonarqubecloud Bot commented Sep 4, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📊 Performance Dashboard

Commit: 5444e7ce126dc7c2aeb21bf0589b18b6f5b13487

🔥 Spectest (AFP 3.4) - FlameGraph

Netatalk Code-time: 3.6% · Runtime: 60s · Stacks: 846

🔥 Click the preview to open the interactive flamegraph (zoom + search).

Flamegraph preview

🔝 Top 10 leaf functions
Function Samples
_raw_spin_unlock_irqrestore 121278850
finish_task_switch.isra.0 100330685
do_syscall_64 94818010
[libsqlite3.so.3.53.4] 88202800
__cp_end 60639425
afpd 59536890
__raw_callee_save___pv_queued_spin_unlock 27563375
__syscall_cp_c 25358305
syscall_trace_enter 22050700
x64_sys_call 19845630

📈 Speedtest (AFP 3.4) - PerfGraph

Speedtest throughput

Peak Read: 7929 MB/s (+6.9% vs hist avg 7419.1 MB/s; min 6342 / max 9526 over 30 PRs)
Peak Write: 1738 MB/s (+43.5% vs hist avg 1210.7 MB/s; min 225 / max 1933 over 30 PRs)

🔝 Throughputs per operation (vs. historical average)
Metric Current (MB/s) Cur Avg Δ% Hist avg Hist min Hist max
Read peak mean 7929 +6.9% 7419.1 6342 9526
Read avg mean 4252 -1.3% 4306.8 3529 5455
Read avg max 4721 -0.7% 4754.6 4015 6180
Write peak mean 1738 +43.5% 1210.7 225 1933
Write avg mean 545 +35.9% 401 91 591
Write avg max 930 +29.9% 716.1 187 1002
Copy peak mean 2647 -2.3% 2710.2 2294 3353
Copy avg mean 1482 -7.4% 1601.3 1421 1954
Copy avg max 1635 -6.7% 1752.5 1550 2106
ServerCopy peak mean 3314 -11.6% 3747.1 3055 5090
ServerCopy avg mean 1730 -12.1% 1968.4 1604 2501
ServerCopy avg max 1859 -9.2% 2048.4 1675 2658

⏱️ Lantest (AFP 3.4) - LatencyGraph

Lantest latency

Avg total runtime: 3997 ms (+1.0% vs hist avg 3957.7 ms; min 2122 / max 5085 over 30 PRs)
Avg time per AFP op: 80 µs (+0.3% vs hist avg 79.8 µs; min 43 / max 102 over 30 PRs)

🐢 All operations (avg runtime, in test order, vs. historical average)
Metric Current (ms) Cur Avg Δ% Adj Δ% Hist avg Hist min Hist max
Writing one large file 33 -11.0% 37.1 26 47
Reading one large file 15 -2.8% 15.4 12 23
Creating 2000 files 478 +4.5% +6.3% 457.3 207 606
Create 2000 dirs tree (20×9×10) 502 +7.5% +9.3% 467.1 256 587
Open, write 1024 bytes, close 2000 files 341 -4.9% -3.2% 358.7 195 459
Open, read 1024 bytes, close 2000 files 299 -4.9% -3.1% 314.3 172 408
Copying 1000 files client-side (R+W) 531 +2.2% +3.9% 519.7 265 672
Copying 2000 files server-side 507 +12.2% +13.9% 452.1 182 591
Stat (lookup+getparams) 2000 files 191 -10.7% -9.0% 214 123 274
Enumerate dir with 2000 files 12 +25.0% +26.8% 9.60 4 14
Lock then unlock 2000 open forks 153 -4.3% -2.5% 159.9 107 193
Deleting 2000 files 313 +2.7% +4.5% 304.8 121 417
Byte-range lock/unlock 2000 ranges in one fork 159 -1.8% +0.0% 161.9 106 193
Directory cache hits (20 dirs x 100 files) 86 -9.4% -7.7% 95.0 57 120
Mixed cache operations (create/stat/enum/delete) on 500 files 203 +2.7% +4.4% 197.7 87 260
Deep path traversal (20 levels x 100 walks) 93 -8.2% -6.4% 101.3 58 131
Cache validation (500 files x 4 lookups) 81 -11.9% -10.1% 91.9 56 113

Run baseline: median op-test delta -1.8%, MAD 6.3%. Adj Δ% shifts each delta by the median; standouts ≥5% in bold. A large MAD means the run did not move uniformly — read the adjusted column with caution.

Performance trend

@andylemin

andylemin commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

@rdmark rather than calling this unprivileged mode, I think we should call this single user mode.

Unprivileged makes this sound "less than", while it is actually "more than" as no one but the user can access anything, not even the CNID at rest.

One could argue this is a perfect design for home directories, where each user/home has its own single user process, and the CNID is kept separately under the homes path in a hidden location or something.

The SQLite in-process library-based design is perfect for single user mode, as it allows for many concurrent single user databases (being part of afpd they never collide). And you only need MySQL for multiuser mode (keeping the advanced machinery only where needed).

I see lots of value and potential in having both "private"/"single user" shares and standard multi user shares coexisting on the same server.

The more I think about it, the more elegant it appears;


Thought some more; Whilst not your original separate daemon/port intention I know, but the piece we might be missing is a per-volume private = yes option: the parent still federates every connection on 548 and the session child already runs as the authenticated user, but a "private volume" could just tells cnid_sqlite_open() to use a per-user database (vol dbpath already expands $u/$h) at 0700/0600 instead of the shared world-writable one — which means private and shared volumes can coexist on the same server with no new process model.

This class of change (separate DB for private vols) would also help to solve the Xapian/LocalSearch privacy issues (Xapian/LocalSearch would need separate daemons, but private SQLite CNID becomes a trivial solve for home directories)..

@andylemin

Copy link
Copy Markdown
Contributor

Went back through the threads against 5444e7ce — thanks for working through this, and sorry for the reopens: the intent is only to keep the remaining items visible, not to relitigate anything you have already done.

Genuinely closed, and I have left those resolved:

  • the getpass() NULL path, via the new get_password() wrapper — all seven call sites converted, each failure taking an existing cleanup label
  • -P must be absolute
  • re-validation on SIGHUP — you solved this more strongly than I suggested by refusing reloads outright in the mode, which also closes the dbd-via-reload path that would have respawned cnid_metad

The SQLite work you asked me to look at is right for the afpd path; the only gap is that it keys on the process's own command line, so another tool run by the same user undoes it. Details on that thread.

Of what is left, two are the ones I would treat as merge blockers, both unchanged so far: the setuid-root O_CREAT | O_TRUNC on a caller-supplied -p, and the setuplog()-before-daemonize() reordering, which costs file logging for the default root service as well as the new mode. The rest are smaller, and two of them (the old-password proof and the * marker) fall out for free once the afppasswd path and privilege drop are sorted.

Worth flagging one thing that is not in any thread: the manual's own example cannot start a working server yet, because the [Global] block sets no afp port and the documented afppasswd bootstrap produces a root-owned verifier that validate_unprivileged_config() then rejects. Both are covered on their threads.

Happy to re-review as soon as you want another pass, and equally happy to take any of the mechanical ones off your hands if that helps get it in.

@rdmark

rdmark commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

@andylemin I see your point about the naming -- "unprivileged" is a description of the mechanics rather than the utility for the end user. if we lean into the CNID database isolation in the documentation, I might favor netatalk --private as the name of the argument, since it clearly signals the value and outcome. what do you think?

and yes, if you are up for it I'm happy for you to take over this PR and take care of the finishing touches! I'm feeling under the weather this week so at a limited mental capacity.

if you like you can cherry-pick the current commit into a fresh feature branch.

@andylemin

Copy link
Copy Markdown
Contributor

@rdmark Hope you feel better soon. Take care

@andylemin

andylemin commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

@rdmark Happy to take this over.

Let me have a think about how to approach it.
I understand the use-case as you have built it so far.

But I need to find the correct compromise so it works with the standard multi-user architecture (where the parent process federates users).

It needs to work in such a way that the standard model gains the same benefits; individual users can access both private vols/CNID databases and shared vols/CNID databases.

I believe this is the more impactful and common use-case which can work by default for home shares, but needs the child afpd process to maintain multiple CNIDs.

SQLite fits the use case beautifully as its biggest limitation is multiple user contention. And its biggest strength is being in-process library.

So an enterprise setup would have a child proc with a MySQL connection for the master CNID holding all the shared vols, and then as many private in-proc SQLite objects as needed.
Still only a single socket to manage for the master, adding zero new admin overhead.

So requires some form of CNID backend array.
I'm leaning towards keeping the required master CNID exactly as-is, and having a separate array of optional references to only private SQLite objects.

This keeps the existing code structurally the same (where the master CNID can be different backends), and the new private vol array (which can only be SQLite). These private backends will need to be stored inline within the same private vols for both scalability, security and compartmentalisation.

@andylemin

Copy link
Copy Markdown
Contributor

@rdmark playing devils advocate;

Given the above described functionality which offers a containerised-like vol access (with the private CNID inside the target vol), what is the remaining benefit of this PR as it stands?

The remaining feature it would offer would be a single-user parent. I think that would only benefit very specific use cases or locked down systems where an operator has no admin access (so would need manual start etc)?

What do you think?

@andylemin

Copy link
Copy Markdown
Contributor

This one is definitely for after the upcoming release

@rdmark

rdmark commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

@andylemin the original use case I had in mind was a non-sysadmin user who have a regular account and want to share their home directory casually

it could be someone who installs Netatalk on macOS via Homebrew, for instance, where the default assumption/expectation is that an installed app runs as a non-root user

I've also toyed with the idea of a macOS GUI app that emulates old-school "personal file sharing" by launching Netatalk -- which obviously would run as a non-privileged user!

another use case would be for testing: maybe the spectest could by default launch its own netatalk process with a temporary file system

@andylemin

Copy link
Copy Markdown
Contributor

Working on this now when I can

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.

Allow netatalk to run as non-root

2 participants