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:
Shreyas 2025-12-15 11:54:37 +05:30 committed by GitHub
parent 05927f3d4d
commit e025b8c8e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 977 additions and 2932 deletions

View file

@ -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": {

View file

@ -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..."

View file

@ -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

View file

@ -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/

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -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.

View 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
)

View 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=

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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"
}

View file

@ -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)
}

View 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")
}

View file

@ -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()
}
}

View file

@ -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)))
}
}

View file

@ -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
}

View file

@ -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()))
}
}

View file

@ -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))
}
}

View file

@ -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>;

View file

@ -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()
}
}

View file

@ -1,8 +0,0 @@
mod builder;
mod error;
mod manager;
mod model;
pub use builder::BundleBuilder;
pub use error::BundleError;
pub use manager::BundleManager;

View file

@ -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 }
}
}

View file

@ -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
}
}

View file

@ -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(_))
)
}
}

View file

@ -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(())
}

View file

@ -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))
}
}

View file

@ -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
}
}

View file

@ -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)
}
}

View file

@ -1,5 +0,0 @@
mod error;
mod key;
pub use error::SigningError;
pub use key::SigningKeyPair;

View file

@ -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