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
parent
af951d9f91
commit
a4edbbf801
|
|
@ -34,5 +34,6 @@ Thumbs.db
|
||||||
*.pyc
|
*.pyc
|
||||||
# generated broker credentials + data (not committed)
|
# generated broker credentials + data (not committed)
|
||||||
/docker/mosquitto/config/passwd
|
/docker/mosquitto/config/passwd
|
||||||
|
/docker/mosquitto/config/reload
|
||||||
/docker/mosquitto/data/
|
/docker/mosquitto/data/
|
||||||
/.claude/
|
/.claude/
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,15 @@ class Show extends Component
|
||||||
|
|
||||||
public bool $flashOk = true;
|
public bool $flashOk = true;
|
||||||
|
|
||||||
|
/** One-time MQTT credential to display after provisioning at assignment. */
|
||||||
|
public ?array $mqttCredential = null;
|
||||||
|
|
||||||
public function mount(Device $device): void
|
public function mount(Device $device): void
|
||||||
{
|
{
|
||||||
$this->device = $device;
|
$this->device = $device;
|
||||||
$this->name = $device->name;
|
$this->name = $device->name;
|
||||||
$this->roomId = $device->room_id;
|
$this->roomId = $device->room_id;
|
||||||
|
$this->mqttCredential = session('mqtt_credential');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveName(): void
|
public function saveName(): void
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ namespace App\Livewire\Modals;
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
use App\Models\DiscoveryFinding;
|
use App\Models\DiscoveryFinding;
|
||||||
use App\Models\Room;
|
use App\Models\Room;
|
||||||
|
use App\Services\MqttCredentialProvisioner;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
use LivewireUI\Modal\ModalComponent;
|
use LivewireUI\Modal\ModalComponent;
|
||||||
|
|
||||||
|
|
@ -60,6 +61,17 @@ class AssignDevice extends ModalComponent
|
||||||
|
|
||||||
$finding->update(['status' => 'assigned', 'device_id' => $device->id]);
|
$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();
|
$this->closeModal();
|
||||||
|
|
||||||
return redirect()->route('devices.show', $device);
|
return redirect()->route('devices.show', $device);
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -76,8 +76,10 @@ services:
|
||||||
|
|
||||||
mosquitto:
|
mosquitto:
|
||||||
image: eclipse-mosquitto:2
|
image: eclipse-mosquitto:2
|
||||||
|
# Wrapper runs mosquitto + reloads it (SIGHUP) when a device credential is provisioned.
|
||||||
|
command: ["/bin/sh", "/mosquitto/config/entrypoint.sh"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/mosquitto/config:/mosquitto/config:ro
|
- ./docker/mosquitto/config:/mosquitto/config
|
||||||
- mosquitto-data:/mosquitto/data
|
- mosquitto-data:/mosquitto/data
|
||||||
ports:
|
ports:
|
||||||
# LAN-reachable so real devices can connect (auth + ACL enforced)
|
# LAN-reachable so real devices can connect (auth + ACL enforced)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -36,6 +36,8 @@ docker run --rm -e LPASS -e DPASS \
|
||||||
rm -f /cfg/passwd
|
rm -f /cfg/passwd
|
||||||
mosquitto_passwd -c -b /cfg/passwd laravel "$LPASS"
|
mosquitto_passwd -c -b /cfg/passwd laravel "$LPASS"
|
||||||
mosquitto_passwd -b /cfg/passwd sidecar "$DPASS"
|
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).'
|
echo 'Mosquitto passwd generated (users: laravel, sidecar).'
|
||||||
|
|
|
||||||
|
|
@ -58,4 +58,9 @@ return [
|
||||||
|
|
||||||
'restart_confirm_title' => 'Gerät neu starten?',
|
'restart_confirm_title' => 'Gerät neu starten?',
|
||||||
'restart_confirm_body' => ':name wird neu gestartet und ist kurz nicht erreichbar.',
|
'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',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -58,4 +58,9 @@ return [
|
||||||
|
|
||||||
'restart_confirm_title' => 'Restart device?',
|
'restart_confirm_title' => 'Restart device?',
|
||||||
'restart_confirm_body' => ':name will restart and be briefly unavailable.',
|
'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',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,18 @@
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@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">
|
<div class="grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-5 items-start">
|
||||||
{{-- main --}}
|
{{-- main --}}
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue