Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/halo-app-design/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,4 @@ talking. Optional warm/cool weather hues alongside the accent when data demands.
| Device | wall-mounted touch panel — no hover, big targets/numbers |
| Density | data-dense (clock, energy charts, Hue cards) |
| Motion | drawer unfold (150ms), colon pulse each second, breathing lit bulbs, energy-flow stroke scroll, eased counters |
| Icons | `lucide-react`, inline per-icon import, `currentColor` (migrated off Material Icons) |
| Icons | `lucide-react`, inline per-icon import, `currentColor` — **migration in progress**: 5 files done, ~12 still render Material Icons through `components/Icon.tsx` (and `index.html` still loads the two Material Icons stylesheets). New code uses lucide; convert the Material call sites you touch. |
29 changes: 29 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# EditorConfig — https://editorconfig.org
# The cross-language source of truth for indentation, line width, and newline
# hygiene. Prettier reads it (indent_style, indent_size, max_line_length,
# end_of_line); editors read it natively; rustfmt ignores it but already matches.
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
# Prettier's default is 80. Line width is the one knob most repos override; set
# it here once and every editor + Prettier follows.
max_line_length = 100

# Rust (rustfmt) indents with 4 spaces.
[*.rs]
indent_size = 4

[Makefile]
indent_style = tab

# Markdown keeps trailing whitespace (two trailing spaces = a hard line break).
[*.md]
trim_trailing_whitespace = false

# YAML inherits spaces from [*] above — never set it to tabs (illegal in YAML).
4 changes: 2 additions & 2 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ fi

if echo "$STAGED" | grep -q "^backend/"; then
echo "Running backend lint..."
(cd "$ROOT/backend" && cargo fmt --check)
(cd "$ROOT/backend" && cargo clippy -- -D warnings)
(cd "$ROOT/backend" && cargo fmt --all -- --check)
(cd "$ROOT/backend" && cargo clippy --workspace --all-targets -- -D warnings)
fi
11 changes: 9 additions & 2 deletions .github/dependabot.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,15 @@ updates:
- "minor"
- "patch"

# github-actions only honours `default-days` — the semver-* keys silently
# break parsing for this ecosystem, so they're omitted.
# docker + github-actions only honour `default-days` — the semver-* keys
# silently break parsing for these ecosystems, so they're omitted.
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7

- package-ecosystem: "github-actions"
directory: "/"
schedule:
Expand Down
31 changes: 19 additions & 12 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,21 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "24"
- run: corepack enable
- run: yarn install --immutable
- run: yarn playwright install chromium
- run: yarn format
- run: yarn lint
- run: yarn test
- run: yarn build
# The pinned major lives in one place; a hardcoded literal here would
# silently drift from the file and the Dockerfile's node stage.
node-version-file: frontend/.node-version
# Yarn is vendored (.yarn/releases/*.cjs) and invoked via node — no
# corepack (node 25+ dropped the bundle).
- name: Frontend checks
run: |
yarn() { node .yarn/releases/yarn-*.cjs "$@"; }
yarn install --immutable
yarn playwright install chromium
yarn format
yarn lint
yarn typecheck
yarn test
yarn build

backend:
name: "Backend"
Expand All @@ -40,7 +47,7 @@ jobs:
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: backend
- run: cargo fmt --check
- run: cargo clippy -- -D warnings
- run: cargo test
- run: cargo build
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets -- -D warnings
- run: cargo test --workspace
- run: cargo build --workspace --release
47 changes: 47 additions & 0 deletions .github/workflows/cve-scan.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Weekly trivy scan of the published image → SARIF to the Security tab.
# Report-only: never fails the run.
name: CVE scan (GHCR image)

on:
schedule:
- cron: "0 6 * * 1" # Mondays 06:00 UTC
workflow_dispatch:

env:
REGISTRY: ghcr.io

jobs:
scan:
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
security-events: write

steps:
- name: Log in to the Container registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Trivy scan
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
image-ref: ${{ env.REGISTRY }}/${{ github.repository }}:main
format: sarif
output: trivy.sarif
severity: CRITICAL,HIGH
exit-code: "0"
# Drop CVEs with no upstream fix — the scratch Rust image carries
# almost no userland; self-heals once a fix ships (the CVE resurfaces).
ignore-unfixed: true
env:
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db,public.ecr.aws/aquasecurity/trivy-db

- name: Upload SARIF to Security tab
uses: github/codeql-action/upload-sarif@c54b30b7df092240050e69945842bc67aee0f0f4 # v4.37.3
if: always()
with:
sarif_file: trivy.sarif
26 changes: 22 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ siblings [chat](../chat), [scribe](../scribe), [ocular](../ocular),

```
backend/ actix-web 4 — Hue/FMI/SolisCloud clients, SQLite (WAL), polling loops
frontend/ Vite + React 19 + Emotion + TanStack Router (file-based)
frontend/ Vite + React 19 + Emotion; views are switched by App.tsx state,
not a router
justfile task runner — `just dev` runs backend (bacon) + frontend together
scripts/ refresh-pv-forecast.sh (cron-driven PV upsert) + misc
documentation/ design notes
.claude/skills/ halo-app-design skill (visual language, brand)
Expand All @@ -32,11 +34,25 @@ Per-area instructions in `backend/CLAUDE.md` and `frontend/CLAUDE.md`.
disabled).
- **Hue events.** `GET /api/hue/events` is an SSE stream off a broadcast channel
— live bridge-state pushes, not polling.
- **Installable + touch-first.** The SPA ships a web manifest and a full icon set
(`frontend/scripts/gen-icons.sh` regenerates the PNGs from `favicon.svg` —
rerun and commit after editing it). The layout assumes `viewport-fit=cover`:
`#root` is the scroll container and pads off `env(safe-area-inset-*)`, and
height is `100vh` when installed (`html.standalone`, set in `main.tsx`) vs
`100dvh` in a tab — iOS resolves `100dvh` short at cold start in a PWA.
- **Stale-tab guard.** Frontend and backend ship in one image, so a deploy
replaces both. `vite.config.ts` emits `version.json` holding the baked
`VITE_HALO_IMAGE_TAG`; `useDeployReload` polls it every 60 s and reloads when
it differs. Keyed off the image tag, not a semver — a `:main` rebuild never
bumps a semver.

## Working on this repo

- Backend `:3000` (`PORT`); needs a `.env` with at least `HUE_BRIDGE_ADDRESS` /
`HUE_BRIDGE_USER` (pair via `POST /api/hue/pair`). Run: `cargo run -p halo-backend`.
- `just dev` runs both halves together (backend headless under bacon + Vite) and
tears them down on one Ctrl-C. `just --list` for the rest.
- Backend `:3000` (`PORT`); needs `backend/.env` with at least
`HUE_BRIDGE_ADDRESS` / `HUE_BRIDGE_USER` (pair via `POST /api/hue/pair`).
Alone: `cd backend && bacon` (or `cargo run`).
- Frontend dev `:5173` (`yarn dev`); Vite proxies `/api`, `/hue/events`,
`/status` to `:3000`.
- Key env: `SOLIS_KEY_ID/SECRET/STATION_ID`, `TOMORROW_IO_API_KEY` (fallback
Expand All @@ -47,7 +63,9 @@ Per-area instructions in `backend/CLAUDE.md` and `frontend/CLAUDE.md`.

- Authentication / per-user data
- Shared/team dashboards, admin UI
- Mobile/native app (web only, touch-friendly)
- A native app — but the web UI is touch-first and installable (see
"Installable + touch-first"); phones are a supported target, not an
afterthought
- Non-Hue smart-home integrations

If a feature crosses into those areas, raise it before implementing.
6 changes: 4 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ WORKDIR /app
COPY frontend/package.json frontend/yarn.lock frontend/.yarnrc.yml* ./
# .yarnrc.yml pins yarnPath to a committed release; copy it in before install
COPY frontend/.yarn/ ./.yarn/
RUN corepack enable && yarn install --immutable --network-timeout 1000000
# Invoke the vendored yarn through node — no corepack (node 25+ dropped the
# bundle), so this stage stays independent of the node version.
RUN node .yarn/releases/yarn-*.cjs install --immutable --network-timeout 1000000
COPY frontend/ .
RUN yarn build
RUN node .yarn/releases/yarn-*.cjs build

# --- Stage 2: Build backend dependencies (native, cross-compiled) ---
FROM --platform=$BUILDPLATFORM rust:1-alpine AS backend-deps
Expand Down
76 changes: 76 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Security

halo is a self-hosted, single-household wall dashboard. It runs on a home Pi and
is reachable only from the LAN — it is **not** a public or multi-tenant service.
The threat model is: everything on the LAN is trusted, so the controls that
matter are keeping the upstream credentials (Hue bridge user, SolisCloud API
key/secret, weather API key) out of the image and out of the browser, and never
exposing the app beyond the LAN.

## Trust boundaries

- **Network is the boundary — there is no app auth.** Every route is public by
design: no login, no session, no forward-auth, no `DEV_AUTH`. Anyone who can
reach the port can read every reading and toggle every light. This is
deliberate for a wall panel, and the only thing keeping it safe is reachability:
halo sits behind Traefik on a subdomain whose DNS record — public though it is
(`public_dns: True` in `../raspi`) — resolves to the **LAN IP**, with no port
forward. Its route is deliberately **not** in Traefik's `_gated_hosts`, so
there is no oauth2-proxy in front of it either. Making halo reachable from the
internet therefore requires adding auth first; nothing in the app would stop
it otherwise.

- **No per-user isolation, by design.** There is no user model. `user_settings`
is a single global row (`id=1`) shared by every viewer; anything written there
is visible to everyone on the LAN.

- **CORS is `Cors::permissive()`.** Acceptable only because the app is LAN-only
and holds nothing a cross-origin read could escalate — there are no cookies,
no session, and no auth header to steal, so a hostile page can at most do what
any LAN client can already do directly. Tighten this to an origin allowlist if
halo ever gains auth or leaves the LAN.

- **No CSP / security response headers are set.** Same rationale; the SPA is
served from `STATIC_DIR` with no user-supplied HTML anywhere in the response
path. Worth adding if the trust model changes.

- **Outbound fetches.** The backend calls fixed, config-pinned upstreams over
verified TLS: the Hue bridge (LAN), FMI (WFS/WMS/download), tomorrow.io,
SolisCloud, and the spot-price API. Base URLs come from env, not from request
input, so no request can steer an outbound fetch at an arbitrary host. halo is
deliberately **not** in `../raspi`'s `network_restrict.RESTRICTED` — unlike the
LAN-only siblings it needs egress for those upstreams.

- **`POST /api/pv/forecast` is an unauthenticated write.** The cron-driven
`scripts/refresh-pv-forecast.sh` upserts ~66 rows through it. Any LAN client
can also write those rows; the blast radius is a wrong PV forecast until the
next 3-hourly refresh overwrites it.

## Secrets

All secrets are injected at runtime via env, never baked into the image or
committed: `HUE_BRIDGE_USER`, `SOLIS_KEY_ID`, `SOLIS_KEY_SECRET`,
`SOLIS_STATION_ID`, `TOMORROW_IO_API_KEY`. On the Pi they come from
`/etc/secrets/halo.env` (written by `../raspi`'s `tasks/secrets.py`). `.env`,
`.env.pv`, and `*.db*` are gitignored. The container runs as UID 1000 on
`scratch`.

## Accepted risks

- **Unauthenticated light control and history writes from the LAN.** Accepted:
the panel exists to be tapped without a login, and the LAN is the trust
boundary. Revisit if halo is ever exposed beyond the LAN, or if guest/IoT
devices share the same segment.
- **Permissive CORS + no CSP.** Accepted for the same reason (see above).
Revisit alongside any auth work.

## Out of scope

Authentication, per-user data, shared/team dashboards, and rate-limiting against
a hostile LAN client are deliberately not built — see the root `CLAUDE.md`
"Out of scope". Don't add them piecemeal; they change the trust model above.

## Reporting

This is a personal project. Flag an issue privately to the maintainer rather
than opening a public issue with exploit detail.
12 changes: 11 additions & 1 deletion backend/bacon.toml
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
# Run `bacon` (TUI) for local work, or `bacon --headless -j run` (what the root
# `just dev` recipe invokes) to stream logs into the composed dev output. Both
# auto-reload on source + .env changes; the binary loads backend/.env via
# dotenvy, and watching .env restarts when that changes too.
default_job = "run"

[jobs.check]
command = ["cargo", "check", "--color", "always"]
need_stdout = false

[jobs.run]
command = ["cargo", "run"]
command = ["cargo", "run", "--color", "always"]
watch = ["src", ".env"]
default_watch = true
need_stdout = true
allow_warnings = true
background = true
on_change_strategy = "kill_then_restart"
apply_gitignore = false
13 changes: 12 additions & 1 deletion frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,15 @@ Individual scripts:
- `yarn format` / `yarn format:fix`
- `yarn typecheck`

Use yarn (not npm).
Use yarn (not npm). It's vendored at `.yarn/releases/` and pinned by `yarnPath`
— CI and Docker call it as `node .yarn/releases/yarn-*.cjs`, no corepack.

## Icons

- **UI icons:** `lucide-react`, imported per-icon. `components/Icon.tsx` is the
legacy Material Icons wrapper — don't add call sites, convert the ones you
touch.
- **App icons:** `scripts/gen-icons.sh` regenerates `public/icon-*.png` +
`apple-touch-icon.png` from `public/favicon.svg` (needs librsvg + ImageMagick).
Rerun and commit the PNGs after editing the SVG — the build ships no
rasterizer.
13 changes: 12 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,24 @@

<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- viewport-fit=cover: edge-to-edge under the notch/home-indicator AND the
thing that makes env(safe-area-inset-*) resolve non-zero. Required for
the installed-PWA full-height layout in Root.tsx. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="halo" />
<!-- black-translucent overlays the status bar → #root pads with
env(safe-area-inset-top). -->
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- Keep in sync with themes.ts colors.body (light/dark). -->
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f0f0f0" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0f0f0f" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
<link rel="alternate icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<link href="https://fonts.googleapis.com/css2?family=Abril+Fatface&family=DM+Serif+Display&family=Instrument+Serif&family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "vite_react_template",
"name": "halo-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
Expand Down
Binary file modified frontend/public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/public/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/public/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/public/icon-maskable-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions frontend/public/manifest.webmanifest
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "halo",
"short_name": "halo",
"description": "koti — valot, lämpötilat, sää ja aurinkoenergia",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f0f0f0",
"theme_color": "#f0f0f0",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
Loading