diff --git a/.gitignore b/.gitignore index 7fe66ca..35e5f77 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/app/Livewire/Devices/Show.php b/app/Livewire/Devices/Show.php index d05ccc0..1d41b35 100644 --- a/app/Livewire/Devices/Show.php +++ b/app/Livewire/Devices/Show.php @@ -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 diff --git a/app/Livewire/Modals/AssignDevice.php b/app/Livewire/Modals/AssignDevice.php index b0afe20..50afe44 100644 --- a/app/Livewire/Modals/AssignDevice.php +++ b/app/Livewire/Modals/AssignDevice.php @@ -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); diff --git a/app/Services/MqttCredentialProvisioner.php b/app/Services/MqttCredentialProvisioner.php new file mode 100644 index 0000000..27774e4 --- /dev/null +++ b/app/Services/MqttCredentialProvisioner.php @@ -0,0 +1,59 @@ +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()); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index df8babe..ea98f8a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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) diff --git a/docker/mosquitto/config/entrypoint.sh b/docker/mosquitto/config/entrypoint.sh new file mode 100644 index 0000000..981590b --- /dev/null +++ b/docker/mosquitto/config/entrypoint.sh @@ -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" diff --git a/docker/mosquitto/gen-passwd.sh b/docker/mosquitto/gen-passwd.sh index a73bf07..09d8542 100755 --- a/docker/mosquitto/gen-passwd.sh +++ b/docker/mosquitto/gen-passwd.sh @@ -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).' diff --git a/lang/de/devices.php b/lang/de/devices.php index 73f3cfc..9c69082 100644 --- a/lang/de/devices.php +++ b/lang/de/devices.php @@ -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', ]; diff --git a/lang/en/devices.php b/lang/en/devices.php index e873a01..76c8a56 100644 --- a/lang/en/devices.php +++ b/lang/en/devices.php @@ -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', ]; diff --git a/resources/views/livewire/devices/show.blade.php b/resources/views/livewire/devices/show.blade.php index af9a807..7e0e7fa 100644 --- a/resources/views/livewire/devices/show.blade.php +++ b/resources/views/livewire/devices/show.blade.php @@ -24,6 +24,18 @@ @endif + @if ($mqttCredential) +
+
{{ __('devices.mqtt_credential_title') }}
+

{{ __('devices.mqtt_credential_hint') }}

+
+ {{ $mqttCredential['username'] }} + {{ $mqttCredential['password'] }} + {{ $mqttCredential['port'] }} +
+
+ @endif +
{{-- main --}}