perf(webapp-server): opt for build over run time (#5644)
Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
parent
05927f3d4d
commit
e025b8c8e1
33 changed files with 977 additions and 2932 deletions
28
devenv.lock
28
devenv.lock
|
|
@ -3,10 +3,10 @@
|
|||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1761922975,
|
||||
"lastModified": 1764669403,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "c9f0b47815a4895fadac87812de8a4de27e0ace1",
|
||||
"rev": "3f2d25e7af748127da0571266054575dd8fec5ab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -24,10 +24,10 @@
|
|||
"rust-analyzer-src": "rust-analyzer-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1762238689,
|
||||
"lastModified": 1764658058,
|
||||
"owner": "nix-community",
|
||||
"repo": "fenix",
|
||||
"rev": "0f94d1e67ea9ef983a9b5caf9c14bc52ae2eac44",
|
||||
"rev": "12bd9c7bcbeb949741b3ad0ca2b3506d0718cf4d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -60,10 +60,10 @@
|
|||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1760663237,
|
||||
"lastModified": 1763988335,
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "ca5b894d3e3e151ffc1db040b6ce4dcc75d31c37",
|
||||
"rev": "50b9238891e388c9fdc6a5c49e49c42533a1b5ce",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -80,10 +80,10 @@
|
|||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"lastModified": 1762808025,
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -94,10 +94,10 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1762156382,
|
||||
"lastModified": 1764611609,
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "7241bcbb4f099a66aafca120d37c65e8dda32717",
|
||||
"rev": "8c29968b3a942f2903f90797f9623737c215737c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -122,10 +122,10 @@
|
|||
"rust-analyzer-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1762201112,
|
||||
"lastModified": 1764603480,
|
||||
"owner": "rust-lang",
|
||||
"repo": "rust-analyzer",
|
||||
"rev": "132d3338f4526b5c71046e5dc7ddf800e279daf4",
|
||||
"rev": "f25db5500baa047106d74962fe361ea59ce6f91e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -142,10 +142,10 @@
|
|||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1762223900,
|
||||
"lastModified": 1764643237,
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "cfe1598d69a42a5edb204770e71b8df77efef2c3",
|
||||
"rev": "e66d6b924ac59e6c722f69332f6540ea57c69233",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ in {
|
|||
e.exec = "emacs";
|
||||
lima-setup.exec = "limactl start template://docker";
|
||||
lima-clean.exec = "limactl rm -f $(limactl ls -q)";
|
||||
colima-start.exec = "colima start --cpu 4 --memory 50";
|
||||
colima-start.exec = "colima start --cpu 8 --memory 50";
|
||||
|
||||
docker-prune.exec = ''
|
||||
echo "Cleaning up unused Docker resources..."
|
||||
|
|
|
|||
6
packages/hoppscotch-selfhost-web/.gitignore
vendored
6
packages/hoppscotch-selfhost-web/.gitignore
vendored
|
|
@ -27,4 +27,8 @@ dist-ssr
|
|||
.sitemap-gen
|
||||
|
||||
# Backend Code generation
|
||||
src/api/generated
|
||||
src/api/generated
|
||||
|
||||
# webapp-server
|
||||
webapp-server/webapp-server
|
||||
webapp-server/.webapp-server
|
||||
|
|
|
|||
|
|
@ -1,21 +1,5 @@
|
|||
# Devenv
|
||||
.devenv*
|
||||
devenv.local.nix
|
||||
# compiled binary
|
||||
webapp-server
|
||||
|
||||
# direnv
|
||||
.direnv
|
||||
|
||||
# pre-commit
|
||||
.pre-commit-config.yaml
|
||||
|
||||
/target/
|
||||
|
||||
/gen/schemas
|
||||
|
||||
.env
|
||||
|
||||
bundles
|
||||
|
||||
trust/
|
||||
|
||||
site/
|
||||
# dev mode key directory
|
||||
.webapp-server/
|
||||
|
|
|
|||
1925
packages/hoppscotch-selfhost-web/webapp-server/Cargo.lock
generated
1925
packages/hoppscotch-selfhost-web/webapp-server/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,26 +0,0 @@
|
|||
[package]
|
||||
name = "webapp-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.7" }
|
||||
base64 = "0.22.1"
|
||||
blake3 = { version = "1.5.4", features = ["serde"] }
|
||||
bytes = "1.8.0"
|
||||
chrono = { version = "0.4.38", features = ["serde"] }
|
||||
ed25519-dalek = { version = "2.1.1", features = ["rand_core", "serde"] }
|
||||
mime_guess = "2.0.5"
|
||||
rand = "0.8.5"
|
||||
rayon = "1.10.0"
|
||||
serde = { version = "1.0.215", features = ["derive"] }
|
||||
serde_json = "1.0.133"
|
||||
thiserror = "2.0.3"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tower-http = { version = "0.6", features = ["compression-zstd", "fs", "trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
walkdir = "2.5.0"
|
||||
zip = "2.2.0"
|
||||
zstd = "0.13.2"
|
||||
|
|
@ -1,33 +1,105 @@
|
|||
# Hoppscotch Webapp Server
|
||||
# Hoppscotch Webapp Server (Go)
|
||||
|
||||
A secure static web server for Hoppscotch Webapp with content bundling (`zstd` + `zip`) and verification (`blake3` + `ed25519`).
|
||||
Static web server for Hoppscotch Webapp with content bundling (zstd + zip) and verification (blake3 + ed25519).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Build from source
|
||||
cargo build --release
|
||||
go build -o webapp-server .
|
||||
GO_ENV=development ./webapp-server
|
||||
```
|
||||
|
||||
# or use Docker
|
||||
or with Docker
|
||||
```bash
|
||||
docker build -t hoppscotch-webapp-server .
|
||||
```
|
||||
|
||||
> [!note]
|
||||
> Configuration via environment variables:
|
||||
> - `FRONTEND_PATH`: UI assets build location
|
||||
> - `DEFAULT_PORT`: Server port (default: 3200)
|
||||
## Configuration
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------------------------------|------------------------------------|------------------------------------------------|
|
||||
| `WEBAPP_SERVER_PORT` | Server port | `3200` |
|
||||
| `FRONTEND_PATH` | Path to frontend assets | `/site/selfhost-web` (prod) or `../dist` (dev) |
|
||||
| `WEBAPP_SERVER_SIGNING_SECRET` | Secret string for key derivation | None |
|
||||
| `WEBAPP_SERVER_SIGNING_SEED` | Base64 encoded 32-byte seed | None |
|
||||
| `WEBAPP_SERVER_SIGNING_KEY` | Base64 encoded 64-byte private key | None |
|
||||
| `WEBAPP_SERVER_SIGNING_KEY_FILE` | Custom path for key file | `/data/webapp-server/signing.key` |
|
||||
| `GO_ENV` | Set to `development` for dev mode | None |
|
||||
|
||||
## Signing Key Persistence
|
||||
|
||||
The server needs a stable signing key. Without one, users get "Invalid signature" errors when they have cached bundles from a previous server instance. Keys are resolved in order:
|
||||
|
||||
1. Environment variable: `WEBAPP_SERVER_SIGNING_KEY`, `WEBAPP_SERVER_SIGNING_SEED`, or `WEBAPP_SERVER_SIGNING_SECRET`
|
||||
2. Key file on disk at `/data/webapp-server/signing.key`
|
||||
3. Auto-generate and persist to disk
|
||||
4. Ephemeral fallback (logs the key for manual config)
|
||||
|
||||
For Kubernetes, either mount a persistent volume at `/data/webapp-server` or set `WEBAPP_SERVER_SIGNING_SECRET` to the same value across replicas.
|
||||
|
||||
If the server can't persist to disk, it logs the generated key:
|
||||
|
||||
```
|
||||
========================================
|
||||
SIGNING KEY PERSISTENCE FAILED
|
||||
========================================
|
||||
Could not save signing key to: /data/webapp-server/signing.key
|
||||
|
||||
To persist this key, set this environment variable:
|
||||
|
||||
WEBAPP_SERVER_SIGNING_KEY=<base64-encoded-key>
|
||||
|
||||
Or mount a persistent volume at:
|
||||
/data/webapp-server
|
||||
========================================
|
||||
```
|
||||
|
||||
Copy the logged key value and set it as an environment variable before the next restart.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- Health check: `GET /health`
|
||||
- Bundle manifest: `GET /api/v1/manifest`
|
||||
- Download bundle: `GET /api/v1/bundle`
|
||||
- Public key info: `GET /api/v1/key`
|
||||
| Endpoint | Description |
|
||||
|------------------------|------------------------------------------------|
|
||||
| `GET /health` | Health check |
|
||||
| `GET /api/v1/manifest` | Bundle metadata with file hashes and signature |
|
||||
| `GET /api/v1/bundle` | Download signed ZIP bundle |
|
||||
| `GET /api/v1/key` | Public verification key |
|
||||
|
||||
All endpoints also available under `/desktop-app-server/` prefix for desktop app compatibility.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Frontend files → zstd ZIP → BLAKE3 per file → ED25519 sign → HTTP serve
|
||||
↓
|
||||
Manifest JSON
|
||||
(paths, sizes, hashes, MIME types)
|
||||
```
|
||||
|
||||
## Bundle Format
|
||||
|
||||
| Component | Method |
|
||||
|----------------|--------------------------|
|
||||
| Compression | zstd (ZIP method 93) |
|
||||
| File hashing | BLAKE3 (base64) |
|
||||
| Bundle signing | ED25519 over ZIP content |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
"Invalid signature" after restart: Server generated a new key because persistence wasn't configured. Mount a volume at `/data/webapp-server` or set `WEBAPP_SERVER_SIGNING_SECRET`.
|
||||
|
||||
"Invalid signature" with multiple replicas: Each replica has a different key. Use env var config with the same secret across all replicas.
|
||||
|
||||
Key file permission errors: Container can't write to `/data/webapp-server`. Make it writable or use env var config.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cargo watch -x run # Dev build with hot reload
|
||||
cargo test # Run tests
|
||||
cargo build --release # Production build
|
||||
GO_ENV=development go run .
|
||||
go test ./...
|
||||
CGO_ENABLED=0 GOOS=linux go build -o webapp-server .
|
||||
```
|
||||
|
||||
## Migration from Rust Version
|
||||
|
||||
Full API and bundle format compatibility with the Rust version. Same ZIP structure, same BLAKE3 hashing, same ED25519 signatures, identical API responses. New feature is automatic signing key persistence.
|
||||
|
|
|
|||
15
packages/hoppscotch-selfhost-web/webapp-server/go.mod
Normal file
15
packages/hoppscotch-selfhost-web/webapp-server/go.mod
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
module hoppscotch-selfhost-web/webapp-server
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.1
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.18.0
|
||||
github.com/zeebo/blake3 v0.2.4
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
)
|
||||
12
packages/hoppscotch-selfhost-web/webapp-server/go.sum
Normal file
12
packages/hoppscotch-selfhost-web/webapp-server/go.sum
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
||||
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
|
||||
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
|
||||
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
|
||||
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
// Package bundle handles creating and managing frontend bundles.
|
||||
//
|
||||
// Bundles are zstd-compressed ZIP archives with blake3 hashes per file
|
||||
// and an ed25519 signature over the whole thing.
|
||||
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
"github.com/zeebo/blake3"
|
||||
)
|
||||
|
||||
// Builder walks frontend files and packs them into a signed bundle
|
||||
type Builder struct{}
|
||||
|
||||
func NewBuilder() (*Builder, error) {
|
||||
return &Builder{}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
// zstd is ZIP method 93
|
||||
// see: https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT
|
||||
zip.RegisterCompressor(ZipMethodZstd, func(w io.Writer) (io.WriteCloser, error) {
|
||||
return zstd.NewWriter(w)
|
||||
})
|
||||
|
||||
// register decompressor for ZIP validation in manager.go
|
||||
zip.RegisterDecompressor(ZipMethodZstd, func(r io.Reader) io.ReadCloser {
|
||||
decoder, err := zstd.NewReader(r)
|
||||
if err != nil {
|
||||
// return a reader that errors on read
|
||||
return errReadCloser{err}
|
||||
}
|
||||
return decoder.IOReadCloser()
|
||||
})
|
||||
}
|
||||
|
||||
// errReadCloser is a ReadCloser that always returns an error on Read.
|
||||
type errReadCloser struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e errReadCloser) Read(p []byte) (int, error) {
|
||||
return 0, e.err
|
||||
}
|
||||
|
||||
func (e errReadCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build walks frontendPath and creates a zstd-compressed ZIP.
|
||||
// Returns the raw bytes, file metadata, and any error.
|
||||
//
|
||||
// NOTE: compression happens at the ZIP level (each file is zstd'd individually),
|
||||
// matching the Rust implementation's approach. This plays nice with partial
|
||||
// downloads if we ever want to support range requests.
|
||||
func (b *Builder) Build(frontendPath string) ([]byte, []FileEntry, error) {
|
||||
if _, err := os.Stat(frontendPath); os.IsNotExist(err) {
|
||||
return nil, nil, fmt.Errorf("frontend path does not exist: %s", frontendPath)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zipWriter := zip.NewWriter(&buf)
|
||||
|
||||
var files []FileEntry
|
||||
var fileCount int
|
||||
|
||||
err := filepath.Walk(frontendPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("error accessing %s: %w", path, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read file %s: %w", path, err)
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(frontendPath, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compute relative path for %s: %w", path, err)
|
||||
}
|
||||
|
||||
// normalize to forward slashes for cross-platform compat
|
||||
normalizedPath := filepath.ToSlash(relPath)
|
||||
|
||||
header := &zip.FileHeader{
|
||||
Name: normalizedPath,
|
||||
Method: ZipMethodZstd,
|
||||
}
|
||||
header.SetMode(0644)
|
||||
|
||||
writer, err := zipWriter.CreateHeader(header)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create ZIP entry for %s: %w", relPath, err)
|
||||
}
|
||||
|
||||
if _, err := writer.Write(content); err != nil {
|
||||
return fmt.Errorf("failed to write file %s to ZIP: %w", relPath, err)
|
||||
}
|
||||
|
||||
// blake3 for file integrity checks
|
||||
hasher := blake3.New()
|
||||
hasher.Write(content)
|
||||
hash := hasher.Sum(nil)
|
||||
|
||||
mimeType := detectMimeType(path)
|
||||
|
||||
files = append(files, FileEntry{
|
||||
Path: normalizedPath,
|
||||
Size: info.Size(),
|
||||
Hash: base64.StdEncoding.EncodeToString(hash),
|
||||
MimeType: mimeType,
|
||||
})
|
||||
|
||||
fileCount++
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to finalize ZIP archive: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Built bundle with %d files (%d bytes)", fileCount, buf.Len())
|
||||
return buf.Bytes(), files, nil
|
||||
}
|
||||
|
||||
// detectMimeType guesses MIME type from extension.
|
||||
// Returns nil if unknown (matches Rust's Option<String> behavior).
|
||||
func detectMimeType(path string) *string {
|
||||
ext := filepath.Ext(path)
|
||||
if ext == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// try Go's builtin mime registry first
|
||||
mimeType := mime.TypeByExtension(ext)
|
||||
if mimeType != "" {
|
||||
// strip params like "; charset=utf-8"
|
||||
if idx := strings.Index(mimeType, ";"); idx != -1 {
|
||||
mimeType = strings.TrimSpace(mimeType[:idx])
|
||||
}
|
||||
return &mimeType
|
||||
}
|
||||
|
||||
// handle web-specific types Go doesn't know about
|
||||
switch strings.ToLower(ext) {
|
||||
case ".wasm":
|
||||
m := "application/wasm"
|
||||
return &m
|
||||
case ".mjs":
|
||||
m := "application/javascript"
|
||||
return &m
|
||||
case ".tsx", ".ts":
|
||||
m := "application/typescript"
|
||||
return &m
|
||||
case ".vue":
|
||||
m := "application/vue"
|
||||
return &m
|
||||
case ".svelte":
|
||||
m := "application/svelte"
|
||||
return &m
|
||||
case ".json5":
|
||||
m := "application/json5"
|
||||
return &m
|
||||
case ".webmanifest":
|
||||
m := "application/manifest+json"
|
||||
return &m
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
package bundle
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Manager holds the bundle in memory and handles signing.
|
||||
// Thread-safe for concurrent reads (writes only happen at startup).
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
bundle *Bundle
|
||||
maxSize int
|
||||
signingKey ed25519.PrivateKey
|
||||
verifyingKey ed25519.PublicKey
|
||||
}
|
||||
|
||||
// NewManager creates a manager with a pre-built bundle.
|
||||
// Signs the bundle content immediately so it's ready to serve.
|
||||
func NewManager(
|
||||
content []byte,
|
||||
files []FileEntry,
|
||||
signingKey ed25519.PrivateKey,
|
||||
verifyingKey ed25519.PublicKey,
|
||||
maxSize int,
|
||||
) (*Manager, error) {
|
||||
if len(content) > maxSize {
|
||||
return nil, fmt.Errorf("bundle too large: %d bytes (max: %d)", len(content), maxSize)
|
||||
}
|
||||
|
||||
// sanity check that we actually have a valid zip
|
||||
if _, err := zip.NewReader(bytes.NewReader(content), int64(len(content))); err != nil {
|
||||
return nil, fmt.Errorf("invalid zip archive: %w", err)
|
||||
}
|
||||
|
||||
// sign the raw bytes, clients will verify against this
|
||||
signature := ed25519.Sign(signingKey, content)
|
||||
|
||||
bundle := &Bundle{
|
||||
Metadata: Metadata{
|
||||
Version: Version,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Signature: base64.StdEncoding.EncodeToString(signature),
|
||||
Manifest: Manifest{Files: files},
|
||||
},
|
||||
Content: content,
|
||||
}
|
||||
|
||||
log.Println("Bundle signed and stored successfully")
|
||||
|
||||
return &Manager{
|
||||
bundle: bundle,
|
||||
maxSize: maxSize,
|
||||
signingKey: signingKey,
|
||||
verifyingKey: verifyingKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetBundle() *Bundle {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.bundle
|
||||
}
|
||||
|
||||
func (m *Manager) GetVerifyingKey() ed25519.PublicKey {
|
||||
return m.verifyingKey
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package bundle
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
Version = "2025.12.0"
|
||||
|
||||
DefaultMaxSize = 50 * 1024 * 1024
|
||||
|
||||
// zstd compression method for ZIP
|
||||
// see: https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT
|
||||
ZipMethodZstd = 93
|
||||
)
|
||||
|
||||
type FileEntry struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Hash string `json:"hash"` // blake3, base64 encoded
|
||||
MimeType *string `json:"mime_type"` // nil if unknown
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
Files []FileEntry `json:"files"`
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
Version string `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Signature string `json:"signature"` // ed25519 over Content, base64 encoded
|
||||
Manifest Manifest `json:"manifest"`
|
||||
}
|
||||
|
||||
type Bundle struct {
|
||||
Metadata Metadata
|
||||
Content []byte // raw ZIP bytes
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultPort = 3200
|
||||
DefaultFrontendPath = "/site/selfhost-web"
|
||||
DevFrontendPath = "../dist"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port int
|
||||
FrontendPath string
|
||||
}
|
||||
|
||||
// Load reads config from env vars with sensible defaults
|
||||
func Load() *Config {
|
||||
cfg := &Config{
|
||||
Port: DefaultPort,
|
||||
}
|
||||
|
||||
if portStr := os.Getenv("WEBAPP_SERVER_PORT"); portStr != "" {
|
||||
if port, err := strconv.Atoi(portStr); err == nil {
|
||||
cfg.Port = port
|
||||
log.Printf("Using WEBAPP_SERVER_PORT from environment: %d", port)
|
||||
} else {
|
||||
log.Printf("Warning: Invalid WEBAPP_SERVER_PORT value '%s', using default %d", portStr, DefaultPort)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Using default port: %d", DefaultPort)
|
||||
}
|
||||
|
||||
// NOTE: env var takes priority, then we check GO_ENV for dev mode
|
||||
if frontendPath := os.Getenv("FRONTEND_PATH"); frontendPath != "" {
|
||||
cfg.FrontendPath = frontendPath
|
||||
log.Printf("Using FRONTEND_PATH from environment: %s", frontendPath)
|
||||
} else if os.Getenv("GO_ENV") == "development" {
|
||||
cfg.FrontendPath = DevFrontendPath
|
||||
log.Println("Running in development mode, using frontend path: ../dist")
|
||||
} else {
|
||||
cfg.FrontendPath = DefaultFrontendPath
|
||||
log.Println("Running in production mode, using frontend path: /site/selfhost-web")
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
// Package crypto handles ed25519 key generation and persistence.
|
||||
//
|
||||
// Key sources (in priority order):
|
||||
// 1. WEBAPP_SERVER_SIGNING_KEY: full 64-byte private key, base64
|
||||
// 2. WEBAPP_SERVER_SIGNING_SEED: 32-byte seed, base64
|
||||
// 3. WEBAPP_SERVER_SIGNING_SECRET: any string (SHA-256 derived)
|
||||
// 4. Key file on disk
|
||||
// 5. Generate new and try to persist
|
||||
//
|
||||
// For production, either mount a volume at /data/webapp-server
|
||||
// or set one of the WEBAPP_SERVER_SIGNING_* env vars.
|
||||
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultKeyFileName = "signing.key"
|
||||
DefaultKeyDir = "/data/webapp-server"
|
||||
DevKeyDir = ".webapp-server"
|
||||
)
|
||||
|
||||
type KeyPair struct {
|
||||
SigningKey ed25519.PrivateKey
|
||||
VerifyingKey ed25519.PublicKey
|
||||
}
|
||||
|
||||
// GenerateKeyPair gets or creates an ed25519 key pair.
|
||||
// Tries env vars first, then disk, then generates new.
|
||||
func GenerateKeyPair() (*KeyPair, error) {
|
||||
// try env vars first (explicit config always wins)
|
||||
if keyB64 := os.Getenv("WEBAPP_SERVER_SIGNING_KEY"); keyB64 != "" {
|
||||
return loadFromBase64Key(keyB64)
|
||||
}
|
||||
|
||||
if seedB64 := os.Getenv("WEBAPP_SERVER_SIGNING_SEED"); seedB64 != "" {
|
||||
return loadFromBase64Seed(seedB64)
|
||||
}
|
||||
|
||||
if secret := os.Getenv("WEBAPP_SERVER_SIGNING_SECRET"); secret != "" {
|
||||
return deriveFromSecret(secret)
|
||||
}
|
||||
|
||||
// try loading from disk
|
||||
keyPath := getKeyFilePath()
|
||||
if kp, err := loadFromFile(keyPath); err == nil {
|
||||
return kp, nil
|
||||
}
|
||||
|
||||
// nothing found, generate fresh and try to persist
|
||||
return generateAndPersist(keyPath)
|
||||
}
|
||||
|
||||
func getKeyFilePath() string {
|
||||
if path := os.Getenv("WEBAPP_SERVER_SIGNING_KEY_FILE"); path != "" {
|
||||
return path
|
||||
}
|
||||
|
||||
var keyDir string
|
||||
if isDevMode() {
|
||||
keyDir = DevKeyDir
|
||||
} else {
|
||||
keyDir = DefaultKeyDir
|
||||
}
|
||||
|
||||
return filepath.Join(keyDir, DefaultKeyFileName)
|
||||
}
|
||||
|
||||
func loadFromFile(path string) (*KeyPair, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(string(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid key file format: %w", err)
|
||||
}
|
||||
|
||||
if len(keyBytes) != ed25519.PrivateKeySize {
|
||||
return nil, fmt.Errorf("invalid key length in file: expected %d, got %d", ed25519.PrivateKeySize, len(keyBytes))
|
||||
}
|
||||
|
||||
priv := ed25519.PrivateKey(keyBytes)
|
||||
pub := priv.Public().(ed25519.PublicKey)
|
||||
|
||||
log.Printf("Loaded signing key from file: %s", path)
|
||||
log.Printf("Verifying key: %s", base64.StdEncoding.EncodeToString(pub))
|
||||
|
||||
return &KeyPair{
|
||||
SigningKey: priv,
|
||||
VerifyingKey: pub,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func saveToFile(path string, priv ed25519.PrivateKey) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return fmt.Errorf("failed to create key directory: %w", err)
|
||||
}
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString(priv)
|
||||
if err := os.WriteFile(path, []byte(encoded), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write key file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateAndPersist creates a new key and tries to save it.
|
||||
// If we can't persist, we log the key so operators can set it manually.
|
||||
func generateAndPersist(keyPath string) (*KeyPair, error) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate key pair: %w", err)
|
||||
}
|
||||
|
||||
kp := &KeyPair{
|
||||
SigningKey: priv,
|
||||
VerifyingKey: pub,
|
||||
}
|
||||
|
||||
if err := saveToFile(keyPath, priv); err == nil {
|
||||
log.Printf("Generated and saved signing key to: %s", keyPath)
|
||||
log.Printf("Verifying key: %s", base64.StdEncoding.EncodeToString(pub))
|
||||
return kp, nil
|
||||
}
|
||||
|
||||
// couldn't persist, log the key so it can be set via env var
|
||||
// this is annoying but better than silent failures
|
||||
keyB64 := base64.StdEncoding.EncodeToString(priv)
|
||||
|
||||
log.Println("========================================")
|
||||
log.Println("SIGNING KEY PERSISTENCE FAILED")
|
||||
log.Println("========================================")
|
||||
log.Printf("Could not save signing key to: %s", keyPath)
|
||||
log.Println("")
|
||||
log.Println("This key will be lost on restart, causing")
|
||||
log.Println("'Invalid signature' errors for users with")
|
||||
log.Println("cached bundles.")
|
||||
log.Println("")
|
||||
log.Println("To persist this key, set this environment variable:")
|
||||
log.Println("")
|
||||
log.Printf(" WEBAPP_SERVER_SIGNING_KEY=%s", keyB64)
|
||||
log.Println("")
|
||||
log.Println("Or mount a persistent volume at:")
|
||||
log.Printf(" %s", filepath.Dir(keyPath))
|
||||
log.Println("========================================")
|
||||
|
||||
log.Printf("Verifying key: %s", base64.StdEncoding.EncodeToString(pub))
|
||||
|
||||
return kp, nil
|
||||
}
|
||||
|
||||
func loadFromBase64Key(keyB64 string) (*KeyPair, error) {
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(keyB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid WEBAPP_SERVER_SIGNING_KEY: %w", err)
|
||||
}
|
||||
|
||||
if len(keyBytes) != ed25519.PrivateKeySize {
|
||||
return nil, fmt.Errorf("WEBAPP_SERVER_SIGNING_KEY must be %d bytes, got %d", ed25519.PrivateKeySize, len(keyBytes))
|
||||
}
|
||||
|
||||
priv := ed25519.PrivateKey(keyBytes)
|
||||
pub := priv.Public().(ed25519.PublicKey)
|
||||
|
||||
log.Printf("Loaded signing key from WEBAPP_SERVER_SIGNING_KEY")
|
||||
log.Printf("Verifying key: %s", base64.StdEncoding.EncodeToString(pub))
|
||||
|
||||
return &KeyPair{
|
||||
SigningKey: priv,
|
||||
VerifyingKey: pub,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadFromBase64Seed(seedB64 string) (*KeyPair, error) {
|
||||
seedBytes, err := base64.StdEncoding.DecodeString(seedB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid WEBAPP_SERVER_SIGNING_SEED: %w", err)
|
||||
}
|
||||
|
||||
if len(seedBytes) != ed25519.SeedSize {
|
||||
return nil, fmt.Errorf("WEBAPP_SERVER_SIGNING_SEED must be %d bytes, got %d", ed25519.SeedSize, len(seedBytes))
|
||||
}
|
||||
|
||||
priv := ed25519.NewKeyFromSeed(seedBytes)
|
||||
pub := priv.Public().(ed25519.PublicKey)
|
||||
|
||||
log.Printf("Derived signing key from WEBAPP_SERVER_SIGNING_SEED")
|
||||
log.Printf("Verifying key: %s", base64.StdEncoding.EncodeToString(pub))
|
||||
|
||||
return &KeyPair{
|
||||
SigningKey: priv,
|
||||
VerifyingKey: pub,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// deriveFromSecret hashes an arbitrary string to get a seed.
|
||||
// Simple but works for shared secrets across replicas.
|
||||
func deriveFromSecret(secret string) (*KeyPair, error) {
|
||||
hash := sha256.Sum256([]byte(secret))
|
||||
priv := ed25519.NewKeyFromSeed(hash[:])
|
||||
pub := priv.Public().(ed25519.PublicKey)
|
||||
|
||||
log.Printf("Derived signing key from WEBAPP_SERVER_SIGNING_SECRET")
|
||||
log.Printf("Verifying key: %s", base64.StdEncoding.EncodeToString(pub))
|
||||
|
||||
return &KeyPair{
|
||||
SigningKey: priv,
|
||||
VerifyingKey: pub,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// isDevMode returns true if GO_ENV=development.
|
||||
func isDevMode() bool {
|
||||
return os.Getenv("GO_ENV") == "development"
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
// Package server handles HTTP endpoints for bundle distribution.
|
||||
//
|
||||
// Endpoints:
|
||||
// GET /health - health check
|
||||
// GET /api/v1/manifest - bundle metadata (files, hashes, signature)
|
||||
// GET /api/v1/bundle - download the actual ZIP
|
||||
// GET /api/v1/key - public key for signature verification
|
||||
//
|
||||
// All endpoints also available under /desktop-app-server/ for backwards compat.
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"hoppscotch-selfhost-web/webapp-server/internal/bundle"
|
||||
)
|
||||
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
bundleManager *bundle.Manager
|
||||
}
|
||||
|
||||
func New(bundleManager *bundle.Manager) *Server {
|
||||
return &Server{
|
||||
bundleManager: bundleManager,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) HandleManifest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
s.writeErrorResponse(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Fetching bundle manifest")
|
||||
|
||||
b := s.bundleManager.GetBundle()
|
||||
|
||||
response := Response{
|
||||
Success: true,
|
||||
Data: b.Metadata,
|
||||
Code: http.StatusOK,
|
||||
}
|
||||
|
||||
s.writeJSONResponse(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) HandleDownloadBundle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Starting bundle download")
|
||||
|
||||
b := s.bundleManager.GetBundle()
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(b.Content)))
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\"bundle.zip\"")
|
||||
|
||||
if _, err := w.Write(b.Content); err != nil {
|
||||
log.Printf("Error writing bundle response: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Successfully sent bundle for download (size: %d bytes)", len(b.Content))
|
||||
}
|
||||
|
||||
func (s *Server) HandleKey(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
s.writeErrorResponse(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("Listing public key")
|
||||
|
||||
keyInfo := map[string]string{
|
||||
"key": base64.StdEncoding.EncodeToString(s.bundleManager.GetVerifyingKey()),
|
||||
}
|
||||
|
||||
response := Response{
|
||||
Success: true,
|
||||
Data: keyInfo,
|
||||
Code: http.StatusOK,
|
||||
}
|
||||
|
||||
s.writeJSONResponse(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
// writeJSONResponse buffers the response first to avoid partial writes on error
|
||||
func (s *Server) writeJSONResponse(w http.ResponseWriter, response Response, statusCode int) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(response); err != nil {
|
||||
log.Printf("Error encoding JSON response: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
if _, err := w.Write(buf.Bytes()); err != nil {
|
||||
log.Printf("Error writing response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeErrorResponse(w http.ResponseWriter, message string, statusCode int) {
|
||||
response := Response{
|
||||
Success: false,
|
||||
Error: message,
|
||||
Code: statusCode,
|
||||
}
|
||||
s.writeJSONResponse(w, response, statusCode)
|
||||
}
|
||||
|
||||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/health", s.HandleHealth)
|
||||
mux.HandleFunc("/api/v1/manifest", s.HandleManifest)
|
||||
mux.HandleFunc("/api/v1/bundle", s.HandleDownloadBundle)
|
||||
mux.HandleFunc("/api/v1/key", s.HandleKey)
|
||||
|
||||
// desktop app backwards compat
|
||||
mux.HandleFunc("/desktop-app-server/api/v1/manifest", s.HandleManifest)
|
||||
mux.HandleFunc("/desktop-app-server/api/v1/bundle", s.HandleDownloadBundle)
|
||||
mux.HandleFunc("/desktop-app-server/api/v1/key", s.HandleKey)
|
||||
}
|
||||
98
packages/hoppscotch-selfhost-web/webapp-server/main.go
Normal file
98
packages/hoppscotch-selfhost-web/webapp-server/main.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// Hoppscotch Webapp Server
|
||||
//
|
||||
// Builds a signed bundle from frontend assets and serves it over HTTP.
|
||||
// The bundle is zstd-compressed and signed with ed25519 so clients can verify integrity.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"hoppscotch-selfhost-web/webapp-server/internal/bundle"
|
||||
"hoppscotch-selfhost-web/webapp-server/internal/config"
|
||||
"hoppscotch-selfhost-web/webapp-server/internal/crypto"
|
||||
"hoppscotch-selfhost-web/webapp-server/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.Println("Initializing Hoppscotch Web Static Server")
|
||||
|
||||
cfg := config.Load()
|
||||
|
||||
// NOTE: key generation handles persistence internally
|
||||
// it'll try env vars first, then disk, then generate new
|
||||
keyPair, err := crypto.GenerateKeyPair()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate key pair: %v", err)
|
||||
}
|
||||
|
||||
builder, err := bundle.NewBuilder()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create bundle builder: %v", err)
|
||||
}
|
||||
|
||||
// this walks the frontend dir and creates a zstd-compressed zip
|
||||
content, files, err := builder.Build(cfg.FrontendPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to build bundle: %v", err)
|
||||
}
|
||||
|
||||
// manager holds the bundle in memory and handles signing
|
||||
bundleManager, err := bundle.NewManager(
|
||||
content,
|
||||
files,
|
||||
keyPair.SigningKey,
|
||||
keyPair.VerifyingKey,
|
||||
bundle.DefaultMaxSize,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create bundle manager: %v", err)
|
||||
}
|
||||
|
||||
srv := server.New(bundleManager)
|
||||
mux := http.NewServeMux()
|
||||
srv.RegisterRoutes(mux)
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||
|
||||
// NOTE: these timeouts are pretty conservative
|
||||
// bump them if you're serving huge bundles over slow connections
|
||||
httpServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("Server starting on %s", addr)
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// wait for shutdown signal
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("Server shutting down...")
|
||||
|
||||
// give in-flight requests 30s to finish
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := httpServer.Shutdown(ctx); err != nil {
|
||||
log.Printf("Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Server stopped")
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
use axum::http::StatusCode;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApiError {
|
||||
#[error("Failed to download bundle: {0}")]
|
||||
DownloadFailed(String),
|
||||
|
||||
#[error("Invalid request: {0}")]
|
||||
InvalidRequest(String),
|
||||
|
||||
#[error(transparent)]
|
||||
Bundle(#[from] crate::bundle::BundleError),
|
||||
|
||||
#[error(transparent)]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Axum(#[from] axum::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ApiError>;
|
||||
|
||||
impl axum::response::IntoResponse for ApiError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let (status, error_message) = match &self {
|
||||
ApiError::DownloadFailed(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||
ApiError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
_ => {
|
||||
tracing::error!(error = ?self, "Internal server error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Internal server error".to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let body = serde_json::json!({
|
||||
"success": false,
|
||||
"error": error_message,
|
||||
"code": status.as_u16()
|
||||
});
|
||||
|
||||
(status, axum::Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
|
||||
use super::error::{ApiError, Result};
|
||||
use super::model::{ApiResponse, BundleManifest, PublicKeyInfo};
|
||||
use crate::bundle::BundleManager;
|
||||
|
||||
pub struct ApiHandler {
|
||||
pub bundle_manager: Arc<BundleManager>,
|
||||
}
|
||||
|
||||
impl ApiHandler {
|
||||
pub fn new(bundle_manager: Arc<BundleManager>) -> Self {
|
||||
Self { bundle_manager }
|
||||
}
|
||||
|
||||
pub async fn get_manifest(&self) -> Result<impl IntoResponse> {
|
||||
tracing::info!("Fetching bundle manifest");
|
||||
let bundle = self.bundle_manager.bundle().await;
|
||||
|
||||
let version = bundle.metadata.version;
|
||||
let created_at = bundle.metadata.created_at;
|
||||
let signature = bundle.metadata.signature;
|
||||
let manifest = bundle.metadata.manifest;
|
||||
|
||||
let manifest = BundleManifest {
|
||||
version,
|
||||
created_at,
|
||||
signature,
|
||||
manifest,
|
||||
};
|
||||
|
||||
tracing::info!("Successfully retrieved bundle manifest");
|
||||
|
||||
Ok(Json(ApiResponse::ok(manifest)))
|
||||
}
|
||||
|
||||
pub async fn download_bundle(&self) -> Result<impl IntoResponse> {
|
||||
tracing::info!("Starting bundle download");
|
||||
let bundle = self.bundle_manager.bundle().await;
|
||||
|
||||
let response = Response::builder()
|
||||
.header("content-type", "application/zip")
|
||||
.header("content-length", bundle.content.len().to_string())
|
||||
.header("content-disposition", "attachment; filename=\"bundle.zip\"")
|
||||
.body(Body::from(bundle.content.clone()))
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = ?e, "Failed to create download response");
|
||||
ApiError::DownloadFailed(e.to_string())
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
content_length = bundle.content.len(),
|
||||
"Successfully prepared bundle for download"
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn key(&self) -> Result<impl IntoResponse> {
|
||||
tracing::info!("Listing public key");
|
||||
let server_config = self.bundle_manager.server_config();
|
||||
let verifying_key = server_config.verifying_key;
|
||||
|
||||
let verifying_key = verifying_key.as_ref().ok_or_else(|| {
|
||||
tracing::error!("No signing key configured");
|
||||
ApiError::InvalidRequest("No signing key configured".into())
|
||||
})?;
|
||||
|
||||
let verifying_key = STANDARD.encode(verifying_key.to_bytes());
|
||||
tracing::debug!(verifying_key = ?verifying_key);
|
||||
|
||||
let key_info = PublicKeyInfo { key: verifying_key };
|
||||
|
||||
tracing::info!("Successfully retrieved public key info");
|
||||
Ok(Json(ApiResponse::ok(key_info)))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::{extract::State, routing::get, Router};
|
||||
|
||||
mod error;
|
||||
mod handler;
|
||||
mod model;
|
||||
|
||||
use error::Result;
|
||||
use handler::ApiHandler;
|
||||
|
||||
pub fn routes(bundle_manager: Arc<crate::bundle::BundleManager>) -> Router {
|
||||
tracing::info!("Setting up API routes");
|
||||
let handler = Arc::new(ApiHandler::new(bundle_manager));
|
||||
|
||||
let api_routes = Router::new()
|
||||
.route("/api/v1/manifest", get(get_manifest))
|
||||
.route("/api/v1/bundle", get(download_bundle))
|
||||
.route("/api/v1/key", get(key))
|
||||
.with_state(handler.clone());
|
||||
|
||||
// NOTE: A hack to allow subpath access override
|
||||
let desktop_app_routes = Router::new()
|
||||
.nest("/desktop-app-server", api_routes.clone());
|
||||
|
||||
api_routes.merge(desktop_app_routes)
|
||||
}
|
||||
|
||||
async fn get_manifest(
|
||||
State(handler): State<Arc<ApiHandler>>,
|
||||
) -> Result<impl axum::response::IntoResponse> {
|
||||
tracing::debug!("Received request for bundle manifest");
|
||||
handler.get_manifest().await
|
||||
}
|
||||
|
||||
async fn download_bundle(
|
||||
State(handler): State<Arc<ApiHandler>>,
|
||||
) -> Result<impl axum::response::IntoResponse> {
|
||||
tracing::debug!("Received request to download bundle");
|
||||
handler.download_bundle().await
|
||||
}
|
||||
|
||||
async fn key(State(handler): State<Arc<ApiHandler>>) -> Result<impl axum::response::IntoResponse> {
|
||||
tracing::debug!("Received request to list public keys");
|
||||
handler.key().await
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use ed25519_dalek::Signature;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::model::Manifest;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ApiResponse<T> {
|
||||
pub success: bool,
|
||||
pub data: T,
|
||||
}
|
||||
|
||||
impl<T> ApiResponse<T> {
|
||||
pub fn ok(data: T) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BundleManifest {
|
||||
pub version: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(with = "signature_serde")]
|
||||
pub signature: Signature,
|
||||
pub manifest: Manifest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PublicKeyInfo {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
mod signature_serde {
|
||||
use super::*;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
|
||||
pub fn serialize<S>(sig: &Signature, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&STANDARD.encode(sig.to_bytes()))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
use rayon::prelude::*;
|
||||
use std::io::{Cursor, Write};
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter};
|
||||
|
||||
use super::error::{BundleError, Result};
|
||||
use crate::model::FileEntry;
|
||||
|
||||
pub struct BundleBuilder {
|
||||
writer: ZipWriter<Cursor<Vec<u8>>>,
|
||||
files: Vec<FileEntry>,
|
||||
}
|
||||
|
||||
impl BundleBuilder {
|
||||
pub fn new<P: AsRef<Path>>(frontend_path: P) -> Result<Self> {
|
||||
let frontend_path = frontend_path.as_ref();
|
||||
|
||||
if !frontend_path.exists() {
|
||||
return Err(BundleError::Config(format!(
|
||||
"Frontend path {} does not exist",
|
||||
frontend_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
struct FileInfo {
|
||||
relative_path: String,
|
||||
content: Vec<u8>,
|
||||
hash: blake3::Hash,
|
||||
size: u64,
|
||||
mime_type: Option<String>,
|
||||
}
|
||||
|
||||
let file_infos: Vec<FileInfo> = WalkDir::new(frontend_path)
|
||||
.into_iter()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| entry.file_type().is_file())
|
||||
.par_bridge()
|
||||
.map(|entry| {
|
||||
let path = entry.path();
|
||||
let relative_path = path
|
||||
.strip_prefix(frontend_path)
|
||||
.unwrap()
|
||||
.components()
|
||||
.map(|comp| comp.as_os_str().to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
|
||||
let content = std::fs::read(path).map_err(|e| {
|
||||
BundleError::Config(format!("Failed to read file {}: {}", path.display(), e))
|
||||
})?;
|
||||
|
||||
let hash = blake3::hash(&content);
|
||||
let size = content.len() as u64;
|
||||
|
||||
let mime_type = mime_guess::from_path(path)
|
||||
.first()
|
||||
.map(|mime| mime.to_string());
|
||||
|
||||
Ok(FileInfo {
|
||||
relative_path,
|
||||
content,
|
||||
hash,
|
||||
size,
|
||||
mime_type,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
let mut builder = Self {
|
||||
writer: ZipWriter::new(Cursor::new(Vec::new())),
|
||||
files: Vec::with_capacity(file_infos.len()),
|
||||
};
|
||||
|
||||
for file_info in file_infos {
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(CompressionMethod::Zstd)
|
||||
.unix_permissions(0o644);
|
||||
|
||||
builder
|
||||
.writer
|
||||
.start_file(&file_info.relative_path, options)
|
||||
.map_err(|e| BundleError::Config(format!("Failed to start file in zip: {}", e)))?;
|
||||
|
||||
builder
|
||||
.writer
|
||||
.write_all(&file_info.content)
|
||||
.map_err(|e| BundleError::Config(format!("Failed to write file to zip: {}", e)))?;
|
||||
|
||||
builder.files.push(FileEntry {
|
||||
path: file_info.relative_path,
|
||||
size: file_info.size,
|
||||
hash: file_info.hash,
|
||||
mime_type: file_info.mime_type,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(builder)
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Result<(Vec<u8>, Vec<FileEntry>)> {
|
||||
let writer = self
|
||||
.writer
|
||||
.finish()
|
||||
.map_err(|e| BundleError::Config(format!("Failed to finish zip archive: {}", e)))?;
|
||||
|
||||
Ok((writer.into_inner(), self.files))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum BundleError {
|
||||
#[error("Bundle not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Bundle validation failed: {0}")]
|
||||
ValidationFailed(String),
|
||||
|
||||
#[error("Path access error: {0}")]
|
||||
PathAccess(String),
|
||||
|
||||
#[error("Unknown public key: {0}")]
|
||||
UnknownKey(String),
|
||||
|
||||
#[error("Bundle too large: {size} bytes (max: {max} bytes)")]
|
||||
TooLarge { size: usize, max: usize },
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Zip(#[from] zip::result::ZipError),
|
||||
|
||||
#[error(transparent)]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Signing(#[from] crate::signing::SigningError),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, BundleError>;
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ed25519_dalek::Signer;
|
||||
use tokio::sync::RwLock;
|
||||
use zip::ZipArchive;
|
||||
|
||||
use super::error::{BundleError, Result};
|
||||
use super::model::Bundle;
|
||||
use crate::config::ServerConfig;
|
||||
use crate::model::FileEntry;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BundleManager {
|
||||
current_bundle: Arc<RwLock<Bundle>>,
|
||||
config: Arc<ServerConfig>,
|
||||
}
|
||||
|
||||
impl BundleManager {
|
||||
pub fn new(config: Arc<ServerConfig>, content: Vec<u8>, files: Vec<FileEntry>) -> Result<Self> {
|
||||
tracing::info!("Initializing BundleManager with a new bundle");
|
||||
|
||||
{ content.len() <= config.max_bundle_size }
|
||||
.then_some(())
|
||||
.ok_or_else(|| {
|
||||
let err = BundleError::TooLarge {
|
||||
size: content.len(),
|
||||
max: config.max_bundle_size,
|
||||
};
|
||||
tracing::error!(
|
||||
size = content.len(),
|
||||
max_size = config.max_bundle_size,
|
||||
"Bundle exceeds size limit"
|
||||
);
|
||||
err
|
||||
})?;
|
||||
|
||||
ZipArchive::new(Cursor::new(&content)).map_err(|e| {
|
||||
tracing::error!(error = ?e, "Invalid ZIP archive");
|
||||
BundleError::Zip(e)
|
||||
})?;
|
||||
|
||||
let signing_key = config.signing_key.as_ref().ok_or_else(|| {
|
||||
tracing::error!("No signing key configured");
|
||||
BundleError::Config("No signing key configured".into())
|
||||
})?;
|
||||
let signature = signing_key.sign(&content);
|
||||
|
||||
let bundle_version = &config.bundle_version;
|
||||
let bundle = Bundle::new(bundle_version.clone(), content, signature, files);
|
||||
tracing::info!("Successfully created initial bundle");
|
||||
|
||||
Ok(Self {
|
||||
current_bundle: Arc::new(RwLock::new(bundle)),
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn server_config(&self) -> &ServerConfig {
|
||||
tracing::info!("Retrieving the current config");
|
||||
&self.config
|
||||
}
|
||||
|
||||
pub async fn bundle(&self) -> Bundle {
|
||||
tracing::info!("Retrieving the current bundle");
|
||||
self.current_bundle.read().await.clone()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
mod builder;
|
||||
mod error;
|
||||
mod manager;
|
||||
mod model;
|
||||
|
||||
pub use builder::BundleBuilder;
|
||||
pub use error::BundleError;
|
||||
pub use manager::BundleManager;
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use ed25519_dalek::Signature;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model::{FileEntry, Manifest};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BundleMetadata {
|
||||
pub version: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(with = "signature_serde")]
|
||||
pub signature: Signature,
|
||||
pub manifest: Manifest,
|
||||
}
|
||||
|
||||
mod signature_serde {
|
||||
use super::*;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
|
||||
pub fn serialize<S>(sig: &Signature, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&STANDARD.encode(sig.to_bytes()))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Signature, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Error;
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let bytes = STANDARD.decode(&s).map_err(D::Error::custom)?;
|
||||
let bytes: [u8; 64] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| D::Error::custom("invalid signature length"))?;
|
||||
Ok(Signature::from_bytes(&bytes))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Bundle {
|
||||
pub metadata: BundleMetadata,
|
||||
pub content: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Bundle {
|
||||
pub fn new(bundle_version: Option<String>, content: Vec<u8>, signature: Signature, files: Vec<FileEntry>) -> Self {
|
||||
let metadata = BundleMetadata {
|
||||
version: "2025.11.2".to_string(),
|
||||
created_at: Utc::now(),
|
||||
signature,
|
||||
manifest: Manifest { files },
|
||||
};
|
||||
|
||||
Self { metadata, content }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::signing::SigningKeyPair;
|
||||
|
||||
pub const DEFAULT_MAX_BUNDLE_SIZE: usize = 50 * 1024 * 1024; // 50MB
|
||||
pub const DEFAULT_PORT: u16 = 3200;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
|
||||
#[serde(default = "default_max_bundle_size")]
|
||||
pub max_bundle_size: usize,
|
||||
|
||||
#[serde(default)]
|
||||
pub bundle_version: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub csp_directives: Option<String>,
|
||||
|
||||
#[serde(skip)]
|
||||
pub signing_key: Option<SigningKey>,
|
||||
|
||||
#[serde(skip)]
|
||||
pub verifying_key: Option<VerifyingKey>,
|
||||
|
||||
#[serde(default = "default_frontend_path")]
|
||||
pub frontend_path: String,
|
||||
|
||||
#[serde(default = "default_is_dev")]
|
||||
pub is_dev: bool,
|
||||
}
|
||||
|
||||
fn default_port() -> u16 {
|
||||
DEFAULT_PORT
|
||||
}
|
||||
|
||||
fn default_max_bundle_size() -> usize {
|
||||
DEFAULT_MAX_BUNDLE_SIZE
|
||||
}
|
||||
|
||||
fn default_frontend_path() -> String {
|
||||
"/site/selfhost-web".to_string()
|
||||
}
|
||||
|
||||
fn default_is_dev() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
port: default_port(),
|
||||
max_bundle_size: default_max_bundle_size(),
|
||||
bundle_version: Some("2025.11.2".to_string()),
|
||||
csp_directives: None,
|
||||
signing_key: None,
|
||||
verifying_key: None,
|
||||
frontend_path: default_frontend_path(),
|
||||
is_dev: default_is_dev(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerConfig {
|
||||
pub fn load(key_pair: SigningKeyPair) -> Self {
|
||||
let frontend_path = if cfg!(debug_assertions) {
|
||||
"../dist".to_string()
|
||||
} else {
|
||||
"/site/selfhost-web".to_string()
|
||||
};
|
||||
|
||||
Self {
|
||||
signing_key: Some(key_pair.signing_key),
|
||||
verifying_key: Some(key_pair.verifying_key),
|
||||
bundle_version: Some("2025.11.2".to_string()),
|
||||
frontend_path,
|
||||
is_dev: cfg!(debug_assertions),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn frontend_path(&self) -> &str {
|
||||
&self.frontend_path
|
||||
}
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Bundle not found: {0}")]
|
||||
BundleNotFound(String),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
ValidationError(String),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error(transparent)]
|
||||
Bundle(#[from] crate::bundle::BundleError),
|
||||
|
||||
#[error(transparent)]
|
||||
Signing(#[from] crate::signing::SigningError),
|
||||
|
||||
#[error(transparent)]
|
||||
SerdeJson(#[from] serde_json::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Axum(#[from] axum::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Http(#[from] axum::http::Error),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorResponse {
|
||||
success: bool,
|
||||
error: String,
|
||||
code: u16,
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, error_message) = match &self {
|
||||
Error::BundleNotFound(name) => {
|
||||
tracing::warn!(bundle_name = %name, "Bundle not found");
|
||||
(StatusCode::NOT_FOUND, self.to_string())
|
||||
}
|
||||
Error::ValidationError(msg) => {
|
||||
tracing::warn!(message = %msg, "Validation error");
|
||||
(StatusCode::BAD_REQUEST, self.to_string())
|
||||
}
|
||||
Error::Bundle(crate::bundle::BundleError::NotFound(msg)) => {
|
||||
tracing::warn!(message = %msg, "Bundle not found");
|
||||
(StatusCode::NOT_FOUND, msg.clone())
|
||||
}
|
||||
Error::Bundle(crate::bundle::BundleError::ValidationFailed(msg)) => {
|
||||
tracing::warn!(message = %msg, "Bundle validation failed");
|
||||
(StatusCode::BAD_REQUEST, msg.clone())
|
||||
}
|
||||
Error::Config(msg) => {
|
||||
tracing::error!(error = %msg, "Configuration error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||
}
|
||||
_ => {
|
||||
tracing::error!(error = ?self, "Internal server error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Internal server error".to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let body = ErrorResponse {
|
||||
success: false,
|
||||
error: error_message,
|
||||
code: status.as_u16(),
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
status = %status,
|
||||
error = %body.error,
|
||||
code = body.code,
|
||||
"Generating error response"
|
||||
);
|
||||
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Error> for StatusCode {
|
||||
fn from(error: Error) -> Self {
|
||||
match error {
|
||||
Error::BundleNotFound(_) => StatusCode::NOT_FOUND,
|
||||
Error::ValidationError(_) => StatusCode::BAD_REQUEST,
|
||||
Error::Bundle(crate::bundle::BundleError::NotFound(_)) => StatusCode::NOT_FOUND,
|
||||
Error::Bundle(crate::bundle::BundleError::ValidationFailed(_)) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn is_not_found(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Error::BundleNotFound(_) | Error::Bundle(crate::bundle::BundleError::NotFound(_))
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_validation_error(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Error::ValidationError(_)
|
||||
| Error::Bundle(crate::bundle::BundleError::ValidationFailed(_))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
|
||||
use axum::{http::StatusCode, routing::get, Router};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
mod api;
|
||||
mod bundle;
|
||||
mod config;
|
||||
mod error;
|
||||
mod model;
|
||||
mod signing;
|
||||
|
||||
use bundle::{BundleBuilder, BundleManager};
|
||||
use config::{ServerConfig, DEFAULT_PORT};
|
||||
use signing::SigningKeyPair;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> error::Result<()> {
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info,tower_http=debug".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer().without_time())
|
||||
.init();
|
||||
|
||||
tracing::info!("Initializing Hoppscotch Web Static Server");
|
||||
|
||||
let key_pair = SigningKeyPair::new();
|
||||
tracing::debug!("Generated new signing key pair");
|
||||
|
||||
let config = ServerConfig::load(key_pair);
|
||||
tracing::debug!(?config, "Configuration loaded successfully");
|
||||
|
||||
let frontend_path = Path::new(config.frontend_path()).canonicalize()?;
|
||||
if !frontend_path.exists() {
|
||||
tracing::error!(?frontend_path, "Frontend path does not exist");
|
||||
panic!("Frontend path does not exist");
|
||||
}
|
||||
tracing::info!(?frontend_path, "Frontend path verified");
|
||||
|
||||
let builder = BundleBuilder::new(&frontend_path)?;
|
||||
tracing::info!(?frontend_path, "Initialized bundle builder from path",);
|
||||
|
||||
let (content, files) = builder.finish()?;
|
||||
tracing::info!("Bundle built successfully with {} files", files.len());
|
||||
|
||||
tracing::debug!("Initialized bundle manager");
|
||||
let bundle_manager = BundleManager::new(config.into(), content, files)?;
|
||||
tracing::info!("Bundle signed and stored successfully in the bundle manager");
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(|| async { StatusCode::OK }))
|
||||
.merge(api::routes(bundle_manager.into()))
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], DEFAULT_PORT));
|
||||
tracing::info!("Attempting to bind to address: {}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
tracing::info!("Server successfully bound to {}", listener.local_addr()?);
|
||||
|
||||
tracing::info!("Starting server");
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
use blake3::Hash;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileEntry {
|
||||
pub path: String,
|
||||
pub size: u64,
|
||||
#[serde(with = "hash_serde")]
|
||||
pub hash: Hash,
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub files: Vec<FileEntry>,
|
||||
}
|
||||
|
||||
mod hash_serde {
|
||||
use super::*;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use blake3::Hash;
|
||||
|
||||
pub fn serialize<S>(hash: &Hash, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&STANDARD.encode(hash.as_bytes()))
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Hash, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Error;
|
||||
let s = String::deserialize(deserializer)?;
|
||||
let bytes = STANDARD.decode(&s).map_err(D::Error::custom)?;
|
||||
let bytes: [u8; 32] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| D::Error::custom("invalid hash length"))?;
|
||||
Ok(Hash::from_bytes(bytes))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
use base64::DecodeError;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SigningError {
|
||||
#[error("Environment variable not found")]
|
||||
EnvVarMissing,
|
||||
|
||||
#[error("Invalid base64 encoding: {0}")]
|
||||
InvalidBase64(#[from] DecodeError),
|
||||
|
||||
#[error("Invalid key length: expected 32 bytes")]
|
||||
InvalidKeyLength,
|
||||
|
||||
#[error("Invalid signature")]
|
||||
InvalidSignature,
|
||||
|
||||
#[error("Invalid key format: {0}")]
|
||||
InvalidKeyFormat(String),
|
||||
|
||||
#[error("Signature verification failed: {0}")]
|
||||
VerificationFailed(String),
|
||||
|
||||
#[error("Key generation failed: {0}")]
|
||||
GenerationFailed(String),
|
||||
}
|
||||
|
||||
impl SigningError {
|
||||
pub fn is_invalid_signature(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
SigningError::InvalidSignature | SigningError::VerificationFailed(_)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_configuration_error(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
SigningError::EnvVarMissing
|
||||
| SigningError::InvalidKeyFormat(_)
|
||||
| SigningError::InvalidKeyLength
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ed25519_dalek::SignatureError> for SigningError {
|
||||
fn from(err: ed25519_dalek::SignatureError) -> Self {
|
||||
tracing::error!(error = ?err, "Signature verification failed");
|
||||
SigningError::InvalidSignature
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use chrono::Utc;
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SigningKeyPair {
|
||||
pub signing_key: SigningKey,
|
||||
pub verifying_key: VerifyingKey,
|
||||
pub key_id: String,
|
||||
}
|
||||
|
||||
impl SigningKeyPair {
|
||||
pub fn new() -> Self {
|
||||
tracing::info!("Generating new signing key pair");
|
||||
|
||||
let signing_key = SigningKey::generate(&mut rand::rngs::OsRng);
|
||||
let verifying_key = VerifyingKey::from(&signing_key);
|
||||
|
||||
let key_id = format!("key_{}", Utc::now().format("%Y%m%d_%H%M%S"));
|
||||
|
||||
tracing::info!(key_id = %key_id, "Generated new signing key pair");
|
||||
tracing::info!(signing_key_bytes_encoded = ?STANDARD.encode(signing_key.to_bytes()));
|
||||
tracing::info!(verifying_key_bytes_encoded = ?STANDARD.encode(verifying_key.to_bytes()));
|
||||
|
||||
Self {
|
||||
signing_key,
|
||||
verifying_key,
|
||||
key_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SigningKeyPair {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "SigningKeyPair(id: {})", self.key_id)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
mod error;
|
||||
mod key;
|
||||
|
||||
pub use error::SigningError;
|
||||
pub use key::SigningKeyPair;
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
# This step is used to build a custom build of Caddy to prevent
|
||||
# vulnerable packages on the dependency chain
|
||||
FROM alpine:3.23.0 AS caddy_builder
|
||||
# Base Go builder with Go lang installation
|
||||
# This stage is used to build both Caddy and the webapp server,
|
||||
# preventing vulnerable packages on the dependency chain
|
||||
FROM alpine:3.23.0 AS go_builder
|
||||
|
||||
RUN apk add --no-cache curl git && \
|
||||
mkdir -p /tmp/caddy-build && \
|
||||
curl -L -o /tmp/caddy-build/src.tar.gz https://github.com/caddyserver/caddy/releases/download/v2.10.2/caddy_2.10.2_src.tar.gz
|
||||
|
|
@ -40,10 +42,22 @@ RUN tar xvf /tmp/caddy-build/src.tar.gz && \
|
|||
go mod tidy && \
|
||||
go mod vendor
|
||||
|
||||
# Build Caddy from the Go base
|
||||
FROM go_builder AS caddy_builder
|
||||
WORKDIR /tmp/caddy-build/cmd/caddy
|
||||
# Build using the updated vendored dependencies
|
||||
RUN go build
|
||||
|
||||
# Build webapp server from the Go base
|
||||
# This reuses the Go installation from go_builder, avoiding a separate image pull
|
||||
# and significantly reducing build time (especially on ARM64 in CI)
|
||||
FROM go_builder AS webapp_server_builder
|
||||
WORKDIR /usr/src/app
|
||||
COPY . .
|
||||
WORKDIR /usr/src/app/packages/hoppscotch-selfhost-web/webapp-server
|
||||
RUN go mod download
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o webapp-server .
|
||||
|
||||
|
||||
|
||||
# Shared Node.js base with optimized NPM installation
|
||||
|
|
@ -123,13 +137,6 @@ FROM base_builder AS fe_builder
|
|||
WORKDIR /usr/src/app/packages/hoppscotch-selfhost-web
|
||||
RUN pnpm run generate
|
||||
|
||||
FROM rust:1-alpine AS webapp_server_builder
|
||||
WORKDIR /usr/src/app
|
||||
RUN apk add --no-cache musl-dev
|
||||
COPY . .
|
||||
WORKDIR /usr/src/app/packages/hoppscotch-selfhost-web/webapp-server
|
||||
RUN cargo build --release
|
||||
|
||||
|
||||
|
||||
FROM node_base AS app
|
||||
|
|
@ -137,7 +144,7 @@ FROM node_base AS app
|
|||
COPY --from=caddy_builder /tmp/caddy-build/cmd/caddy/caddy /usr/bin/caddy
|
||||
|
||||
# Copy over webapp server bin
|
||||
COPY --from=webapp_server_builder /usr/src/app/packages/hoppscotch-selfhost-web/webapp-server/target/release/webapp-server /usr/local/bin/
|
||||
COPY --from=webapp_server_builder /usr/src/app/packages/hoppscotch-selfhost-web/webapp-server/webapp-server /usr/local/bin/
|
||||
|
||||
COPY --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/prod_run.mjs /site/prod_run.mjs
|
||||
COPY --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/selfhost-web.Caddyfile /etc/caddy/selfhost-web.Caddyfile
|
||||
|
|
@ -198,7 +205,7 @@ COPY --from=backend_builder /dist/backend /dist/backend
|
|||
COPY --from=base_builder /usr/src/app/packages/hoppscotch-backend/prod_run.mjs /dist/backend
|
||||
|
||||
# Static Server
|
||||
COPY --from=webapp_server_builder /usr/src/app/packages/hoppscotch-selfhost-web/webapp-server/target/release/webapp-server /usr/local/bin/
|
||||
COPY --from=webapp_server_builder /usr/src/app/packages/hoppscotch-selfhost-web/webapp-server/webapp-server /usr/local/bin/
|
||||
RUN mkdir -p /site/selfhost-web
|
||||
COPY --from=fe_builder /usr/src/app/packages/hoppscotch-selfhost-web/dist /site/selfhost-web
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue