Make the console's access list reach the proxy, price in euros, and answer errors
tests / pest (push) Successful in 7m4s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 4s Details

The console was unreachable, and not for the reason it looked like. Two causes,
both mine.

A failed `artisan optimize` — the one that hit duplicate route names — left a
broken route cache behind, so every request to the console host answered 500.
Rebuilt.

Underneath that: the reverse proxy has its own console allowlist, hard-coded,
and it runs BEFORE the application. Everything added on the console's own
access page was therefore ineffective, and when the owner's address changed
they were turned away by the proxy before reaching the page that would have
fixed it. The proxy now imports a fragment generated from the same list the
console manages, regenerated by the host agent and reloaded when it changes.

Getting that safe took most of the review. It refuses to rewrite an ambiguous
Caddyfile rather than replacing some other site's matcher; it never falls back
to a loopback-only list when the application cannot be reached, because that
list validates cleanly and locks out every remote operator; it retries a reload
that failed instead of assuming it worked; and an installation that upgrades
without rerunning the installer is told, because otherwise the whole mechanism
is invisibly absent.

Prices are entered in euros. The form asked for cents, so €799 was typed as
79900 and one slipped digit was a factor of ten on an invoice. Conversion
happens in one place, on the string rather than through a float — (float)
'79.90' × 100 is 7989.999… and casting truncates to 7989, one cent short on
exactly the prices people charge — and it refuses an amount the column cannot
hold instead of failing at the database.

Every error code has a page now, in the site's own language and typeface,
self-contained so it still renders when the asset manifest is the thing that
broke. There was only a bare white 404.

The VPN list stops being a seven-column table nobody could fit: names broke
across two lines and so did the headings. These are attributes of one access,
not quantities compared down a column, so each access is a row — identity on
one line, measurements in mono on the next.

The status page says what it measures rather than what it promises. "New orders
are delivered without failures" read as a marketing claim on a page whose only
job is to be believed, and said nothing about the last 24 hours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/portal-design tested-20260727-0509-bc8bbc5
Claude 2026-07-27 06:51:05 +02:00
parent 233fc73430
commit bc8bbc56a5
27 changed files with 719 additions and 145 deletions

BIN
..env.swp

Binary file not shown.

View File

@ -17,7 +17,7 @@ use Illuminate\Console\Command;
class ConsoleAccess extends Command
{
protected $signature = 'clupilot:console-access
{action=show : show|allow|deny|open|close}
{action=show : show|allow|deny|open|close|caddy}
{value? : an address or CIDR, for allow and deny}';
protected $description = 'Show or change who may reach the operator console';
@ -33,10 +33,49 @@ class ConsoleAccess extends Command
'deny' => $this->deny($value),
'open' => $this->setRestricted(false),
'close' => $this->setRestricted(true),
'caddy' => $this->caddy(),
default => $this->refuse("Unknown action: {$action}"),
};
}
/**
* The allowlist as a Caddy matcher, for the reverse proxy to import.
*
* The proxy has its own allowlist, hard-coded, and it runs FIRST so
* everything the owner adds in the console has no effect whatsoever, and
* the console's own access page is a decoration. Worse, when the owner's
* address changes they are turned away by the proxy before the application
* they could have fixed it in is ever reached.
*
* Emitting the matcher from the same list the console manages makes the
* console the single authority. The agent on the host writes this out and
* reloads the proxy.
*/
private function caddy(): int
{
// The switch has to reach the proxy too. With the restriction off the
// application lets everyone through on purpose — and a proxy still
// holding the old list would keep rejecting them before Laravel ever
// sees the request, so "open" in the console would do nothing at all.
$ranges = RestrictConsoleNetwork::isRestricted()
? RestrictConsoleNetwork::allowedRanges()
: ['0.0.0.0/0', '::/0'];
// Never empty: an empty remote_ip matcher matches NOTHING in Caddy, and
// the console would become unreachable from anywhere at all — including
// from the place someone would fix it. Loopback always survives, so a
// shell on the box is always a way back.
if ($ranges === []) {
$ranges = ['127.0.0.1', '::1'];
}
$this->line('# Generated from the console allowlist — do not edit by hand.');
$this->line('# Regenerated by deploy/update-agent.sh; edit it in the console.');
$this->line('@allowed remote_ip '.implode(' ', $ranges));
return self::SUCCESS;
}
private function show(): int
{
$this->line(' restricted : '.(RestrictConsoleNetwork::isRestricted() ? 'yes' : 'no — anyone reaching the hostname gets in'));

View File

@ -6,6 +6,7 @@ use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Support\Money;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
@ -45,9 +46,14 @@ class PlanVersions extends Component
/** @var array<int, string> */
public array $features = [];
public int $monthlyCents = 4900;
/**
* In EUROS, as typed. The catalogue stores cents; asking the owner to type
* them too turned €799 into "79900", where one slipped digit is a factor of
* ten on an invoice. Money::toCents does the conversion, once.
*/
public string $monthlyPrice = '49,00';
public int $yearlyCents = 58800;
public string $yearlyPrice = '588,00';
/** Publishing: when it goes on sale, and optionally when it stops. */
public string $availableFrom = '';
@ -80,8 +86,8 @@ class PlanVersions extends Component
$monthly = $latest->priceFor(Subscription::TERM_MONTHLY);
$yearly = $latest->priceFor(Subscription::TERM_YEARLY);
$this->monthlyCents = $monthly?->amount_cents ?? $this->monthlyCents;
$this->yearlyCents = $yearly?->amount_cents ?? $this->yearlyCents;
$this->monthlyPrice = Money::fromCents($monthly?->amount_cents ?? 4900);
$this->yearlyPrice = Money::fromCents($yearly?->amount_cents ?? 58800);
}
}
@ -119,12 +125,24 @@ class PlanVersions extends Component
// anything, and an unknown key is frozen at publication and shown
// to customers as a raw translation key.
'features.*' => ['string', Rule::in(array_keys((array) __('billing.feature')))],
// 9,999,999.99 in the catalogue currency — far past anything we
// would charge, far short of overflowing an unsigned int.
'monthlyCents' => 'required|integer|min:0|max:999999999',
'yearlyCents' => 'required|integer|min:0|max:999999999',
// Euros with at most two decimals, either separator. The upper
// bound lives in Money's pattern: nine whole digits is far past
// anything we would charge and far short of overflowing the column.
// Checked as a rule, not afterwards: a price fault has to be
// reported alongside every other fault on the form, and a check
// that runs after validate() never runs at all once some other
// field has already thrown.
'monthlyPrice' => ['required', 'string', $price = function (string $attribute, mixed $value, callable $fail) {
if (Money::toCents((string) $value) === null) {
$fail(__('plans.price_invalid'));
}
}],
'yearlyPrice' => ['required', 'string', $price],
]);
$monthlyCents = Money::toCents($data['monthlyPrice']);
$yearlyCents = Money::toCents($data['yearlyPrice']);
$version = app(PlanCatalogue::class)->draft(
$this->family(),
[
@ -142,8 +160,8 @@ class PlanVersions extends Component
// priced for each — better to find that out here than in front of
// a customer.
[
Subscription::TERM_MONTHLY => $data['monthlyCents'],
Subscription::TERM_YEARLY => $data['yearlyCents'],
Subscription::TERM_MONTHLY => $monthlyCents,
Subscription::TERM_YEARLY => $yearlyCents,
],
);

53
app/Support/Money.php Normal file
View File

@ -0,0 +1,53 @@
<?php
namespace App\Support;
/**
* Between what an operator types and what the catalogue stores.
*
* Prices are stored in cents integers, because money in floats eventually
* bills someone 178.99999999. But the console asked the owner to type cents
* too, so a €799 plan was entered as 79900, and a slipped digit is a factor of
* ten on an invoice. The form takes euros; the conversion happens here, once.
*
* Parsing is done on the STRING, not by multiplying a float: (float) '79.90'
* times 100 is 7989.999 on a binary float, and casting that to int truncates
* to 7989. One cent, every time, on exactly the prices people actually charge.
*/
final class Money
{
/** What plan_prices.amount_cents can actually hold (unsigned int). */
public const MAX_CENTS = 4294967295;
/**
* Euros as typed cents, or null when it is not a price at all.
*
* Accepts both separators: a German keyboard produces "79,90" and a copied
* figure produces "79.90", and rejecting either would just be rude.
* Thousands separators are refused rather than guessed "1.000" is a
* thousand to one reader and one euro to another.
*/
public static function toCents(string $euros): ?int
{
$value = str_replace(',', '.', trim($euros));
if (! preg_match('/^\d{1,9}(\.\d{1,2})?$/', $value)) {
return null;
}
[$whole, $fraction] = array_pad(explode('.', $value, 2), 2, '');
$cents = (int) $whole * 100 + (int) str_pad($fraction, 2, '0');
// The column is an unsigned int. Nine whole digits parse happily and
// then overflow it, turning a typo into a database error on submit
// instead of a message next to the field.
return $cents > self::MAX_CENTS ? null : $cents;
}
/** Cents → the string the form shows, with a decimal comma. */
public static function fromCents(int $cents): string
{
return number_format($cents / 100, 2, ',', '');
}
}

View File

@ -49,6 +49,96 @@ AccuracySec=30s
WantedBy=timers.target
EOF
# ── The proxy's console allowlist ────────────────────────────────────────────
# The proxy has its own allowlist and it runs BEFORE the application, so
# everything the owner adds in the console has no effect until the proxy is
# told. The agent regenerates this fragment from the console's own list.
#
# Entirely optional: an installation may sit behind nginx, Zoraxy, or nothing at
# all, and none of that is a reason for the update agent itself to fail to
# install.
CADDYFILE="${CADDYFILE:-/etc/caddy/Caddyfile}"
ALLOWFILE="/etc/caddy/clupilot-console-allow.conf"
if command -v caddy >/dev/null 2>&1 && [[ -f "$CADDYFILE" ]]; then
echo " Wiring the console allowlist into Caddy"
# Seed it before anything imports it: an import of a missing file makes the
# whole proxy config invalid, and that takes the public site down too.
ALLOWLIST_READY=0
if [[ -s "$ALLOWFILE" ]] && grep -q '@allowed remote_ip .' "$ALLOWFILE"; then
ALLOWLIST_READY=1
else
# Compose discovers its own file from the working directory — naming it
# here guessed wrong once.
if ( cd "$ROOT" && runuser -u "$APP_USER" -- docker compose exec -T app \
php artisan clupilot:console-access caddy ) > "$ALLOWFILE.new" 2>/dev/null \
&& grep -q '@allowed remote_ip .' "$ALLOWFILE.new"; then
mv -f "$ALLOWFILE.new" "$ALLOWFILE"
ALLOWLIST_READY=1
else
# NO fallback matcher here. A loopback-only list looks like a valid
# allowlist, passes validation, and locks out every remote operator
# the moment Caddy reloads — because the application happened to be
# unavailable for the ten seconds this ran.
rm -f "$ALLOWFILE.new"
echo " ! Could not read the console allowlist from the application."
echo " Leaving the proxy configuration untouched."
fi
fi
[[ -f "$ALLOWFILE" ]] && { chown "$APP_USER":root "$ALLOWFILE"; chmod 0644 "$ALLOWFILE"; }
# Replace a hard-coded matcher with the import, once. Backed up and
# validated first — a broken Caddyfile is an outage of everything, not just
# of the console.
if [[ "$ALLOWLIST_READY" == "1" ]] && ! grep -q "import $ALLOWFILE" "$CADDYFILE"; then
# Exactly one, or none of them. An unqualified substitution would
# rewrite a matcher belonging to some other site — and the result would
# still validate, so the first sign of it would be that site's access
# control quietly changing.
matches="$(grep -cE '^[[:space:]]*@allowed remote_ip ' "$CADDYFILE" || true)"
if [[ "$matches" == "1" ]]; then
cp -a "$CADDYFILE" "$CADDYFILE.clupilot.bak"
sed -i "s|^\([[:space:]]*\)@allowed remote_ip .*|\1import $ALLOWFILE|" "$CADDYFILE"
if caddy validate --config "$CADDYFILE" --adapter caddyfile >/dev/null 2>&1; then
if systemctl reload caddy >/dev/null 2>&1; then
echo " Console allowlist is now managed from the console."
else
# Handed to the agent. Without this marker the file already
# matches what the agent would generate, so nothing would
# ever trigger another reload and the proxy would keep the
# old hard-coded list indefinitely.
: > "$ROOT/storage/app/deploy/.caddy-reload-pending"
chown "$APP_USER":"$APP_USER" "$ROOT/storage/app/deploy/.caddy-reload-pending"
echo " ! Caddy did not reload — the agent retries within a few minutes."
fi
else
echo " ! Caddy rejected the change — restoring the previous config."
mv -f "$CADDYFILE.clupilot.bak" "$CADDYFILE"
fi
else
# Said out loud rather than assumed. Nothing to replace, or more
# than one candidate and no way to tell which belongs to the
# console — either way, carrying on silently would leave the
# console's allowlist a decoration while the proxy kept its own.
echo " ! Found $matches single-line '@allowed remote_ip' entries in $CADDYFILE (expected exactly 1)."
echo " Put this inside the console's site block yourself, then reload Caddy:"
echo " import $ALLOWFILE"
fi
fi
# Narrow on purpose: one command, no arguments, nothing else.
cat > /etc/sudoers.d/clupilot-caddy-reload <<EOF
$APP_USER ALL=(root) NOPASSWD: /usr/bin/systemctl reload caddy
EOF
chmod 0440 /etc/sudoers.d/clupilot-caddy-reload
visudo -cf /etc/sudoers.d/clupilot-caddy-reload >/dev/null || rm -f /etc/sudoers.d/clupilot-caddy-reload
fi
systemctl daemon-reload
systemctl enable --now clupilot-update-agent.timer >/dev/null

View File

@ -32,6 +32,12 @@ RUNLOG="$STATE_DIR/update-last-run.log"
# "idle" — and an operator would usually never see that the update failed.
LASTRUN="$STATE_DIR/update-last-run.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
# FIRST, so everything added in the console has no effect at all — and when the
# owner's address changes they are turned away before reaching the page that
# would have let them fix it.
ALLOWFILE="${CONSOLE_ALLOW_FILE:-/etc/caddy/clupilot-console-allow.conf}"
# A request older than this is abandoned. The panel already refuses to show one
# as pending; the agent has to agree, or a request written while the agent was
@ -45,6 +51,37 @@ mkdir -p "$STATE_DIR"
exec 9>"$LOCK"
flock -n 9 || exit 0
sync_console_allowlist() {
[[ -w "$(dirname "$ALLOWFILE")" || -w "$ALLOWFILE" ]] || return 0
local generated
generated="$(docker compose exec -T app php artisan clupilot:console-access caddy 2>/dev/null)" || return 0
# Never write an empty matcher: in Caddy that matches nothing, and the
# console would be unreachable from everywhere including the shell.
grep -q '@allowed remote_ip .' <<<"$generated" || return 0
local pending="$STATE_DIR/.caddy-reload-pending"
if [[ ! -f "$ALLOWFILE" ]] || ! diff -q <(printf '%s\n' "$generated") "$ALLOWFILE" >/dev/null 2>&1; then
printf '%s\n' "$generated" > "$ALLOWFILE"
: > "$pending"
fi
# A reload that failed once must be retried. Without the marker the next
# run sees an unchanged file, does nothing, and the proxy keeps the old
# list indefinitely — the console and the proxy quietly disagreeing is the
# exact failure this whole mechanism exists to prevent.
if [[ -f "$pending" ]]; then
# Reload, never restart: a restart drops every connection in flight,
# including the one belonging to whoever just changed the list.
if systemctl reload caddy >/dev/null 2>&1 || sudo -n systemctl reload caddy >/dev/null 2>&1; then
rm -f "$pending"
fi
fi
}
sync_console_allowlist
MODE="$(release_mode)"
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)"
TARGET_RELEASE=''

View File

@ -223,10 +223,24 @@ release_write_manifest "$after" "$source_ref" "$mode"
# The panel's update button needs a host-side timer, and this script cannot
# install it: it runs as the service account and /etc/systemd is root's. Say so
# once per update rather than leaving the button disabled without explanation.
agent_hint=''
if ! systemctl list-unit-files clupilot-update-agent.timer >/dev/null 2>&1 \
|| ! systemctl is-enabled --quiet clupilot-update-agent.timer 2>/dev/null; then
printf '\033[1;33m !\033[0m %s\n' "Update agent not installed — the panel's update button stays disabled."
printf ' %s\n' "Install it once: sudo bash $(pwd)/deploy/install-agent.sh"
agent_hint="the panel's update button stays disabled"
fi
# The proxy's console allowlist has to be wired once, as root, and an update
# never runs as root — so an existing installation would keep its hard-coded
# list and everything the owner changes in the console would do nothing.
if [[ -z "$agent_hint" ]] && command -v caddy >/dev/null 2>&1 \
&& [[ -f /etc/caddy/Caddyfile ]] \
&& ! grep -q 'clupilot-console-allow.conf' /etc/caddy/Caddyfile 2>/dev/null; then
agent_hint="the console's access list does not reach the reverse proxy"
fi
if [[ -n "$agent_hint" ]]; then
printf '\033[1;33m !\033[0m %s\n' "One-time setup missing — $agent_hint."
printf ' %s\n' "Run once: sudo bash $(pwd)/deploy/install-agent.sh"
fi
log "Done — $(release_version) on $(git rev-parse --short HEAD) (${source_ref})"

45
lang/de/errors.php Normal file
View File

@ -0,0 +1,45 @@
<?php
return [
'heading' => 'Das hat nicht geklappt',
'back' => 'Zurück',
'home' => 'Zur Startseite',
// Deliberately plain, and never technical: whoever reads this is already
// stuck, and "Unprocessable Entity" has never helped anyone get unstuck.
'401' => [
'title' => 'Bitte zuerst anmelden.',
'body' => 'Diese Seite ist nur nach der Anmeldung erreichbar.',
'hint' => 'Falls Sie angemeldet waren: Ihre Sitzung ist abgelaufen.',
],
'403' => [
'title' => 'Dafür fehlt die Berechtigung.',
'body' => 'Ihr Konto darf diese Seite nicht öffnen.',
'hint' => 'Wenn das ein Irrtum ist, wenden Sie sich an den Inhaber des Kontos.',
],
'404' => [
'title' => 'Diese Seite gibt es nicht.',
'body' => 'Die Adresse führt ins Leere — vertippt, veraltet oder verschoben.',
'hint' => null,
],
'405' => [
'title' => 'So lässt sich diese Seite nicht aufrufen.',
'body' => 'Die Anfrage passt nicht zu dieser Adresse.',
'hint' => null,
],
'419' => [
'title' => 'Die Sitzung ist abgelaufen.',
'body' => 'Aus Sicherheitsgründen wird ein Formular nach längerer Zeit ungültig.',
'hint' => 'Laden Sie die Seite neu und senden Sie das Formular noch einmal ab.',
],
'429' => [
'title' => 'Zu viele Versuche.',
'body' => 'Bitte warten Sie einen Moment, bevor Sie es erneut versuchen.',
'hint' => null,
],
'500' => [
'title' => 'Bei uns ist etwas schiefgegangen.',
'body' => 'Der Fehler liegt nicht bei Ihnen. Er wurde protokolliert.',
'hint' => 'Bleibt es dabei, schreiben Sie an office@clupilot.com.',
],
];

View File

@ -84,4 +84,6 @@ return [
'f_cores' => 'vCPU',
'f_disk' => 'Festplatte',
'f_template' => 'Vorlage (VMID)',
'euro_hint' => 'In Euro, netto — z. B. 799 oder 79,90.',
'price_invalid' => 'Bitte einen Betrag in Euro angeben, z. B. 799 oder 79,90.',
];

View File

@ -19,17 +19,21 @@ return [
],
'component' => [
'portal' => 'Website & Kundenportal',
'instances' => 'Cloud-Instanzen',
'provisioning' => 'Bereitstellung neuer Instanzen',
'portal' => 'Anmeldung & Kundenbereich',
'instances' => 'Ihre Cloud',
'provisioning' => 'Neue Bestellungen',
'backups' => 'Sicherungen',
],
// What is MEASURED, not what is promised. "Wird ohne Fehler ausgeliefert"
// read as a marketing claim on a page whose only job is to be believed —
// and it says nothing about the last 24 hours, which is what a reader is
// actually asking about.
'about' => [
'portal' => 'Erreichbarkeit von www.clupilot.com und dem Kundenbereich.',
'instances' => 'Erreichbarkeit der betriebenen Kundeninstanzen.',
'provisioning' => 'Neue Bestellungen werden ohne Fehler ausgeliefert.',
'backups' => 'Jede Instanz hat eine Sicherung aus den letzten 48 Stunden.',
'portal' => 'Ob Anmeldung und Kundenbereich antworten.',
'instances' => 'Ob die laufenden Kundeninstanzen erreichbar sind.',
'provisioning' => 'Ob Bestellungen der letzten 24 Stunden vollständig ausgeliefert wurden.',
'backups' => 'Ob jede Instanz eine Sicherung aus den letzten 48 Stunden hat.',
],
'detail' => [

45
lang/en/errors.php Normal file
View File

@ -0,0 +1,45 @@
<?php
return [
'heading' => 'That did not work',
'back' => 'Back',
'home' => 'To the home page',
// Deliberately plain, and never technical: whoever reads this is already
// stuck, and "Unprocessable Entity" has never helped anyone get unstuck.
'401' => [
'title' => 'Please sign in first.',
'body' => 'This page is only available once you are signed in.',
'hint' => 'If you were signed in, your session has expired.',
],
'403' => [
'title' => 'Your account may not open this.',
'body' => 'This page needs a permission your account does not have.',
'hint' => 'If that seems wrong, ask whoever owns the account.',
],
'404' => [
'title' => 'This page does not exist.',
'body' => 'The address leads nowhere — mistyped, out of date, or moved.',
'hint' => null,
],
'405' => [
'title' => 'This page cannot be reached that way.',
'body' => 'The request does not match this address.',
'hint' => null,
],
'419' => [
'title' => 'The session has expired.',
'body' => 'For security, a form stops being valid after a while.',
'hint' => 'Reload the page and submit the form again.',
],
'429' => [
'title' => 'Too many attempts.',
'body' => 'Please wait a moment before trying again.',
'hint' => null,
],
'500' => [
'title' => 'Something went wrong on our side.',
'body' => 'This is not your fault. It has been logged.',
'hint' => 'If it keeps happening, write to office@clupilot.com.',
],
];

View File

@ -84,4 +84,6 @@ return [
'f_cores' => 'vCPU',
'f_disk' => 'Disk',
'f_template' => 'Template (VMID)',
'euro_hint' => 'In euros, net — e.g. 799 or 79.90.',
'price_invalid' => 'Enter an amount in euros, e.g. 799 or 79.90.',
];

View File

@ -19,17 +19,20 @@ return [
],
'component' => [
'portal' => 'Website & customer portal',
'instances' => 'Cloud instances',
'provisioning' => 'Provisioning of new instances',
'portal' => 'Sign-in & customer area',
'instances' => 'Your cloud',
'provisioning' => 'New orders',
'backups' => 'Backups',
],
// What is MEASURED, not what is promised. "Delivered without failures" read
// as a marketing claim on a page whose only job is to be believed — and it
// says nothing about the last 24 hours, which is what a reader is asking.
'about' => [
'portal' => 'Availability of www.clupilot.com and the customer area.',
'instances' => 'Availability of the customer instances we operate.',
'provisioning' => 'New orders are being delivered without failures.',
'backups' => 'Every instance has a backup from the last 48 hours.',
'portal' => 'Whether sign-in and the customer area respond.',
'instances' => 'Whether the customer instances we run are reachable.',
'provisioning' => 'Whether orders in the last 24 hours were delivered in full.',
'backups' => 'Whether every instance has a backup from the last 48 hours.',
],
'detail' => [

View File

@ -0,0 +1,6 @@
@include('errors.layout', [
'code' => 401,
'title' => __('errors.401.title'),
'body' => __('errors.401.body'),
'hint' => __('errors.401.hint'),
])

View File

@ -0,0 +1,6 @@
@include('errors.layout', [
'code' => 403,
'title' => __('errors.403.title'),
'body' => __('errors.403.body'),
'hint' => __('errors.403.hint'),
])

View File

@ -0,0 +1,6 @@
@include('errors.layout', [
'code' => 404,
'title' => __('errors.404.title'),
'body' => __('errors.404.body'),
'hint' => __('errors.404.hint'),
])

View File

@ -0,0 +1,6 @@
@include('errors.layout', [
'code' => 405,
'title' => __('errors.405.title'),
'body' => __('errors.405.body'),
'hint' => __('errors.405.hint'),
])

View File

@ -0,0 +1,6 @@
@include('errors.layout', [
'code' => 419,
'title' => __('errors.419.title'),
'body' => __('errors.419.body'),
'hint' => __('errors.419.hint'),
])

View File

@ -0,0 +1,6 @@
@include('errors.layout', [
'code' => 429,
'title' => __('errors.429.title'),
'body' => __('errors.429.body'),
'hint' => __('errors.429.hint'),
])

View File

@ -0,0 +1,6 @@
@include('errors.layout', [
'code' => 500,
'title' => __('errors.500.title'),
'body' => __('errors.500.body'),
'hint' => __('errors.500.hint'),
])

View File

@ -0,0 +1,90 @@
@props(['code', 'title', 'body', 'hint' => null])
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{{ $title }} CluPilot Cloud</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
{{--
Self-contained, like the maintenance page and for the same reason: this is
shown when something has already gone wrong, which includes "mid-deploy" and
"the asset manifest is missing". Pulling styles in through @vite would throw
a second exception on top of the first and render nothing at all.
The fonts are the site's own, served as static files if they are missing
the page degrades to a system serif and still says what it needs to say.
--}}
@verbatim
<style>
@font-face{font-family:"Plex Serif";src:url("/fonts/ibm-plex-serif-latin-600-normal.woff2") format("woff2");font-weight:600;font-display:swap}
@font-face{font-family:"Plex Sans";src:url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
@font-face{font-family:"Plex Mono";src:url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");font-weight:400;font-display:swap}
:root{
--paper:#f7f5f1; --card:#fffefb; --ink:#17140f; --ink-soft:#413a31;
--muted:#7b7367; --rule:#e3ded3; --rule-mid:#cbc3b4;
--accent:#f97316; --accent-text:#b8500a;
}
@media (prefers-color-scheme: dark){
:root{--paper:#17140f; --card:#211d16; --ink:#fffefb; --ink-soft:#ded7c9;
--muted:#93897a; --rule:#39332a; --rule-mid:#4a4238;}
}
*{margin:0;padding:0;box-sizing:border-box}
body{
font-family:"Plex Sans",-apple-system,BlinkMacSystemFont,sans-serif;
background:var(--paper);color:var(--ink-soft);line-height:1.6;
min-height:100vh;display:grid;place-items:center;padding:clamp(24px,6vw,64px);
-webkit-font-smoothing:antialiased;
}
main{max-width:520px;width:100%}
.plate{border:1px solid var(--rule-mid);border-radius:3px;background:var(--card);position:relative}
/* Registration marks, as on the rest of the site. */
.plate::before,.plate::after{content:"+";position:absolute;font-family:"Plex Mono",monospace;font-size:.8rem;color:var(--rule-mid);line-height:1}
.plate::before{top:-6px;left:-6px}
.plate::after{bottom:-6px;right:-6px}
.head{
display:flex;justify-content:space-between;align-items:center;gap:14px;
padding:13px 20px;border-bottom:1px solid var(--rule);
font-family:"Plex Mono",monospace;font-size:.7rem;text-transform:uppercase;letter-spacing:.15em;color:var(--muted);
}
.head b{color:var(--accent-text);font-weight:400}
.body{padding:28px 20px 26px}
h1{font-family:"Plex Serif",Georgia,serif;font-weight:600;font-size:clamp(1.5rem,4vw,2rem);
letter-spacing:-.025em;line-height:1.15;color:var(--ink)}
p{margin-top:12px;color:var(--muted);font-size:.95rem}
.hint{margin-top:16px;padding-top:14px;border-top:1px solid var(--rule);font-size:.86rem}
.acts{display:flex;flex-wrap:wrap;gap:10px;margin-top:24px}
.btn{display:inline-flex;align-items:center;border-radius:3px;font-size:.88rem;font-weight:500;padding:10px 18px;text-decoration:none;transition:background .18s,border-color .18s}
.ink{background:var(--ink);color:var(--paper)}
.ink:hover{background:var(--accent-text);color:#fff}
.line{border:1px solid var(--rule-mid);color:var(--ink)}
.line:hover{border-color:var(--ink)}
.mark{margin-top:22px;font-family:"Plex Mono",monospace;font-size:.72rem;color:var(--muted);text-align:center}
</style>
@endverbatim
</head>
<body>
<main>
<div class="plate">
<div class="head"><span>{{ __('errors.heading') }}</span><b>{{ $code }}</b></div>
<div class="body">
<h1>{{ $title }}</h1>
<p>{{ $body }}</p>
@if ($hint)
<p class="hint">{{ $hint }}</p>
@endif
<div class="acts">
{{-- Back first: on an error page the thing someone actually wants is
the page they came from, not the front door. --}}
<a class="btn ink" href="javascript:history.back()">{{ __('errors.back') }}</a>
<a class="btn line" href="{{ url('/') }}">{{ __('errors.home') }}</a>
</div>
</div>
</div>
<p class="mark">CluPilot Cloud</p>
</main>
</body>
</html>

View File

@ -172,8 +172,8 @@
<p class="pt-1 text-xs font-semibold uppercase tracking-wide text-faint">{{ __('plans.pricing') }}</p>
<div class="grid grid-cols-2 gap-3">
<x-ui.input name="monthlyCents" type="number" min="0" max="999999999" :label="__('plans.term_monthly')" :hint="__('plans.cents_hint')" wire:model="monthlyCents" />
<x-ui.input name="yearlyCents" type="number" min="0" max="999999999" :label="__('plans.term_yearly')" :hint="__('plans.cents_hint')" wire:model="yearlyCents" />
<x-ui.input name="monthlyPrice" inputmode="decimal" :label="__('plans.term_monthly')" :hint="__('plans.euro_hint')" wire:model="monthlyPrice" />
<x-ui.input name="yearlyPrice" inputmode="decimal" :label="__('plans.term_yearly')" :hint="__('plans.euro_hint')" wire:model="yearlyPrice" />
</div>
<p class="pt-1 text-xs font-semibold uppercase tracking-wide text-faint">{{ __('plans.features') }}</p>

View File

@ -72,118 +72,121 @@
@if ($peers->isEmpty())
<p class="p-8 text-center text-sm text-muted">{{ __('vpn.empty') }}</p>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
<th class="px-4 py-3 font-semibold">{{ __('vpn.peer') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('vpn.owner') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('vpn.address') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('vpn.traffic') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('vpn.handshake') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('vpn.status') }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ __('vpn.actions') }}</th>
</tr>
</thead>
<tbody>
@foreach ($peers as $peer)
@php
$status = $peer->status();
$tone = [
'online' => 'border-success-border bg-success-bg text-success',
'idle' => 'border-line-strong bg-surface-2 text-muted',
'blocked' => 'border-danger-border bg-danger-bg text-danger',
'pending' => 'border-warning-border bg-warning-bg text-warning',
][$status];
@endphp
<tr wire:key="peer-{{ $peer->uuid }}" class="border-b border-line last:border-0">
<td class="px-4 py-3">
<div class="font-medium text-ink">{{ $peer->name }}</div>
<div class="mt-0.5 flex items-center gap-1.5 text-xs text-faint">
@if ($peer->host)
<x-ui.icon name="server" class="size-3.5" />{{ __('vpn.host_peer') }}
@else
<x-ui.icon name="users" class="size-3.5" />{{ __('vpn.operator_peer') }}
@endif
</div>
</td>
<td class="px-4 py-3 text-xs">
@if ($peer->owner)
<span class="text-body">{{ $peer->owner->name }}</span>
@if ($peer->user_id === auth()->id())
<span class="ml-1 rounded-pill border border-accent-border bg-accent-bg px-1.5 py-0.5 text-[10px] font-medium text-accent-text">{{ __('vpn.you') }}</span>
@endif
@else
<span class="text-faint">{{ $peer->host ? __('vpn.host_peer') : __('vpn.no_owner') }}</span>
{{--
A register of accesses, not a spreadsheet.
Seven columns in a fixed table left every one of them too
narrow: names broke across two lines, "Letzter Kontakt"
wrapped in its own heading, and the row still did not fit.
These are attributes of ONE access, not quantities anyone
compares down a column so each access gets a row of its
own with the identity on the first line and the measurements
on the second, in mono where they line up by themselves.
--}}
<ul class="divide-y divide-line">
@foreach ($peers as $peer)
@php
$status = $peer->status();
$tone = [
'online' => 'border-success-border bg-success-bg text-success',
'idle' => 'border-line-strong bg-surface-2 text-muted',
'blocked' => 'border-danger-border bg-danger-bg text-danger',
'pending' => 'border-warning-border bg-warning-bg text-warning',
][$status];
// The rule carries the status too, so a blocked access is
// visible while scanning without reading a single pill.
$rule = [
'online' => 'bg-success-bright',
'idle' => 'bg-line-strong',
'blocked' => 'bg-danger',
'pending' => 'bg-warning',
][$status];
@endphp
<li wire:key="peer-{{ $peer->uuid }}" class="relative flex flex-wrap items-start gap-x-4 gap-y-3 py-4 pl-6 pr-4 transition hover:bg-surface-hover">
<span class="absolute left-0 top-4 bottom-4 w-0.5 rounded-pill {{ $rule }}" aria-hidden="true"></span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<span class="font-medium text-ink">{{ $peer->name }}</span>
<span class="inline-flex items-center gap-1 rounded-pill border border-line px-2 py-0.5 text-[11px] text-muted">
<x-ui.icon :name="$peer->host ? 'server' : 'users'" class="size-3" />
{{ $peer->host ? __('vpn.host_peer') : __('vpn.operator_peer') }}
</span>
@if ($peer->owner)
<span class="text-sm text-muted">{{ $peer->owner->name }}</span>
@if ($peer->user_id === auth()->id())
<span class="rounded-pill border border-accent-border bg-accent-subtle px-1.5 py-0.5 text-[10px] font-medium text-accent-text">{{ __('vpn.you') }}</span>
@endif
</td>
<td class="px-4 py-3">
<div class="font-mono text-xs text-body">{{ $peer->allowed_ip }}</div>
@if ($peer->endpoint)
<div class="mt-0.5 font-mono text-xs text-faint">{{ $peer->endpoint }}</div>
@endif
</td>
<td class="px-4 py-3 font-mono text-xs text-muted whitespace-nowrap">
&darr; {{ \App\Support\Bytes::human($peer->rx_bytes) }}<br>
&uarr; {{ \App\Support\Bytes::human($peer->tx_bytes) }}
</td>
<td class="px-4 py-3 text-xs text-muted whitespace-nowrap">
{{ $peer->last_handshake_at?->diffForHumans() ?? __('vpn.never') }}
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center gap-1.5 whitespace-nowrap rounded-pill border px-2.5 py-0.5 text-xs font-medium {{ $tone }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('vpn.status_'.$status) }}
@elseif (! $peer->host)
<span class="text-sm text-faint">{{ __('vpn.no_owner') }}</span>
@endif
</div>
{{-- The measurements, each with its own label: without a
header row they have to name themselves. --}}
<div class="mt-1.5 flex flex-wrap items-center gap-x-5 gap-y-1 font-mono text-xs text-muted">
<span class="text-body">{{ $peer->allowed_ip }}</span>
@if ($peer->endpoint)
<span class="text-faint">{{ $peer->endpoint }}</span>
@endif
<span class="whitespace-nowrap">&darr;&nbsp;{{ \App\Support\Bytes::human($peer->rx_bytes) }}&nbsp;&nbsp;&uarr;&nbsp;{{ \App\Support\Bytes::human($peer->tx_bytes) }}</span>
<span class="whitespace-nowrap">{{ __('vpn.handshake') }}: {{ $peer->last_handshake_at?->diffForHumans() ?? __('vpn.never') }}</span>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<span class="inline-flex items-center gap-1.5 whitespace-nowrap rounded-pill border px-2.5 py-0.5 text-xs font-medium {{ $tone }}">
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
{{ __('vpn.status_'.$status) }}
</span>
<div class="inline-flex gap-1">
{{-- A stored config can be fetched again; without one there is
nothing to hand out, and saying so beats an absent button. --}}
@if (! $peer->hasStoredConfig() && $peer->kind === \App\Models\VpnPeer::KIND_STAFF && $peer->user_id === auth()->id())
<span title="{{ __('vpn.no_stored_config') }}"
class="grid size-8 cursor-help place-items-center rounded-md border border-dashed border-line text-faint">
<x-ui.icon name="download" class="size-4" />
</span>
</td>
<td class="px-4 py-3 text-right">
<div class="inline-flex gap-1">
{{-- A stored config can be fetched again; without one there is
nothing to hand out, and saying so beats an absent button. --}}
@if (! $peer->hasStoredConfig() && $peer->kind === \App\Models\VpnPeer::KIND_STAFF && $peer->user_id === auth()->id())
<span title="{{ __('vpn.no_stored_config') }}"
class="grid size-8 cursor-help place-items-center rounded-md border border-dashed border-line text-faint">
<x-ui.icon name="download" class="size-4" />
</span>
@endif
@can('downloadConfig', $peer)
<button type="button" aria-label="{{ __('vpn.get_config') }}"
x-on:click="$dispatch('openModal', { component: 'admin.vpn-config-access', arguments: { uuid: '{{ $peer->uuid }}' } })"
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-accent-border hover:text-accent-text">
<x-ui.icon name="download" class="size-4" />
</button>
@endcan
@can('block', $peer)
<button type="button" wire:click="toggle('{{ $peer->uuid }}')"
aria-label="{{ $peer->enabled ? __('vpn.block') : __('vpn.unblock') }}"
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-warning-border hover:text-warning">
<x-ui.icon :name="$peer->enabled ? 'lock' : 'unlock'" class="size-4" />
@endif
@can('downloadConfig', $peer)
<button type="button" aria-label="{{ __('vpn.get_config') }}" title="{{ __('vpn.get_config') }}"
x-on:click="$dispatch('openModal', { component: 'admin.vpn-config-access', arguments: { uuid: '{{ $peer->uuid }}' } })"
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-accent-border hover:text-accent-text">
<x-ui.icon name="download" class="size-4" />
</button>
@endcan
@can('block', $peer)
<button type="button" wire:click="toggle('{{ $peer->uuid }}')"
aria-label="{{ $peer->enabled ? __('vpn.block') : __('vpn.unblock') }}" title="{{ $peer->enabled ? __('vpn.block') : __('vpn.unblock') }}"
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-warning-border hover:text-warning">
<x-ui.icon :name="$peer->enabled ? 'lock' : 'unlock'" class="size-4" />
</button>
@endcan
@can('update', $peer)
@if ($peer->kind === \App\Models\VpnPeer::KIND_STAFF)
<button type="button" wire:click="reissue('{{ $peer->uuid }}')"
wire:confirm="{{ __('vpn.reissue_confirm', ['name' => $peer->name]) }}"
title="{{ __('vpn.reissue') }}" aria-label="{{ __('vpn.reissue') }}"
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-accent-border hover:text-accent-text">
<x-ui.icon name="refresh" class="size-4" />
</button>
@endcan
@can('update', $peer)
@if ($peer->kind === \App\Models\VpnPeer::KIND_STAFF)
<button type="button" wire:click="reissue('{{ $peer->uuid }}')"
wire:confirm="{{ __('vpn.reissue_confirm', ['name' => $peer->name]) }}"
title="{{ __('vpn.reissue') }}" aria-label="{{ __('vpn.reissue') }}"
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-accent-border hover:text-accent-text">
<x-ui.icon name="refresh" class="size-4" />
</button>
@endif
@endcan
@can('delete', $peer)
<button type="button" aria-label="{{ __('vpn.delete') }}"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-delete-vpn-peer', arguments: { uuid: '{{ $peer->uuid }}' } })"
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-danger hover:text-danger">
<x-ui.icon name="trash-2" class="size-4" />
</button>
@endcan
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@endcan
@can('delete', $peer)
<button type="button" aria-label="{{ __('vpn.delete') }}" title="{{ __('vpn.delete') }}"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-delete-vpn-peer', arguments: { uuid: '{{ $peer->uuid }}' } })"
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-danger hover:text-danger">
<x-ui.icon name="trash-2" class="size-4" />
</button>
@endcan
</div>
</div>
</li>
@endforeach
</ul>
@endif
</div>

View File

@ -112,8 +112,9 @@
<p class="meta">Stand: {{ $checkedAt->timezone(config('app.timezone'))->format('d.m.Y, H:i') }} Uhr</p>
<p class="note">
Diese Seite wird bei jedem Aufruf neu ermittelt aus Überwachung, Sicherungsprotokoll und
Bereitstellungsläufen. Wo nichts überwacht wird, steht das auch so da und nicht „in Betrieb“.
Jede Zeile wird bei jedem Aufruf neu gemessen aus Überwachung, Sicherungsprotokoll und
Bereitstellungsläufen. Wo gerade nichts gemessen wird, steht „unbekannt“ und nicht „in Betrieb“:
eine fehlende Messung ist kein Nachweis, dass alles läuft.
Eine Störung, die Sie hier nicht sehen, melden Sie bitte an
<a href="mailto:office&#64;clupilot.com">office&#64;clupilot.com</a> — Antwort am selben Werktag.
</p>

View File

@ -129,3 +129,40 @@ it('offers a way back in that does not depend on the console', function () {
$this->artisan('clupilot:console-access open')->assertSuccessful();
expect(RestrictConsoleNetwork::isRestricted())->toBeFalse();
});
it('tells the reverse proxy the same thing it tells itself', function () {
// The proxy has its own allowlist and it runs FIRST. If the two disagree,
// everything the owner changes in the console is silently ineffective —
// and when their address changes they are turned away before reaching the
// page that would have fixed it.
Settings::set('console.network_restricted', true);
Settings::set('console.allowed_ips', ['203.0.113.7']);
// One assertion on the whole line: the ranges have to arrive together, in
// the matcher, not merely appear somewhere in the output.
$this->artisan('clupilot:console-access caddy')
->expectsOutputToContain('@allowed remote_ip 10.66.0.0/24 203.0.113.7')
->assertSuccessful();
});
it('opens the proxy too when the restriction is lifted', function () {
// Otherwise "open" in the console changes nothing a visitor can feel: the
// proxy keeps rejecting them before the application is reached.
Settings::set('console.network_restricted', false);
$this->artisan('clupilot:console-access caddy')
->expectsOutputToContain('0.0.0.0/0')
->assertSuccessful();
});
it('never emits a matcher that lets nobody in', function () {
// An empty remote_ip matcher matches NOTHING in Caddy — the console would
// be unreachable from everywhere, including from where it gets fixed.
config()->set('admin_access.trusted_ranges', []);
Settings::set('console.network_restricted', true);
Settings::set('console.allowed_ips', []);
$this->artisan('clupilot:console-access caddy')
->expectsOutputToContain('127.0.0.1')
->assertSuccessful();
});

View File

@ -86,9 +86,9 @@ it('drafts a version prefilled from the last one, priced for both terms', functi
// Prefilled, so "same plan, new price" is one edit rather than nine.
$page->assertSet('ramMb', 8192)
->assertSet('quotaGb', 500)
->assertSet('monthlyCents', 17900);
->assertSet('monthlyPrice', '179,00');
$page->set('monthlyCents', 19900)->set('yearlyCents', 238800)->call('draft')->assertHasNoErrors();
$page->set('monthlyPrice', '199,00')->set('yearlyPrice', '2388,00')->call('draft')->assertHasNoErrors();
$draft = teamFamily()->versions()->where('version', 2)->sole();
@ -105,10 +105,10 @@ it('refuses a mistyped figure with a message instead of overflowing the column',
// A stray keystroke on the price field: unbounded, this overflows an
// unsigned int and the owner gets a 500 with no idea which number
// was wrong.
->set('monthlyCents', 1790019900)
->set('monthlyPrice', 'neunzehn Euro')
->set('ramMb', 999999999)
->call('draft')
->assertHasErrors(['monthlyCents', 'ramMb']);
->assertHasErrors(['monthlyPrice', 'ramMb']);
expect(teamFamily()->versions()->count())->toBe(1);
});
@ -151,7 +151,7 @@ it('refuses a disk smaller than the storage it has to hold', function () {
it('publishes a draft into a window, and refuses one that overlaps', function () {
$page = Livewire::actingAs(owner())->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]);
$page->set('monthlyCents', 19900)->call('draft');
$page->set('monthlyPrice', '199,00')->call('draft');
$draft = teamFamily()->versions()->where('version', 2)->sole();

49
tests/Unit/MoneyTest.php Normal file
View File

@ -0,0 +1,49 @@
<?php
use App\Support\Money;
/**
* Euros in, cents out.
*
* The console asked the owner to type cents, so a €799 plan was entered as
* "79900" and one slipped digit was a factor of ten on an invoice.
*/
it('converts what an operator actually types', function (string $typed, ?int $cents) {
expect(Money::toCents($typed))->toBe($cents);
})->with([
['799', 79900],
['79,90', 7990], // German keyboard
['79.90', 7990], // copied from somewhere
['0', 0],
['0,05', 5],
[' 149 ', 14900],
['79,9', 7990], // one decimal is still a price
['', null],
['neunzehn', null],
['1.000', null], // a thousand, or one euro? refuse rather than guess
['79,999', null], // no fractions of a cent
['-5', null],
// Parses fine, then overflows the unsigned column — a typo must come back
// as a message beside the field, not as a database error on submit.
['999999999', null],
['42949672,96', null],
['42949672,95', 4294967295],
]);
it('does not lose a cent to binary floating point', function () {
// (float) '79.90' * 100 is 7989.999… and casting truncates to 7989 — one
// cent short, on exactly the prices people charge.
foreach (['79,90', '19,90', '0,29', '1234,56'] as $typed) {
expect(Money::toCents($typed))->toBe((int) round((float) str_replace(',', '.', $typed) * 100), $typed);
}
expect(Money::toCents('79,90'))->toBe(7990)
->and(Money::toCents('0,29'))->toBe(29);
});
it('round-trips through the form', function () {
foreach ([0, 5, 7990, 79900, 958800] as $cents) {
expect(Money::toCents(Money::fromCents($cents)))->toBe($cents);
}
});