Fix R15: provision a per-device MQTT credential when assigning a device

Assigning a Shelly now issues a real broker credential so the physical device can
connect (onboarding was incomplete before):
- MqttCredentialProvisioner writes a mosquitto-compatible PBKDF2-SHA512 ($7$)
  password line (in PHP) for username = the device's topic prefix, and touches a
  reload trigger. A small wrapper in the mosquitto container (docker/mosquitto/
  config/entrypoint.sh) SIGHUPs mosquitto so it re-reads the passwd live — no
  restart. Verified: a provisioned device authenticates and publishes to its own
  prefix (bound by the pattern %u ACL).
- The credential is shown once on the device page after assignment (enter it into
  the Shelly). passwd is app-owned + world-readable so the web request can write it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 00:50:53 +02:00
parent af951d9f91
commit a4edbbf801
10 changed files with 128 additions and 2 deletions

1
.gitignore vendored
View File

@ -34,5 +34,6 @@ Thumbs.db
*.pyc
# generated broker credentials + data (not committed)
/docker/mosquitto/config/passwd
/docker/mosquitto/config/reload
/docker/mosquitto/data/
/.claude/

View File

@ -24,11 +24,15 @@ class Show extends Component
public bool $flashOk = true;
/** One-time MQTT credential to display after provisioning at assignment. */
public ?array $mqttCredential = null;
public function mount(Device $device): void
{
$this->device = $device;
$this->name = $device->name;
$this->roomId = $device->room_id;
$this->mqttCredential = session('mqtt_credential');
}
public function saveName(): void

View File

@ -5,6 +5,7 @@ namespace App\Livewire\Modals;
use App\Models\Device;
use App\Models\DiscoveryFinding;
use App\Models\Room;
use App\Services\MqttCredentialProvisioner;
use Livewire\Attributes\Computed;
use LivewireUI\Modal\ModalComponent;
@ -60,6 +61,17 @@ class AssignDevice extends ModalComponent
$finding->update(['status' => 'assigned', 'device_id' => $device->id]);
// Provision a per-device MQTT credential (bound to its own prefix by the ACL) and
// surface it once so the user can enter it into the Shelly.
if ($isShelly && $prefix) {
$password = app(MqttCredentialProvisioner::class)->provision($prefix);
session()->flash('mqtt_credential', [
'username' => $prefix,
'password' => $password,
'port' => (int) config('mqtt-client.connections.default.port', 1883),
]);
}
$this->closeModal();
return redirect()->route('devices.show', $device);

View File

@ -0,0 +1,59 @@
<?php
namespace App\Services;
use Illuminate\Support\Str;
/**
* Provisions a per-device MQTT credential at onboarding. Writes a mosquitto
* PBKDF2-SHA512 ($7$) password line and touches the reload trigger so the broker
* re-reads it live (see docker/mosquitto/entrypoint.sh). The device is then bound to its
* own topic prefix by the `pattern %u` ACL.
*/
class MqttCredentialProvisioner
{
private const ITERATIONS = 101;
private string $passwdFile;
private string $reloadFile;
public function __construct()
{
$this->passwdFile = base_path('docker/mosquitto/config/passwd');
$this->reloadFile = base_path('docker/mosquitto/config/reload');
}
/** Provision (or rotate) a credential for $username. Returns the plaintext password (show once). */
public function provision(string $username): string
{
$password = Str::random(20);
$this->writeUser($username, $username.':'.$this->hash($password));
$this->triggerReload();
return $password;
}
private function hash(string $password): string
{
$salt = random_bytes(12);
$derived = hash_pbkdf2('sha512', $password, $salt, self::ITERATIONS, 64, true);
return '$7$'.self::ITERATIONS.'$'.base64_encode($salt).'$'.base64_encode($derived);
}
private function writeUser(string $username, string $line): void
{
$lines = is_file($this->passwdFile) ? file($this->passwdFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) : [];
$lines = array_values(array_filter($lines, fn ($l) => ! str_starts_with($l, $username.':')));
$lines[] = $line;
file_put_contents($this->passwdFile, implode("\n", $lines)."\n", LOCK_EX);
@chmod($this->passwdFile, 0644); // world-readable so the mosquitto user can read it
}
private function triggerReload(): void
{
file_put_contents($this->reloadFile, (string) now()->getTimestampMs());
}
}

View File

@ -76,8 +76,10 @@ services:
mosquitto:
image: eclipse-mosquitto:2
# Wrapper runs mosquitto + reloads it (SIGHUP) when a device credential is provisioned.
command: ["/bin/sh", "/mosquitto/config/entrypoint.sh"]
volumes:
- ./docker/mosquitto/config:/mosquitto/config:ro
- ./docker/mosquitto/config:/mosquitto/config
- mosquitto-data:/mosquitto/data
ports:
# LAN-reachable so real devices can connect (auth + ACL enforced)

View File

@ -0,0 +1,24 @@
#!/bin/sh
# Runs mosquitto and reloads it (SIGHUP → re-reads password_file + acl_file) whenever the
# backend provisions a new per-device credential and touches the reload trigger. Same
# container as mosquitto, so it can signal it — no docker socket, no restart needed.
set -e
mosquitto -c /mosquitto/config/mosquitto.conf &
MPID=$!
trap 'kill -TERM "$MPID" 2>/dev/null; exit 0' TERM INT
TRIGGER=/mosquitto/config/reload
LAST=$(stat -c %Y "$TRIGGER" 2>/dev/null || echo 0)
while kill -0 "$MPID" 2>/dev/null; do
CUR=$(stat -c %Y "$TRIGGER" 2>/dev/null || echo 0)
if [ "$CUR" != "$LAST" ]; then
kill -HUP "$MPID" 2>/dev/null || true
LAST="$CUR"
fi
sleep 2
done
wait "$MPID"

View File

@ -36,6 +36,8 @@ docker run --rm -e LPASS -e DPASS \
rm -f /cfg/passwd
mosquitto_passwd -c -b /cfg/passwd laravel "$LPASS"
mosquitto_passwd -b /cfg/passwd sidecar "$DPASS"
chown 1883:1883 /cfg/passwd && chmod 0600 /cfg/passwd
# Owned by the app uid so the backend can add per-device credentials at onboarding;
# world-readable so the mosquitto user can read it.
chown '"$(id -u)"':'"$(id -g)"' /cfg/passwd && chmod 0644 /cfg/passwd
'
echo 'Mosquitto passwd generated (users: laravel, sidecar).'

View File

@ -58,4 +58,9 @@ return [
'restart_confirm_title' => 'Gerät neu starten?',
'restart_confirm_body' => ':name wird neu gestartet und ist kurz nicht erreichbar.',
'mqtt_credential_title' => 'MQTT-Zugangsdaten (nur jetzt sichtbar)',
'mqtt_credential_hint' => 'Im Shelly MQTT aktivieren und diese Zugangsdaten sowie den Broker (Host der HomeOS-VM) eintragen. Das Passwort wird nicht erneut angezeigt.',
'mqtt_username' => 'Benutzer',
'mqtt_password' => 'Passwort',
];

View File

@ -58,4 +58,9 @@ return [
'restart_confirm_title' => 'Restart device?',
'restart_confirm_body' => ':name will restart and be briefly unavailable.',
'mqtt_credential_title' => 'MQTT credentials (shown only now)',
'mqtt_credential_hint' => 'Enable MQTT on the Shelly and enter these credentials plus the broker (the HomeOS VM host). The password is not shown again.',
'mqtt_username' => 'Username',
'mqtt_password' => 'Password',
];

View File

@ -24,6 +24,18 @@
</div>
@endif
@if ($mqttCredential)
<div class="rounded-card border border-accent/30 bg-accent/10 p-4 flex flex-col gap-2">
<div class="flex items-center gap-2 text-[13px] font-bold text-ink"><x-icon name="lock" :size="16" class="text-accent" /> {{ __('devices.mqtt_credential_title') }}</div>
<p class="text-[12px] text-ink-2 max-w-2xl">{{ __('devices.mqtt_credential_hint') }}</p>
<dl class="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2 mt-1">
<x-detail :label="__('devices.mqtt_username')" mono>{{ $mqttCredential['username'] }}</x-detail>
<x-detail :label="__('devices.mqtt_password')" mono>{{ $mqttCredential['password'] }}</x-detail>
<x-detail label="Port" mono>{{ $mqttCredential['port'] }}</x-detail>
</dl>
</div>
@endif
<div class="grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-5 items-start">
{{-- main --}}
<div class="flex flex-col gap-5">