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');
+});