Repair cross-platform lockfile and verify secure production login behind Nginx

This commit is contained in:
wolf-demon 2026-09-09 14:23:10 +01:00
parent 49d02be623
commit 41f8d805e8
6 changed files with 115 additions and 7 deletions

View File

@ -45,6 +45,25 @@ jobs:
- name: Package reviewed images
if: github.event_name != 'pull_request'
run: docker save guestops-api:${{ github.sha }} guestops-worker:${{ github.sha }} | gzip > guestops-images.tar.gz
- name: Smoke test production containers and restart persistence
env:
GUESTOPS_API_IMAGE: guestops-api:${{ github.sha }}
GUESTOPS_WORKER_IMAGE: guestops-worker:${{ github.sha }}
BOOTSTRAP_EMAIL: ci-owner@example.invalid
BOOTSTRAP_HOTEL: CI test hotel
run: |
export MONGO_ROOT_PASSWORD=$(openssl rand -hex 32)
export MONGO_APP_PASSWORD=$(openssl rand -hex 32)
export BOOTSTRAP_PASSWORD=$(openssl rand -hex 24)
trap 'docker compose down --volumes' EXIT
docker compose config --quiet
docker compose up -d --no-build
curl --retry 30 --retry-delay 2 --retry-all-errors --fail http://127.0.0.1:8080/health
docker compose run --rm --no-deps -e BOOTSTRAP_EMAIL -e BOOTSTRAP_HOTEL -e BOOTSTRAP_PASSWORD api --bootstrap
python3 tests/production_smoke.py
docker compose restart api worker
curl --retry 30 --retry-delay 2 --retry-all-errors --fail http://127.0.0.1:8080/health
python3 tests/production_smoke.py --read
- uses: actions/upload-artifact@v4
if: github.event_name != 'pull_request'
with:

View File

@ -20,7 +20,8 @@ services:
environment:
<<: *app-env
ASPNETCORE_ENVIRONMENT: Production
AllowedHosts: sandbox-guestops.futuresens.co.uk;localhost
AllowedHosts: sandbox-guestops.futuresens.co.uk;localhost;127.0.0.1
Proxy__KnownAddress: ${GUESTOPS_GATEWAY:-172.30.87.1}
volumes: ["app-keys:/var/lib/guestops/keys"]
depends_on:
mongo: { condition: service_healthy }
@ -59,3 +60,9 @@ services:
volumes:
mongo-data:
app-keys:
networks:
default:
ipam:
config:
- subnet: ${GUESTOPS_SUBNET:-172.30.87.0/24}
gateway: ${GUESTOPS_GATEWAY:-172.30.87.1}

View File

@ -52,6 +52,8 @@ Verify the hostname's DNS resolves to this server. Obtain a valid certificate fo
Merge `deploy/nginx.conf` into the existing host configuration, check with `nginx -t`, then reload Nginx. Confirm HTTPS serves the login page. API cookies are always Secure outside preview mode; logging in through plain HTTP is intentionally unsupported.
Compose reserves the private bridge `172.30.87.0/24`, with gateway `172.30.87.1`. The API trusts the host gateway's `X-Forwarded-Proto` header so Nginx's HTTPS connections receive secure session and CSRF cookies. Check for an existing network using that range. If it conflicts, set both `GUESTOPS_SUBNET` and `GUESTOPS_GATEWAY` in `.env` to a free matching subnet and gateway. Do not replace this with unrestricted forwarded-header trust.
The initial rate limiter keys off the direct peer address. Behind this loopback reverse proxy it is shared across users (10 login attempts/minute), which is conservative for a pilot. Before scaling, configure explicitly trusted forwarded headers and per-account/IP rate limits; never trust arbitrary client-supplied forwarding headers.
## 5. Configure Google

View File

@ -8,8 +8,11 @@ using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
using System.Net;
var builder = WebApplication.CreateBuilder(args);
var bootstrap = args.Contains("--bootstrap");
var builder = WebApplication.CreateBuilder(args.Where(arg => arg != "--bootstrap").ToArray());
builder.Logging.ClearProviders(); builder.Logging.AddConsole();
builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning);
builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning);
@ -38,6 +41,15 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
};
});
builder.Services.AddAuthorization(o => o.AddPolicy("Owner", p => p.RequireRole("Owner")));
builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
// Trust only HTTPS information from the configured host reverse proxy.
// Client IP forwarding remains disabled until per-client limits are introduced.
o.ForwardedHeaders = ForwardedHeaders.XForwardedProto;
o.ForwardLimit = 1;
if (builder.Configuration["Proxy:KnownAddress"] is { Length: > 0 } address)
o.KnownProxies.Add(IPAddress.Parse(address));
});
builder.Services.AddAntiforgery(o => { o.HeaderName = "X-CSRF-TOKEN"; o.Cookie.Name = "guestops.csrf"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Strict; o.Cookie.SecurePolicy = preview ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; });
builder.Services.AddRateLimiter(o =>
{
@ -45,9 +57,10 @@ builder.Services.AddRateLimiter(o =>
o.AddPolicy("login", context => RateLimitPartition.GetFixedWindowLimiter(context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new() { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
});
var app = builder.Build();
app.UseForwardedHeaders();
var store = app.Services.GetRequiredService<IStore>();
await store.Initialize();
if (args.Contains("--bootstrap"))
if (bootstrap)
{
var email = Environment.GetEnvironmentVariable("BOOTSTRAP_EMAIL")?.Trim().ToLowerInvariant() ?? "";
var password = Environment.GetEnvironmentVariable("BOOTSTRAP_PASSWORD") ?? "";

54
tests/production_smoke.py Normal file
View File

@ -0,0 +1,54 @@
"""Exercise disposable CI containers through the host's trusted proxy address.
The forwarded HTTPS header simulates Nginx TLS termination; this is never run
against an existing hotel database. Cookie values and credentials are not logged.
"""
import json
import os
import sys
from http.cookies import SimpleCookie
from urllib.request import Request, urlopen
from urllib.error import HTTPError
cookies = SimpleCookie()
csrf = ""
def request(path, method="GET", data=None, expected=200):
headers = {"X-Forwarded-Proto": "https", "Content-Type": "application/json",
"Cookie": "; ".join(f"{k}={v.value}" for k, v in cookies.items()),
"X-CSRF-TOKEN": csrf}
req = Request("http://127.0.0.1:8080" + path, method=method, headers=headers,
data=json.dumps(data).encode() if data is not None else None)
try:
response = urlopen(req, timeout=15)
except HTTPError as error:
response = error
assert response.status == expected, f"{method} {path}: {response.status}, expected {expected}"
for value in response.headers.get_all("Set-Cookie", []):
parsed = SimpleCookie(value)
for cookie in parsed.values():
assert cookie["secure"] and cookie["httponly"], "Authentication cookies must be secure and HttpOnly"
cookies.load(value)
body = response.read().decode()
return json.loads(body) if "application/json" in response.headers.get("Content-Type", "") else body
assert "root" in request("/"), "Container must serve the built React interface"
request("/api/hotel", expected=401)
session = request("/api/session")
assert session["preview"] is False and session["user"] is None
csrf = session["csrfToken"]
request("/api/auth/login", "POST", {"email": os.environ["BOOTSTRAP_EMAIL"], "password": os.environ["BOOTSTRAP_PASSWORD"]})
session = request("/api/session")
assert session["user"]["role"] == "Owner"
csrf = session["csrfToken"]
hotel = request("/api/hotel")
if "--read" in sys.argv:
assert hotel["signature"] == "Persisted across container restart"
else:
hotel["signature"] = "Persisted across container restart"
request("/api/hotel", "PUT", hotel)
request("/api/auth/logout", "POST")
request("/api/hotel", expected=401)
print("Production smoke checks passed: built UI, secure cookies, owner login, MongoDB settings and logout.")

21
web/package-lock.json generated
View File

@ -252,6 +252,23 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz",
"integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz",
@ -798,10 +815,6 @@
"@rolldown/binding-win32-x64-msvc": "1.2.8"
}
},
"node_modules/rolldown/node_modules/@rolldown/binding-openharmony-arm64": {
"dev": true,
"optional": true
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",