feat: trust X-Remote-User only from the proxy (HADO_TRUSTED_PROXIES); open tailnet inbound for the agent

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
nikita.hohlov
2026-09-05 07:25:31 -03:00
parent 36d6af659e
commit bc82bbab19
5 changed files with 40 additions and 4 deletions

View File

@@ -43,6 +43,8 @@ CACHE_STORE=database
HADO_DEFAULT_TZ=America/Montevideo
# Только dev (APP_ENV=local): логин вместо X-Remote-User, чтобы открыть инбокс без хаба
HADO_DEV_USER=
# Откуда верим X-Remote-User (TCP-сосед): loopback и докер-мосты, т.е. Caddy. С тейлнета заголовок не принимается.
HADO_TRUSTED_PROXIES=127.0.0.0/8,172.16.0.0/12
HADO_PRESENCE_TIMEOUT=2
HADO_FALLBACK_DAY=30
HADO_FALLBACK_HOUR=10

View File

@@ -5,11 +5,14 @@ namespace App\Http\Middleware;
use App\Ingest\EventUpserter;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
/**
* Доверяет X-Remote-User от Caddy forward_auth (сеть изолирована, как в sekai).
* На публичных маршрутах (/api, /a, /hooks) этот middleware не стоит.
* Доверяет X-Remote-User от Caddy forward_auth и только ему: заголовок принимается,
* если TCP-сосед (REMOTE_ADDR, не X-Forwarded-For) из HADO_TRUSTED_PROXIES. Порт hado
* виден и из тейлнета (агент), оттуда подделать SSO-заголовок нельзя.
* На публичных маршрутах (/api, /a, /hooks, /mcp) этот middleware не стоит.
*/
class RemoteUser
{
@@ -18,6 +21,9 @@ class RemoteUser
public function handle(Request $request, Closure $next): Response
{
$login = trim((string) $request->header('X-Remote-User', ''));
if ($login !== '' && ! IpUtils::checkIp((string) $request->server('REMOTE_ADDR'), config('hado.trusted_proxies'))) {
return response()->json(['message' => 'X-Remote-User принимается только от прокси'], 401);
}
if ($login === '' && app()->environment('local')) {
// Dev без хаба: HADO_DEV_USER подставляет пользователя. В проде не работает.
$login = trim((string) config('hado.dev_user'));

View File

@@ -4,6 +4,8 @@ return [
'default_tz' => env('HADO_DEFAULT_TZ', 'UTC'),
// Только для APP_ENV=local: логин, который подставляется вместо X-Remote-User, если заголовка нет.
'dev_user' => env('HADO_DEV_USER', ''),
// Откуда верим X-Remote-User: loopback и докер-мосты (Caddy заходит через docker-proxy с адреса шлюза).
'trusted_proxies' => array_map('trim', explode(',', env('HADO_TRUSTED_PROXIES', '127.0.0.0/8,172.16.0.0/12'))),
'presence_timeout' => (int) env('HADO_PRESENCE_TIMEOUT', 2),
'presence_ttl' => 60,
'fallback_day_minutes' => (int) env('HADO_FALLBACK_DAY', 30),

View File

@@ -0,0 +1,25 @@
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/** X-Remote-User верим только соседу по докер-сети (Caddy); с тейлнета или снаружи заголовок — не пропуск. */
class RemoteUserTrustTest extends TestCase
{
use RefreshDatabase;
public function test_header_is_trusted_from_docker_bridge_and_loopback(): void
{
$this->withServerVariables(['REMOTE_ADDR' => '172.21.0.1'])->getJson('/me', ['X-Remote-User' => 'luci'])->assertOk();
$this->withServerVariables(['REMOTE_ADDR' => '127.0.0.1'])->getJson('/me', ['X-Remote-User' => 'luci'])->assertOk();
}
public function test_header_from_tailnet_or_internet_is_rejected(): void
{
$this->withServerVariables(['REMOTE_ADDR' => '100.124.15.61'])->getJson('/me', ['X-Remote-User' => 'luci'])
->assertStatus(401)->assertJsonPath('message', 'X-Remote-User принимается только от прокси');
$this->withServerVariables(['REMOTE_ADDR' => '8.8.8.8'])->getJson('/me', ['X-Remote-User' => 'luci'])->assertStatus(401);
}
}

View File

@@ -6,9 +6,10 @@
# Tailscale — сайдкар в netns приложения: в тейлнете только этот стек, хост и остальные
# сервисы не затронуты. Владелец netns — app (самый стабильный), tailscale/scheduler/worker
# садятся к нему: рестарт tailscale не роняет сайт. Контейнер крутит голый tailscaled,
# логин — один раз руками: `docker compose exec tailscale tailscale up --shields-up --hostname=hado --accept-dns=false`
# логин — один раз руками: `docker compose exec tailscale tailscale up --hostname=hado --accept-dns=false`
# (печатает ссылку, ждёт; состояние в volume, дальше поднимается сам).
# --shields-up: нода ничего не принимает из тейлнета, только ходит наружу (в Home Assistant).
# Из тейлнета hado доступен как http://<ts-ip>:8080 (агент Мия → /mcp); X-Remote-User с тейлнета
# ядро не принимает — только от Caddy (HADO_TRUSTED_PROXIES), так что подделать SSO оттуда нельзя.
services:
app:
build: ./core