Mint the archive collection key from the console instead of by hand
tests / pest (push) Failing after 7m49s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details

Setting a NAS up to collect the invoice archive meant three machines and a
dozen commands: generate a keypair there, carry the public half to the server,
write it into authorized_keys with a restriction, install rsync, and get every
path right on the first try. Every one of those steps was a place to be told
"permission denied" with no clue which of the three was wrong. It was, and
several times over.

One button now. The panel asks, the host does it — because the host is where
all of it lives: the home directory, ssh-keygen, rrsync, and the archive itself.
The panel is www-data in a container and owns none of that, so it uses the
mailbox it already uses for updates. The private half is shown exactly once,
alongside the finished rsync command, and is never written to the database: it
exists to be copied into a NAS, and storing it "for convenience" would put a
working credential in every backup of that database.

rrsync does the restricting, not a pinned rsync option string. That string
differs between rsync versions and fails silently — a refusal with no reason
given, which is the shape of the afternoon this replaces. If rrsync is missing
the agent refuses rather than issuing an unrestricted key while the panel says
it is restricted.

rsync now comes with install-agent.sh, which already runs as root once per
machine. It has to be on the HOST: a NAS connects by ssh and sshd starts
`rsync --server` here, so without it the pull fails with "command not found"
from a NAS whose own setup is perfectly correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/granted-plans v1.3.2
nexxo 2026-07-29 10:08:32 +02:00
parent 186f7f2de7
commit 15b536f393
8 changed files with 407 additions and 6 deletions

View File

@ -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<string, string>|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');

View File

@ -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<string, mixed> $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;
}

View File

@ -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 <<EOF
[Unit]
Description=CluPilot update agent

View File

@ -38,6 +38,10 @@ LASTRUN="$STATE_DIR/update-last-run.json"
# LASTRUN: a restart is not a deployment (no fetch, no maintenance mode, no
# update.sh), and the two must be able to fail independently.
RESTARTLAST="$STATE_DIR/restart-last-run.json"
# Where a freshly generated collection key is handed to the panel, once. Written
# 0600 by this agent, read and deleted by the panel — the same shape as the
# initial admin password an instance holds until somebody notes it down.
ARCHIVE_KEY="$STATE_DIR/archive-key.json"
LOCK="$STATE_DIR/.agent.lock"
# The reverse proxy's console allowlist, generated from the one the owner keeps
# in the console. Without this the proxy has its own hard-coded list that runs
@ -287,11 +291,78 @@ fi
# ever meant until now.
REQUEST_KIND="$(sed -n 's/.*"kind"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$REQUEST" 2>/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

View File

@ -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',

View File

@ -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',

View File

@ -126,16 +126,53 @@
@endif
</p>
</div>
<x-ui.button variant="secondary"
x-on:click="$dispatch('openModal', { component: 'admin.edit-export-target', arguments: { uuid: '{{ $row['model']->uuid }}' } })">
{{ __('finance.series.edit') }}
</x-ui.button>
<div class="flex shrink-0 gap-2">
@if ($row['model']->driver === 'local')
{{-- Everything an operator otherwise does by hand
across three machines. --}}
<x-ui.button variant="secondary" wire:click="createPullAccess('{{ $row['model']->uuid }}')"
wire:loading.attr="disabled" wire:target="createPullAccess">
<x-ui.icon name="lock" class="size-4" />{{ __('finance.pull_create') }}
</x-ui.button>
@endif
<x-ui.button variant="secondary"
x-on:click="$dispatch('openModal', { component: 'admin.edit-export-target', arguments: { uuid: '{{ $row['model']->uuid }}' } })">
{{ __('finance.series.edit') }}
</x-ui.button>
</div>
</li>
@endforeach
</ul>
@endif
</div>
{{-- 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)
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise" wire:poll.2s="collectArchiveKey">
<p class="flex items-center gap-2 text-sm font-medium text-body">
<x-ui.icon name="refresh" class="size-4 animate-spin" />{{ __('finance.pull_waiting') }}
</p>
</div>
@endif
@if ($archiveKey)
<div class="rounded-lg border border-accent-border bg-accent-subtle p-6 animate-rise">
<h2 class="font-semibold text-ink">{{ __('finance.pull_ready_title') }}</h2>
<p class="mt-1 max-w-2xl text-sm text-body">{{ __('finance.pull_ready_body') }}</p>
<p class="mt-4 text-xs font-medium text-body">{{ __('finance.pull_key_label') }}</p>
<textarea readonly rows="8" class="mt-1.5 w-full select-all rounded-md border border-line-strong bg-surface p-3 font-mono text-xs text-ink">{{ $archiveKey['private_key'] }}</textarea>
<p class="mt-4 text-xs font-medium text-body">{{ __('finance.pull_command_label') }}</p>
<textarea readonly rows="3" class="mt-1.5 w-full select-all rounded-md border border-line-strong bg-surface p-3 font-mono text-xs text-ink">rsync -az --ignore-existing -e "ssh -i /root/.ssh/clupilot" {{ config('admin_access.hosts')[0] ?? 'SERVER' }}:{{ $archiveKey['path'] }}/ /volume1/rechnungen/</textarea>
<p class="mt-3 text-xs text-muted">{{ __('finance.pull_ready_hint') }}</p>
</div>
@endif
{{-- The Rechnungskreise. Each has its own prefix and its own gapless
counter a credit note is not an invoice with a minus sign. --}}
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:90ms]">

View File

@ -0,0 +1,115 @@
<?php
use App\Livewire\Admin\Finance;
use App\Models\ExportTarget;
use App\Services\Deployment\UpdateChannel;
use Illuminate\Support\Facades\File;
use Livewire\Livewire;
/**
* Minting the key a NAS collects the archive with, from the console.
*
* Everything it replaces was done by hand across three machines: generate a
* keypair, install the public half restricted to one directory, carry the
* private half over. None of it can happen in the panel the keys, the home
* directory and rrsync are all on the host, and the panel is www-data inside a
* container. So it asks, exactly as it does for an update.
*/
beforeEach(function () {
File::deleteDirectory(storage_path('app/deploy'));
$this->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');
});