1 Commits

Author SHA1 Message Date
user
35af9c99d5 Add µPaaS deployment setup for fsn1app1
All checks were successful
check / check (push) Successful in 58s
- Add Docker HEALTHCHECK instruction probing /.well-known/healthcheck.json
  (30s interval, 5s timeout, 10s start period, 3 retries) for µPaaS
  container health verification
- Create deploy/README.md with full µPaaS app configuration reference
  (app name, repo URL, branch, env vars, volumes, ports, production config)
- Add Deployment section to README.md linking to deploy docs
- Add deploy/ to .dockerignore (docs not needed in build context)
2026-03-17 02:17:15 -07:00
7 changed files with 141 additions and 54 deletions

View File

@@ -6,3 +6,4 @@
node_modules node_modules
bin/ bin/
data/ data/
deploy/

View File

@@ -75,4 +75,7 @@ WORKDIR /var/lib/pixa
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q --spider http://localhost:8080/.well-known/healthcheck.json
ENTRYPOINT ["/usr/local/bin/pixad", "--config", "/etc/pixa/config.yml"] ENTRYPOINT ["/usr/local/bin/pixad", "--config", "/etc/pixa/config.yml"]

View File

@@ -98,7 +98,9 @@ expiration 1704067200:
**Whitelist patterns:** **Whitelist patterns:**
- **Exact match only**: `cdn.example.com` — matches only that host - **Exact match**: `cdn.example.com` — matches only that host
- **Suffix match**: `.example.com` — matches `cdn.example.com`,
`images.example.com`, and `example.com`
### Configuration ### Configuration
@@ -123,6 +125,17 @@ See `config.example.yml` for all options with defaults.
- **Metrics**: Prometheus - **Metrics**: Prometheus
- **Logging**: stdlib slog - **Logging**: stdlib slog
## Deployment
Pixa is deployed via
[µPaaS](https://git.eeqj.de/sneak/upaas) on `fsn1app1`
(paas.datavi.be). Pushes to `main` trigger automatic builds and
deployments. The Dockerfile includes a `HEALTHCHECK` that probes
`/.well-known/healthcheck.json`.
See [deploy/README.md](deploy/README.md) for the full µPaaS app
configuration, volume mounts, and production setup instructions.
## TODO ## TODO
See [TODO.md](TODO.md) for the full prioritized task list. See [TODO.md](TODO.md) for the full prioritized task list.

View File

@@ -13,7 +13,8 @@ state_dir: ./data
# Generate with: openssl rand -base64 32 # Generate with: openssl rand -base64 32
signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32" signing_key: "CHANGE_ME_generate_with_openssl_rand_base64_32"
# Hosts that don't require signatures (exact match only) # Hosts that don't require signatures
# Use "." prefix for wildcard subdomain matching (e.g., ".example.com" matches "cdn.example.com")
whitelist_hosts: whitelist_hosts:
- s3.sneak.cloud - s3.sneak.cloud
- static.sneak.cloud - static.sneak.cloud

78
deploy/README.md Normal file
View File

@@ -0,0 +1,78 @@
# Pixa Deployment via µPaaS
Pixa is deployed on `fsn1app1` via
[µPaaS](https://git.eeqj.de/sneak/upaas) (paas.datavi.be).
## µPaaS App Configuration
Create the app in the µPaaS web UI with these settings:
| Setting | Value |
| --- | --- |
| **App name** | `pixa` |
| **Repo URL** | `git@git.eeqj.de:sneak/pixa.git` |
| **Branch** | `main` |
| **Dockerfile path** | `Dockerfile` |
### Environment Variables
| Variable | Description | Required |
| --- | --- | --- |
| `PORT` | HTTP listen port (default: 8080) | No |
Configuration is provided via the config file baked into the Docker
image at `/etc/pixa/config.yml`. To override it, mount a custom
config file as a volume (see below).
### Volumes
| Host Path | Container Path | Description |
| --- | --- | --- |
| `/srv/pixa/data` | `/var/lib/pixa` | SQLite database and image cache |
| `/srv/pixa/config.yml` | `/etc/pixa/config.yml` | Production config (signing key, whitelist, etc.) |
### Ports
| Host Port | Container Port | Protocol |
| --- | --- | --- |
| (assigned) | 8080 | TCP |
### Docker Network
Attach to the shared reverse-proxy network if using Caddy/Traefik
for TLS termination.
## Production Configuration
Copy `config.example.yml` from the repo root and customize for
production:
```yaml
port: 8080
debug: false
maintenance_mode: false
state_dir: /var/lib/pixa
signing_key: "<generate with: openssl rand -base64 32>"
whitelist_hosts:
- s3.sneak.cloud
- static.sneak.cloud
- sneak.berlin
allow_http: false
```
**Important:** Generate a unique `signing_key` for production. Never
use the default placeholder value.
## Health Check
The Dockerfile includes a `HEALTHCHECK` instruction that probes
`/.well-known/healthcheck.json` every 30 seconds. µPaaS verifies
container health 60 seconds after deployment.
## Deployment Flow
1. Push to `main` triggers the Gitea webhook
2. µPaaS clones the repo and runs `docker build .`
3. The Dockerfile runs `make check` (format, lint, test) during build
4. On success, µPaaS stops the old container and starts the new one
5. After 60 seconds, µPaaS checks container health

View File

@@ -6,19 +6,22 @@ import (
) )
// HostWhitelist implements the Whitelist interface for checking allowed source hosts. // HostWhitelist implements the Whitelist interface for checking allowed source hosts.
// Only exact host matches are supported. Leading dots in patterns are stripped
// (e.g. ".example.com" becomes an exact match for "example.com").
type HostWhitelist struct { type HostWhitelist struct {
// hosts contains hosts that must match exactly (e.g., "cdn.example.com") // exactHosts contains hosts that must match exactly (e.g., "cdn.example.com")
hosts map[string]struct{} exactHosts map[string]struct{}
// suffixHosts contains domain suffixes to match (e.g., ".example.com" matches "cdn.example.com")
suffixHosts []string
} }
// NewHostWhitelist creates a whitelist from a list of host patterns. // NewHostWhitelist creates a whitelist from a list of host patterns.
// All patterns are treated as exact matches. Leading dots are stripped // Patterns starting with "." are treated as suffix matches.
// for backwards compatibility (e.g. ".example.com" matches "example.com" only). // Examples:
// - "cdn.example.com" - exact match only
// - ".example.com" - matches cdn.example.com, images.example.com, etc.
func NewHostWhitelist(patterns []string) *HostWhitelist { func NewHostWhitelist(patterns []string) *HostWhitelist {
w := &HostWhitelist{ w := &HostWhitelist{
hosts: make(map[string]struct{}), exactHosts: make(map[string]struct{}),
suffixHosts: make([]string, 0),
} }
for _, pattern := range patterns { for _, pattern := range patterns {
@@ -27,22 +30,17 @@ func NewHostWhitelist(patterns []string) *HostWhitelist {
continue continue
} }
// Strip leading dot — suffix matching is not supported. if strings.HasPrefix(pattern, ".") {
// ".example.com" is treated as exact match for "example.com". w.suffixHosts = append(w.suffixHosts, pattern)
pattern = strings.TrimPrefix(pattern, ".") } else {
w.exactHosts[pattern] = struct{}{}
if pattern == "" {
continue
} }
w.hosts[pattern] = struct{}{}
} }
return w return w
} }
// IsWhitelisted checks if a URL's host is in the whitelist. // IsWhitelisted checks if a URL's host is in the whitelist.
// Only exact host matches are supported.
func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool { func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
if u == nil { if u == nil {
return false return false
@@ -53,17 +51,32 @@ func (w *HostWhitelist) IsWhitelisted(u *url.URL) bool {
return false return false
} }
_, ok := w.hosts[host] // Check exact match
if _, ok := w.exactHosts[host]; ok {
return true
}
return ok // Check suffix match
for _, suffix := range w.suffixHosts {
if strings.HasSuffix(host, suffix) {
return true
}
// Also match if host equals the suffix without the leading dot
// e.g., pattern ".example.com" should match "example.com"
if host == strings.TrimPrefix(suffix, ".") {
return true
}
}
return false
} }
// IsEmpty returns true if the whitelist has no entries. // IsEmpty returns true if the whitelist has no entries.
func (w *HostWhitelist) IsEmpty() bool { func (w *HostWhitelist) IsEmpty() bool {
return len(w.hosts) == 0 return len(w.exactHosts) == 0 && len(w.suffixHosts) == 0
} }
// Count returns the total number of whitelist entries. // Count returns the total number of whitelist entries.
func (w *HostWhitelist) Count() int { func (w *HostWhitelist) Count() int {
return len(w.hosts) return len(w.exactHosts) + len(w.suffixHosts)
} }

View File

@@ -31,46 +31,40 @@ func TestHostWhitelist_IsWhitelisted(t *testing.T) {
want: false, want: false,
}, },
{ {
name: "dot prefix does not enable suffix matching", name: "suffix match",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://cdn.example.com/image.jpg", testURL: "https://cdn.example.com/image.jpg",
want: false, want: true,
}, },
{ {
name: "dot prefix does not match deep subdomain", name: "suffix match deep subdomain",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://cdn.images.example.com/image.jpg", testURL: "https://cdn.images.example.com/image.jpg",
want: false, want: true,
}, },
{ {
name: "dot prefix stripped matches apex domain exactly", name: "suffix match apex domain",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://example.com/image.jpg", testURL: "https://example.com/image.jpg",
want: true, want: true,
}, },
{ {
name: "dot prefix does not match unrelated domain", name: "suffix match not found",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://notexample.com/image.jpg", testURL: "https://notexample.com/image.jpg",
want: false, want: false,
}, },
{ {
name: "dot prefix does not match partial domain", name: "suffix match partial not allowed",
patterns: []string{".example.com"}, patterns: []string{".example.com"},
testURL: "https://fakeexample.com/image.jpg", testURL: "https://fakeexample.com/image.jpg",
want: false, want: false,
}, },
{ {
name: "multiple patterns exact only", name: "multiple patterns",
patterns: []string{"cdn.example.com", "photos.images.org", "static.test.net"},
testURL: "https://photos.images.org/image.jpg",
want: true,
},
{
name: "multiple patterns no suffix match",
patterns: []string{"cdn.example.com", ".images.org", "static.test.net"}, patterns: []string{"cdn.example.com", ".images.org", "static.test.net"},
testURL: "https://photos.images.org/image.jpg", testURL: "https://photos.images.org/image.jpg",
want: false, want: true,
}, },
{ {
name: "empty whitelist", name: "empty whitelist",
@@ -96,12 +90,6 @@ func TestHostWhitelist_IsWhitelisted(t *testing.T) {
testURL: "https://cdn.example.com/image.jpg", testURL: "https://cdn.example.com/image.jpg",
want: true, want: true,
}, },
{
name: "whitespace dot prefix stripped matches exactly",
patterns: []string{" .other.com "},
testURL: "https://other.com/image.jpg",
want: true,
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -151,11 +139,6 @@ func TestHostWhitelist_IsEmpty(t *testing.T) {
patterns: []string{"example.com"}, patterns: []string{"example.com"},
want: false, want: false,
}, },
{
name: "dot prefix entry still counts",
patterns: []string{".example.com"},
want: false,
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -185,7 +168,7 @@ func TestHostWhitelist_Count(t *testing.T) {
want: 3, want: 3,
}, },
{ {
name: "dot prefix hosts treated as exact", name: "suffix hosts only",
patterns: []string{".a.com", ".b.com"}, patterns: []string{".a.com", ".b.com"},
want: 2, want: 2,
}, },
@@ -194,11 +177,6 @@ func TestHostWhitelist_Count(t *testing.T) {
patterns: []string{"exact.com", ".suffix.com"}, patterns: []string{"exact.com", ".suffix.com"},
want: 2, want: 2,
}, },
{
name: "dot prefix deduplicates with exact",
patterns: []string{"example.com", ".example.com"},
want: 1,
},
} }
for _, tt := range tests { for _, tt := range tests {