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
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
COMPOSE_PROJECT_NAME=wiredraft
WIREDRAFT_IMAGE=ghcr.io/arumes31/wiredraft:latest
POSTGRES_IMAGE=postgres:17-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
# 0.0.0.0 publishes WireDraft on every host interface. Use 127.0.0.1 when only
# a local reverse proxy should reach it.
WIREDRAFT_BIND_ADDRESS=0.0.0.0
# WireDraft serves plain HTTP. The loopback default lets only a TLS reverse
# proxy on this host reach it; use 0.0.0.0 to publish on every interface.
WIREDRAFT_BIND_ADDRESS=127.0.0.1
WIREDRAFT_PUBLISHED_PORT=8080

# Bundled PostgreSQL container.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
node-version: "24.12.0"
cache: npm
- name: Check JavaScript syntax
run: find web scripts e2e -type f \( -name '*.js' -o -name '*.mjs' \) -print0 | xargs -0 -r -n1 node --check
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
node-version: "24.12.0"
cache: npm
- name: Install locked dependencies
run: npm ci --ignore-scripts
Expand Down Expand Up @@ -119,7 +119,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
node-version: "24.12.0"
cache: npm
- name: Install locked dependencies
run: npm ci --ignore-scripts
Expand Down Expand Up @@ -170,7 +170,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
node-version: "24.12.0"
cache: npm
- name: Install locked dependencies
run: npm ci --ignore-scripts
Expand Down Expand Up @@ -217,7 +217,7 @@ jobs:
- name: Set up Node.js for frontend minification
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
node-version: "24.12.0"
package-manager-cache: false
- name: Prepare minified JavaScript before image build
run: |
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
node-version: "24.12.0"
cache: npm
- name: Install pinned scanners
run: |
Expand Down Expand Up @@ -115,7 +115,7 @@ jobs:
- name: Set up Node.js for frontend minification
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
node-version: "24.12.0"
package-manager-cache: false
- name: Prepare minified JavaScript before image build
run: |
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/strix-penetration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
STRIX_SANDBOX_CONNECT_TIMEOUT: "3600"
steps:
- name: Check out repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
timeout-minutes: 5
with:
fetch-depth: 0
Expand Down Expand Up @@ -78,7 +78,10 @@ jobs:
env:
STRIX_LLM: ${{ secrets.STRIX_LLM }}
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_API_BASE: https://nimbusroute.wowcraft.pw/v1
# The scan sends repository source and LLM_API_KEY to this endpoint.
# Override it with the STRIX_LLM_API_BASE repository variable to use a
# provider whose data-handling terms the maintainers have reviewed.
LLM_API_BASE: ${{ vars.STRIX_LLM_API_BASE || 'https://nimbusroute.wowcraft.pw/v1' }}
STRIX_DIFF_BASE: ${{ github.base_ref }}
shell: bash
run: |
Expand Down
19 changes: 17 additions & 2 deletions .github/workflows/supply-chain.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
node-version: "24.12.0"
package-manager-cache: false
- name: Build minified embedded modules
run: |
Expand Down Expand Up @@ -87,6 +87,9 @@ jobs:

container:
name: Publish multi-architecture GHCR image
# Only main and release tags publish images. A manual dispatch from any
# other branch would otherwise turn its branch name into a GHCR tag.
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
Expand All @@ -100,13 +103,25 @@ jobs:
- name: Set up Node.js for frontend minification
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
node-version: "24.12.0"
package-manager-cache: false
- name: Prepare minified JavaScript before image build
run: |
npm ci --ignore-scripts
npm run minify:js
find .quality-data/minified-js -type f -name '*.js' -print0 | xargs -0 -n1 node --check
- name: Verify release tag points at a commit on main
if: startsWith(github.ref, 'refs/tags/v')
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
run: |
status="$(gh api "repos/$GH_REPO/compare/main...$SHA" --jq .status)"
if [[ "$status" != "identical" && "$status" != "behind" ]]; then
echo "::error::Release tags must point at a commit already on main (compare status: $status)."
exit 1
fi
- name: Set up QEMU
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
- name: Set up Docker Buildx
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/visual-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
node-version: "24.12.0"
cache: npm
- name: Install locked dependencies
run: npm ci --ignore-scripts
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ docker compose up --build -d
docker compose ps
```

Open <http://localhost:8080>. Session cookies are HTTPS-only by default; browsers accept them on `localhost`, but to sign in over plain HTTP from another host set `WIREDRAFT_COOKIE_SECURE=false` in `.env` (evaluation only). View logs or stop the stack with:
Open <http://localhost:8080>. Session cookies are HTTPS-only by default; browsers accept them on `localhost`, but to sign in over plain HTTP from another host set `WIREDRAFT_BIND_ADDRESS=0.0.0.0` and `WIREDRAFT_COOKIE_SECURE=false` in `.env` (evaluation only). The port is published on `127.0.0.1` by default. View logs or stop the stack with:

```sh
docker compose logs -f wiredraft
Expand Down Expand Up @@ -113,7 +113,7 @@ Compose-specific settings are separate from the application configuration below:
| `COMPOSE_PROJECT_NAME` | `wiredraft` | Compose project and generated resource-name prefix |
| `WIREDRAFT_IMAGE` | `ghcr.io/arumes31/wiredraft:latest` | Application image tag or digest |
| `POSTGRES_IMAGE` | pinned PostgreSQL 17 Alpine digest | PostgreSQL image tag or digest |
| `WIREDRAFT_BIND_ADDRESS` | `0.0.0.0` | Host address that publishes the web port; use `127.0.0.1` behind a local reverse proxy |
| `WIREDRAFT_BIND_ADDRESS` | `127.0.0.1` | Host address that publishes the web port. WireDraft serves plain HTTP, so keep the loopback default behind a TLS reverse proxy on the host; set `0.0.0.0` only when another trusted host terminates TLS |
| `WIREDRAFT_PUBLISHED_PORT` | `8080` | Host-side web port |
| `POSTGRES_DB` | `wiredraft` | Database initialized in a new PostgreSQL data directory |
| `POSTGRES_USER` | `wiredraft` | Database owner initialized in a new PostgreSQL data directory |
Expand Down
42 changes: 42 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"io"
"io/fs"
"log/slog"
"net/http"
"os"
"os/signal"
Expand Down Expand Up @@ -50,6 +51,11 @@ func run(args []string) error {
return err
}
defer databasePool.Close()
releaseInstanceLock, err := store.AcquireInstanceLock(context.Background(), databasePool)
if err != nil {
return err
}
defer releaseInstanceLock()
if err := store.MigrateDatabase(databasePool); err != nil {
return fmt.Errorf("migrating database: %w", err)
}
Expand Down Expand Up @@ -79,6 +85,7 @@ func run(args []string) error {
appLogger.Error("closing photo storage", "error", err)
}
}()
sweepOrphanedMedia(context.Background(), topologyStore, mediaStore, appLogger)
organizations, err := topologyStore.ListOrganizations(context.Background())
if err != nil {
return fmt.Errorf("listing organizations: %w", err)
Expand All @@ -88,6 +95,9 @@ func run(args []string) error {
authOrganizations[index] = auth.OrganizationRef{ID: organization.ID, Name: organization.Name}
}
secretKey, previousSecretKeys, err := readAuthSecretKeys(cfg.SecretKeyFile, cfg.PreviousSecretKeyFile)
if cfg.SecretKeyFile == "" {
appLogger.Warn("TOTP secrets are sealed with a key stored in the same database row; set WIREDRAFT_AUTH_SECRET_KEY_FILE so a database dump alone cannot open them")
}
if err != nil {
return err
}
Expand Down Expand Up @@ -269,3 +279,35 @@ func probeHealth(url string) (returnErr error) {
}
return nil
}

// sweepOrphanedMedia removes photo files that no topology references. It runs
// before the server accepts requests, and it skips the sweep entirely when the
// complete reference set cannot be read.
func sweepOrphanedMedia(ctx context.Context, topologyStore store.Store, mediaStore *media.Store, logger *slog.Logger) {
summaries, err := topologyStore.List(ctx)
if err != nil {
logger.Warn("skipping orphaned media sweep", "error", err)
return
}
var references []media.Reference
for _, summary := range summaries {
topology, err := topologyStore.Get(ctx, summary.ID)
if err != nil {
logger.Warn("skipping orphaned media sweep", "topology_id", summary.ID, "error", err)
return
}
for _, photo := range topology.Photos {
references = append(references, media.Reference{
TopologyID: topology.ID, PhotoID: photo.ID, MediaType: photo.MediaType,
})
}
}
removed, err := mediaStore.Sweep(references)
if err != nil {
logger.Warn("orphaned media sweep incomplete", "removed", removed, "error", err)
return
}
if removed > 0 {
logger.Info("removed orphaned media", "count", removed)
}
}
2 changes: 1 addition & 1 deletion docker-compose.ghcr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ services:
image: ${WIREDRAFT_IMAGE:-ghcr.io/arumes31/wiredraft:latest}
pull_policy: always
ports:
- "${WIREDRAFT_BIND_ADDRESS:-0.0.0.0}:${WIREDRAFT_PUBLISHED_PORT:-8080}:${PORT:-8080}"
- "${WIREDRAFT_BIND_ADDRESS:-127.0.0.1}:${WIREDRAFT_PUBLISHED_PORT:-8080}:${PORT:-8080}"
environment:
PORT: ${PORT:-8080}
DATABASE_URL: ${DATABASE_URL:-}
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ services:
build: .
image: wiredraft:latest
ports:
- "8080:8080"
- "${WIREDRAFT_BIND_ADDRESS:-127.0.0.1}:8080:8080"
environment:
PORT: "8080"
PGHOST: postgres
Expand Down
5 changes: 3 additions & 2 deletions e2e/critical-workflows.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -390,10 +390,11 @@ test("stores plan comments in the inspector and manages map resources", async ({
const comments = page.locator(".inspector-comments");
await expect(comments).toContainText("PLAN COMMENTS");
await expect(comments).toContainText("STORED WITH THIS MAP");
await comments.locator('[name="author"]').fill("E2E Operator");
await expect(comments.locator('[name="author"]')).toHaveValue("Guest");
await expect(comments.locator('[name="author"]')).toHaveAttribute("readonly", "");
await comments.locator('[name="body"]').fill("Validate the maintenance path before change window.");
await comments.locator(".inspector-comment-form button").click();
await expect(comments).toContainText("E2E Operator");
await expect(comments.locator(".inspector-comment-list")).toContainText("Guest");
await expect(comments).toContainText("Validate the maintenance path before change window.");

const persisted = await request.get(`/api/v1/topologies/${encodeURIComponent(topologyID)}`).then((response) => response.json());
Expand Down
13 changes: 9 additions & 4 deletions internal/auth/entra.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,16 @@ func (p *EntraProvider) Complete(
p.mu.Lock()
p.pruneFlowsLocked(now)
flow, exists := p.flows[state]
delete(p.flows, state)
p.mu.Unlock()
providedHash := sha256.Sum256([]byte(flowToken))
if !exists || state == "" || flowToken == "" || code == "" ||
subtle.ConstantTimeCompare(flow.CookieHash[:], providedHash[:]) != 1 {
matches := exists && state != "" && flowToken != "" &&
subtle.ConstantTimeCompare(flow.CookieHash[:], providedHash[:]) == 1
// Only the browser holding the flow cookie may consume the flow, so a
// party that merely learns the state value cannot cancel the login.
if matches {
delete(p.flows, state)
}
p.mu.Unlock()
if !matches || code == "" {
return ExternalIdentity{}, ErrInvalidExternalIdentity
}
runtime, err := p.oidcRuntime(exchangeContext)
Expand Down
17 changes: 17 additions & 0 deletions internal/auth/entra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,20 @@ func TestEntraHomeTenantMemberRequiresAccountTypeClaimWhenConfigured(t *testing.
}
}
}

func TestEntraFlowIsNotConsumedByAWrongCookie(t *testing.T) {
t.Parallel()
now := time.Now()
provider := &EntraProvider{
config: EntraConfig{TenantID: "00000000-0000-4000-8000-000000000001"},
flows: map[string]pendingEntraFlow{},
now: func() time.Time { return now },
}
provider.flows["state"] = pendingEntraFlow{CookieHash: sha256.Sum256([]byte("browser-cookie")), ExpiresAt: now.Add(time.Minute)}
if _, err := provider.Complete(t.Context(), "state", "attacker-cookie", "code"); err == nil {
t.Fatal("completion with the wrong flow cookie succeeded")
}
if _, exists := provider.flows["state"]; !exists {
t.Fatal("a caller without the flow cookie cancelled the pending login")
}
}
Loading
Loading