Fix R15: per-device MQTT ACLs, monotonic presence, real-device online semantics
- ACL: drop the shared `shelly` account + `+` wildcard (allowed cross-device spoofing/control). Devices now authenticate with per-device credentials (username = topic prefix, provisioned at onboarding) bound to their own prefix via `pattern %u`. gen-passwd.sh creates only laravel + sidecar. - last_seen_at is set from the message receive time (observed_at), monotonically, so a delayed/retried/stale ingest job can't mark a device online incorrectly. - Device::isOnline: a real device with no last_seen is OFFLINE (never connected); only demo devices are assumed reachable. Added `demo` flag + presence tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/phase-1-bootstrap
parent
66f0d7b067
commit
23ef993cb2
|
|
@ -73,8 +73,8 @@ MQTT_AUTH_PASSWORD=
|
|||
# leave empty so a random client id is generated per connection
|
||||
MQTT_CLIENT_ID=
|
||||
MQTT_AUTO_RECONNECT_ENABLED=false
|
||||
# device/sidecar passwords — used only to generate the broker passwd file (see README)
|
||||
MQTT_SHELLY_PASSWORD=
|
||||
# sidecar password — used only to generate the broker passwd file (see README).
|
||||
# Physical devices get per-device credentials provisioned at onboarding.
|
||||
MQTT_SIDECAR_PASSWORD=
|
||||
|
||||
# ================= docker compose (deployment) =================
|
||||
|
|
|
|||
|
|
@ -51,8 +51,13 @@ class IngestShellyMessage implements ShouldQueue
|
|||
}
|
||||
|
||||
$observedAt = $this->observedAt ?? (int) round(microtime(true) * 1_000_000);
|
||||
$observedTime = \Illuminate\Support\Carbon::createFromTimestampMs(intdiv($observedAt, 1000));
|
||||
|
||||
$device->forceFill(['last_seen_at' => now()])->save();
|
||||
// Presence reflects when the message was RECEIVED, and only advances — queue latency,
|
||||
// retries or stale messages can never refresh (or rewind) last_seen incorrectly.
|
||||
if ($device->last_seen_at === null || $observedTime->gt($device->last_seen_at)) {
|
||||
$device->forceFill(['last_seen_at' => $observedTime])->save();
|
||||
}
|
||||
|
||||
foreach (ShellyNormalizer::normalize($component, $data) as $update) {
|
||||
$entity = $device->entities()->firstOrCreate(
|
||||
|
|
|
|||
|
|
@ -13,11 +13,12 @@ class Device extends Model
|
|||
|
||||
protected $fillable = [
|
||||
'uuid', 'name', 'vendor', 'model', 'protocol', 'config',
|
||||
'room_id', 'status', 'last_seen_at',
|
||||
'room_id', 'status', 'demo', 'last_seen_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'config' => 'array',
|
||||
'demo' => 'boolean',
|
||||
'last_seen_at' => 'datetime',
|
||||
];
|
||||
|
||||
|
|
@ -32,8 +33,9 @@ class Device extends Model
|
|||
}
|
||||
|
||||
/**
|
||||
* Reachable now. Real devices use last_seen recency (kept fresh by MQTT from Phase 3);
|
||||
* a mock device with no last_seen is treated as reachable so the demo doesn't rot.
|
||||
* Reachable now. Real devices must have been seen recently (kept fresh by MQTT); a
|
||||
* never-seen real device is offline. Only demo devices are "assumed reachable" when
|
||||
* they have no last_seen, so the seeded demo doesn't rot.
|
||||
*/
|
||||
public function isOnline(): bool
|
||||
{
|
||||
|
|
@ -41,7 +43,10 @@ class Device extends Model
|
|||
return false;
|
||||
}
|
||||
|
||||
return $this->last_seen_at === null
|
||||
|| $this->last_seen_at->gt(now()->subMinutes(10));
|
||||
if ($this->last_seen_at !== null) {
|
||||
return $this->last_seen_at->gt(now()->subMinutes(10));
|
||||
}
|
||||
|
||||
return $this->demo;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
// Seeded/simulated device. Real devices require a recent last_seen to be online;
|
||||
// a demo device with no last_seen is treated as reachable so the demo never rots.
|
||||
$table->boolean('demo')->default(false)->after('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropColumn('demo');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -92,6 +92,7 @@ class DemoHomeSeeder extends Seeder
|
|||
'protocol' => 'mqtt',
|
||||
'config' => ['mqtt_prefix' => 'shelly-'.Str::slug($deviceName)],
|
||||
'status' => 'active',
|
||||
'demo' => true,
|
||||
'last_seen_at' => $online ? $onlineSeen : $stale,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,16 +4,15 @@
|
|||
user laravel
|
||||
topic readwrite #
|
||||
|
||||
# Shelly devices (shared role): publish their own status/telemetry, read their commands.
|
||||
# `+` matches the per-device topic prefix.
|
||||
user shelly
|
||||
topic write +/status/#
|
||||
topic write +/events/#
|
||||
topic write +/online
|
||||
topic read +/command/#
|
||||
topic readwrite +/rpc
|
||||
topic write +/rpc_ntf
|
||||
|
||||
# Discovery sidecar (Phase 4): only publishes discovery findings.
|
||||
user sidecar
|
||||
topic write homeos/discovery/#
|
||||
|
||||
# Physical devices: each authenticates with its OWN credentials (username = its topic
|
||||
# prefix, provisioned at onboarding) and is bound to its own prefix via %u — a compromised
|
||||
# or misconfigured device cannot publish status for, or control, another device.
|
||||
pattern write %u/status/#
|
||||
pattern write %u/events/#
|
||||
pattern write %u/online
|
||||
pattern read %u/command/#
|
||||
pattern readwrite %u/rpc
|
||||
|
|
|
|||
|
|
@ -20,22 +20,22 @@ ensure_pw() {
|
|||
printf '%s' "$val"
|
||||
}
|
||||
|
||||
# Backend + discovery accounts. Physical devices get PER-DEVICE credentials provisioned
|
||||
# at onboarding (username = topic prefix) — never a shared account.
|
||||
LPASS="$(ensure_pw MQTT_AUTH_PASSWORD)"
|
||||
SPASS="$(ensure_pw MQTT_SHELLY_PASSWORD)"
|
||||
DPASS="$(ensure_pw MQTT_SIDECAR_PASSWORD)"
|
||||
|
||||
# Export + forward with bare `-e NAME` so docker passes the values verbatim (no host
|
||||
# interpolation). Read inside the container (single-quoted body) as env vars, so any
|
||||
# character in a password — quotes, $, ;, backticks — is handled safely.
|
||||
export LPASS SPASS DPASS
|
||||
export LPASS DPASS
|
||||
|
||||
docker run --rm -e LPASS -e SPASS -e DPASS \
|
||||
docker run --rm -e LPASS -e DPASS \
|
||||
-v "$(pwd)/docker/mosquitto/config:/cfg" \
|
||||
eclipse-mosquitto:2 sh -c '
|
||||
rm -f /cfg/passwd
|
||||
mosquitto_passwd -c -b /cfg/passwd laravel "$LPASS"
|
||||
mosquitto_passwd -b /cfg/passwd shelly "$SPASS"
|
||||
mosquitto_passwd -b /cfg/passwd sidecar "$DPASS"
|
||||
chown 1883:1883 /cfg/passwd && chmod 0600 /cfg/passwd
|
||||
'
|
||||
echo 'Mosquitto passwd generated (users: laravel, shelly, sidecar).'
|
||||
echo 'Mosquitto passwd generated (users: laravel, sidecar).'
|
||||
|
|
|
|||
|
|
@ -71,6 +71,19 @@ class HomeTest extends TestCase
|
|||
->assertSee(__('dashboard.no_room'));
|
||||
}
|
||||
|
||||
public function test_online_semantics(): void
|
||||
{
|
||||
$demoNeverSeen = Device::create(['name' => 'D', 'status' => 'active', 'demo' => true, 'last_seen_at' => null]);
|
||||
$realNeverSeen = Device::create(['name' => 'R', 'status' => 'active', 'demo' => false, 'last_seen_at' => null]);
|
||||
$recent = Device::create(['name' => 'Rec', 'status' => 'active', 'demo' => false, 'last_seen_at' => now()]);
|
||||
$stale = Device::create(['name' => 'S', 'status' => 'active', 'demo' => false, 'last_seen_at' => now()->subHour()]);
|
||||
|
||||
$this->assertTrue($demoNeverSeen->isOnline());
|
||||
$this->assertFalse($realNeverSeen->isOnline()); // a real, never-connected device is offline
|
||||
$this->assertTrue($recent->isOnline());
|
||||
$this->assertFalse($stale->isOnline());
|
||||
}
|
||||
|
||||
public function test_device_room_update_rejects_invalid_room(): void
|
||||
{
|
||||
$device = Device::create(['name' => 'Lampe', 'status' => 'active', 'last_seen_at' => now()]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue