diff --git a/app/Livewire/Admin/Finance.php b/app/Livewire/Admin/Finance.php index 62f10e0..ad96894 100644 --- a/app/Livewire/Admin/Finance.php +++ b/app/Livewire/Admin/Finance.php @@ -6,6 +6,7 @@ use App\Models\ExportTarget; use App\Models\Invoice; use App\Models\InvoiceExport; use App\Models\InvoiceSeries; +use App\Services\Deployment\UpdateChannel; use App\Support\CompanyProfile; use App\Support\Settings; use Illuminate\Support\Facades\Storage; @@ -35,6 +36,21 @@ class Finance extends Component public float $taxRate = 20.0; + /** + * A freshly minted collection key, shown once and never stored. + * + * Held in the component for the length of one page view. The private half + * exists to be copied into a NAS and nowhere else — writing it into the + * database "for convenience" would put a working credential in every backup + * of that database. + * + * @var array|null + */ + public ?array $archiveKey = null; + + /** True between asking the host for a key and the host answering. */ + public bool $waitingForKey = false; + /** New logo upload, validated on save. */ public $logo = null; @@ -99,6 +115,61 @@ class Finance extends Component $this->dispatch('notify', message: __('finance.company_saved')); } + /** + * Ask the host to mint an SSH key that may only read this archive. + * + * Everything an operator otherwise does by hand across three machines — + * generate a keypair, install the public half restricted to one directory, + * carry the private half to the NAS. None of it can happen here: the panel + * is www-data in a container and the keys, the home directory and rrsync + * are all on the host. So it asks, and the agent does it. + */ + public function createPullAccess(string $uuid): void + { + $this->authorize('site.manage'); + + $target = ExportTarget::query()->where('uuid', $uuid)->firstOrFail(); + + if ($target->driver !== ExportTarget::LOCAL) { + // Only a directory on this host has a home directory and an + // authorized_keys to put anything in. A destination somewhere else + // is somebody else's machine. + $this->dispatch('notify', message: __('finance.pull_local_only')); + + return; + } + + $accepted = app(UpdateChannel::class)->requestArchiveKey( + (string) (auth('operator')->user()?->email ?? ''), + $target->path, + 'clupilot-archiv-'.$target->uuid, + ); + + $this->archiveKey = null; + $this->waitingForKey = $accepted; + + $this->dispatch('notify', message: __($accepted + ? 'finance.pull_requested' + : 'admin_settings.update_already_requested')); + } + + /** Polled while waiting. Takes the key the moment the host has left one. */ + public function collectArchiveKey(): void + { + $this->authorize('site.manage'); + + if (! $this->waitingForKey) { + return; + } + + $key = app(UpdateChannel::class)->takeArchiveKey(); + + if ($key !== null) { + $this->archiveKey = $key; + $this->waitingForKey = false; + } + } + public function removeLogo(): void { $this->authorize('site.manage'); diff --git a/app/Services/Deployment/UpdateChannel.php b/app/Services/Deployment/UpdateChannel.php index 2c6c0e2..25969f5 100644 --- a/app/Services/Deployment/UpdateChannel.php +++ b/app/Services/Deployment/UpdateChannel.php @@ -68,6 +68,28 @@ final class UpdateChannel */ private const KIND_RESTART = 'restart'; + /** + * A request to mint an SSH key that may do exactly one thing: read the + * invoice archive over rsync. + * + * The panel cannot do any of it. Generating a keypair, installing the public + * half restricted to one directory and handing over the private half all + * happen on the HOST, in the service account's own home — and the panel is + * www-data inside a container that owns none of that. So it asks, like it + * asks for everything else here. + */ + private const KIND_ARCHIVE_KEY = 'archive-key'; + + /** + * Where the agent leaves a freshly minted key, once. + * + * Read and deleted by the panel in one go: the private half exists on disk + * for the seconds between the agent writing it and somebody looking at it, + * and not a minute longer. Same shape as the initial admin password an + * instance holds until it is acknowledged. + */ + private const ARCHIVE_KEY = 'deploy/archive-key.json'; + /** Written by the agent after every check and every run. */ private const STATUS = 'deploy/update-status.json'; @@ -399,7 +421,50 @@ final class UpdateChannel return $this->submit($by, self::KIND_RESTART); } - private function submit(string $by, string $kind): bool + /** + * Ask the host to mint a collection key for one archive directory. + * + * The path is passed rather than looked up on the host, because the panel + * is the only side that knows which destination the operator pressed the + * button on. + */ + public function requestArchiveKey(string $by, string $path, string $label): bool + { + return $this->submit($by, self::KIND_ARCHIVE_KEY, [ + 'archive_path' => $path, + 'label' => $label, + ]); + } + + /** + * Take the minted key, if one is waiting. + * + * Taking, not reading: the file is deleted in the same call. A private key + * that stays on disk after somebody has seen it is a private key on disk. + * + * @return array{created_at:string, label:string, path:string, private_key:string}|null + */ + public function takeArchiveKey(): ?array + { + $data = $this->readJson(self::ARCHIVE_KEY); + + if ($data === [] || ! isset($data['private_key'])) { + return null; + } + + try { + File::delete(storage_path('app/'.self::ARCHIVE_KEY)); + } catch (Throwable) { + // Shown once regardless. Failing to delete it is a reason to warn, + // not a reason to withhold the key from the person who asked for it + // — they would simply ask again and leave a second one behind. + } + + return $data; + } + + /** @param array $extra */ + private function submit(string $by, string $kind, array $extra = []): bool { // Both conditions, not just the request file. The agent DELETES the // request before it starts a run — so between that moment and the end @@ -415,7 +480,7 @@ final class UpdateChannel 'requested_at' => Carbon::now()->toIso8601String(), 'requested_by' => $by, 'kind' => $kind, - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + ] + $extra, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); return true; } diff --git a/deploy/install-agent.sh b/deploy/install-agent.sh index 1c781cf..bb2f47c 100755 --- a/deploy/install-agent.sh +++ b/deploy/install-agent.sh @@ -21,6 +21,28 @@ id -u "$APP_USER" >/dev/null 2>&1 || { echo "No such account: $APP_USER" >&2; ex install -o "$APP_USER" -g "$APP_USER" -d "$ROOT/storage/app/deploy" chmod +x "$ROOT/deploy/update-agent.sh" +# rsync, for the invoice archive. +# +# It has to be on the HOST, not in the image: a backup that collects the archive +# connects by ssh to this machine, and sshd then starts `rsync --server` here. +# Without it the pull fails with "command not found" from a NAS whose own setup +# is perfectly correct — which is a bad afternoon for whoever is looking. +# +# Installed from here because this is the entry point that already runs as root, +# once per machine, and because the alternative is an operator logging into a +# server to install a package by hand every time they set up a new one. rrsync +# comes with it, and that is what locks the collecting key to one directory. +if ! command -v rsync >/dev/null 2>&1; then + echo " Installing rsync (needed by the invoice archive)" + if command -v apt-get >/dev/null 2>&1; then + DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq rsync >/dev/null 2>&1 \ + || echo " ! Could not install rsync — the invoice archive cannot be collected until it is there." + else + echo " ! No apt-get here. Install rsync yourself, or the invoice archive cannot be collected." + fi +fi + cat > /etc/systemd/system/clupilot-update-agent.service </dev/null | head -1)" +# The whole document, kept for the kinds that carry more than a kind. Read here +# for the same reason the kind is: the file is consumed on the next line, and a +# field fetched afterwards would be fetched from a file that no longer exists. +REQUEST_BODY="$(cat "$REQUEST" 2>/dev/null || true)" + # Consumed BEFORE anything below runs, not after: update.sh restarts the # container stack and may well kill this shell with it. A request left in # place would be picked up again on the next tick and update in a loop. rm -f "$REQUEST" +# ── A collection key for the invoice archive ────────────────────────────── +# Everything an operator otherwise does by hand across three machines: generate +# a keypair, install the public half restricted to one directory, hand over the +# private half. Done here because it is all on the HOST — the panel is www-data +# in a container and owns none of it. +# +# The restriction is rrsync, which ships with rsync: it parses what the client +# asked for, forces read-only and forces the directory. The obvious alternative, +# pinning the exact rsync option string in authorized_keys, breaks the moment +# either side is a different rsync version — and breaks silently, as a refusal +# with no reason given. +if [[ "$REQUEST_KIND" == "archive-key" ]]; then + ARCHIVE_PATH="$(sed -n 's/.*"archive_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' <<<"$REQUEST_BODY" | head -1)" + KEY_NAME="$(sed -n 's/.*"label"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' <<<"$REQUEST_BODY" | head -1)" + KEY_NAME="${KEY_NAME:-clupilot-archiv}" + + ARCHIVE_ERROR='' + + if [[ -z "$ARCHIVE_PATH" || "$ARCHIVE_PATH" != /* ]]; then + ARCHIVE_ERROR='archive_path_invalid' + elif ! command -v rrsync >/dev/null 2>&1; then + # Named, not guessed at. Without rrsync the only way to restrict the key + # is the brittle option string, and issuing a key that is NOT restricted + # when the panel says it is would be worse than issuing none. + ARCHIVE_ERROR='rrsync_missing' + else + HOME_DIR="$(getent passwd "$(id -un)" | cut -d: -f6)" + HOME_DIR="${HOME_DIR:-$HOME}" + KEY_FILE="$(mktemp "$STATE_DIR/.newkey.XXXXXX")" + rm -f "$KEY_FILE" + + if ssh-keygen -q -t ed25519 -N '' -C "$KEY_NAME" -f "$KEY_FILE" 2>/dev/null; then + mkdir -p "$HOME_DIR/.ssh" + chmod 700 "$HOME_DIR/.ssh" + touch "$HOME_DIR/.ssh/authorized_keys" + chmod 600 "$HOME_DIR/.ssh/authorized_keys" + + # restrict switches off every forwarding and the pty; the forced + # command means this key can do one thing and read one directory. + printf 'command="/usr/bin/rrsync -ro %s",restrict %s\n' \ + "$ARCHIVE_PATH" "$(cat "$KEY_FILE.pub")" >> "$HOME_DIR/.ssh/authorized_keys" + + umask 077 + printf '{\n "created_at": "%s",\n "label": "%s",\n "path": "%s",\n "private_key": "%s"\n}\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + "$(json_escape "$KEY_NAME")" \ + "$(json_escape "$ARCHIVE_PATH")" \ + "$(sed ':a;N;$!ba;s/\n/\\n/g' "$KEY_FILE" | sed 's/"/\\"/g')" \ + > "$ARCHIVE_KEY" + chmod 600 "$ARCHIVE_KEY" + + rm -f "$KEY_FILE" "$KEY_FILE.pub" + else + ARCHIVE_ERROR='keygen_failed' + rm -f "$KEY_FILE" "$KEY_FILE.pub" + fi + fi + + write_status idle "$ARCHIVE_ERROR" + exit 0 +fi + if [[ "$REQUEST_KIND" == "check" ]]; then # Nothing further to do: the fetch and the BEHIND calculation above # already answered it. The operator asked to look, not to touch diff --git a/lang/de/finance.php b/lang/de/finance.php index 3eab77e..b628294 100644 --- a/lang/de/finance.php +++ b/lang/de/finance.php @@ -55,6 +55,16 @@ return [ 'series_floor' => 'Kann nur erhöht werden. Eine niedrigere Nummer wäre schon vergeben — mindestens :n.', 'cancel' => 'Abbrechen', + 'pull_create' => 'Abhol-Zugang erzeugen', + 'pull_local_only' => 'Ein Abhol-Zugang geht nur für ein Verzeichnis auf diesem Server.', + 'pull_requested' => 'Schlüssel wird auf dem Server erzeugt …', + 'pull_waiting' => 'Der Server erzeugt den Schlüssel und trägt ihn ein — einen Moment.', + 'pull_ready_title' => 'Abhol-Zugang erstellt', + 'pull_ready_body' => 'Dieser private Schlüssel wird genau einmal angezeigt und nirgends gespeichert. Legen Sie ihn auf der NAS unter /root/.ssh/clupilot ab (Rechte 600) und tragen Sie den Befehl darunter als geplante Aufgabe ein.', + 'pull_key_label' => 'Privater Schlüssel (einmalig sichtbar)', + 'pull_command_label' => 'Befehl für die geplante Aufgabe auf der NAS', + 'pull_ready_hint' => 'Der Schlüssel darf ausschließlich dieses eine Verzeichnis lesen — keine Shell, kein Schreiben, kein anderer Pfad. Prüfen Sie den Servernamen im Befehl, falls die NAS ihn anders erreicht.', + 'targets_title' => 'Exportziele', 'targets_sub' => 'Wohin eine Kopie jeder Rechnung geschrieben wird. Mehrere sind der Sinn der Sache — der Grund für ein zweites Ziel ist, dass das erste ausfallen kann.', 'targets_add' => 'Ziel hinzufügen', diff --git a/lang/en/finance.php b/lang/en/finance.php index 51964a1..0cdbf81 100644 --- a/lang/en/finance.php +++ b/lang/en/finance.php @@ -55,6 +55,16 @@ return [ 'series_floor' => 'Can only be raised. A lower number has already been issued — at least :n.', 'cancel' => 'Cancel', + 'pull_create' => 'Create collection access', + 'pull_local_only' => 'Collection access only works for a directory on this server.', + 'pull_requested' => 'The server is creating the key …', + 'pull_waiting' => 'The server is generating the key and installing it — one moment.', + 'pull_ready_title' => 'Collection access created', + 'pull_ready_body' => 'This private key is shown exactly once and stored nowhere. Put it on the NAS at /root/.ssh/clupilot (mode 600) and use the command below as a scheduled task.', + 'pull_key_label' => 'Private key (shown once)', + 'pull_command_label' => 'Command for the scheduled task on the NAS', + 'pull_ready_hint' => 'The key may read this one directory and nothing else — no shell, no writing, no other path. Check the server name in the command if the NAS reaches it differently.', + 'targets_title' => 'Export destinations', 'targets_sub' => 'Where a copy of every invoice is written. Several is the point — the reason for a second is that the first can fail.', 'targets_add' => 'Add destination', diff --git a/resources/views/livewire/admin/finance.blade.php b/resources/views/livewire/admin/finance.blade.php index 04651c9..e03cfa9 100644 --- a/resources/views/livewire/admin/finance.blade.php +++ b/resources/views/livewire/admin/finance.blade.php @@ -126,16 +126,53 @@ @endif

- - {{ __('finance.series.edit') }} - +
+ @if ($row['model']->driver === 'local') + {{-- Everything an operator otherwise does by hand + across three machines. --}} + + {{ __('finance.pull_create') }} + + @endif + + {{ __('finance.series.edit') }} + +
@endforeach @endif + {{-- A minted key, shown once. It is never written to the database: the + private half exists to be copied into a NAS and nowhere else, and + storing it "for convenience" would put a working credential into every + backup of that database. --}} + @if ($waitingForKey) +
+

+ {{ __('finance.pull_waiting') }} +

+
+ @endif + + @if ($archiveKey) +
+

{{ __('finance.pull_ready_title') }}

+

{{ __('finance.pull_ready_body') }}

+ +

{{ __('finance.pull_key_label') }}

+ + +

{{ __('finance.pull_command_label') }}

+ + +

{{ __('finance.pull_ready_hint') }}

+
+ @endif + {{-- The Rechnungskreise. Each has its own prefix and its own gapless counter — a credit note is not an invoice with a minus sign. --}}
diff --git a/tests/Feature/Billing/ArchivePullAccessTest.php b/tests/Feature/Billing/ArchivePullAccessTest.php new file mode 100644 index 0000000..8f68fba --- /dev/null +++ b/tests/Feature/Billing/ArchivePullAccessTest.php @@ -0,0 +1,115 @@ +target = ExportTarget::create([ + 'name' => 'Übergabe', 'driver' => ExportTarget::LOCAL, + 'path' => '/opt/clupilot/storage/archive', 'layout' => ExportTarget::BY_DAY, 'active' => true, + ]); +}); + +it('asks the host for a key, and names the directory it may read', function () { + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Finance::class) + ->call('createPullAccess', $this->target->uuid) + ->assertSet('waitingForKey', true); + + $request = json_decode(File::get(storage_path('app/deploy/update-request.json')), true); + + expect($request['kind'])->toBe('archive-key') + // The path travels with the request: the panel is the only side that + // knows which destination the button was pressed on. + ->and($request['archive_path'])->toBe('/opt/clupilot/storage/archive'); +}); + +it('refuses for a destination that is not on this host', function () { + // Only a directory here has a home directory and an authorized_keys to put + // anything into. Anywhere else is somebody else's machine. + $remote = ExportTarget::create([ + 'name' => 'Anbieter', 'driver' => ExportTarget::SFTP, 'path' => '/rechnungen', + 'host' => 'backup.example.com', 'port' => 22, 'username' => 'u1', 'active' => true, + ]); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Finance::class) + ->call('createPullAccess', $remote->uuid) + ->assertSet('waitingForKey', false); + + expect(File::exists(storage_path('app/deploy/update-request.json')))->toBeFalse(); +}); + +it('shows the minted key once and takes it off the disk in the same breath', function () { + // A private key that stays on disk after somebody has seen it is a private + // key on disk. + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/archive-key.json'), json_encode([ + 'created_at' => now()->toIso8601String(), + 'label' => 'clupilot-archiv', + 'path' => '/opt/clupilot/storage/archive', + 'private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----", + ])); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Finance::class) + ->set('waitingForKey', true) + ->call('collectArchiveKey') + ->assertSet('waitingForKey', false) + ->assertSee('BEGIN OPENSSH PRIVATE KEY'); + + expect(File::exists(storage_path('app/deploy/archive-key.json')))->toBeFalse(); +}); + +it('never writes the key into the database', function () { + // It is shown to be copied into a NAS and nowhere else. Storing it "for + // convenience" would put a working credential into every backup of this + // database. + File::ensureDirectoryExists(storage_path('app/deploy')); + File::put(storage_path('app/deploy/archive-key.json'), json_encode([ + 'created_at' => now()->toIso8601String(), 'label' => 'x', + 'path' => '/opt/clupilot/storage/archive', 'private_key' => 'GEHEIM-XYZ', + ])); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Finance::class) + ->set('waitingForKey', true) + ->call('collectArchiveKey'); + + foreach (ExportTarget::all() as $target) { + expect(json_encode($target->getAttributes()))->not->toContain('GEHEIM-XYZ'); + } + + expect(json_encode(App\Models\ExportTarget::query()->get()->toArray()))->not->toContain('GEHEIM-XYZ'); +}); + +it('locks the key to one directory, read-only, with no shell', function () { + // The agent's half, which no PHP test can reach. rrsync rather than pinning + // the exact rsync option string: that string differs between rsync versions + // and breaks silently, as a refusal with no reason given. + $agent = File::get(base_path('deploy/update-agent.sh')); + + expect($agent)->toContain('archive-key') + ->and($agent)->toContain('/usr/bin/rrsync -ro') + ->and($agent)->toContain(',restrict ') + // Refuses rather than issuing an UNrestricted key when rrsync is absent. + ->and($agent)->toContain('rrsync_missing'); + + // And the installer puts rsync on the host, because sshd starts + // `rsync --server` HERE when the NAS connects. + expect(File::get(base_path('deploy/install-agent.sh')))->toContain('install -y -qq rsync'); +});