Ausgangsfrage: Lässt sich eine einzelne AppleShare-Freigabe in mehrere Bereiche mit unterschiedlichen Rechten aufteilen — und lässt sich der Server dabei vom Vintage-Mac aus administrieren, statt über SSH von einem modernen Rechner?
Beides geht. Der Weg dorthin war allerdings länger als gedacht, und mehrere Annahmen unterwegs waren falsch. Dieser Log hält beides fest: das Ergebnis und die Irrwege, weil letztere die eigentliche Arbeit waren.
Kontext
Der Aufbau besteht aus drei Maschinen:
| Rolle | System |
|---|---|
| Client | Mac OS 7.6.1 in Basilisk II, Netscape Navigator 3.04, AppleShare-Client |
| Server | ODROID, altes Ubuntu auf armhf, netatalk 2.2.2-1 |
| Host | macOS, auf dem der Emulator läuft |
Der Emulator hängt seit einiger Zeit nicht mehr über slirp am Netz, sondern über etherhelper — also als Bridge auf Layer 2, mit eigener MAC-Adresse am Ethernet. Diese eine Tatsache erklärt am Ende fast alle Überraschungen weiter unten.
Zu Beginn gab es genau ein Volume, definiert durch eine einzige Zeile:
/media/fc8aeef5-792a-4ef0-afc8-fe169088495c/Home/Pit/AppleShare AppleShare ea:ad
Befunde
2026-07-25 — Die Version entscheidet über alles Weitere
dpkg -l | grep netatalk liefert 2.2.2-1. Das ist wichtiger, als es klingt: netatalk 3.x und neuer konfigurieren sich über eine einzelne afp.conf im INI-Format. Die 2.2er-Reihe tut das nicht. Dort liegen die Volumes zeilenweise in AppleVolumes.default, die Serveroptionen in afpd.conf, und welche Daemons überhaupt starten, steht in /etc/default/netatalk.
Jede Anleitung, die afp.conf erwähnt, ist für diesen Server unbrauchbar.
Nebenbefund: apt gibt es nicht, das Kommando kam erst 2014. apt-cache policy tut es. Und die Paketquellen sind tot — für Wheezy oder Trusty muss die sources.list auf archive.debian.org beziehungsweise old-releases.ubuntu.com zeigen, sonst lässt sich nicht einmal inotify-tools nachinstallieren.
2026-07-25 — Was der Daemon kann
afpd -V schreibt auf stderr, afpd -V | head zeigt deshalb nichts. Mit 2>&1 kommt die Antwort:
afpd 2.2.2 - Apple Filing Protocol (AFP) daemon of Netatalk
AFP versions: 1.1 2.0 2.1 2.2 3.0 3.1 3.2 3.3
DDP(AppleTalk) Support: Yes
CNID backends: dbd last tdb
TCP wrappers support: Yes
afpd.conf: /etc/netatalk/afpd.conf
AppleVolumes.default: /etc/netatalk/AppleVolumes.default
Drei Dinge stehen damit fest: die Pfade stimmen, AFP 2.1 ist für einen 7.6.1-Client vorhanden, und AppleTalk ist einkompiliert. Einkompiliert heißt aber nicht in Betrieb.
2026-07-25 — Der Mac benutzt Port 548 überhaupt nicht
Das war der Wendepunkt. Aufgefallen ist es an einem Detail im Verzeichnislisting: neben afpd.conf lagen eine atalkd.conf und dazu .bak, .bak3 und eine Datei namens atalkd.conf.WORKING. Das sieht nach einer Sitzung aus, in der jemand AppleTalk zum Laufen gebracht hat.
# nbplkup
ApfelNetz:AFPServer 65280.79:128
ApfelNetz:netatalk 65280.79:4
ApfelNetz:Workstation 65280.79:4
<Unnamed>: Macintosh 65280.1:252
Der Server ist per NBP als ApfelNetz angemeldet, auf Node 79 im Netz 65280 — das ist 0xFF00, die Startup-Range, es gibt also keinen Seed-Router im Netz und atalkd hat sich die Adresse selbst gegeben. Auf Node 1 hängt der emulierte Mac.

Dieselbe Information, später im Werkzeug: die Zonen- und NBP-Ansicht liest atalkd.conf und die Anmeldungen aus und bringt damit auf den Mac-Bildschirm, wofür oben noch eine Shell nötig war. Der Screenshot stammt aus einer Ausbaustufe nach dem unten abgedruckten Quelltext 1.0.
Die Gegenprobe:
# netstat -an | grep 548
tcp 0 0 0.0.0.0:548 0.0.0.0:* LISTEN
Nur LISTEN, keine einzige ESTABLISHED-Verbindung — und das, während im Prozessbaum ein afpd-Kindprozess für den Benutzer Pit lief. Der Mac mountet über DDP, nicht über TCP.
Und hier schließt sich der Kreis zu etherhelper: AppleTalk kommt nicht durch slirp. Slirp NATet ausschließlich IP. Dass AppleTalk funktioniert, ist kein Zufall neben dem Bridge-Modus, sondern seine Folge.
2026-07-25 — Eine Sicherheitslücke, die hier nicht greift, aber offen steht
Netatalk vor 3.1.12 hat einen Out-of-bounds-Write in dsi_opensess.c: ein nicht authentifizierter Angreifer kann darüber Code mit Root-Rechten ausführen (CVE-2018-1160, CVSS 9.8). Ubuntu hat für dieselbe Upstream-Version 2.2.2-1ubuntu2.2 als Fix ausgeliefert — ein schlichtes 2.2.2-1 liegt darunter.
Die Lücke sitzt im DSI-Pfad, also in AFP über TCP. Der Mac benutzt diesen Pfad nicht. Damit gibt es eine elegante Antwort: den Transport abschalten, statt ihn zu filtern.
- -ddp -nosavepassword -zeroconf -uamlist uams_dhx2.so,uams_clrtxt.so
Wo kein Socket offen ist, gibt es nichts anzugreifen. Ein Paketfilter davor bleibt trotzdem sinnvoll, solange -transall noch gesetzt ist.
2026-07-25 — Welche Konfigurationsdatei gewinnt
Der laufende Prozess zeigte -U uams_clrtxt.so,uams_guest.so — das stammt aus /etc/default/netatalk. In afpd.conf stand eine ganz andere, längere Liste. Naheliegende Vermutung: die Kommandozeile überstimmt die Datei.
Falsch. asip-status.pl gegen den Server selbst beweist das Gegenteil:
Machine type: Netatalk2.2.2
AFP versions: AFPVersion 1.1,AFPVersion 2.0,AFPVersion 2.1,AFP2.2,AFPX03,AFP3.1,AFP3.2,AFP3.3
UAMs: No User Authent,Cleartxt Passwrd,DHX2
Flags: SupportsCopyFile,DontAllowSavePwd,...
DHX2 steht ausschließlich in afpd.conf, nicht in der Kommandozeile. Und das Flag DontAllowSavePwd stammt aus dem -nosavepassword derselben Datei, obwohl dieser Parameter im Prozessbaum gar nicht auftauchte. afpd.conf gewinnt. Wer den Gastzugang abschalten will, muss dort ansetzen, nicht in /etc/default/netatalk.
Und No User Authent in dieser Liste heißt: der Gastzugang ist aktiv. Jeder, der den Server erreicht, kann ohne Anmeldung mounten. Solange das so ist, bleibt jede Zugangsliste pro Volume ein Zaun mit offenem Tor daneben.
2026-07-25 — Drei Lektionen aus dem Paketfilter
Der Versuch, Port 548 einzugrenzen, ging dreimal schief, und zwar auf lehrreiche Weise.
Reihenfolge. iptables -A hängt hinten an, ausgewertet wird von oben nach unten beim ersten Treffer. Eine ACCEPT-Regel, die nach einem passenden DROP steht, wird nie erreicht. -I INPUT 1 für die Ausnahme, -A für den Riegel — oder beides mit expliziten Positionsnummern.
Loopback. Ein pauschales DROP auf Port 548 trifft auch 127.0.0.1. Ein asip-status.pl 127.0.0.1 lief daraufhin in einen Timeout — und der Timeout selbst war die Diagnose: DROP verwirft stillschweigend, REJECT oder ein fehlender Dienst hätten „Connection refused" ergeben. Ein iptables -I INPUT 1 -i lo -j ACCEPT gehört ohnehin in jede Kette.
Zähler lesen. iptables -L INPUT -n -v zeigt Paketzähler. Stehen die nach einem Verbindungsversuch immer noch auf 0, kommt am Filter gar nichts an — was in diesem Fall die richtige Anzeige war, weil der Verkehr über DDP lief. Ohne diese Zähler hätte man ewig an der falschen Stelle gesucht.
Dazu ein Fehlschluss meinerseits, den asip-status aufgeklärt hat: die Adresse <odroid-ip> ist der ODROID selbst, nicht der Mac-Host. Im Bridge-Modus hat der emulierte Mac ohnehin eine eigene LAN-Adresse aus dem DHCP — eine ACCEPT-Regel auf die Host-Adresse hätte ihn nie durchgelassen.
Der Plan
Für die Administration vom Mac aus standen drei Wege zur Wahl.
Telnet. NCSA Telnet oder BetterTelnet laufen auf 68k unter 7.6.1. SSH scheidet aus — NiftyTelnet SSH spricht nur SSH-1, das kein aktueller sshd mehr annimmt. Auf dem ODROID wäre busybox telnetd schnell installiert, aber das Passwort ginge im Klartext über das LAN, und man handelt sich eine dauerhaft offene Shell ein.
Dateien über AppleShare editieren. Ein Staging-Volume freigeben, AppleVolumes.default in SimpleText bearbeiten, ein inotifywait-Skript übernimmt und schickt SIGHUP. Charmant, weil auf dem Mac nichts installiert werden muss. Nachteile: der Classic-Editor schreibt CR-Zeilenenden, das Volume sät .AppleDouble-Ordner aus, und es gibt keinerlei Prüfung vor dem Übernehmen.
Ein HTTP-Server auf dem ODROID. Netscape 3.04 ist auf dem Mac vorhanden, die Konfiguration liegt auf dem ODROID — kein SSH-Umweg, kein zweites Programm im knappen 68k-Speicher, und der Server kann validieren, bevor er schreibt.
Der dritte Weg hat gewonnen. Bemerkenswert dabei: ClaudeBridge, das im selben Projekt genau diese HTML-3.2-Maschinerie mitbringt, kommt hier nicht in Frage. Es bindet ausdrücklich Loopback und ist slirp-only — im Bridge-Modus kann es den Gast gar nicht bedienen. Das Werkzeug muss eigenständig sein.
flowchart LR
NS["Netscape 3.04
System 7.6.1"]
CH["Chooser
AppleShare-Client"]
ADM["netatalk-admin
Port 8081"]
AV["AppleVolumes.default"]
AFPD["afpd 2.2.2"]
NS -->|"HTTP, HTML 3.2"| ADM
ADM -->|"schreibt"| AV
ADM -->|"SIGHUP"| AFPD
AFPD -->|"liest"| AV
CH -->|"AFP over DDP"| AFPD
Die Aufteilung selbst
Volumes sind in netatalk keine Partitionen, sondern schlicht Verzeichnisse, die eigenständig freigegeben werden. Jedes erscheint im Chooser als eigener Eintrag und nach dem Mounten als eigenes Icon.
Entscheidend: die Verzeichnisse müssen Geschwister sein. Ein Volume, dessen Pfad unterhalb eines anderen liegt, bringt die CNID-Datenbank durcheinander.
:DEFAULT: ea:ad
/media/fc8aeef5-.../Home/Pit/AppleShare "AppleShare"
/media/fc8aeef5-.../Home/Pit/Projekte "Projekte" allow:Pit
/media/fc8aeef5-.../Home/Pit/Archiv "Archiv" options:ro
Die :DEFAULT:-Zeile zieht ea:ad für alle Volumes vor die Klammer. Danach genügt ein kill -HUP an den afpd-Elternprozess; die Volume-Liste entsteht beim Login, der Mac muss sich also im Chooser ab- und wieder anmelden.
Wichtige Optionen der 2.2er-Syntax:
| Option | Wirkung |
|---|---|
allow:Pit,@Famiglia | Zugang auf Benutzer und Gruppen begrenzen; Gruppen mit @ |
rolist: / rwlist: | Lese- beziehungsweise Schreibrechte pro Benutzer im selben Volume |
options:ro | Volume schreibgeschützt; der Finder meldet es als „locked" |
options:upriv | Unix-Rechte pro Datei statt pauschaler Volume-Rechte; nötig für dperm/fperm |
options:limitsize | Größenmeldung auf 2 GB deckeln, gegen falsche Anzeigen in alten Findern |
ea:ad | Metadaten im AppleDouble-Format, also in .AppleDouble-Unterverzeichnissen |

Dieselben Optionen als Formular: Was oben zeilenweise in AppleVolumes.default steht, wird hier zu Feldern und Ankreuzkästchen — und die Prüfung auf verschachtelte Pfade, fehlende Verzeichnisse und zu lange Namen läuft vor dem Speichern, nicht erst beim nächsten Anmelden im Chooser.
Zwei Stolpersteine in der Praxis: der Volume-Name ist im Mac-Zeichensatz auf 27 Zeichen begrenzt und darf kein : enthalten. Und netatalk legt fehlende Verzeichnisse nicht an — es übergeht solche Zeilen stillschweigend, was sich hervorragend versteckt, weil nichts kaputtgeht, sondern nur nichts passiert.
Der Test danach ist wörtlich diese Meldung:
You cannot copy „FortPoC.Bin" onto the shared disk „Archiv", because the disk is locked.
Damit ist options:ro bestätigt — und dass Projekte im Chooser überhaupt auftaucht, beweist zugleich, dass die Anmeldung als Pit erfolgte und nicht als Gast.
Das Werkzeug
netatalk-admin ist ein einzelnes Python-Skript ohne Abhängigkeiten. Es liest AppleVolumes.default, bietet Formulare zum Anlegen, Ändern und Entfernen, schickt auf Wunsch das SIGHUP und zeigt daneben eine Statusseite.

Die drei Volumes, das Formular für neue Einträge und die Dienstübersicht auf einer Seite. Die Statuszeilen halten zugleich den offenen Stand fest: Transport DDP + TCP (-transall), und uams_guest.so steht weiterhin in der UAM-Liste — das sind die Punkte 1 und 2 der Liste am Ende dieses Logs. Der Screenshot zeigt bereits eine spätere Ausbaustufe als der unten abgedruckte Quelltext 1.0: ein Einseiten-Layout und einen zusätzlichen Blick in atalkd.conf.
Die Entwurfsentscheidungen im Einzelnen:
Nur Standardbibliothek, Python 2.6+ und 3.x. Auf einer Distribution ohne funktionierende Paketquellen ist jede Abhängigkeit ein Risiko. Der Zielrechner hat Python 2.7.
HTML 3.2, ISO-8859-1, dezimale Entities. Netscape 3 kennt kein CSS, kein JavaScript und keine modernen Formularelemente. html.escape() aus der Standardbibliothek ist unbrauchbar, weil es ' erzeugt — hexadezimale Entities stellt Netscape 3 wörtlich dar. Deshalb eine eigene esc()-Funktion mit dezimalen Referenzen.
Kommentare und :DEFAULT: überleben. Der Parser behandelt Kommentare, Leerzeilen, :DEFAULT: und ~-Einträge als unveränderlich und schreibt sie an ihrer Position zurück. Bearbeitet werden ausschließlich Volume-Zeilen.
Volume-Name und Option unterscheiden sich am Doppelpunkt. Ein Name darf keinen enthalten, eine Option enthält immer einen. Das genügt als Heuristik für die stellungsabhängige Syntax der 2.2er-Datei.
latin-1 beim Öffnen, nicht die Locale. Ein Test mit einem Umlaut im Pfad ließ das Skript unter Python 3 sofort abstürzen, weil open() den Locale-Zeichensatz benutzt. latin-1 bildet jedes Byte auf genau ein Zeichen ab — Lesen und unverändertes Zurückschreiben lässt also auch Zeilen unangetastet, die in einem anderen Zeichensatz geschrieben wurden.
Den afpd-Master über /proc finden. Auf dem Zielsystem existiert keine PID-Datei — weder /var/run/afpd.pid noch etwas unter /var/run/netatalk/. Der Elternprozess lässt sich zuverlässiger über /proc/*/stat bestimmen: comm == "afpd" und PPid == 1. Die Kindprozesse desselben Masters sind dann genau die offenen Sitzungen, samt Benutzer über die UID.
Schreiben über eine temporäre Datei plus os.rename. Damit gibt es keinen Moment, in dem afpd eine halb geschriebene Konfiguration liest. Vor jedem Schreibvorgang landet eine Kopie in /var/backups/netatalk-admin.
afpd.conf wird nur gelesen. Den Gastzugang abzuschalten verlangt einen vollständigen Neustart, und der kann bei einem Fehler den Zugang zum Server kosten. Das gehört nicht hinter einen Formularknopf.
Bedienung
# Zeichensatz und Zielpfade oben im Skript prüfen,
# vor allem ALLOWED_PREFIXES auf die IP des Macs setzen
python2.7 /usr/local/sbin/netatalk-admin.py
Danach im Netscape auf http://<server>:8081/. Für den Dauerbetrieb genügt ein nohup-Aufruf oder ein Eintrag in /etc/rc.local.
Sicherheitshinweise
Das Skript läuft als root — anders kommt es nicht an /etc/netatalk und kann keine Signale senden. ALLOWED_PREFIXES ist deshalb kein Beiwerk, sondern die eigentliche Absicherung. Es gibt keine Authentifizierung; das Werkzeug gehört in ein vertrauenswürdiges Netz und keinesfalls auf ein Interface, das ins Internet zeigt. Wer mehr will, stellt einen Reverse Proxy mit Basic Auth davor — was Netscape 3 noch beherrscht.
Quelltext
#!/usr/bin/env python
# -*- coding: iso-8859-1 -*-
"""
netatalk-admin 1.0
==================
Administration von netatalk 2.2.x aus Netscape Navigator 3 heraus.
Ein winziger HTTP-Server, der auf dem netatalk-Rechner selbst laeuft und
AppleVolumes.default ueber Formulare bearbeitbar macht. Kein Framework,
keine externen Pakete, lauffaehig unter Python 2.6+ und Python 3.x.
AppleVolumes.default Volumes anlegen, aendern, loeschen
afpd.conf nur lesen (Transport und UAMs anzeigen)
SIGHUP an den Master Konfiguration uebernehmen
Der Server bindet auf eine feste Adresse und beantwortet nur Anfragen von
Clients aus ALLOWED_PREFIXES. Er muss als root laufen, weil er nach
/etc/netatalk schreibt und Signale sendet -- entsprechend gehoert er nicht
auf ein Interface, das ins Internet zeigt.
Start: python netatalk-admin.py
Aufruf: http://<server>:8081/
"""
import os
import re
import shlex
import shutil
import signal
import sys
import time
try: # Python 3
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
PY3 = True
except ImportError: # Python 2
from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler
from urlparse import urlparse, parse_qs
PY3 = False
try:
import pwd
except ImportError:
pwd = None
# ---------------------------------------------------------------- Einstellungen
LISTEN_HOST = "0.0.0.0"
LISTEN_PORT = 8081
# Wer den Server benutzen darf. Praefixvergleich auf die Client-IP.
# Fuer einen einzelnen Rechner die vollstaendige Adresse eintragen.
ALLOWED_PREFIXES = ["127.0.0.1", "<lan-prefix>"]
VOLFILE = "/etc/netatalk/AppleVolumes.default"
AFPDCONF = "/etc/netatalk/afpd.conf"
BACKUP_DIR = "/var/backups/netatalk-admin"
KEEP_BACKUPS = 20
MAX_NAME = 27 # AFP-Grenze fuer Volume-Namen im Mac-Zeichensatz
CHARSET = "iso-8859-1"
# ---------------------------------------------------------------- Text/Bytes
# Unter Python 2 liefert open() Bytes, waehrend Formulareingaben als unicode
# ankommen. Beides ungeprueft zu mischen bricht, sobald ein Pfad oder ein
# Volume-Name einen Umlaut enthaelt -- deshalb hier einmal sauber trennen.
def _dec(value):
if not PY3 and isinstance(value, str):
return value.decode(CHARSET, "replace")
return value
def _enc(value):
if not PY3 and isinstance(value, unicode): # noqa: F821
return value.encode(CHARSET, "replace")
return value
def _open(path, mode):
"""Dateien mit festem Zeichensatz oeffnen, nicht nach Locale.
latin-1 ist hier bewusst gewaehlt: es bildet jedes Byte auf genau ein
Zeichen ab. Lesen und unveraendertes Zurueckschreiben laesst deshalb auch
Zeilen unangetastet, die in einem anderen Zeichensatz geschrieben wurden.
"""
if PY3:
return open(path, mode, encoding=CHARSET, newline="")
return open(path, mode)
# ---------------------------------------------------------------- Volume-Modell
class Volume(object):
"""Eine Volume-Zeile aus AppleVolumes.default."""
def __init__(self, path, name="", opts=None):
self.path = path
self.name = name
self.opts = list(opts or [])
# -- Optionen der Form schluessel:wert ---------------------------------
def get(self, key):
prefix = key + ":"
for opt in self.opts:
if opt.startswith(prefix):
return opt[len(prefix):]
return ""
def set(self, key, value):
prefix = key + ":"
kept = [o for o in self.opts if not o.startswith(prefix)]
if value:
kept.append(prefix + value)
self.opts = kept
# -- options: nimmt eine kommaseparierte Liste -------------------------
def flags(self):
raw = self.get("options")
return [f for f in raw.split(",") if f]
def set_flags(self, flags):
self.set("options", ",".join(flags))
def has_flag(self, flag):
return flag in self.flags()
def toggle_flag(self, flag, on):
flags = self.flags()
if on and flag not in flags:
flags.append(flag)
elif not on and flag in flags:
flags.remove(flag)
self.set_flags(flags)
# -- Darstellung -------------------------------------------------------
def effective_name(self):
"""Ohne expliziten Namen benutzt netatalk den Verzeichnisnamen."""
if self.name:
return self.name
return os.path.basename(self.path.rstrip("/")) or self.path
def line(self):
parts = [_quote(self.path)]
if self.name:
parts.append('"%s"' % self.name)
parts.extend(self.opts)
return " ".join(parts)
def _quote(value):
return '"%s"' % value if " " in value else value
# ---------------------------------------------------------------- Datei lesen
def read_volfile():
"""Liefert eine Liste aus ("raw", zeile) und ("vol", Volume).
Kommentare, Leerzeilen, :DEFAULT: und ~-Eintraege bleiben unangetastet
und behalten ihre Position, damit die Datei beim Schreiben wiedererkennbar
bleibt.
"""
entries = []
try:
handle = _open(VOLFILE, "r")
except IOError:
return entries
try:
for raw in handle:
line = raw.rstrip("\r\n")
stripped = line.strip()
if (not stripped or stripped.startswith("#")
or stripped.startswith(":") or stripped.startswith("~")):
entries.append(("raw", _dec(line)))
continue
try:
parts = shlex.split(stripped)
except ValueError:
entries.append(("raw", _dec(line)))
continue
if not parts:
entries.append(("raw", _dec(line)))
continue
parts = [_dec(p) for p in parts]
path = parts[0]
rest = parts[1:]
name = ""
# Ein Volume-Name kann keinen Doppelpunkt enthalten, eine Option
# enthaelt immer einen -- daran lassen sich beide unterscheiden.
if rest and ":" not in rest[0]:
name = rest[0]
rest = rest[1:]
entries.append(("vol", Volume(path, name, rest)))
finally:
handle.close()
return entries
def volumes(entries):
return [(i, v) for i, (kind, v) in enumerate(entries) if kind == "vol"]
# ---------------------------------------------------------------- Datei schreiben
def make_backup():
if not os.path.isdir(BACKUP_DIR):
os.makedirs(BACKUP_DIR, 0o700)
stamp = time.strftime("%Y%m%d-%H%M%S")
target = os.path.join(BACKUP_DIR, "AppleVolumes.default." + stamp)
if os.path.exists(VOLFILE):
shutil.copy2(VOLFILE, target)
prune_backups()
return target
def prune_backups():
try:
names = sorted(os.listdir(BACKUP_DIR))
except OSError:
return
for name in names[:-KEEP_BACKUPS]:
try:
os.remove(os.path.join(BACKUP_DIR, name))
except OSError:
pass
def write_volfile(entries):
"""Schreibt ueber eine temporaere Datei und benennt sie um.
Damit gibt es keinen Zustand, in dem afpd eine halb geschriebene
Konfiguration liest.
"""
backup = make_backup()
tmp = VOLFILE + ".tmp"
handle = _open(tmp, "w")
try:
for kind, value in entries:
text = value if kind == "raw" else value.line()
handle.write(_enc(text + "\n"))
finally:
handle.close()
os.rename(tmp, VOLFILE)
return backup
# ---------------------------------------------------------------- Pruefungen
def validate(vol, entries, skip_index=None):
"""Liefert eine Liste von Fehlermeldungen; leer heisst in Ordnung."""
errors = []
if not vol.path.startswith("/"):
errors.append("Der Pfad muss absolut sein und mit / beginnen.")
elif not os.path.isdir(vol.path):
errors.append("Das Verzeichnis %s existiert nicht. netatalk uebergeht "
"solche Zeilen stillschweigend -- lege es zuerst an."
% vol.path)
name = vol.effective_name()
if ":" in name:
errors.append("Der Volume-Name darf keinen Doppelpunkt enthalten.")
if len(name) > MAX_NAME:
errors.append("Der Name ist %d Zeichen lang. AFP kuerzt oberhalb von "
"%d Zeichen, der Mac zeigt dann etwas anderes an."
% (len(name), MAX_NAME))
mine = vol.path.rstrip("/") + "/"
for index, other in volumes(entries):
if index == skip_index:
continue
if other.effective_name().lower() == name.lower():
errors.append("Der Name %s ist bereits vergeben." % name)
theirs = other.path.rstrip("/") + "/"
if mine != theirs and (mine.startswith(theirs) or theirs.startswith(mine)):
errors.append("Der Pfad liegt innerhalb von %s oder umgekehrt. "
"Verschachtelte Volumes bringen die CNID-Datenbank "
"durcheinander -- lege die Verzeichnisse "
"nebeneinander an." % other.path)
elif mine == theirs and index != skip_index:
errors.append("Dieser Pfad ist bereits als Volume eingetragen.")
return errors
# ---------------------------------------------------------------- Prozesse
def afpd_master():
"""PID des afpd-Elternprozesses, oder None.
Ueber /proc statt ueber ps, weil das unabhaengig davon funktioniert,
ob eine PID-Datei geschrieben wurde.
"""
for entry in os.listdir("/proc"):
if not entry.isdigit():
continue
try:
handle = open("/proc/%s/stat" % entry)
data = handle.read()
handle.close()
except IOError:
continue
start = data.find("(")
end = data.rfind(")")
if start < 0 or end < 0:
continue
comm = data[start + 1:end]
fields = data[end + 2:].split()
if len(fields) < 2:
continue
if comm == "afpd" and fields[1] == "1":
return int(entry)
return None
def afpd_sessions(master):
"""Kindprozesse des Masters, also offene Verbindungen, mit Benutzer."""
found = []
if master is None:
return found
for entry in os.listdir("/proc"):
if not entry.isdigit() or int(entry) == master:
continue
try:
handle = open("/proc/%s/status" % entry)
text = handle.read()
handle.close()
except IOError:
continue
if not re.search(r"^Name:\s+afpd\s*$", text, re.M):
continue
parent = re.search(r"^PPid:\s+(\d+)", text, re.M)
if not parent or int(parent.group(1)) != master:
continue
user = "?"
uid = re.search(r"^Uid:\s+(\d+)", text, re.M)
if uid and pwd:
try:
user = pwd.getpwuid(int(uid.group(1))).pw_name
except KeyError:
user = uid.group(1)
found.append((int(entry), user))
found.sort()
return found
def send_sighup():
master = afpd_master()
if master is None:
return False, ("Kein afpd-Elternprozess gefunden. Laeuft netatalk? "
"Pruefe das mit: ps -ef | grep afpd")
try:
os.kill(master, signal.SIGHUP)
except OSError:
error = sys.exc_info()[1]
return False, "SIGHUP an PID %d fehlgeschlagen: %s" % (master, error)
return True, ("SIGHUP an PID %d gesendet. Neue Volumes erscheinen, sobald "
"sich der Mac im Chooser ab- und wieder anmeldet." % master)
def afpd_conf_summary():
"""Erste wirksame Zeile aus afpd.conf, plus Transport und UAMs."""
line = ""
try:
handle = _open(AFPDCONF, "r")
for raw in handle:
stripped = raw.strip()
if stripped and not stripped.startswith("#"):
line = stripped
break
handle.close()
except IOError:
return "", "unbekannt", [], False
if "-transall" in line:
transport = "AppleTalk und TCP"
elif "-ddp" in line:
transport = "nur AppleTalk"
elif "-tcp" in line:
transport = "nur TCP"
else:
transport = "Voreinstellung"
uams = []
match = re.search(r"-uamlist\s+(\S+)", line)
if match:
uams = [u for u in match.group(1).split(",") if u]
guest = any("guest" in u for u in uams)
return line, transport, uams, guest
# ---------------------------------------------------------------- HTML 3.2
def esc(text):
"""Netscape 3 versteht keine hexadezimalen Entities."""
if text is None:
text = ""
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
return text.replace('"', """).replace("'", "'")
HEADER = """<HTML>
<HEAD>
<TITLE>netatalk-admin - %(title)s</TITLE>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=%(charset)s">
</HEAD>
<BODY BGCOLOR="#FFFFFF" TEXT="#000000" LINK="#0000CC" VLINK="#0000CC">
<TABLE WIDTH="100%%" BORDER="0" CELLPADDING="4" CELLSPACING="0" BGCOLOR="#000066">
<TR><TD><FONT COLOR="#FFFFFF" SIZE="+1"><B>netatalk-admin</B></FONT>
<FONT COLOR="#CCCCFF"> %(server)s</FONT></TD></TR>
</TABLE>
<TABLE WIDTH="100%%" BORDER="0" CELLPADDING="4" CELLSPACING="0" BGCOLOR="#CCCCCC">
<TR><TD>
<A HREF="/">Volumes</A> |
<A HREF="/add">Volume hinzufügen</A> |
<A HREF="/status">Status</A>
</TD></TR>
</TABLE>
<P>
<H2>%(title)s</H2>
"""
FOOTER = """<P>
<HR>
<FONT SIZE="-1">netatalk-admin 1.0 · %(file)s</FONT>
</BODY>
</HTML>
"""
def page(title, body, server="", notice="", errors=None):
out = [HEADER % {"title": esc(title), "charset": CHARSET,
"server": esc(server)}]
if errors:
out.append('<TABLE BORDER="0" CELLPADDING="6" CELLSPACING="0" '
'WIDTH="100%" BGCOLOR="#FFCCCC"><TR><TD>')
out.append("<B>Nicht gespeichert:</B><UL>")
for error in errors:
out.append("<LI>%s</LI>" % esc(error))
out.append("</UL></TD></TR></TABLE><P>")
if notice:
out.append('<TABLE BORDER="0" CELLPADDING="6" CELLSPACING="0" '
'WIDTH="100%%" BGCOLOR="#CCFFCC"><TR><TD>%s</TD></TR>'
"</TABLE><P>" % esc(notice))
out.append(body)
out.append(FOOTER % {"file": esc(VOLFILE)})
return "".join(out)
def volume_table(entries):
rows = ['<TABLE BORDER="1" CELLPADDING="5" CELLSPACING="0" WIDTH="100%">',
'<TR BGCOLOR="#CCCCCC"><TH ALIGN="LEFT">Name</TH>'
'<TH ALIGN="LEFT">Verzeichnis</TH>'
'<TH ALIGN="LEFT">Zugang</TH>'
'<TH ALIGN="LEFT">Optionen</TH>'
'<TH ALIGN="LEFT"> </TH></TR>']
found = volumes(entries)
if not found:
rows.append('<TR><TD COLSPAN="5">Noch kein Volume eingetragen.</TD></TR>')
for index, vol in found:
allow = vol.get("allow") or "alle Benutzer"
flags = ", ".join(vol.flags()) or "—"
missing = "" if os.path.isdir(vol.path) else \
'<BR><FONT COLOR="#CC0000">Verzeichnis fehlt</FONT>'
rows.append(
"<TR><TD><B>%s</B></TD><TD><TT>%s</TT>%s</TD><TD>%s</TD>"
'<TD>%s</TD><TD NOWRAP><A HREF="/edit?i=%d">Ändern</A> '
'· <A HREF="/delete?i=%d">Entfernen</A></TD></TR>'
% (esc(vol.effective_name()), esc(vol.path), missing,
esc(allow), flags, index, index))
rows.append("</TABLE>")
return "".join(rows)
def volume_form(vol, action, index=None):
ro = 'CHECKED' if vol.has_flag("ro") else ''
upriv = 'CHECKED' if vol.has_flag("upriv") else ''
limitsize = 'CHECKED' if vol.has_flag("limitsize") else ''
extra = [o for o in vol.opts
if not o.startswith("options:") and not o.startswith("allow:")]
hidden = '<INPUT TYPE="HIDDEN" NAME="i" VALUE="%d">' % index \
if index is not None else ""
return """
<FORM ACTION="%(action)s" METHOD="POST">
%(hidden)s
<TABLE BORDER="0" CELLPADDING="5" CELLSPACING="0">
<TR><TD ALIGN="RIGHT" VALIGN="TOP"><B>Verzeichnis</B></TD>
<TD><INPUT TYPE="TEXT" NAME="path" SIZE="60" VALUE="%(path)s"><BR>
<FONT SIZE="-1">Vollständiger Pfad. Das Verzeichnis muss existieren und
darf nicht innerhalb eines anderen Volumes liegen.</FONT></TD></TR>
<TR><TD ALIGN="RIGHT" VALIGN="TOP"><B>Name</B></TD>
<TD><INPUT TYPE="TEXT" NAME="name" SIZE="30" VALUE="%(name)s"><BR>
<FONT SIZE="-1">So erscheint das Volume im Chooser. Höchstens %(max)d
Zeichen, kein Doppelpunkt. Leer lassen übernimmt den
Verzeichnisnamen.</FONT></TD></TR>
<TR><TD ALIGN="RIGHT" VALIGN="TOP"><B>Zugang</B></TD>
<TD><INPUT TYPE="TEXT" NAME="allow" SIZE="40" VALUE="%(allow)s"><BR>
<FONT SIZE="-1">Benutzernamen mit Komma trennen, Gruppen mit @ davor
(etwa <TT>Pit,@Famiglia</TT>). Leer heißt: alle, die sich anmelden
dürfen — einschließlich Gast, falls der Gastzugang aktiv
ist.</FONT></TD></TR>
<TR><TD ALIGN="RIGHT" VALIGN="TOP"><B>Optionen</B></TD>
<TD>
<INPUT TYPE="CHECKBOX" NAME="ro" VALUE="1" %(ro)s> <B>Schreibgeschützt</B>
— der Finder meldet das Volume als gesperrt<BR>
<INPUT TYPE="CHECKBOX" NAME="upriv" VALUE="1" %(upriv)s> <B>Unix-Rechte pro
Datei</B> — nötig für gemeinsam genutzte Bereiche<BR>
<INPUT TYPE="CHECKBOX" NAME="limitsize" VALUE="1" %(limitsize)s> <B>Größe
auf 2 GB deckeln</B> — gegen falsche Angaben in alten Findern
</TD></TR>
<TR><TD ALIGN="RIGHT" VALIGN="TOP"><B>Weitere</B></TD>
<TD><INPUT TYPE="TEXT" NAME="extra" SIZE="60" VALUE="%(extra)s"><BR>
<FONT SIZE="-1">Zusätzliche Optionen der Form
<TT>schlüssel:wert</TT>, durch Leerzeichen getrennt, etwa
<TT>ea:ad dperm:0775 fperm:0664</TT>.</FONT></TD></TR>
<TR><TD> </TD><TD>
<INPUT TYPE="CHECKBOX" NAME="reload" VALUE="1" CHECKED>
Danach neu einlesen (SIGHUP an afpd)</TD></TR>
<TR><TD> </TD>
<TD><INPUT TYPE="SUBMIT" VALUE="Speichern">
<A HREF="/">Abbrechen</A></TD></TR>
</TABLE>
</FORM>
""" % {"action": action, "hidden": hidden, "path": esc(vol.path),
"name": esc(vol.name), "allow": esc(vol.get("allow")),
"extra": esc(" ".join(extra)), "ro": ro, "upriv": upriv,
"limitsize": limitsize, "max": MAX_NAME}
# ---------------------------------------------------------------- HTTP
class AdminHandler(BaseHTTPRequestHandler):
server_version = "netatalk-admin/1.0"
protocol_version = "HTTP/1.0"
# -- Infrastruktur -----------------------------------------------------
def log_message(self, fmt, *args):
sys.stderr.write("%s %s - %s\n" % (time.strftime("%H:%M:%S"),
self.client_address[0], fmt % args))
def allowed(self):
client = self.client_address[0]
return any(client.startswith(p) for p in ALLOWED_PREFIXES)
def send_html(self, text, code=200):
body = text.encode(CHARSET, "replace")
self.send_response(code)
self.send_header("Content-Type", "text/html; charset=%s" % CHARSET)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def redirect(self, target):
self.send_response(302)
self.send_header("Location", target)
self.send_header("Content-Length", "0")
self.end_headers()
def form(self):
length = int(self.headers.get("Content-Length", 0) or 0)
raw = self.rfile.read(length) if length else b""
if PY3:
raw = raw.decode(CHARSET, "replace")
# Der Browser sendet im Zeichensatz des Dokuments, nicht in MacRoman.
parsed = parse_qs(raw, keep_blank_values=True)
out = {}
for key, values in parsed.items():
value = values[0]
if not PY3 and isinstance(value, str):
value = value.decode(CHARSET, "replace")
out[key] = value
return out
def server_label(self):
try:
import socket
return socket.gethostname()
except Exception:
return ""
# -- Routen ------------------------------------------------------------
def do_GET(self):
if not self.allowed():
self.send_html("<HTML><BODY>Kein Zugriff von dieser Adresse."
"</BODY></HTML>", 403)
return
parsed = urlparse(self.path)
query = parse_qs(parsed.query)
route = parsed.path
if route == "/":
self.view_index(query)
elif route == "/add":
self.view_add()
elif route == "/edit":
self.view_edit(query)
elif route == "/delete":
self.view_delete(query)
elif route == "/status":
self.view_status()
elif route == "/reload":
self.do_reload()
else:
self.send_html(page("Nicht gefunden",
'<P>Diese Seite gibt es nicht. '
'<A HREF="/">Zurück zur Übersicht</A>.'),
404)
def do_POST(self):
if not self.allowed():
self.send_html("<HTML><BODY>Kein Zugriff von dieser Adresse."
"</BODY></HTML>", 403)
return
route = urlparse(self.path).path
if route == "/save":
self.do_save()
elif route == "/dodelete":
self.do_delete()
else:
self.redirect("/")
# -- Ansichten ---------------------------------------------------------
def view_index(self, query):
entries = read_volfile()
notice = query.get("msg", [""])[0]
master = afpd_master()
body = [volume_table(entries), "<P>"]
if master:
body.append('<FORM ACTION="/reload" METHOD="GET">'
'<INPUT TYPE="SUBMIT" VALUE="Konfiguration neu einlesen">'
" <FONT SIZE=\"-1\">Sendet SIGHUP an afpd "
"(PID %d).</FONT></FORM>" % master)
else:
body.append('<FONT COLOR="#CC0000">afpd läuft nicht — '
"Änderungen bleiben ohne Wirkung.</FONT>")
self.send_html(page("Volumes", "".join(body),
self.server_label(), notice))
def view_add(self):
body = volume_form(Volume(""), "/save")
self.send_html(page("Volume hinzufügen", body, self.server_label()))
def view_edit(self, query):
entries = read_volfile()
index = self._index(query, entries)
if index is None:
self.redirect("/")
return
body = volume_form(entries[index][1], "/save", index)
self.send_html(page("Volume ändern", body, self.server_label()))
def view_delete(self, query):
entries = read_volfile()
index = self._index(query, entries)
if index is None:
self.redirect("/")
return
vol = entries[index][1]
body = """
<P>Das Volume <B>%s</B> wird aus der Konfiguration entfernt.
Der Mac sieht es nach der nächsten Anmeldung nicht mehr.</P>
<P>Die Dateien in <TT>%s</TT> bleiben unberührt — entfernt wird nur
der Eintrag, nicht das Verzeichnis.</P>
<FORM ACTION="/dodelete" METHOD="POST">
<INPUT TYPE="HIDDEN" NAME="i" VALUE="%d">
<INPUT TYPE="CHECKBOX" NAME="reload" VALUE="1" CHECKED>
Danach neu einlesen<BR><P>
<INPUT TYPE="SUBMIT" VALUE="Volume entfernen">
<A HREF="/">Abbrechen</A>
</FORM>
""" % (esc(vol.effective_name()), esc(vol.path), index)
self.send_html(page("Volume entfernen", body, self.server_label()))
def view_status(self):
master = afpd_master()
sessions = afpd_sessions(master)
line, transport, uams, guest = afpd_conf_summary()
rows = ['<TABLE BORDER="1" CELLPADDING="5" CELLSPACING="0">']
def row(label, value):
rows.append('<TR><TD BGCOLOR="#CCCCCC"><B>%s</B></TD><TD>%s</TD></TR>'
% (label, value))
row("afpd", "läuft, PID %d" % master if master
else '<FONT COLOR="#CC0000">läuft nicht</FONT>')
row("Transport", esc(transport))
row("Anmeldeverfahren", esc(", ".join(uams)) or "unbekannt")
row("Offene Sitzungen", str(len(sessions)))
rows.append("</TABLE><P>")
if sessions:
rows.append('<TABLE BORDER="1" CELLPADDING="5" CELLSPACING="0">')
rows.append('<TR BGCOLOR="#CCCCCC"><TH ALIGN="LEFT">PID</TH>'
'<TH ALIGN="LEFT">Benutzer</TH></TR>')
for pid, user in sessions:
rows.append("<TR><TD>%d</TD><TD>%s</TD></TR>" % (pid, esc(user)))
rows.append("</TABLE><P>")
if guest:
rows.append('<TABLE BORDER="0" CELLPADDING="6" CELLSPACING="0" '
'WIDTH="100%"><TR><TD BGCOLOR="#FFFFCC">'
"<B>Der Gastzugang ist aktiv.</B> Jeder, der den Server "
"erreicht, kann ohne Anmeldung mounten. Solange das so "
"ist, wirken die Zugangslisten der einzelnen Volumes "
"nur für angemeldete Benutzer. Abschalten lässt "
"sich das nur in <TT>" + esc(AFPDCONF) + "</TT>, indem "
"<TT>uams_guest.so</TT> aus <TT>-uamlist</TT> "
"verschwindet — danach ist ein vollständiger "
"Neustart nötig, ein SIGHUP genügt dafür "
"nicht.</TD></TR></TABLE><P>")
if line:
rows.append("<H3>Erste wirksame Zeile aus %s</H3><PRE>%s</PRE>"
% (esc(AFPDCONF), esc(line)))
rows.append("<H3>Sicherungen</H3><P>Vor jedem Schreibvorgang landet eine "
"Kopie in <TT>%s</TT>. Die letzten %d bleiben erhalten.</P>"
% (esc(BACKUP_DIR), KEEP_BACKUPS))
self.send_html(page("Status", "".join(rows), self.server_label()))
# -- Aktionen ----------------------------------------------------------
def do_save(self):
data = self.form()
entries = read_volfile()
index = None
if data.get("i", "").strip():
try:
candidate = int(data["i"])
except ValueError:
candidate = -1
if 0 <= candidate < len(entries) and entries[candidate][0] == "vol":
index = candidate
vol = Volume(data.get("path", "").strip().rstrip("/") or "/",
data.get("name", "").strip())
if index is not None:
vol.opts = list(entries[index][1].opts)
for token in data.get("extra", "").split():
if ":" in token:
key, value = token.split(":", 1)
vol.set(key, value)
vol.set("allow", data.get("allow", "").strip().replace(" ", ""))
vol.toggle_flag("ro", data.get("ro") == "1")
vol.toggle_flag("upriv", data.get("upriv") == "1")
vol.toggle_flag("limitsize", data.get("limitsize") == "1")
errors = validate(vol, entries, index)
if errors:
action = "/save"
body = volume_form(vol, action, index)
self.send_html(page("Volume speichern", body,
self.server_label(), "", errors))
return
if index is None:
entries.append(("vol", vol))
message = "Volume %s angelegt." % vol.effective_name()
else:
entries[index] = ("vol", vol)
message = "Volume %s geändert." % vol.effective_name()
write_volfile(entries)
if data.get("reload") == "1":
ok, detail = send_sighup()
message = message + " " + detail
self.redirect("/?msg=" + _urlish(message))
def do_delete(self):
data = self.form()
entries = read_volfile()
try:
index = int(data.get("i", "-1"))
except ValueError:
index = -1
if not (0 <= index < len(entries)) or entries[index][0] != "vol":
self.redirect("/")
return
name = entries[index][1].effective_name()
del entries[index]
write_volfile(entries)
message = "Volume %s entfernt." % name
if data.get("reload") == "1":
ok, detail = send_sighup()
message = message + " " + detail
self.redirect("/?msg=" + _urlish(message))
def do_reload(self):
ok, detail = send_sighup()
self.redirect("/?msg=" + _urlish(detail))
# -- Hilfen ------------------------------------------------------------
def _index(self, query, entries):
try:
index = int(query.get("i", ["-1"])[0])
except ValueError:
return None
if 0 <= index < len(entries) and entries[index][0] == "vol":
return index
return None
def _urlish(text):
"""Sehr einfache Kodierung fuer die Meldung in der Redirect-URL."""
out = []
for char in text:
if (char.isalnum() and ord(char) < 128) or char in "-_.":
out.append(char)
elif char == " ":
out.append("+")
else:
out.append("%%%02X" % (ord(char) & 0xFF))
return "".join(out)
# ---------------------------------------------------------------- Start
def main():
if not os.path.exists(VOLFILE):
sys.stderr.write("Konfiguration nicht gefunden: %s\n" % VOLFILE)
sys.stderr.write("Passe VOLFILE oben im Skript an. Wo netatalk "
"wirklich liest, zeigt: afpd -V 2>&1\n")
return 1
if hasattr(os, "geteuid") and os.geteuid() != 0:
sys.stderr.write("Warnung: nicht als root gestartet. Lesen geht, "
"Speichern und SIGHUP werden fehlschlagen.\n")
server = HTTPServer((LISTEN_HOST, LISTEN_PORT), AdminHandler)
sys.stderr.write("netatalk-admin auf http://%s:%d/ (erlaubt: %s)\n"
% (LISTEN_HOST, LISTEN_PORT, ", ".join(ALLOWED_PREFIXES)))
try:
server.serve_forever()
except KeyboardInterrupt:
sys.stderr.write("\nBeendet.\n")
return 0
if __name__ == "__main__":
sys.exit(main())
Stand und offene Punkte
Erledigt: Die Aufteilung in drei Volumes läuft, allow: filtert nach Benutzer, options:ro verhindert das Schreiben nachweislich. Der Diagnoseweg hat nebenbei geklärt, dass der Client über DDP arbeitet und dass afpd.conf die maßgebliche Konfigurationsdatei ist.
Offen und in dieser Reihenfolge sinnvoll:
- Gastzugang entfernen.
uams_guest.soaus der-uamlistinafpd.confstreichen. Vorher sicherstellen, dass die Anmeldung alsPitmit Passwort funktioniert — sonst sperrt man sich per AFP aus. Eine Konsolen- oder SSH-Verbindung offen halten. -transalldurch-ddpersetzen, sofern kein Client AFP über TCP braucht. Damit entfällt der verwundbare DSI-Pfad ersatzlos.- Paketfilter festschreiben mit
iptables-save > /etc/iptables.rulesund einempre-up-Eintrag in/etc/network/interfaces. - Feste Einhängung.
/media/<UUID>/ist ein automatisch vergebener Mountpoint. Fehlt die Platte beim Booten, gibt netatalk das leere Verzeichnis darunter frei, und der Mac sieht ein leeres Volume statt einer Fehlermeldung. Einfstab-Eintrag mit stabilem Pfad nimmt diese Fehlerquelle heraus. - Neustart als Test, ob alles ohne Zutun wieder hochkommt.
Und eine Einschränkung in eigener Sache: das Skript ist gegen Python 3 getestet — Parser, Roundtrip mit Umlaut im Pfad, Erhalt von Kommentaren und :DEFAULT:, die Verschachtelungsprüfung. Gegen Python 2.7, also gegen den tatsächlichen Zielinterpreter, ist es nicht getestet, sondern nur sorgfältig kompatibel geschrieben. Die wahrscheinlichsten Bruchstellen dort wären self.headers.get(), das unter 2.7 noch eine mimetools.Message ist, und os.makedirs mit Modusangabe.
