package caddysnake
import (
"net/http"
"os"
"path/filepath"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"go.uber.org/zap"
)
// resolveWatchRoot returns the absolute, symlink-resolved directory that the
// watcher should be rooted at.
//
// This exists because filepath.Walk lstat()s its root and refuses to descend
// into a symlink, so a working_dir like the `releases/active -> releases/main`
// indirection common to release-directory deploys would add zero watches and
// silently never reload.
//
// CALLERS MUST KEY ANY PATH BOOKKEEPING OFF THE VALUE RETURNED HERE, not off
// the configured directory. fsnotify reports events as
// filepath.Join(<path given to Add>, name), so once the watcher is rooted at a
// resolved path, every event carries the resolved prefix. DynamicApp matches
// events against its dirToKeys map to decide which tenant to reload; keying
// that map off the unresolved path silently drops every event. Note this is
// not limited to a symlinked working_dir — EvalSymlinks resolves every path
// component, so a symlinked ancestor (macOS /var and /tmp, a symlinked /home
// or /srv, a container's /app) is enough to make the two disagree.
//
// On error the absolute path is returned unchanged, which leaves a root whose
// components contain no symlinks behaving exactly as before.
func resolveWatchRoot(dir string, logger *zap.Logger) (string, error) {
abs, err := filepath.Abs(dir)
if err != nil {
return "", err
}
resolved, err := filepath.EvalSymlinks(abs)
if err != nil {
// Broken, missing or unreadable path: fall back to the literal one.
logger.Warn("autoreload: failed to resolve working directory symlinks",
zap.String("path", abs),
zap.Error(err),
)
return abs, nil
}
if resolved != abs {
// Debug, not Info: this fires for any symlinked ancestor, so on macOS
// (/var -> private/var) it would otherwise log on every single app.
logger.Debug("autoreload: resolved working directory",
zap.String("path", abs),
zap.String("resolved", resolved),
)
}
return resolved, nil
}
// watchDirRecursive adds all directories under root to the fsnotify watcher.
// It is used by both AutoreloadableApp and DynamicApp.
//
// root must already have been passed through resolveWatchRoot. Symlinks
// *inside* the tree are not followed by the initial walk, so it stays free of
// cycles and of excursions outside the app.
func watchDirRecursive(watcher *fsnotify.Watcher, root string, logger *zap.Logger) {
if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
return nil
}
if addErr := watcher.Add(path); addErr != nil {
logger.Warn("autoreload: failed to watch directory",
zap.String("path", path),
zap.Error(addErr),
)
}
return nil
}); err != nil {
logger.Warn("autoreload: failed to walk working directory",
zap.String("path", root),
zap.Error(err),
)
}
}
// isPythonFileEvent returns true if the event is a write/create/remove/rename
// of a .py file.
func isPythonFileEvent(event fsnotify.Event) bool {
if filepath.Ext(event.Name) != ".py" {
return false
}
return event.Has(fsnotify.Write) || event.Has(fsnotify.Create) ||
event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename)
}
// handleNewDirEvent checks if the event is a newly created directory and adds
// it to the watcher if appropriate.
func handleNewDirEvent(event fsnotify.Event, watcher *fsnotify.Watcher) {
if !event.Has(fsnotify.Create) {
return
}
// Lstat, not Stat: Stat follows symlinks, so a symlink-to-directory created
// inside the tree at runtime would be watched and extend the watcher
// outside the app — inconsistent with the initial walk, which lstat()s and
// skips them.
info, err := os.Lstat(event.Name)
if err != nil || !info.IsDir() {
return
}
if err := watcher.Add(event.Name); err != nil {
return
}
}
// AutoreloadableApp wraps an AppServer to support hot-reloading when Python
// files in the working directory change. It watches for .py file modifications
// and reloads the app after a debounce period to group rapid changes.
type AutoreloadableApp struct {
mu sync.RWMutex
reloadMu sync.Mutex
app AppServer
factory func() (AppServer, error)
watcher *fsnotify.Watcher
stopCh chan struct{}
logger *zap.Logger
workingDir string
exitOnReloadFailure func(code int) // if set, process exits on reload failure instead of serving 503
closed bool
}
// NewAutoreloadableApp creates an AutoreloadableApp that wraps the given app and
// starts a filesystem watcher on the working directory. When any .py file changes,
// the app is reloaded after a 500ms debounce window.
// If exitOnReloadFailure is non-nil, it is called with code 1 when a reload fails
// (e.g. app deleted), so the process can terminate and stop serving requests.
func NewAutoreloadableApp(
app AppServer,
workingDir string,
factory func() (AppServer, error),
logger *zap.Logger,
exitOnReloadFailure func(code int),
) (*AutoreloadableApp, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
a := &AutoreloadableApp{
app: app,
factory: factory,
watcher: watcher,
stopCh: make(chan struct{}),
logger: logger,
workingDir: workingDir,
exitOnReloadFailure: exitOnReloadFailure,
}
watchRoot, err := resolveWatchRoot(workingDir, logger)
if err != nil {
logger.Warn("autoreload: failed to resolve working directory",
zap.String("working_dir", workingDir),
zap.Error(err),
)
watchRoot = workingDir
}
watchDirRecursive(watcher, watchRoot, logger)
go a.watch()
logger.Info("autoreload enabled", zap.String("working_dir", workingDir))
return a, nil
}
// watch runs in a goroutine and listens for filesystem events.
// It debounces rapid changes (e.g. editor save + format) into a single reload.
func (a *AutoreloadableApp) watch() {
var debounceTimer *time.Timer
const debounceDuration = 500 * time.Millisecond
for {
select {
case event, ok := <-a.watcher.Events:
if !ok {
return
}
if !isPythonFileEvent(event) {
handleNewDirEvent(event, a.watcher)
continue
}
a.logger.Debug("python file changed",
zap.String("file", event.Name),
zap.String("op", event.Op.String()),
)
if debounceTimer != nil {
debounceTimer.Stop()
}
debounceTimer = time.AfterFunc(debounceDuration, func() {
a.reload()
})
case err, ok := <-a.watcher.Errors:
if !ok {
return
}
a.logger.Error("autoreload watcher error", zap.Error(err))
case <-a.stopCh:
if debounceTimer != nil {
debounceTimer.Stop()
}
return
}
}
}
// reload performs the actual app reload by stopping the old worker processes
// and starting new ones via the factory function.
func (a *AutoreloadableApp) reload() {
a.reloadMu.Lock()
defer a.reloadMu.Unlock()
if a.closed {
return
}
a.logger.Info("reloading python app due to file changes")
// Create new app OUTSIDE lock to avoid blocking requests
newApp, err := a.factory()
if err != nil {
a.logger.Error("failed to reload python app", zap.Error(err))
if a.exitOnReloadFailure != nil {
a.exitOnReloadFailure(1)
}
a.mu.Lock()
oldApp := a.app
a.app = &errorApp{err: err}
a.mu.Unlock()
if cleanupErr := oldApp.Cleanup(); cleanupErr != nil {
a.logger.Error("failed to cleanup old python app after reload failure", zap.Error(cleanupErr))
}
return
}
// Swap under lock (fast operation)
a.mu.Lock()
oldApp := a.app
a.app = newApp
a.mu.Unlock()
a.logger.Info("python app reloaded successfully")
// Cleanup old app OUTSIDE lock. The write lock above guarantees all
// in-flight requests using oldApp have completed before the swap.
if err := oldApp.Cleanup(); err != nil {
a.logger.Error("failed to cleanup old python app during reload", zap.Error(err))
}
}
// HandleRequest forwards the request to the underlying app while holding a read
// lock. This ensures the app isn't swapped mid-request.
func (a *AutoreloadableApp) HandleRequest(w http.ResponseWriter, r *http.Request) error {
a.mu.RLock()
defer a.mu.RUnlock()
return a.app.HandleRequest(w, r)
}
// Cleanup stops the filesystem watcher and cleans up the underlying app.
func (a *AutoreloadableApp) Cleanup() error {
a.reloadMu.Lock()
defer a.reloadMu.Unlock()
if a.closed {
return nil
}
a.closed = true
close(a.stopCh)
_ = a.watcher.Close()
a.mu.RLock()
app := a.app
a.mu.RUnlock()
return app.Cleanup()
}
// errorApp is a stub AppServer returned when a reload fails.
// It returns HTTP 503 for all requests until the next successful reload.
type errorApp struct {
err error
}
func (e *errorApp) HandleRequest(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte("Service temporarily unavailable"))
return nil
}
func (e *errorApp) Cleanup() error {
return nil
}
package caddysnake
import (
"bufio"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"io"
"math"
"net"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Env vars passed to Python workers for cache access.
const (
EnvCaddysnakeCacheAddr = "CADDYSNAKE_CACHE_ADDR"
EnvCaddysnakeCacheToken = "CADDYSNAKE_CACHE_TOKEN"
EnvCaddysnakeWorkerInterface = "CADDYSNAKE_WORKER_INTERFACE"
EnvCaddysnakeWorkerID = "CADDYSNAKE_WORKER_ID"
EnvCaddysnakeCacheTimeoutSeconds = "CADDYSNAKE_CACHE_TIMEOUT"
)
// DefaultCacheClientTimeoutSec is a read/connect timeout hint for cache clients (seconds); client may ignore.
const DefaultCacheClientTimeoutSec = 30
// cacheAddrUnixScheme prefixes CADDYSNAKE_CACHE_ADDR when listening on a Unix domain socket.
const cacheAddrUnixScheme = "unix://"
// Resource limits (conservative defaults).
const (
maxCacheKeyLen = 8192
maxCacheScalarLen = 1 << 20 // 1 MiB
maxCacheListElemLen = 1 << 20
maxCacheListLen = 100_000
maxCacheKeys = 1_000_000
maxRESPProtoLineBytes = 1 << 20
defaultCSKEYSLimit = 1000
maxCSKEYSLimit = 1000
maxSubscribeTimeoutSec = 300.0
)
var (
errCacheLimit = errors.New("limit")
errWrongType = errors.New("wrong type")
)
type entryKind int
const (
entryScalar entryKind = iota
entryList
entrySet
)
type cacheEntry struct {
kind entryKind
scalar []byte
list [][]byte
members map[string][]byte // set: dedup by string(member bytes)
expiry *time.Time // wall clock; nil = no expiry
}
func (e *cacheEntry) expired(now time.Time) bool {
if e == nil || e.expiry == nil {
return false
}
return !now.Before(*e.expiry)
}
type cacheStore struct {
mu sync.Mutex
data map[string]*cacheEntry
conds map[string]*sync.Cond // lazily created; all use &mu (Wait unlocks while blocked)
closing bool
}
func newCacheStore() *cacheStore {
s := &cacheStore{
data: make(map[string]*cacheEntry),
conds: make(map[string]*sync.Cond),
}
return s
}
func (s *cacheStore) condForKey(key string) *sync.Cond {
if c, ok := s.conds[key]; ok {
return c
}
c := sync.NewCond(&s.mu)
s.conds[key] = c
return c
}
func (s *cacheStore) dropCond(key string) {
delete(s.conds, key)
}
func (s *cacheStore) broadcastKey(key string) {
if c := s.conds[key]; c != nil {
c.Broadcast()
}
}
func (s *cacheStore) deleteEntryLocked(key string) {
delete(s.data, key)
s.dropCond(key)
}
func (s *cacheStore) shutdownLocked() {
s.closing = true
for _, c := range s.conds {
c.Broadcast()
}
}
func (s *cacheStore) Shutdown() {
s.mu.Lock()
s.shutdownLocked()
s.mu.Unlock()
}
func (s *cacheStore) entryLocked(k string, now time.Time) (*cacheEntry, bool) {
e := s.data[k]
if e == nil {
return nil, false
}
if e.expired(now) {
s.deleteEntryLocked(k)
return nil, false
}
return e, true
}
func (s *cacheStore) checkKeyLen(key []byte) error {
if len(key) == 0 || len(key) > maxCacheKeyLen {
return fmt.Errorf("%w: key size", errCacheLimit)
}
return nil
}
func sanitizeErrMsg(msg string) string {
msg = strings.ReplaceAll(msg, "\r", " ")
msg = strings.ReplaceAll(msg, "\n", " ")
return msg
}
func (s *cacheStore) checkScalarVal(v []byte) error {
if len(v) > maxCacheScalarLen {
return fmt.Errorf("%w: value size", errCacheLimit)
}
return nil
}
func (s *cacheStore) checkElem(v []byte) error {
if len(v) > maxCacheListElemLen {
return fmt.Errorf("%w: list element size", errCacheLimit)
}
return nil
}
func (s *cacheStore) enforceKeysCap() error {
if len(s.data) >= maxCacheKeys {
return fmt.Errorf("%w: too many keys", errCacheLimit)
}
return nil
}
func wallExpiry(ttlSec int64) *time.Time {
if ttlSec <= 0 {
return nil
}
t := time.Now().Add(time.Duration(ttlSec) * time.Second)
return &t
}
// Set overwrites any prior value (scalar or list).
func (s *cacheStore) Set(key, value []byte, ttlSec int64) error {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
if err := s.checkKeyLen(key); err != nil {
return err
}
if err := s.checkScalarVal(value); err != nil {
return err
}
k := string(key)
if e := s.data[k]; e != nil && e.expired(now) {
s.deleteEntryLocked(k)
e = nil
}
if _, ok := s.data[k]; !ok {
if err := s.enforceKeysCap(); err != nil {
return err
}
}
exp := wallExpiry(ttlSec)
s.data[k] = &cacheEntry{kind: entryScalar, scalar: append([]byte(nil), value...), expiry: exp}
s.broadcastKey(k) // wake pops if type changed
return nil
}
// Get returns value data and kind. ok=false on miss or invalid key.
func (s *cacheStore) Get(key []byte) (scalar []byte, list [][]byte, setMembers [][]byte, kind entryKind, ok bool) {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
if err := s.checkKeyLen(key); err != nil {
return nil, nil, nil, entryScalar, false
}
k := string(key)
e, ok := s.entryLocked(k, now)
if !ok {
return nil, nil, nil, entryScalar, false
}
switch e.kind {
case entryScalar:
return append([]byte(nil), e.scalar...), nil, nil, entryScalar, true
case entryList:
out := make([][]byte, len(e.list))
for i, b := range e.list {
out[i] = append([]byte(nil), b...)
}
return nil, out, nil, entryList, true
case entrySet:
out := setMembersSorted(e.members)
return nil, nil, out, entrySet, true
default:
return nil, nil, nil, entryScalar, false
}
}
func (s *cacheStore) Delete(key []byte) int {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
if err := s.checkKeyLen(key); err != nil {
return 0
}
k := string(key)
e := s.data[k]
if e == nil {
return 0
}
if e.expired(now) {
s.deleteEntryLocked(k)
return 0
}
s.broadcastKey(k)
s.deleteEntryLocked(k)
return 1
}
func (s *cacheStore) Append(key, value []byte) error {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
if err := s.checkKeyLen(key); err != nil {
return err
}
if err := s.checkElem(value); err != nil {
return err
}
k := string(key)
e := s.data[k]
if e != nil && e.expired(now) {
s.deleteEntryLocked(k)
e = nil
}
if e == nil {
if err := s.enforceKeysCap(); err != nil {
return err
}
if 1 > maxCacheListLen {
return fmt.Errorf("%w: list length", errCacheLimit)
}
s.data[k] = &cacheEntry{
kind: entryList,
list: [][]byte{append([]byte(nil), value...)},
expiry: nil,
}
s.broadcastKey(k)
return nil
}
// clear TTL on append (per spec)
noTTL := (*time.Time)(nil)
if e.kind == entrySet {
return errWrongType
}
if e.kind == entryScalar {
if 2 > maxCacheListLen {
return fmt.Errorf("%w: list length", errCacheLimit)
}
old := append([]byte(nil), e.scalar...)
s.data[k] = &cacheEntry{
kind: entryList,
list: [][]byte{old, append([]byte(nil), value...)},
expiry: noTTL,
}
s.broadcastKey(k)
return nil
}
if len(e.list) >= maxCacheListLen {
return fmt.Errorf("%w: list length", errCacheLimit)
}
e.list = append(e.list, append([]byte(nil), value...))
e.expiry = noTTL
s.broadcastKey(k)
return nil
}
// Pop returns (value, true) | (nil, false) for immediate nil (miss, scalar, timeout, cancelled).
func (s *cacheStore) Pop(key []byte, deadline *time.Time) ([]byte, bool) {
s.mu.Lock()
if err := s.checkKeyLen(key); err != nil {
s.mu.Unlock()
return nil, false
}
k := string(key)
timedOut := false
var timer *time.Timer
if deadline != nil {
d := time.Until(*deadline)
if d <= 0 {
s.mu.Unlock()
return nil, false
}
timer = time.AfterFunc(d, func() {
s.mu.Lock()
timedOut = true
s.broadcastKey(k)
s.mu.Unlock()
})
}
for {
if s.closing {
if timer != nil {
timer.Stop()
}
s.mu.Unlock()
return nil, false
}
now := time.Now()
e, exists := s.entryLocked(k, now)
if !exists {
if timer != nil {
timer.Stop()
}
s.mu.Unlock()
return nil, false
}
if e.kind == entryScalar || e.kind == entrySet {
if timer != nil {
timer.Stop()
}
s.mu.Unlock()
return nil, false
}
if len(e.list) > 0 {
v := e.list[0]
e.list = e.list[1:]
out := append([]byte(nil), v...)
if timer != nil {
timer.Stop()
}
s.mu.Unlock()
return out, true
}
if timedOut {
if timer != nil {
timer.Stop()
}
s.mu.Unlock()
return nil, false
}
cond := s.condForKey(k)
cond.Wait()
}
}
func setMembersSorted(m map[string][]byte) [][]byte {
if len(m) == 0 {
return nil
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sortStrings(keys)
out := make([][]byte, len(keys))
for i, k := range keys {
out[i] = append([]byte(nil), m[k]...)
}
return out
}
func sortStrings(ss []string) {
sort.Strings(ss)
}
// SAdd returns 1 if member was added, 0 if already present.
func (s *cacheStore) SAdd(key, member []byte) (int, error) {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
if err := s.checkKeyLen(key); err != nil {
return 0, err
}
if err := s.checkElem(member); err != nil {
return 0, err
}
k := string(key)
mk := string(member)
e := s.data[k]
if e != nil && e.expired(now) {
s.deleteEntryLocked(k)
e = nil
}
if e != nil && e.kind != entrySet {
return 0, errWrongType
}
if e == nil {
if err := s.enforceKeysCap(); err != nil {
return 0, err
}
s.data[k] = &cacheEntry{
kind: entrySet,
members: map[string][]byte{mk: append([]byte(nil), member...)},
expiry: nil,
}
return 1, nil
}
if _, ok := e.members[mk]; ok {
return 0, nil
}
if len(e.members) >= maxCacheListLen {
return 0, fmt.Errorf("%w: set size", errCacheLimit)
}
e.members[mk] = append([]byte(nil), member...)
return 1, nil
}
// SRem returns 1 if member was removed, 0 if absent.
func (s *cacheStore) SRem(key, member []byte) (int, error) {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
if err := s.checkKeyLen(key); err != nil {
return 0, err
}
k := string(key)
e := s.data[k]
if e == nil || e.expired(now) {
if e != nil {
s.deleteEntryLocked(k)
}
return 0, nil
}
if e.kind != entrySet {
return 0, errWrongType
}
mk := string(member)
if _, ok := e.members[mk]; !ok {
return 0, nil
}
delete(e.members, mk)
if len(e.members) == 0 {
s.deleteEntryLocked(k)
}
return 1, nil
}
// SMembers returns sorted member copies; empty slice if key missing (Redis-aligned).
func (s *cacheStore) SMembers(key []byte) ([][]byte, error) {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
if err := s.checkKeyLen(key); err != nil {
return nil, err
}
k := string(key)
e, ok := s.entryLocked(k, now)
if !ok {
return [][]byte{}, nil
}
if e.kind != entrySet {
return nil, errWrongType
}
return setMembersSorted(e.members), nil
}
// SetNX returns 1 if key was set, 0 if key already exists (any non-expired kind).
func (s *cacheStore) SetNX(key, value []byte, ttlSec int64) (int, error) {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
if err := s.checkKeyLen(key); err != nil {
return 0, err
}
if err := s.checkScalarVal(value); err != nil {
return 0, err
}
k := string(key)
if e, ok := s.entryLocked(k, now); ok && e != nil {
return 0, nil
}
if err := s.enforceKeysCap(); err != nil {
return 0, err
}
exp := wallExpiry(ttlSec)
s.data[k] = &cacheEntry{kind: entryScalar, scalar: append([]byte(nil), value...), expiry: exp}
s.broadcastKey(k)
return 1, nil
}
// Keys returns up to limit key names matching prefix (sorted). Prefix must be non-empty.
func (s *cacheStore) Keys(prefix []byte, limit int) ([][]byte, error) {
if len(prefix) == 0 {
return nil, fmt.Errorf("%w: keys prefix required", errCacheLimit)
}
if limit <= 0 {
limit = defaultCSKEYSLimit
}
if limit > maxCSKEYSLimit {
limit = maxCSKEYSLimit
}
pfx := string(prefix)
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
var matched []string
for k, e := range s.data {
if e.expired(now) {
s.deleteEntryLocked(k)
continue
}
if !strings.HasPrefix(k, pfx) {
continue
}
matched = append(matched, k)
}
sortStrings(matched)
if len(matched) > limit {
matched = matched[:limit]
}
out := make([][]byte, len(matched))
for i, k := range matched {
out[i] = []byte(k)
}
return out, nil
}
// --- pub/sub (blocking one-shot receive) ---
type pubSubWaiter struct {
msg []byte
ready bool
cancelled bool
}
type pubSubChannel struct {
waiters []*pubSubWaiter
cond *sync.Cond
}
type pubSub struct {
mu sync.Mutex
channels map[string]*pubSubChannel
closing bool
}
func newPubSub() *pubSub {
ps := &pubSub{channels: make(map[string]*pubSubChannel)}
return ps
}
func (ps *pubSub) channelLocked(name string) *pubSubChannel {
ch, ok := ps.channels[name]
if !ok {
ch = &pubSubChannel{cond: sync.NewCond(&ps.mu)}
ps.channels[name] = ch
}
return ch
}
func (ps *pubSub) removeWaiter(ch *pubSubChannel, w *pubSubWaiter) {
for i, x := range ch.waiters {
if x == w {
ch.waiters = append(ch.waiters[:i], ch.waiters[i+1:]...)
return
}
}
}
func (ps *pubSub) Subscribe(channel []byte, deadline time.Time) ([]byte, bool) {
if err := checkPubSubName(channel); err != nil {
return nil, false
}
name := string(channel)
ps.mu.Lock()
if ps.closing {
ps.mu.Unlock()
return nil, false
}
ch := ps.channelLocked(name)
w := &pubSubWaiter{}
ch.waiters = append(ch.waiters, w)
timedOut := false
timer := time.AfterFunc(time.Until(deadline), func() {
ps.mu.Lock()
timedOut = true
ch.cond.Broadcast()
ps.mu.Unlock()
})
for !w.ready && !w.cancelled && !ps.closing && !timedOut {
ch.cond.Wait()
}
timer.Stop()
var out []byte
if w.ready {
out = append([]byte(nil), w.msg...)
}
ps.removeWaiter(ch, w)
if len(ch.waiters) == 0 {
delete(ps.channels, name)
}
ps.mu.Unlock()
if w.ready {
return out, true
}
return nil, false
}
func (ps *pubSub) Publish(channel, message []byte) (int, error) {
if err := checkPubSubName(channel); err != nil {
return 0, err
}
if len(message) > maxCacheScalarLen {
return 0, fmt.Errorf("%w: message size", errCacheLimit)
}
name := string(channel)
ps.mu.Lock()
defer ps.mu.Unlock()
if ps.closing {
return 0, nil
}
ch, ok := ps.channels[name]
if !ok || len(ch.waiters) == 0 {
return 0, nil
}
n := 0
for _, w := range ch.waiters {
if w.ready {
continue
}
w.msg = append([]byte(nil), message...)
w.ready = true
n++
}
ch.cond.Broadcast()
return n, nil
}
func (ps *pubSub) Shutdown() {
ps.mu.Lock()
ps.closing = true
for _, ch := range ps.channels {
for _, w := range ch.waiters {
w.cancelled = true
}
ch.cond.Broadcast()
}
ps.mu.Unlock()
}
func checkPubSubName(channel []byte) error {
if len(channel) == 0 || len(channel) > maxCacheKeyLen {
return fmt.Errorf("%w: channel size", errCacheLimit)
}
return nil
}
// --- RESP ---
func respWriteSimpleString(w *bufio.Writer, s string) error {
if _, err := fmt.Fprintf(w, "+%s\r\n", s); err != nil {
return err
}
return w.Flush()
}
func respWriteError(w *bufio.Writer, msg string) error {
if _, err := fmt.Fprintf(w, "-ERR %s\r\n", sanitizeErrMsg(msg)); err != nil {
return err
}
return w.Flush()
}
func respWriteInt(w *bufio.Writer, n int64) error {
if _, err := fmt.Fprintf(w, ":%d\r\n", n); err != nil {
return err
}
return w.Flush()
}
func respWriteBulk(w *bufio.Writer, b []byte) error {
if b == nil {
if _, err := io.WriteString(w, "$-1\r\n"); err != nil {
return err
}
return w.Flush()
}
if _, err := fmt.Fprintf(w, "$%d\r\n", len(b)); err != nil {
return err
}
if _, err := w.Write(b); err != nil {
return err
}
if _, err := io.WriteString(w, "\r\n"); err != nil {
return err
}
return w.Flush()
}
func respWriteArrayHeader(w *bufio.Writer, n int) error {
if _, err := fmt.Fprintf(w, "*%d\r\n", n); err != nil {
return err
}
return nil
}
// respWriteArrayOfBulks writes *n followed by n bulk strings (no trailing flush until end).
func respWriteArrayOfBulks(w *bufio.Writer, elems [][]byte) error {
if err := respWriteArrayHeader(w, len(elems)); err != nil {
return err
}
for _, b := range elems {
if _, err := fmt.Fprintf(w, "$%d\r\n", len(b)); err != nil {
return err
}
if _, err := w.Write(b); err != nil {
return err
}
if _, err := io.WriteString(w, "\r\n"); err != nil {
return err
}
}
return w.Flush()
}
func respReadLine(r *bufio.Reader) ([]byte, error) {
line, err := r.ReadBytes('\n')
if err != nil {
return nil, err
}
if len(line) < 2 || line[len(line)-2] != '\r' {
return nil, fmt.Errorf("invalid line ending")
}
if len(line)-2 > maxRESPProtoLineBytes {
return nil, errCacheLimit
}
return line[:len(line)-2], nil
}
func respReadBulk(r *bufio.Reader) ([]byte, error) {
line, err := respReadLine(r)
if err != nil {
return nil, err
}
if len(line) < 1 || line[0] != '$' {
return nil, fmt.Errorf("expected bulk")
}
n, err := strconv.Atoi(string(line[1:]))
if err != nil {
return nil, err
}
if n == -1 {
return nil, nil // null bulk in request? treat as empty
}
if n < 0 || n > maxCacheScalarLen {
return nil, errCacheLimit
}
buf := make([]byte, n+2)
if _, err := io.ReadFull(r, buf); err != nil {
return nil, err
}
if buf[n] != '\r' || buf[n+1] != '\n' {
return nil, fmt.Errorf("bulk trailer")
}
return buf[:n], nil
}
func respReadArray(r *bufio.Reader) ([][]byte, error) {
line, err := respReadLine(r)
if err != nil {
return nil, err
}
if len(line) < 1 || line[0] != '*' {
return nil, fmt.Errorf("expected array")
}
n, err := strconv.Atoi(string(line[1:]))
if err != nil || n < 1 || n > 32 {
return nil, fmt.Errorf("bad array len")
}
out := make([][]byte, n)
for i := 0; i < n; i++ {
b, err := respReadBulk(r)
if err != nil {
return nil, err
}
out[i] = b
}
return out, nil
}
// --- IPC server (Unix socket on unix-like OS; loopback TCP on Windows) ---
type cacheServer struct {
store *cacheStore
pubsub *pubSub
ln net.Listener
done chan struct{}
closeMu sync.Mutex
closed bool
requireAuth bool
token string
// addr is CADDYSNAKE_CACHE_ADDR: "unix://" + filesystem path, or "127.0.0.1:<port>" on Windows.
addr string
sockDir string // non-empty when using a Unix socket — removed on Close
}
func generateSecretToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func startCacheServer() (*cacheServer, error) {
if runtime.GOOS == "windows" {
return startCacheServerTCPOnly()
}
return startCacheServerUnixSocket()
}
func startCacheServerForIsolation(isolation *IsolationConfig) (*cacheServer, error) {
if isolation != nil && isolation.usesDocker() {
return startCacheServerTCPOnly()
}
return startCacheServer()
}
func startCacheServerTCPOnly() (*cacheServer, error) {
token, err := generateSecretToken()
if err != nil {
return nil, err
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, err
}
s := &cacheServer{
store: newCacheStore(),
pubsub: newPubSub(),
ln: ln,
done: make(chan struct{}),
addr: ln.Addr().String(),
requireAuth: true,
token: token,
}
go s.acceptLoop()
return s, nil
}
func startCacheServerUnixSocket() (*cacheServer, error) {
dir, err := os.MkdirTemp("", "cs-*")
if err != nil {
return nil, err
}
if chErr := os.Chmod(dir, 0o700); chErr != nil {
os.RemoveAll(dir)
return nil, chErr
}
path := filepath.Join(dir, "cache.sock")
_ = os.Remove(path)
ln, err := net.Listen("unix", path)
if err != nil {
os.RemoveAll(dir)
return nil, err
}
absPath, err := filepath.Abs(path)
if err != nil {
ln.Close()
os.RemoveAll(dir)
return nil, err
}
envAddr := cacheAddrUnixScheme + filepath.ToSlash(absPath)
s := &cacheServer{
store: newCacheStore(),
pubsub: newPubSub(),
ln: ln,
done: make(chan struct{}),
addr: envAddr,
sockDir: dir,
}
go s.acceptLoop()
return s, nil
}
func (s *cacheServer) Addr() string { return s.addr }
// Token returns the shared-secret for TCP cache clients, or empty when auth is not required.
func (s *cacheServer) Token() string { return s.token }
func (s *cacheServer) Close() error {
s.closeMu.Lock()
if s.closed {
s.closeMu.Unlock()
return nil
}
s.closed = true
s.closeMu.Unlock()
s.store.Shutdown()
s.pubsub.Shutdown()
err := s.ln.Close()
<-s.done
if s.sockDir != "" {
_ = os.RemoveAll(s.sockDir)
}
return err
}
func (s *cacheServer) acceptLoop() {
defer close(s.done)
for {
conn, err := s.ln.Accept()
if err != nil {
return
}
if tcp, ok := conn.(*net.TCPConn); ok {
_ = tcp.SetNoDelay(true)
}
go s.handleConn(conn)
}
}
func (s *cacheServer) handleConn(conn net.Conn) {
defer conn.Close()
r := bufio.NewReader(conn)
w := bufio.NewWriter(conn)
authenticated := !s.requireAuth
for {
parts, err := respReadArray(r)
if err != nil {
if errors.Is(err, io.EOF) {
return
}
_ = respWriteError(w, err.Error())
return
}
if len(parts) == 0 {
_ = respWriteError(w, "empty command")
return
}
cmd := strings.ToUpper(string(parts[0]))
if !authenticated {
if cmd != "CSAUTH" || len(parts) != 2 || subtle.ConstantTimeCompare(parts[1], []byte(s.token)) != 1 {
_ = respWriteError(w, "NOAUTH Authentication required")
return
}
authenticated = true
_ = respWriteSimpleString(w, "OK")
_ = w.Flush()
continue
}
switch cmd {
case "CSQUIT":
_ = respWriteSimpleString(w, "OK")
return
case "CSGET":
if len(parts) != 2 {
_ = respWriteError(w, "wrong number of arguments for CSGET")
return
}
scalar, list, _, kind, ok := s.store.Get(parts[1])
if !ok {
_ = respWriteBulk(w, nil) // $-1
continue
}
if kind == entrySet {
_ = respWriteError(w, errWrongType.Error())
continue
}
if kind == entryScalar {
_ = respWriteBulk(w, scalar)
continue
}
if len(list) == 0 {
if err := respWriteArrayHeader(w, 0); err != nil {
return
}
_ = w.Flush()
continue
}
_ = respWriteArrayOfBulks(w, list)
case "CSDEL":
if len(parts) != 2 {
_ = respWriteError(w, "wrong number of arguments for CSDEL")
return
}
n := s.store.Delete(parts[1])
_ = respWriteInt(w, int64(n))
case "CSSET":
if len(parts) != 3 && len(parts) != 4 {
_ = respWriteError(w, "wrong number of arguments for CSSET")
return
}
var ttl int64
if len(parts) == 4 && len(parts[3]) > 0 {
t, err := strconv.ParseInt(string(parts[3]), 10, 64)
if err != nil || t < 0 {
_ = respWriteError(w, "invalid TTL")
return
}
ttl = t
}
if err := s.store.Set(parts[1], parts[2], ttl); err != nil {
_ = respWriteError(w, err.Error())
continue
}
_ = respWriteSimpleString(w, "OK")
case "CSAPPEND":
if len(parts) != 3 {
_ = respWriteError(w, "wrong number of arguments for CSAPPEND")
return
}
if err := s.store.Append(parts[1], parts[2]); err != nil {
if errors.Is(err, errWrongType) {
_ = respWriteError(w, errWrongType.Error())
} else {
_ = respWriteError(w, err.Error())
}
continue
}
_ = respWriteSimpleString(w, "OK")
case "CSPOP":
if len(parts) != 2 && len(parts) != 3 {
_ = respWriteError(w, "wrong number of arguments for CSPOP")
return
}
var dl *time.Time
if len(parts) == 3 && len(parts[2]) > 0 {
sec, err := strconv.ParseFloat(string(parts[2]), 64)
// Allow 0 (immediate) but reject NaN/Inf and cap like CSSUBSCRIBE.
if err != nil || math.IsNaN(sec) || math.IsInf(sec, 0) || sec < 0 || sec > maxSubscribeTimeoutSec {
_ = respWriteError(w, "invalid timeout")
return
}
t := time.Now().Add(time.Duration(sec * float64(time.Second)))
dl = &t
}
v, ok := s.store.Pop(parts[1], dl)
if !ok {
_ = respWriteBulk(w, nil)
continue
}
_ = respWriteBulk(w, v)
case "CSSADD":
if len(parts) != 3 {
_ = respWriteError(w, "wrong number of arguments for CSSADD")
return
}
n, err := s.store.SAdd(parts[1], parts[2])
if err != nil {
if errors.Is(err, errWrongType) {
_ = respWriteError(w, errWrongType.Error())
} else {
_ = respWriteError(w, err.Error())
}
continue
}
_ = respWriteInt(w, int64(n))
case "CSSREM":
if len(parts) != 3 {
_ = respWriteError(w, "wrong number of arguments for CSSREM")
return
}
n, err := s.store.SRem(parts[1], parts[2])
if err != nil {
if errors.Is(err, errWrongType) {
_ = respWriteError(w, errWrongType.Error())
} else {
_ = respWriteError(w, err.Error())
}
continue
}
_ = respWriteInt(w, int64(n))
case "CSSMEMBERS":
if len(parts) != 2 {
_ = respWriteError(w, "wrong number of arguments for CSSMEMBERS")
return
}
members, err := s.store.SMembers(parts[1])
if err != nil {
if errors.Is(err, errWrongType) {
_ = respWriteError(w, errWrongType.Error())
} else {
_ = respWriteError(w, err.Error())
}
continue
}
if len(members) == 0 {
if err := respWriteArrayHeader(w, 0); err != nil {
return
}
_ = w.Flush()
continue
}
_ = respWriteArrayOfBulks(w, members)
case "CSSETNX":
if len(parts) != 3 && len(parts) != 4 {
_ = respWriteError(w, "wrong number of arguments for CSSETNX")
return
}
var ttl int64
if len(parts) == 4 && len(parts[3]) > 0 {
t, err := strconv.ParseInt(string(parts[3]), 10, 64)
if err != nil || t < 0 {
_ = respWriteError(w, "invalid TTL")
return
}
ttl = t
}
n, err := s.store.SetNX(parts[1], parts[2], ttl)
if err != nil {
_ = respWriteError(w, err.Error())
continue
}
_ = respWriteInt(w, int64(n))
case "CSKEYS":
if len(parts) != 1 && len(parts) != 2 && len(parts) != 3 {
_ = respWriteError(w, "wrong number of arguments for CSKEYS")
return
}
prefix := []byte{}
limit := defaultCSKEYSLimit
if len(parts) >= 2 {
prefix = parts[1]
}
if len(parts) == 3 {
l, err := strconv.Atoi(string(parts[2]))
if err != nil || l < 0 {
_ = respWriteError(w, "invalid limit")
return
}
limit = l
}
keys, err := s.store.Keys(prefix, limit)
if err != nil {
_ = respWriteError(w, err.Error())
continue
}
if len(keys) == 0 {
if err := respWriteArrayHeader(w, 0); err != nil {
return
}
_ = w.Flush()
continue
}
_ = respWriteArrayOfBulks(w, keys)
case "CSPUBLISH":
if len(parts) != 3 {
_ = respWriteError(w, "wrong number of arguments for CSPUBLISH")
return
}
n, err := s.pubsub.Publish(parts[1], parts[2])
if err != nil {
_ = respWriteError(w, err.Error())
continue
}
_ = respWriteInt(w, int64(n))
case "CSSUBSCRIBE":
if len(parts) != 3 {
_ = respWriteError(w, "wrong number of arguments for CSSUBSCRIBE")
return
}
sec, err := strconv.ParseFloat(string(parts[2]), 64)
if err != nil || math.IsNaN(sec) || math.IsInf(sec, 0) || sec <= 0 || sec > maxSubscribeTimeoutSec {
_ = respWriteError(w, "invalid timeout")
return
}
deadline := time.Now().Add(time.Duration(sec * float64(time.Second)))
v, ok := s.pubsub.Subscribe(parts[1], deadline)
if !ok {
_ = respWriteBulk(w, nil)
continue
}
_ = respWriteBulk(w, v)
default:
_ = respWriteError(w, fmt.Sprintf("unknown command %q", cmd))
return
}
}
}
// Caddy plugin to serve Python apps.
package caddysnake
import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
caddycmd "github.com/caddyserver/caddy/v2/cmd"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/caddyserver/certmagic"
"github.com/dustin/go-humanize"
"github.com/spf13/cobra"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
_ "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode/gzip"
_ "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode/zstd"
_ "github.com/caddyserver/caddy/v2/modules/caddyhttp/fileserver"
)
//go:embed caddysnake.py
var caddysnake_py string
// Hop-internal headers: set only by the Go module after stripping client-supplied
// values. The Python worker uses these for REMOTE_ADDR / ASGI client.
const (
caddySnakeRemoteAddrHeader = "Caddy-Snake-Remote-Addr"
caddySnakeRemotePortHeader = "Caddy-Snake-Remote-Port"
caddySnakeWorkerTokenHeader = "Caddy-Snake-Worker-Token"
// maxPythonWorkers caps process spawn at provision / per dynamic key.
maxPythonWorkers = 256
)
// EnvCaddysnakeWorkerToken is set on worker processes for inbound proxy authentication.
const EnvCaddysnakeWorkerToken = "CADDYSNAKE_WORKER_TOKEN"
// setPythonWorkerOutboundHeaders configures the outbound request to the Python
// worker: target URL, standard X-Forwarded-* (preserve inbound X-Forwarded-For
// chain, then append this hop per [httputil.ProxyRequest.SetXForwarded]), and
// trusted Caddy-Snake-Remote-* from the inbound RemoteAddr.
func setPythonWorkerOutboundHeaders(pr *httputil.ProxyRequest, dialAddr, workerToken string) {
pr.Out.URL.Scheme = "http"
pr.Out.URL.Host = dialAddr
pr.Out.Header["X-Forwarded-For"] = pr.In.Header["X-Forwarded-For"]
pr.SetXForwarded()
pr.Out.Header.Del(caddySnakeRemoteAddrHeader)
pr.Out.Header.Del(caddySnakeRemotePortHeader)
pr.Out.Header.Del(caddySnakeWorkerTokenHeader)
host, port, err := net.SplitHostPort(pr.In.RemoteAddr)
if err != nil {
host = pr.In.RemoteAddr
port = "0"
}
pr.Out.Header.Set(caddySnakeRemoteAddrHeader, host)
pr.Out.Header.Set(caddySnakeRemotePortHeader, port)
if workerToken != "" {
pr.Out.Header.Set(caddySnakeWorkerTokenHeader, workerToken)
}
}
// AppServer defines the interface to interacting with a WSGI or ASGI server
type AppServer interface {
Cleanup() error
HandleRequest(w http.ResponseWriter, r *http.Request) error
}
// DefaultStartTimeout is how long Provision waits for each Python worker to
// become ready when start_timeout is omitted.
const DefaultStartTimeout = 120 * time.Second
// startTimeoutWarnAfter is when a slow-start warning is logged if the configured
// start_timeout allows waiting longer. Overridable via
// CADDYSNAKE_START_TIMEOUT_WARN_AFTER (duration string) for integration tests.
var startTimeoutWarnAfter = DefaultStartTimeout
// errWorkerExited indicates the Python worker process exited before its socket
// or port file became ready.
var errWorkerExited = errors.New("python worker exited before becoming ready")
func applyStartTimeoutWarnAfterEnv() {
if v := os.Getenv("CADDYSNAKE_START_TIMEOUT_WARN_AFTER"); v != "" {
d, err := time.ParseDuration(v)
if err == nil && d > 0 {
startTimeoutWarnAfter = d
}
}
}
// parseCLIEnvVars parses repeated --env-var NAME=VALUE flags into a map.
func parseCLIEnvVars(flags []string) (map[string]string, error) {
if len(flags) == 0 {
return nil, nil
}
out := make(map[string]string, len(flags))
for _, raw := range flags {
name, value, ok := strings.Cut(raw, "=")
if !ok || name == "" {
return nil, fmt.Errorf("invalid --env-var %q (want NAME=VALUE)", raw)
}
if err := validateEnvVarName(name); err != nil {
return nil, fmt.Errorf("invalid --env-var name %q: %w", name, err)
}
out[name] = value
}
return out, nil
}
// CaddySnake is an HTTP handler that serves Python WSGI, ASGI, or ESGI apps
// by spawning worker subprocesses and proxying requests to them.
//
// Exactly one of module_wsgi, module_asgi, or module_esgi must be set.
// Caddyfile: the `python` directive (see https://caddy-snake.readthedocs.io/).
type CaddySnake struct {
// WSGI app import path as "module:variable" (e.g. "main:app" or "mysite.wsgi:application").
// Mutually exclusive with module_asgi and module_esgi. Supports Caddy placeholders for dynamic loading.
ModuleWsgi string `json:"module_wsgi,omitempty"`
// ASGI app import path as "module:variable" (e.g. "main:app" for FastAPI/Starlette).
// Mutually exclusive with module_wsgi and module_esgi. Supports Caddy placeholders for dynamic loading.
ModuleAsgi string `json:"module_asgi,omitempty"`
// ESGI app import path as "module:variable" (synchronous application(scope, protocol) callable).
// Mutually exclusive with module_wsgi and module_asgi. Supports Caddy placeholders for dynamic loading.
ModuleEsgi string `json:"module_esgi,omitempty"`
// Python worker runtime at the gateway boundary.
// WSGI: "sync" (default) or "gevent". ESGI: "gevent" only (default).
// ASGI: "native" or "uvloop" (default when omitted: "uvloop").
Runtime string `json:"runtime,omitempty"`
// ASGI lifespan protocol: "on" or "off" (default). Only applies with module_asgi.
Lifespan string `json:"lifespan,omitempty"`
// Working directory for the Python app (imports, relative paths, and autoreload watch root).
// Supports Caddy placeholders for dynamic resolution per request.
WorkingDir string `json:"working_dir,omitempty"`
// Path to a Python virtual environment; its site-packages are added to sys.path.
// Caddyfile subdirective name is "venv". Supports Caddy placeholders.
VenvPath string `json:"venv_path,omitempty"`
// Number of worker processes to spawn. Defaults to GOMAXPROCS (CPU count) when empty or "0".
Workers string `json:"workers,omitempty"`
// How long Provision waits for each worker to become ready.
// Empty uses 120s; "-1", "forever", "none", "inf", or "indefinite" wait indefinitely.
// Caddyfile: start_timeout. CLI: --start-timeout (use --start-timeout=-1 or forever).
StartTimeout string `json:"start_timeout,omitempty"`
// When set to "on", watch .py files under working_dir and reload workers on changes.
// Caddyfile: the bare "autoreload" subdirective.
Autoreload string `json:"autoreload,omitempty"`
// Path to the Python interpreter. Defaults to venv/bin/python when venv_path is set, else system python3.
PythonPath string `json:"python_path,omitempty"`
// Dotenv-style files loaded into each worker environment (later files override earlier keys).
// Relative paths resolve against working_dir when set. Caddyfile: repeatable "env_file" subdirective.
EnvFiles []string `json:"env_files,omitempty"`
// Individual environment variables for workers. Applied after env_files (overrides file values).
// Caddyfile: repeatable "env_var NAME value". Cannot set PYTHONUNBUFFERED or CADDYSNAKE_*.
EnvVars map[string]string `json:"env_vars,omitempty"`
// Worker isolation backend. Omit or "none" for local subprocess workers; "docker" runs each worker in a container.
Isolation *IsolationConfig `json:"isolation,omitempty"`
// Dynamic mode limits (ignored when module/working_dir/venv have no placeholders).
// MaxDynamicApps: empty uses env/default (CADDYSNAKE_MAX_DYNAMIC_APPS, usually 128);
// a positive value sets the site cap (0 is rejected).
MaxDynamicApps string `json:"max_dynamic_apps,omitempty"`
// Maximum request body size, as a human-readable size (e.g. "10MB", "2GiB").
// Empty means unlimited. Caddyfile: request_body { max_size <size> } (or
// request_body <size>). CLI: --request-body-max-size.
RequestBodyMaxSize string `json:"request_body_max_size,omitempty"`
logger *zap.Logger
app AppServer
cacheSrv *cacheServer
requestBodyMaxBytes int64
}
// parseStartTimeout parses a Caddyfile/JSON/CLI start_timeout value.
// Empty means DefaultStartTimeout. Indefinite wait (until the worker is ready
// or the process exits) is "-1", or the aliases "forever", "none", "inf", and
// "indefinite". Other values use Caddy durations.
//
// CLI note: pass indefinite as --start-timeout=-1 (equals form) or
// --start-timeout forever. A bare "--start-timeout -1" is parsed as flags by
// Cobra/pflag, not as a string value.
func parseStartTimeout(s string) (time.Duration, error) {
if s == "" {
return DefaultStartTimeout, nil
}
switch strings.ToLower(s) {
case "-1", "forever", "none", "inf", "indefinite":
return -1, nil
}
d, err := caddy.ParseDuration(s)
if err != nil {
return 0, fmt.Errorf("invalid start_timeout: %w", err)
}
if d <= 0 {
return 0, fmt.Errorf("start_timeout must be a positive duration, -1, or forever, got %q", s)
}
return d, nil
}
// effectiveStartTimeout maps a Go API zero value to DefaultStartTimeout.
func effectiveStartTimeout(d time.Duration) time.Duration {
if d == 0 {
return DefaultStartTimeout
}
return d
}
// parseRequestBodyMaxSize parses a Caddyfile/JSON/CLI request body size.
// Empty means unlimited (0). Values use github.com/dustin/go-humanize sizes
// (1KB = 1000, 1KiB = 1024), matching Caddy's request_body max_size.
func parseRequestBodyMaxSize(s string) (int64, error) {
if s == "" {
return 0, nil
}
n, err := humanize.ParseBytes(s)
if err != nil {
return 0, fmt.Errorf("invalid request_body max_size %q: %w", s, err)
}
if n == 0 {
return 0, fmt.Errorf("request_body max_size must be greater than 0, got %q", s)
}
if n > uint64(^uint64(0)>>1) {
return 0, fmt.Errorf("request_body max_size %q overflows int64", s)
}
return int64(n), nil
}
func parseRequestBodyCaddyfile(d *caddyfile.Dispenser, f *CaddySnake) error {
if f.RequestBodyMaxSize != "" {
return d.Errf("request_body specified more than once")
}
args := d.RemainingArgs()
switch len(args) {
case 1:
if _, err := parseRequestBodyMaxSize(args[0]); err != nil {
return d.Errf("%v", err)
}
f.RequestBodyMaxSize = args[0]
return nil
case 0:
// block form: request_body { max_size <size> }
default:
return d.ArgErr()
}
got := false
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
case "max_size":
if f.RequestBodyMaxSize != "" {
return d.Errf("max_size specified more than once")
}
var sizeStr string
if !d.Args(&sizeStr) {
return d.Errf("expected exactly one argument for max_size")
}
if _, err := parseRequestBodyMaxSize(sizeStr); err != nil {
return d.Errf("%v", err)
}
f.RequestBodyMaxSize = sizeStr
got = true
default:
return d.Errf("unrecognized request_body subdirective %q (want max_size)", d.Val())
}
}
if !got {
return d.Errf("request_body requires max_size")
}
return nil
}
func isRequestBodyTooLarge(err error) bool {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
return true
}
var he caddyhttp.HandlerError
return errors.As(err, &he) && he.StatusCode == http.StatusRequestEntityTooLarge
}
// maxBytesBody converts http.MaxBytesError into a Caddy 413 HandlerError so
// ReverseProxy and the HTTP server return Request Entity Too Large.
type maxBytesBody struct {
io.ReadCloser
}
func (b maxBytesBody) Read(p []byte) (int, error) {
n, err := b.ReadCloser.Read(p)
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
err = caddyhttp.Error(http.StatusRequestEntityTooLarge, err)
}
return n, err
}
func limitRequestBody(w http.ResponseWriter, r *http.Request, max int64) error {
if max <= 0 {
return nil
}
if r.ContentLength > max {
return caddyhttp.Error(http.StatusRequestEntityTooLarge,
fmt.Errorf("request body exceeds %d bytes", max))
}
if r.Body != nil {
r.Body = maxBytesBody{http.MaxBytesReader(w, r.Body, max)}
}
return nil
}
// effectivePythonRuntime returns the runtime string passed to the Python worker.
// When Runtime is empty: sync for WSGI, gevent for ESGI, uvloop for ASGI.
func effectivePythonRuntime(iface, runtime string) string {
if iface == "asgi" && runtime == "libuv" {
runtime = "uvloop" // legacy alias; configuration name is uvloop
}
if runtime != "" {
return runtime
}
if iface == "asgi" {
return "uvloop"
}
if iface == "esgi" {
return "gevent"
}
return "sync"
}
func validatePythonRuntime(iface, runtime string) error {
eff := effectivePythonRuntime(iface, runtime)
switch iface {
case "wsgi":
if eff != "sync" && eff != "gevent" {
return fmt.Errorf("wsgi runtime must be sync or gevent, got %q", eff)
}
case "esgi":
if eff != "gevent" {
return fmt.Errorf("esgi runtime must be gevent, got %q", eff)
}
case "asgi":
if eff != "native" && eff != "uvloop" {
return fmt.Errorf("asgi runtime must be native or uvloop, got %q", eff)
}
default:
return fmt.Errorf("unknown python interface %q", iface)
}
return nil
}
// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
func (f *CaddySnake) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
for d.Next() {
args := d.RemainingArgs()
if len(args) == 1 {
f.ModuleWsgi = args[0]
} else if len(args) == 0 {
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
case "module_asgi":
if !d.Args(&f.ModuleAsgi) {
return d.Errf("expected exactly one argument for module_asgi")
}
case "module_esgi":
if !d.Args(&f.ModuleEsgi) {
return d.Errf("expected exactly one argument for module_esgi")
}
case "module_wsgi":
if !d.Args(&f.ModuleWsgi) {
return d.Errf("expected exactly one argument for module_wsgi")
}
case "runtime":
if !d.Args(&f.Runtime) {
return d.Errf("expected exactly one argument for runtime")
}
case "lifespan":
if !d.Args(&f.Lifespan) || (f.Lifespan != "on" && f.Lifespan != "off") {
return d.Errf("expected exactly one argument for lifespan: on|off")
}
case "working_dir":
if !d.Args(&f.WorkingDir) {
return d.Errf("expected exactly one argument for working_dir")
}
case "venv":
if !d.Args(&f.VenvPath) {
return d.Errf("expected exactly one argument for venv")
}
case "workers":
if !d.Args(&f.Workers) {
return d.Errf("expected exactly one argument for workers")
}
case "start_timeout":
if !d.Args(&f.StartTimeout) {
return d.Errf("expected exactly one argument for start_timeout")
}
if _, err := parseStartTimeout(f.StartTimeout); err != nil {
return d.Errf("%v", err)
}
case "autoreload":
f.Autoreload = "on"
case "python_path":
if !d.Args(&f.PythonPath) {
return d.Errf("expected exactly one argument for python_path")
}
case "env_file":
var path string
if !d.Args(&path) {
return d.Errf("expected exactly one argument for env_file")
}
f.EnvFiles = append(f.EnvFiles, path)
case "env_var":
var name, value string
if !d.Args(&name, &value) {
return d.Errf("expected exactly two arguments for env_var: VARNAME value")
}
if err := validateEnvVarName(name); err != nil {
return d.Errf("invalid env_var name %q: %v", name, err)
}
if f.EnvVars == nil {
f.EnvVars = make(map[string]string)
}
f.EnvVars[name] = value
case "isolation":
if err := parseIsolationCaddyfile(d, &f.Isolation); err != nil {
return err
}
case "request_body":
if err := parseRequestBodyCaddyfile(d, f); err != nil {
return err
}
case "max_dynamic_apps":
if !d.Args(&f.MaxDynamicApps) {
return d.Errf("expected exactly one argument for max_dynamic_apps")
}
default:
return d.Errf("unknown subdirective: %s", d.Val())
}
}
} else {
return d.ArgErr()
}
}
return nil
}
// CaddyModule returns the Caddy module information.
func (CaddySnake) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "http.handlers.python",
New: func() caddy.Module { return new(CaddySnake) },
}
}
// Provision sets up the module.
func (f *CaddySnake) Provision(ctx caddy.Context) error {
var err error
f.logger = ctx.Logger(f)
cs, err := startCacheServerForIsolation(f.Isolation)
if err != nil {
return fmt.Errorf("in-process cache: %w", err)
}
f.cacheSrv = cs
cacheAddr := cs.Addr()
cacheToken := cs.Token()
success := false
defer func() {
if !success && f.cacheSrv != nil {
_ = f.cacheSrv.Close()
f.cacheSrv = nil
}
}()
workers := 0
if f.Workers != "" {
var parseErr error
workers, parseErr = strconv.Atoi(f.Workers)
if parseErr != nil || workers < 0 {
return fmt.Errorf("invalid workers value %q: must be a non-negative integer", f.Workers)
}
}
if workers <= 0 {
workers = runtime.GOMAXPROCS(0)
}
if workers > maxPythonWorkers {
workers = maxPythonWorkers
}
startTimeout, err := parseStartTimeout(f.StartTimeout)
if err != nil {
return err
}
isDynamic := containsPlaceholder(f.ModuleWsgi) || containsPlaceholder(f.ModuleAsgi) ||
containsPlaceholder(f.ModuleEsgi) ||
containsPlaceholder(f.WorkingDir) || containsPlaceholder(f.VenvPath) ||
envFilesContainPlaceholder(f.EnvFiles) || envMapContainsPlaceholder(f.EnvVars)
if isDynamic {
if err = f.provisionDynamic(workers, cacheAddr, cacheToken, startTimeout); err != nil {
return err
}
success = true
return nil
}
pythonBin := resolvePythonInterpreter(f.PythonPath, f.VenvPath)
envFiles := cloneEnvFiles(f.EnvFiles)
envVars := cloneEnvVars(f.EnvVars)
isolation := cloneIsolationConfig(f.Isolation)
iface, module, err := f.pythonInterfaceAndModule()
if err != nil {
return err
}
lifespan := ""
if iface == "asgi" {
lifespan = f.Lifespan
} else if f.Lifespan != "" {
if iface == "wsgi" {
f.logger.Warn("lifespan attribute is ignored in WSGI mode", zap.String("lifespan", f.Lifespan))
} else {
f.logger.Warn("lifespan is for ASGI only; ignored in ESGI mode", zap.String("lifespan", f.Lifespan))
}
}
rt := effectivePythonRuntime(iface, f.Runtime)
f.app, err = NewPythonWorkerGroup(iface, module, f.WorkingDir, f.VenvPath, lifespan, rt, workers, pythonBin, cacheAddr, cacheToken, envFiles, envVars, startTimeout, isolation, f.logger)
if err != nil {
return err
}
f.logger.Info("serving "+iface+" app",
zap.String("module_"+iface, module),
zap.String("working_dir", f.WorkingDir),
zap.String("venv_path", f.VenvPath),
zap.String("python", pythonBin),
zap.String("runtime", rt),
)
if f.Autoreload == "on" {
watchDir := f.WorkingDir
if watchDir == "" {
watchDir = "."
}
absDir, absErr := filepath.Abs(watchDir)
if absErr != nil {
return fmt.Errorf("autoreload: %w", absErr)
}
factory := func() (AppServer, error) {
return NewPythonWorkerGroup(iface, module, f.WorkingDir, f.VenvPath, lifespan, rt, workers, pythonBin, cacheAddr, cacheToken, envFiles, envVars, startTimeout, isolation, f.logger)
}
// Keep Caddy running on reload errors; failed app serves 503 until recovery.
f.app, err = NewAutoreloadableApp(f.app, absDir, factory, f.logger, nil)
if err != nil {
return fmt.Errorf("autoreload: %w", err)
}
}
success = true
return nil
}
// pythonInterfaceAndModule returns the configured worker interface and module path.
func (f *CaddySnake) pythonInterfaceAndModule() (iface, module string, err error) {
switch {
case f.ModuleWsgi != "":
return "wsgi", f.ModuleWsgi, nil
case f.ModuleAsgi != "":
return "asgi", f.ModuleAsgi, nil
case f.ModuleEsgi != "":
return "esgi", f.ModuleEsgi, nil
default:
return "", "", errors.New("a wsgi, asgi, or esgi app must be specified")
}
}
// provisionDynamic sets up the module in dynamic mode where Caddy placeholders
// in module_wsgi/module_asgi/module_esgi, working_dir, or venv are resolved per-request.
func (f *CaddySnake) provisionDynamic(workers int, cacheAddr, cacheToken string, startTimeout time.Duration) error {
autoreload := f.Autoreload == "on"
pythonPath := f.PythonPath
envFilePatterns := cloneEnvFiles(f.EnvFiles)
envVarPatterns := cloneEnvVars(f.EnvVars)
isolation := cloneIsolationConfig(f.Isolation)
logger := f.logger
limits, err := parseDynamicAppLimits(f.MaxDynamicApps)
if err != nil {
return err
}
iface, modulePattern, err := f.pythonInterfaceAndModule()
if err != nil {
return errors.New("a wsgi, asgi, or esgi app must be specified for dynamic mode")
}
lifespan := ""
if iface == "asgi" {
lifespan = f.Lifespan
} else if f.Lifespan != "" {
if iface == "wsgi" {
f.logger.Warn("lifespan attribute is ignored in WSGI mode", zap.String("lifespan", f.Lifespan))
} else {
f.logger.Warn("lifespan is for ASGI only; ignored in dynamic ESGI mode", zap.String("lifespan", f.Lifespan))
}
}
rt := effectivePythonRuntime(iface, f.Runtime)
factory := func(module, dir, venv string, envFiles []string, envVars map[string]string) (AppServer, error) {
pythonBin := resolvePythonInterpreter(pythonPath, venv)
return NewPythonWorkerGroup(iface, module, dir, venv, lifespan, rt, workers, pythonBin, cacheAddr, cacheToken, envFiles, envVars, startTimeout, isolation, logger)
}
f.app, err = NewDynamicApp(modulePattern, f.WorkingDir, f.VenvPath, envFilePatterns, envVarPatterns, factory, f.logger, autoreload, nil, limits)
if err != nil {
return err
}
f.logger.Info("serving dynamic "+iface+" app",
zap.String("module_"+iface, modulePattern),
zap.String("working_dir", f.WorkingDir),
zap.String("venv_path", f.VenvPath),
)
return nil
}
// Validate implements caddy.Validator.
func (m *CaddySnake) Validate() error {
n := 0
if m.ModuleWsgi != "" {
n++
}
if m.ModuleAsgi != "" {
n++
}
if m.ModuleEsgi != "" {
n++
}
if n != 1 {
return errors.New("exactly one of module_wsgi, module_asgi, or module_esgi is required")
}
var iface string
switch {
case m.ModuleWsgi != "":
iface = "wsgi"
case m.ModuleAsgi != "":
iface = "asgi"
default:
iface = "esgi"
}
if err := validatePythonRuntime(iface, m.Runtime); err != nil {
return err
}
if m.Workers != "" {
w, err := strconv.Atoi(m.Workers)
if err != nil || w < 0 {
return fmt.Errorf("invalid workers value: %s", m.Workers)
}
if w > maxPythonWorkers {
return fmt.Errorf("workers value %d exceeds maximum of %d", w, maxPythonWorkers)
}
}
if _, err := parseStartTimeout(m.StartTimeout); err != nil {
return err
}
if m.Lifespan != "" && m.Lifespan != "on" && m.Lifespan != "off" {
return fmt.Errorf("lifespan must be 'on' or 'off', got: %s", m.Lifespan)
}
if err := validateEnvVars(m.EnvVars); err != nil {
return err
}
if err := m.validateIsolation(); err != nil {
return err
}
if m.Isolation != nil && m.Isolation.usesDocker() && runtime.GOOS == "windows" {
return fmt.Errorf("isolation docker is not supported on windows")
}
if _, err := parseDynamicAppLimits(m.MaxDynamicApps); err != nil {
return err
}
nBytes, err := parseRequestBodyMaxSize(m.RequestBodyMaxSize)
if err != nil {
return err
}
m.requestBodyMaxBytes = nBytes
return nil
}
// Cleanup frees resources uses by module
func (m *CaddySnake) Cleanup() error {
var err error
if m != nil && m.app != nil {
m.logger.Info("cleaning up module")
err = m.app.Cleanup()
}
if m != nil && m.cacheSrv != nil {
if cerr := m.cacheSrv.Close(); cerr != nil && err == nil {
err = cerr
}
m.cacheSrv = nil
}
return err
}
// ServeHTTP implements caddyhttp.MiddlewareHandler.
func (f CaddySnake) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
if err := limitRequestBody(w, r, f.requestBodyMaxBytes); err != nil {
return err
}
if err := f.app.HandleRequest(w, r); err != nil {
return err
}
return nil
}
// Interface guards
var (
_ caddy.Provisioner = (*CaddySnake)(nil)
_ caddy.Validator = (*CaddySnake)(nil)
_ caddy.CleanerUpper = (*CaddySnake)(nil)
_ caddyhttp.MiddlewareHandler = (*CaddySnake)(nil)
_ caddyfile.Unmarshaler = (*CaddySnake)(nil)
)
func parsePythonDirective(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
var app CaddySnake
if err := app.UnmarshalCaddyfile(h.Dispenser); err != nil {
return nil, err
}
return app, nil
}
// resolvePythonInterpreter determines the Python interpreter to use.
// Priority: explicit python_path > venv/bin/python > system python3.
func resolvePythonInterpreter(pythonPath, venvPath string) string {
if pythonPath != "" {
return pythonPath
}
if venvPath != "" {
var binDir string
if runtime.GOOS == "windows" {
binDir = "Scripts"
} else {
binDir = "bin"
}
venvPython := filepath.Join(venvPath, binDir, "python3")
if runtime.GOOS == "windows" {
venvPython = filepath.Join(venvPath, binDir, "python.exe")
}
if _, err := os.Stat(venvPython); err == nil {
return venvPython
}
venvPython2 := filepath.Join(venvPath, binDir, "python")
if _, err := os.Stat(venvPython2); err == nil {
return venvPython2
}
}
return "python3"
}
// writeCaddysnakePyBundle writes the embedded worker implementation as worker_main.py
// inside a private temp directory so user apps can import the PyPI `caddysnake` package.
func writeCaddysnakePyBundle() (scriptPath, bundleDir string, err error) {
bundleDir, err = os.MkdirTemp("", "caddysnake-worker-*")
if err != nil {
return "", "", err
}
if runtime.GOOS != "windows" {
if chErr := os.Chmod(bundleDir, 0700); chErr != nil {
os.RemoveAll(bundleDir)
return "", "", chErr
}
}
scriptPath = filepath.Join(bundleDir, "worker_main.py")
if wrErr := os.WriteFile(scriptPath, []byte(caddysnake_py), 0600); wrErr != nil {
os.RemoveAll(bundleDir)
return "", "", wrErr
}
return scriptPath, bundleDir, nil
}
// Shared worker script bundle: one temp copy of caddysnake.py is reused across
// worker groups (important for dynamic multi-tenant apps).
type pyBundle struct {
scriptPath string
dir string
refs int
}
var (
sharedBundleMu sync.Mutex
sharedBundle *pyBundle
)
func acquireCaddysnakePyBundle() (scriptPath, bundleDir string, err error) {
sharedBundleMu.Lock()
defer sharedBundleMu.Unlock()
if sharedBundle != nil {
sharedBundle.refs++
return sharedBundle.scriptPath, sharedBundle.dir, nil
}
scriptPath, bundleDir, err = writeCaddysnakePyBundle()
if err != nil {
return "", "", err
}
sharedBundle = &pyBundle{scriptPath: scriptPath, dir: bundleDir, refs: 1}
return scriptPath, bundleDir, nil
}
func releaseCaddysnakePyBundle() {
sharedBundleMu.Lock()
defer sharedBundleMu.Unlock()
if sharedBundle == nil {
return
}
sharedBundle.refs--
if sharedBundle.refs <= 0 {
_ = os.RemoveAll(sharedBundle.dir)
sharedBundle = nil
}
}
// proxyBufferPool implements httputil.BufferPool using sync.Pool to reduce GC pressure.
type proxyBufferPool struct {
pool sync.Pool
}
func (p *proxyBufferPool) Get() []byte {
b := p.pool.Get()
if b == nil {
return make([]byte, 32*1024)
}
return *b.(*[]byte)
}
func (p *proxyBufferPool) Put(b []byte) {
p.pool.Put(&b)
}
var sharedProxyBufferPool = &proxyBufferPool{}
type PythonWorker struct {
Interface string
App string
WorkingDir string
Venv string
Lifespan string
Runtime string
PythonBin string
ScriptPath string
ScriptDir string
DialNet string // "unix" or "tcp"
DialAddr string // socket path or host:port
CacheAddr string // CADDYSNAKE_CACHE_ADDR: unix://path (Unix) or 127.0.0.1:port (Windows); empty = omit env
CacheToken string // CADDYSNAKE_CACHE_TOKEN for TCP cache auth; empty on Unix sockets
WorkerToken string // shared secret for inbound proxy requests (CADDYSNAKE_WORKER_TOKEN)
WorkerID string // CADDYSNAKE_WORKER_ID: stable index 0..N-1 within the worker group
EnvFiles []string
EnvVars map[string]string
StartTimeout time.Duration // 0 = DefaultStartTimeout; <0 = indefinite
Isolation *IsolationConfig
logger *zap.Logger
backend WorkerBackend
handle WorkerHandle
Transport *http.Transport
Proxy *httputil.ReverseProxy
}
func NewPythonWorker(iface, app, workingDir, venv, lifespan, pyRuntime, pythonBin, scriptPath, cacheAddr, cacheToken, workerToken, workerID string, envFiles []string, envVars map[string]string, startTimeout time.Duration, isolation *IsolationConfig, logger *zap.Logger) (*PythonWorker, error) {
w := &PythonWorker{
Interface: iface,
App: app,
WorkingDir: workingDir,
Venv: venv,
Lifespan: lifespan,
Runtime: pyRuntime,
PythonBin: pythonBin,
ScriptPath: scriptPath,
ScriptDir: filepath.Dir(scriptPath),
CacheAddr: cacheAddr,
CacheToken: cacheToken,
WorkerToken: workerToken,
WorkerID: workerID,
EnvFiles: cloneEnvFiles(envFiles),
EnvVars: cloneEnvVars(envVars),
StartTimeout: startTimeout,
Isolation: cloneIsolationConfig(isolation),
logger: logger,
}
var err error
w.backend, err = newWorkerBackend(w.Isolation)
if err != nil {
return nil, err
}
if err = w.Start(); err != nil {
_ = w.Cleanup()
return nil, err
}
return w, nil
}
func (w *PythonWorker) Start() error {
w.Transport = &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return w.dialWithRetry(ctx)
},
MaxIdleConns: 1024,
MaxIdleConnsPerHost: 256,
IdleConnTimeout: 90 * time.Second,
DisableCompression: true,
}
w.Proxy = &httputil.ReverseProxy{
Rewrite: func(req *httputil.ProxyRequest) {
setPythonWorkerOutboundHeaders(req, w.DialAddr, w.WorkerToken)
},
Transport: w.Transport,
BufferPool: sharedProxyBufferPool,
ErrorHandler: func(rw http.ResponseWriter, req *http.Request, err error) {
if isRequestBodyTooLarge(err) {
http.Error(rw, http.StatusText(http.StatusRequestEntityTooLarge), http.StatusRequestEntityTooLarge)
return
}
if w.logger != nil {
w.logger.Error("python worker proxy error",
zap.String("app", w.App),
zap.String("interface", w.Interface),
zap.Error(err),
)
}
rw.WriteHeader(http.StatusBadGateway)
},
}
workingDir := w.WorkingDir
if workingDir == "" {
cwd, cwdErr := os.Getwd()
if cwdErr != nil {
return fmt.Errorf("working directory: %w", cwdErr)
}
workingDir = cwd
}
spec := WorkerSpec{
Interface: w.Interface,
App: w.App,
WorkingDir: workingDir,
Venv: w.Venv,
Lifespan: w.Lifespan,
Runtime: w.Runtime,
PythonBin: w.PythonBin,
ScriptPath: w.ScriptPath,
ScriptDir: w.ScriptDir,
EnvFiles: w.EnvFiles,
EnvVars: w.EnvVars,
WorkerID: w.WorkerID,
CacheAddr: w.CacheAddr,
CacheToken: w.CacheToken,
WorkerToken: w.WorkerToken,
StartTimeout: w.StartTimeout,
Isolation: w.Isolation,
Logger: w.logger,
}
handle, err := w.backend.Start(context.Background(), spec)
if err != nil {
return err
}
w.handle = handle
w.DialNet = handle.DialNetwork()
w.DialAddr = handle.DialAddress()
return nil
}
// maybeWarnSlowStart logs once when the app is still loading past startTimeoutWarnAfter
// and the configured timeout allows waiting longer than that.
func maybeWarnSlowStart(logger *zap.Logger, warned *bool, start time.Time, timeout time.Duration, path, kind string) {
if *warned || logger == nil {
return
}
hasDeadline := timeout >= 0
if hasDeadline && timeout <= startTimeoutWarnAfter {
return
}
if time.Since(start) < startTimeoutWarnAfter {
return
}
logger.Warn("Python app is taking a long time to load; still waiting for worker to become ready",
zap.String(kind, path),
zap.Duration("waited", time.Since(start)),
)
*warned = true
}
func checkWorkerExited(exited <-chan error, path, kind string) error {
if exited == nil {
return nil
}
select {
case err := <-exited:
if err != nil {
return fmt.Errorf("%w (%s %s): %w", errWorkerExited, kind, path, err)
}
return fmt.Errorf("%w (%s %s)", errWorkerExited, kind, path)
default:
return nil
}
}
// waitForPortFile polls the given file path until it contains a valid port number.
// timeout < 0 means wait indefinitely. exited, if non-nil, fails fast when the
// worker process exits. logger may be nil.
func waitForPortFile(path string, timeout time.Duration, exited <-chan error, logger *zap.Logger) (int, error) {
start := time.Now()
var deadline time.Time
hasDeadline := timeout >= 0
if hasDeadline {
deadline = start.Add(timeout)
}
warned := false
for {
if err := checkWorkerExited(exited, path, "port file"); err != nil {
return 0, err
}
data, err := os.ReadFile(path)
if err == nil && len(data) > 0 {
port, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err == nil && port > 0 && port < 65536 {
return port, nil
}
}
if hasDeadline && !time.Now().Before(deadline) {
return 0, fmt.Errorf("port file %s not ready within %v", path, timeout)
}
maybeWarnSlowStart(logger, &warned, start, timeout, path, "port_file")
time.Sleep(50 * time.Millisecond)
}
}
// waitForUnixSocket polls until a unix socket at path accepts connections.
// timeout < 0 means wait indefinitely. exited, if non-nil, fails fast when the
// worker process exits. logger may be nil.
func waitForUnixSocket(path string, timeout time.Duration, exited <-chan error, logger *zap.Logger) error {
start := time.Now()
var deadline time.Time
hasDeadline := timeout >= 0
if hasDeadline {
deadline = start.Add(timeout)
}
warned := false
var dialer net.Dialer
for {
if err := checkWorkerExited(exited, path, "unix socket"); err != nil {
return err
}
conn, err := dialer.DialContext(context.Background(), "unix", path)
if err == nil {
conn.Close()
return nil
}
if hasDeadline && !time.Now().Before(deadline) {
return fmt.Errorf("unix socket %s not ready within %v", path, timeout)
}
maybeWarnSlowStart(logger, &warned, start, timeout, path, "socket")
time.Sleep(50 * time.Millisecond)
}
}
// dialWithRetry attempts to establish a connection with retry logic
func (w *PythonWorker) dialWithRetry(ctx context.Context) (net.Conn, error) {
const maxRetries = 5
const baseDelay = 100 * time.Millisecond
var dialer net.Dialer
for attempt := 0; attempt < maxRetries; attempt++ {
conn, err := dialer.DialContext(ctx, w.DialNet, w.DialAddr)
if err == nil {
return conn, nil
}
if attempt == maxRetries-1 {
return nil, fmt.Errorf("failed to connect after %d attempts: %w", maxRetries, err)
}
delay := baseDelay * time.Duration(1<<attempt)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(delay):
}
}
return nil, fmt.Errorf("unexpected error in dialWithRetry")
}
func (w *PythonWorker) Cleanup() error {
if w.Transport != nil {
w.Transport.CloseIdleConnections()
}
if w.backend != nil && w.handle != nil {
_ = w.backend.Stop(w.handle, 5*time.Second)
}
return nil
}
func (w *PythonWorker) HandleRequest(rw http.ResponseWriter, req *http.Request) error {
w.Proxy.ServeHTTP(rw, req)
return nil
}
type PythonWorkerGroup struct {
Workers []*PythonWorker
roundRobin atomic.Uint64
BundleDir string
ScriptPath string // path to worker_main.py inside BundleDir
}
func NewPythonWorkerGroup(iface, app, workingDir, venv, lifespan, runtime string, count int, pythonBin, cacheAddr, cacheToken string, envFiles []string, envVars map[string]string, startTimeout time.Duration, isolation *IsolationConfig, logger *zap.Logger) (*PythonWorkerGroup, error) {
scriptPath, bundleDir, err := acquireCaddysnakePyBundle()
if err != nil {
return nil, fmt.Errorf("failed to write worker bundle: %w", err)
}
workerToken, err := generateSecretToken()
if err != nil {
os.RemoveAll(bundleDir)
return nil, fmt.Errorf("failed to generate worker token: %w", err)
}
errs := make([]error, count)
workers := make([]*PythonWorker, count)
for i := 0; i < count; i++ {
workers[i], errs[i] = NewPythonWorker(iface, app, workingDir, venv, lifespan, runtime, pythonBin, scriptPath, cacheAddr, cacheToken, workerToken, strconv.Itoa(i), envFiles, envVars, startTimeout, isolation, logger)
}
wg := &PythonWorkerGroup{
Workers: workers,
BundleDir: bundleDir,
ScriptPath: scriptPath,
}
if err := errors.Join(errs...); err != nil {
// Prefer the start/readiness error; discard cleanup noise (e.g. ECHILD).
_ = wg.Cleanup()
return nil, err
}
return wg, nil
}
func (wg *PythonWorkerGroup) Cleanup() error {
if wg == nil {
return nil
}
errs := make([]error, len(wg.Workers))
for i, worker := range wg.Workers {
if worker != nil {
errs[i] = worker.Cleanup()
}
}
if wg.BundleDir != "" {
releaseCaddysnakePyBundle()
wg.BundleDir = ""
}
return errors.Join(errs...)
}
func (wg *PythonWorkerGroup) HandleRequest(rw http.ResponseWriter, req *http.Request) error {
n := wg.roundRobin.Add(1)
idx := int(n % uint64(len(wg.Workers)))
return wg.Workers[idx].HandleRequest(rw, req)
}
func init() {
applyStartTimeoutWarnAfterEnv()
caddy.RegisterModule(CaddySnake{})
httpcaddyfile.RegisterHandlerDirective("python", parsePythonDirective)
httpcaddyfile.RegisterDirectiveOrder("python", httpcaddyfile.Before, "route")
caddycmd.RegisterCommand(caddycmd.Command{
Name: "python-server",
Usage: "--server-type wsgi|asgi|esgi --app <module> " +
"[--domain <example.com>] [--listen <addr>] [--workers <count>] " +
"[--python-path <path>] [--working-dir <path>] [--venv <path>] " +
"[--env-file <path>] [--env-var NAME=VALUE] " +
"[--start-timeout=<duration|-1|forever>] " +
"[--static-path <path>] [--static-route <route>] " +
"[--runtime <name>] [--lifespan on|off] " +
"[--isolation none|docker] [--isolation-image <image>] " +
"[--max-dynamic-apps <count>] " +
"[--request-body-max-size <size>] " +
"[--debug] [--access-logs] [--autoreload]",
Short: "Spins up a Python server",
Long: `
A Python WSGI, ASGI, or ESGI server designed for apps and frameworks.
Python-block options mirror the Caddyfile python directive (workers, venv,
working_dir, env_file, env_var, start_timeout, runtime, lifespan, autoreload,
python_path, request_body). CLI-only flags cover listen address, HTTPS domain, and static files.
For an indefinite readiness wait use --start-timeout=-1 (equals form) or
--start-timeout forever. Do not pass a bare "-1" as a separate argv token;
Cobra/pflag treats it as a flag name.
You can specify a custom socket address using the '--listen' option. You can also specify the number of workers to spawn.
Providing a domain name with the '--domain' flag enables HTTPS and sets the listener to the appropriate secure port.
Ensure DNS A/AAAA records are correctly set up if using a public domain for secure connections.
`,
CobraFunc: func(cmd *cobra.Command) {
cmd.Flags().StringP("server-type", "t", "", "Required. The type of server to use: wsgi|asgi|esgi")
cmd.Flags().StringP("app", "a", "", "Required. App module to be imported")
cmd.Flags().StringP("domain", "d", "", "Domain name at which to serve the files")
cmd.Flags().StringP("listen", "l", "", "The address to which to bind the listener")
cmd.Flags().StringP("workers", "w", "0", "The number of workers to spawn")
cmd.Flags().String("python-path", "", "Path to the Python interpreter")
cmd.Flags().String("working-dir", "", "Working directory for the Python app")
cmd.Flags().String("venv", "", "Path to a Python virtual environment to use")
cmd.Flags().StringSlice("env-file", nil, "Dotenv file loaded into worker env (repeatable; later files override)")
cmd.Flags().StringSlice("env-var", nil, "Inline worker env var as NAME=VALUE (repeatable; overrides env-file)")
cmd.Flags().String("start-timeout", "", "Wait for worker readiness (default: 120s; use =-1 or forever for indefinite)")
cmd.Flags().String("static-path", "", "Path to a static directory to serve: path/to/static")
cmd.Flags().String("static-route", "/static", "Route to serve the static directory: /static")
cmd.Flags().Bool("debug", false, "Enable debug logs")
cmd.Flags().Bool("access-logs", false, "Enable access logs")
cmd.Flags().Bool("autoreload", false, "Watch .py files and reload on changes")
cmd.Flags().String("lifespan", "off", "Enable ASGI lifespan support (ignored in WSGI mode)")
cmd.Flags().String("runtime", "", "Worker runtime (wsgi: sync|gevent; esgi: gevent only; asgi: native|uvloop); defaults: sync for WSGI, gevent for ESGI, uvloop for ASGI")
cmd.Flags().String("isolation", "", "Worker isolation backend: none or docker")
cmd.Flags().String("isolation-image", "", "Docker image for --isolation docker")
cmd.Flags().String("isolation-network", "", "Docker network mode/name for isolated workers")
cmd.Flags().String("isolation-docker-host", "", "DOCKER_HOST for the Docker CLI when using isolation docker")
cmd.Flags().String("isolation-memory", "", "Docker memory limit for isolated workers (e.g. 512m)")
cmd.Flags().String("isolation-cpus", "", "Docker CPU limit for isolated workers (e.g. 1.0)")
cmd.Flags().Bool("isolation-read-only", false, "Mount container root filesystem read-only for isolated workers")
cmd.Flags().String("max-dynamic-apps", "", "Max distinct dynamic Python apps (empty = env/default, usually 128)")
cmd.Flags().String("request-body-max-size", "", "Maximum HTTP request body size (e.g. 1KiB, 2GB). Empty means unlimited.")
cmd.RunE = caddycmd.WrapCommandFuncForCobra(pythonServer)
},
})
}
// pythonServer is inspired on the php-server command of the Frankenphp project (MIT License)
func pythonServer(fs caddycmd.Flags) (int, error) {
caddy.TrapSignals()
domain := fs.String("domain")
app := fs.String("app")
listen := fs.String("listen")
workers := fs.String("workers")
debug := fs.Bool("debug")
accessLogs := fs.Bool("access-logs")
autoreload := fs.Bool("autoreload")
staticPath := fs.String("static-path")
staticRoute := fs.String("static-route")
serverType := fs.String("server-type")
pythonPath := fs.String("python-path")
workingDir := fs.String("working-dir")
venv := fs.String("venv")
lifespan := fs.String("lifespan")
runtimeFlag := fs.String("runtime")
maxDynamicApps := fs.String("max-dynamic-apps")
requestBodyMaxSize := fs.String("request-body-max-size")
startTimeout := fs.String("start-timeout")
isolationFlag := fs.String("isolation")
isolationImage := fs.String("isolation-image")
isolationNetwork := fs.String("isolation-network")
isolationDockerHost := fs.String("isolation-docker-host")
isolationMemory := fs.String("isolation-memory")
isolationCPUs := fs.String("isolation-cpus")
isolationReadOnly := fs.Bool("isolation-read-only")
envFiles, err := fs.GetStringSlice("env-file")
if err != nil {
return caddy.ExitCodeFailedStartup, err
}
envVarFlags, err := fs.GetStringSlice("env-var")
if err != nil {
return caddy.ExitCodeFailedStartup, err
}
envVars, err := parseCLIEnvVars(envVarFlags)
if err != nil {
return caddy.ExitCodeFailedStartup, err
}
if serverType == "" {
return caddy.ExitCodeFailedStartup, errors.New("--server-type is required")
}
if serverType != "wsgi" && serverType != "asgi" && serverType != "esgi" {
return caddy.ExitCodeFailedStartup, fmt.Errorf("invalid --server-type %q (want wsgi, asgi, or esgi)", serverType)
}
if app == "" {
return caddy.ExitCodeFailedStartup, errors.New("--app is required")
}
gzip, err := caddy.GetModule("http.encoders.gzip")
if err != nil {
return caddy.ExitCodeFailedStartup, err
}
zstd, err := caddy.GetModule("http.encoders.zstd")
if err != nil {
return caddy.ExitCodeFailedStartup, err
}
encodings := caddy.ModuleMap{
"zstd": caddyconfig.JSON(zstd.New(), nil),
"gzip": caddyconfig.JSON(gzip.New(), nil),
}
prefer := []string{"zstd", "gzip"}
pythonHandler := CaddySnake{}
if serverType == "wsgi" {
pythonHandler.ModuleWsgi = app
} else if serverType == "asgi" {
pythonHandler.ModuleAsgi = app
} else {
pythonHandler.ModuleEsgi = app
}
if venv != "" {
pythonHandler.VenvPath = venv
} else if venv := os.Getenv("VIRTUAL_ENV"); venv != "" {
pythonHandler.VenvPath = venv
}
pythonHandler.Workers = workers
pythonHandler.PythonPath = pythonPath
if autoreload {
pythonHandler.Autoreload = "on"
}
pythonHandler.WorkingDir = workingDir
pythonHandler.Lifespan = lifespan
pythonHandler.Runtime = runtimeFlag
pythonHandler.StartTimeout = startTimeout
pythonHandler.MaxDynamicApps = maxDynamicApps
pythonHandler.RequestBodyMaxSize = requestBodyMaxSize
pythonHandler.EnvFiles = cloneEnvFiles(envFiles)
pythonHandler.EnvVars = envVars
if iso, err := buildIsolationFromCLI(isolationFlag, isolationImage, isolationNetwork, isolationDockerHost, isolationMemory, isolationCPUs, isolationReadOnly); err != nil {
return caddy.ExitCodeFailedStartup, err
} else if iso != nil {
pythonHandler.Isolation = iso
}
if err := pythonHandler.Validate(); err != nil {
return caddy.ExitCodeFailedStartup, err
}
routes := caddyhttp.RouteList{}
if staticPath != "" {
if strings.HasSuffix(staticRoute, "/") {
staticRoute = staticRoute + "*"
} else if !strings.HasSuffix(staticRoute, "/*") {
staticRoute = staticRoute + "/*"
}
staticRoute := caddyhttp.Route{
MatcherSetsRaw: []caddy.ModuleMap{
{
"path": caddyconfig.JSON(caddyhttp.MatchPath{staticRoute}, nil),
},
},
HandlersRaw: []json.RawMessage{
caddyconfig.JSONModuleObject(encode.Encode{
EncodingsRaw: encodings,
Prefer: prefer,
}, "handler", "encode", nil),
caddyconfig.JSON(map[string]interface{}{
"handler": "file_server",
"root": staticPath,
}, nil),
},
}
routes = append(routes, staticRoute)
}
mainRoute := caddyhttp.Route{
MatcherSetsRaw: []caddy.ModuleMap{
{
"path": caddyconfig.JSON(caddyhttp.MatchPath{"/*"}, nil),
},
},
HandlersRaw: []json.RawMessage{
caddyconfig.JSONModuleObject(encode.Encode{
EncodingsRaw: encodings,
Prefer: prefer,
}, "handler", "encode", nil),
caddyconfig.JSONModuleObject(pythonHandler, "handler", "python", nil),
},
}
routes = append(routes, mainRoute)
subroute := caddyhttp.Subroute{
Routes: routes,
}
route := caddyhttp.Route{
HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(subroute, "handler", "subroute", nil)},
}
if domain != "" {
route.MatcherSetsRaw = []caddy.ModuleMap{
{
"host": caddyconfig.JSON(caddyhttp.MatchHost{domain}, nil),
},
}
}
server := &caddyhttp.Server{
ReadHeaderTimeout: caddy.Duration(10 * time.Second),
IdleTimeout: caddy.Duration(30 * time.Second),
MaxHeaderBytes: 1024 * 10,
Routes: caddyhttp.RouteList{route},
}
if listen == "" {
if domain == "" {
listen = "127.0.0.1:9080"
} else {
listen = ":" + strconv.Itoa(certmagic.HTTPSPort)
}
}
server.Listen = []string{listen}
if accessLogs {
server.Logs = &caddyhttp.ServerLogConfig{}
}
httpApp := caddyhttp.App{
Servers: map[string]*caddyhttp.Server{"srv0": server},
}
var f bool
cfg := &caddy.Config{
Admin: &caddy.AdminConfig{
Disabled: false,
Config: &caddy.ConfigSettings{
Persist: &f,
},
},
AppsRaw: caddy.ModuleMap{
"http": caddyconfig.JSON(httpApp, nil),
},
}
if debug {
cfg.Logging = &caddy.Logging{
Logs: map[string]*caddy.CustomLog{
"default": {
BaseLog: caddy.BaseLog{Level: zapcore.DebugLevel.CapitalString()},
},
},
}
}
if err := caddy.Run(cfg); err != nil {
return caddy.ExitCodeFailedStartup, err
}
log.Printf("Serving Python app on %s", listen)
select {}
}
package caddysnake
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/caddyserver/caddy/v2"
"github.com/fsnotify/fsnotify"
"go.uber.org/zap"
)
var validModulePattern = regexp.MustCompile(`^[a-zA-Z_][\w.]*:[a-zA-Z_]\w*$`)
var (
ErrDynamicAppCapacity = errors.New("dynamic app limit reached")
ErrDynamicCreateLimit = errors.New("too many concurrent dynamic app creations")
)
const (
defaultMaxDynamicApps = 128
defaultDynamicMaxConcurrency = 64
defaultDynamicFailureTTL = 5 * time.Second
defaultDynamicAppIdleTTL = 30 * time.Minute
envMaxDynamicApps = "CADDYSNAKE_MAX_DYNAMIC_APPS"
envDynamicAppIdleTTL = "CADDYSNAKE_DYNAMIC_APP_IDLE_TTL"
dynamicAppCleanupGrace = 10 * time.Second
)
// DynamicAppLimits configures capacity for dynamic app loading.
// MaxConcurrency and FailureTTL are internal; only MaxApps is user-configurable.
type DynamicAppLimits struct {
MaxApps int
MaxConcurrency int
FailureTTL time.Duration
}
func defaultDynamicAppLimits() DynamicAppLimits {
return DynamicAppLimits{
MaxApps: defaultMaxDynamicApps,
MaxConcurrency: defaultDynamicMaxConcurrency,
FailureTTL: defaultDynamicFailureTTL,
}
}
func normalizeDynamicAppLimits(l DynamicAppLimits) DynamicAppLimits {
d := defaultDynamicAppLimits()
if l.MaxApps > 0 {
d.MaxApps = l.MaxApps
}
if l.MaxConcurrency > 0 {
d.MaxConcurrency = l.MaxConcurrency
}
if l.FailureTTL > 0 {
d.FailureTTL = l.FailureTTL
}
return d
}
func parseDynamicAppLimits(maxApps string) (DynamicAppLimits, error) {
lim := defaultDynamicAppLimits()
if maxApps != "" {
n, err := strconv.Atoi(maxApps)
if err != nil || n <= 0 {
return lim, fmt.Errorf("invalid max_dynamic_apps: %q", maxApps)
}
lim.MaxApps = n
}
return lim, nil
}
func effectiveMaxDynamicApps(limits DynamicAppLimits) int {
if v := os.Getenv(envMaxDynamicApps); v != "" {
n, err := strconv.Atoi(v)
if err == nil && n > 0 {
return n
}
}
return limits.MaxApps
}
func parseDynamicAppIdleTTL() time.Duration {
if v := os.Getenv(envDynamicAppIdleTTL); v != "" {
d, err := time.ParseDuration(v)
if err == nil && d > 0 {
return d
}
}
return defaultDynamicAppIdleTTL
}
// hasDotDotSegment reports whether the raw (pre-normalization) path contains
// a ".." segment. Checking before filepath.Abs/Clean is important: those
// normalize traversal sequences away (e.g. "/srv/apps/../../etc" becomes
// "/etc"), which would let placeholder-injected values escape the intended
// directory while passing a check on the normalized result.
func hasDotDotSegment(path string) bool {
for _, seg := range strings.FieldsFunc(path, func(r rune) bool {
return r == '/' || r == '\\'
}) {
if seg == ".." {
return true
}
}
return false
}
func validateResolvedValues(module, dir, venv string) error {
if !validModulePattern.MatchString(module) {
return fmt.Errorf("invalid module name: %q", module)
}
if dir != "" {
if hasDotDotSegment(dir) {
return fmt.Errorf("working directory contains path traversal: %q", dir)
}
abs, err := filepath.Abs(dir)
if err != nil {
return fmt.Errorf("invalid working directory: %w", err)
}
info, err := os.Stat(abs)
if err != nil {
return fmt.Errorf("working directory does not exist: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("working directory is not a directory: %q", abs)
}
}
if venv != "" {
if hasDotDotSegment(venv) {
return fmt.Errorf("venv path contains path traversal: %q", venv)
}
abs, err := filepath.Abs(venv)
if err != nil {
return fmt.Errorf("invalid venv path: %w", err)
}
info, err := os.Stat(abs)
if err != nil {
return fmt.Errorf("venv path does not exist: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("venv path is not a directory: %q", abs)
}
}
return nil
}
// containsPlaceholder checks if a string contains Caddy placeholders (e.g. {host.labels.0}).
func containsPlaceholder(s string) bool {
return strings.Contains(s, "{") && strings.Contains(s, "}")
}
func validateResolvedEnvConfig(dir string, envFiles []string) error {
for _, p := range envFiles {
if p == "" {
continue
}
if containsPlaceholder(p) {
return fmt.Errorf("env_file path contains unresolved placeholder: %q", p)
}
if hasDotDotSegment(p) {
return fmt.Errorf("env_file path contains path traversal: %q", p)
}
abs, err := resolveEnvFilePath(dir, p)
if err != nil {
return err
}
info, err := os.Stat(abs)
if err != nil {
return fmt.Errorf("env_file %q: %w", abs, err)
}
if info.IsDir() {
return fmt.Errorf("env_file %q is a directory", abs)
}
}
return nil
}
type dynamicKeyEnvPair struct {
Key string `json:"k"`
Value string `json:"v"`
}
type dynamicKeyPayload struct {
Module string `json:"module"`
Dir string `json:"dir"`
Venv string `json:"venv"`
EnvFiles []string `json:"env_files,omitempty"`
EnvVars []dynamicKeyEnvPair `json:"env_vars,omitempty"`
}
func dynamicAppCacheKey(module, dir, venv string, envFiles []string, envVars map[string]string) string {
payload := dynamicKeyPayload{
Module: module,
Dir: dir,
Venv: venv,
EnvFiles: append([]string(nil), envFiles...),
}
if len(envVars) > 0 {
keys := make([]string, 0, len(envVars))
for k := range envVars {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
payload.EnvVars = append(payload.EnvVars, dynamicKeyEnvPair{Key: k, Value: envVars[k]})
}
}
b, _ := json.Marshal(payload)
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// appFactory is a function that creates a new AppServer for a resolved
// module, working directory, venv path, and env configuration.
type appFactory func(resolvedModule, resolvedDir, resolvedVenv string, envFiles []string, envVars map[string]string) (AppServer, error)
// appCreate tracks an in-flight factory call so concurrent requests for the
// same key wait for one create, while other keys are not blocked.
type appCreate struct {
done chan struct{}
app AppServer
err error
}
// failedAppCreate caches a failed factory result briefly so repeated requests
// for a bad dynamic key do not fork/exec Python on every hit (DoS amplifier).
type failedAppCreate struct {
err error
expiresAt time.Time
}
// DynamicApp implements AppServer by lazily importing Python apps based on
// Caddy placeholders resolved at request time. For example, when working_dir
// contains {host.labels.2}, each subdomain gets its own Python app instance
// imported from the corresponding directory.
//
// Live apps (cached + in-flight creates) are bounded by maxApps (default 128)
// and idleTTL (default 30m). Override with CADDYSNAKE_MAX_DYNAMIC_APPS and
// CADDYSNAKE_DYNAMIC_APP_IDLE_TTL. Apps with active requests are not idle/LRU
// evicted.
type DynamicApp struct {
mu sync.RWMutex
apps map[string]AppServer
lastUsed map[string]time.Time
inUse map[string]int // active HandleRequest refs; blocks idle/LRU eviction
appDirs map[string]string // cache key -> resolved working dir (for eviction/autoreload)
inflight map[string]*appCreate
failed map[string]failedAppCreate
closed bool
modulePattern string
workingDir string
venvPath string
envFilePatterns []string
envVarPatterns map[string]string
factory appFactory
logger *zap.Logger
limits DynamicAppLimits
maxApps int
idleTTL time.Duration
createSem chan struct{}
now func() time.Time // overridable in tests
// Autoreload fields
autoreload bool
watcher *fsnotify.Watcher
dirToKeys map[string][]string // abs working dir -> cache keys that use it
stopCh chan struct{}
stopOnce sync.Once
cleanupMu sync.Mutex
pendingCleanups []context.CancelFunc
exitOnReloadFailure func(code int) // if set and autoreload, process exits when app creation fails
}
// NewDynamicApp creates a DynamicApp that resolves placeholders from
// modulePattern, workingDir, venvPath, env_file paths, and env_var values at
// request time and lazily creates Python app instances via the supplied factory.
// When autoreload is true, if exitOnReloadFailure is non-nil it is called with
// code 1 when app creation fails (e.g. app deleted), so the process can terminate.
func NewDynamicApp(modulePattern, workingDir, venvPath string, envFilePatterns []string, envVarPatterns map[string]string, factory appFactory, logger *zap.Logger, autoreload bool, exitOnReloadFailure func(code int), limits DynamicAppLimits) (*DynamicApp, error) {
limits = normalizeDynamicAppLimits(limits)
d := &DynamicApp{
apps: make(map[string]AppServer),
lastUsed: make(map[string]time.Time),
inUse: make(map[string]int),
appDirs: make(map[string]string),
inflight: make(map[string]*appCreate),
failed: make(map[string]failedAppCreate),
modulePattern: modulePattern,
workingDir: workingDir,
venvPath: venvPath,
envFilePatterns: cloneEnvFiles(envFilePatterns),
envVarPatterns: cloneEnvVars(envVarPatterns),
factory: factory,
logger: logger,
limits: limits,
maxApps: effectiveMaxDynamicApps(limits),
idleTTL: parseDynamicAppIdleTTL(),
createSem: make(chan struct{}, limits.MaxConcurrency),
now: time.Now,
autoreload: autoreload,
exitOnReloadFailure: exitOnReloadFailure,
}
if autoreload {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
d.watcher = watcher
d.dirToKeys = make(map[string][]string)
d.stopCh = make(chan struct{})
go d.watchForChanges()
logger.Info("autoreload enabled for dynamic app")
}
return d, nil
}
// pruneExpiredFailuresLocked removes expired negative-cache entries.
// Caller must hold d.mu for writing.
func (d *DynamicApp) pruneExpiredFailuresLocked(now time.Time) {
for key, f := range d.failed {
if !now.Before(f.expiresAt) {
delete(d.failed, key)
}
}
}
// rememberFailedCreateLocked records a failed create and drops expired entries.
// Caller must hold d.mu for writing.
func (d *DynamicApp) rememberFailedCreateLocked(key string, err error) {
now := time.Now()
d.pruneExpiredFailuresLocked(now)
d.failed[key] = failedAppCreate{err: err, expiresAt: now.Add(d.limits.FailureTTL)}
}
// resolve uses the Caddy replacer from the request context to substitute
// placeholders in the module pattern, working directory, venv path, env files,
// and env_var values.
func (d *DynamicApp) resolve(r *http.Request) (key, module, dir, venv string, envFiles []string, envVars map[string]string) {
module = d.modulePattern
dir = d.workingDir
venv = d.venvPath
envFiles = cloneEnvFiles(d.envFilePatterns)
envVars = cloneEnvVars(d.envVarPatterns)
if repl, ok := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer); ok && repl != nil {
module = repl.ReplaceAll(module, "")
dir = repl.ReplaceAll(dir, "")
venv = repl.ReplaceAll(venv, "")
for i, p := range envFiles {
envFiles[i] = repl.ReplaceAll(p, "")
}
for name, value := range envVars {
envVars[name] = repl.ReplaceAll(value, "")
}
}
key = dynamicAppCacheKey(module, dir, venv, envFiles, envVars)
return
}
// getOrCreateApp returns an existing app for the given key, or creates one
// using the factory if it doesn't exist yet. The factory runs outside the
// exclusive lock so a slow or indefinite start_timeout for one tenant cannot
// stall other dynamic keys (same pattern as AutoreloadableApp.reload).
func (d *DynamicApp) getOrCreateApp(key, module, dir, venv string, envFiles []string, envVars map[string]string) (AppServer, error) {
if err := validateResolvedValues(module, dir, venv); err != nil {
return nil, err
}
if err := validateResolvedEnvConfig(dir, envFiles); err != nil {
return nil, err
}
for name, value := range envVars {
if containsPlaceholder(value) {
return nil, fmt.Errorf("env_var %q contains unresolved placeholder", name)
}
}
now := d.now()
d.mu.RLock()
if d.closed {
d.mu.RUnlock()
return nil, errors.New("dynamic app shutting down")
}
app, ok := d.apps[key]
if ok {
d.mu.RUnlock()
d.mu.Lock()
if d.closed {
d.mu.Unlock()
return nil, errors.New("dynamic app shutting down")
}
if app2, still := d.apps[key]; still {
d.lastUsed[key] = now
d.mu.Unlock()
return app2, nil
}
d.mu.Unlock()
// key disappeared between locks; fall through to create path
} else {
if f, failed := d.failed[key]; failed && time.Now().Before(f.expiresAt) {
err := f.err
d.mu.RUnlock()
return nil, err
}
d.mu.RUnlock()
}
d.mu.Lock()
if d.closed {
d.mu.Unlock()
return nil, errors.New("dynamic app shutting down")
}
d.pruneExpiredFailuresLocked(time.Now())
app, ok = d.apps[key]
if ok {
d.lastUsed[key] = now
d.mu.Unlock()
return app, nil
}
if f, failed := d.failed[key]; failed && time.Now().Before(f.expiresAt) {
err := f.err
d.mu.Unlock()
return nil, err
}
if c, creating := d.inflight[key]; creating {
d.mu.Unlock()
<-c.done
return c.app, c.err
}
// Reserve capacity before running the factory so concurrent first-time
// tenants cannot spawn more live worker groups than maxApps.
now = d.now()
evicted := d.makeRoomLocked(key, now)
if len(d.apps)+len(d.inflight) >= d.maxApps {
d.mu.Unlock()
d.cleanupAppsAsync(evicted)
return nil, ErrDynamicAppCapacity
}
select {
case d.createSem <- struct{}{}:
default:
d.mu.Unlock()
d.cleanupAppsAsync(evicted)
return nil, ErrDynamicCreateLimit
}
c := &appCreate{done: make(chan struct{})}
d.inflight[key] = c
d.mu.Unlock()
d.cleanupAppsAsync(evicted)
releaseCreate := func() { <-d.createSem }
d.logger.Info("dynamically importing python app",
zap.String("module", module),
zap.String("working_dir", dir),
zap.String("venv", venv),
)
// Factory runs outside the lock; if it panics, still remove the inflight
// entry and close done so waiters and Cleanup cannot hang forever.
var err error
func() {
defer func() {
if r := recover(); r != nil {
releaseCreate()
d.mu.Lock()
delete(d.inflight, key)
c.app = nil
c.err = fmt.Errorf("panic creating dynamic app: %v", r)
d.rememberFailedCreateLocked(key, c.err)
close(c.done)
d.mu.Unlock()
panic(r)
}
}()
app, err = d.factory(module, dir, venv, cloneEnvFiles(envFiles), cloneEnvVars(envVars))
}()
releaseCreate()
d.mu.Lock()
delete(d.inflight, key)
if d.closed {
d.mu.Unlock()
if app != nil {
_ = app.Cleanup()
}
if err == nil {
err = errors.New("dynamic app shutting down")
}
c.app = nil
c.err = err
close(c.done)
return nil, err
}
if err == nil {
delete(d.failed, key)
// Refresh after the out-of-lock factory so lastUsed uses wall clock
// at insert time, not create-start.
now = d.now()
d.apps[key] = app
d.lastUsed[key] = now
// Store the RESOLVED dir. Eviction untracks by this value, and by then
// the release directory may be gone — resolving again at that point
// would fail, fall back to the unresolved path, silently fail to match
// dirToKeys, and leak the key. Resolve once, here, while it exists.
resolvedDir := dir
if d.autoreload && dir != "" {
if r, err := resolveWatchRoot(dir, d.logger); err == nil {
resolvedDir = r
}
}
d.appDirs[key] = resolvedDir
if d.autoreload && dir != "" {
d.startWatchingDir(resolvedDir, key)
}
} else {
d.rememberFailedCreateLocked(key, err)
}
c.app = app
c.err = err
close(c.done)
d.mu.Unlock()
return app, err
}
// makeRoomLocked expires idle apps and, if still at capacity, evicts the
// least-recently-used entry (other than excludeKey). Apps with active
// requests are never evicted here. Caller holds d.mu.
func (d *DynamicApp) makeRoomLocked(excludeKey string, now time.Time) []AppServer {
var evicted []AppServer
for key, used := range d.lastUsed {
if key == excludeKey || d.inUse[key] > 0 {
continue
}
if now.Sub(used) >= d.idleTTL {
if app := d.removeAppLocked(key); app != nil {
evicted = append(evicted, app)
}
}
}
for len(d.apps) >= d.maxApps {
lruKey := ""
var lruTime time.Time
for key, used := range d.lastUsed {
if key == excludeKey || d.inUse[key] > 0 {
continue
}
if lruKey == "" || used.Before(lruTime) {
lruKey = key
lruTime = used
}
}
if lruKey == "" {
break
}
if app := d.removeAppLocked(lruKey); app != nil {
evicted = append(evicted, app)
d.logger.Info("evicted dynamic python app (cache full)",
zap.String("key", lruKey),
zap.Int("max_apps", d.maxApps),
)
} else {
break
}
}
return evicted
}
// removeAppLocked deletes a cached app and its autoreload bookkeeping.
// Caller holds d.mu. Returns the app for async Cleanup.
func (d *DynamicApp) removeAppLocked(key string) AppServer {
app, ok := d.apps[key]
if !ok {
return nil
}
delete(d.apps, key)
delete(d.lastUsed, key)
// Leave inUse intact so a still-running handler's releaseApp cannot
// accidentally unpin a replacement app created for the same key.
// appDirs already holds the resolved watch root, which is what dirToKeys is
// keyed by. Do NOT re-resolve here: by eviction time the release directory
// is often gone, EvalSymlinks would fail, and the fallback would not match
// the key — silently leaking it into dirToKeys forever.
dir := d.appDirs[key]
delete(d.appDirs, key)
if d.autoreload && dir != "" {
d.untrackKeyLocked(dir, key)
}
return app
}
// releaseApp decrements the active-request refcount for key and refreshes
// lastUsed when the last request finishes so idle TTL starts after real use.
func (d *DynamicApp) releaseApp(key string) {
d.mu.Lock()
defer d.mu.Unlock()
n := d.inUse[key]
if n <= 1 {
delete(d.inUse, key)
if _, ok := d.apps[key]; ok {
d.lastUsed[key] = d.now()
}
return
}
d.inUse[key] = n - 1
}
func (d *DynamicApp) untrackKeyLocked(absDir, key string) {
keys := d.dirToKeys[absDir]
if len(keys) == 0 {
return
}
out := keys[:0]
for _, k := range keys {
if k != key {
out = append(out, k)
}
}
if len(out) == 0 {
delete(d.dirToKeys, absDir)
return
}
d.dirToKeys[absDir] = out
}
func (d *DynamicApp) cleanupAppsAsync(apps []AppServer) {
if len(apps) == 0 {
return
}
go func() {
time.Sleep(dynamicAppCleanupGrace)
for _, app := range apps {
if err := app.Cleanup(); err != nil {
d.logger.Error("failed to cleanup old dynamic app", zap.Error(err))
}
}
}()
}
// startWatchingDir registers a watch for a tenant's working directory.
//
// absDir MUST be the value returned by resolveWatchRoot — the same string the
// watcher is rooted at. fsnotify reports events under the path given to Add, so
// keying dirToKeys off anything else (e.g. a bare filepath.Abs) makes
// pathWithinDir fail for every event and silently disables dynamic autoreload.
func (d *DynamicApp) startWatchingDir(absDir, key string) {
if absDir == "" {
return
}
if keys, ok := d.dirToKeys[absDir]; ok {
for _, k := range keys {
if k == key {
return
}
}
d.dirToKeys[absDir] = append(keys, key)
return
}
d.dirToKeys[absDir] = []string{key}
watchDirRecursive(d.watcher, absDir, d.logger)
}
// pathWithinDir reports whether path is dir itself or a file/subdir under dir.
// Unlike strings.HasPrefix, it rejects sibling paths that share a prefix
// (e.g. /srv/apps/foobar is not within /srv/apps/foo).
func pathWithinDir(path, dir string) bool {
if path == dir {
return true
}
prefix := dir + string(os.PathSeparator)
return strings.HasPrefix(path, prefix)
}
func (d *DynamicApp) watchForChanges() {
var debounceTimer *time.Timer
const debounceDuration = 500 * time.Millisecond
pendingDirs := make(map[string]bool)
var pendingMu sync.Mutex
for {
select {
case event, ok := <-d.watcher.Events:
if !ok {
return
}
if !isPythonFileEvent(event) {
handleNewDirEvent(event, d.watcher)
continue
}
d.logger.Debug("python file changed (dynamic)",
zap.String("file", event.Name),
zap.String("op", event.Op.String()),
)
d.mu.RLock()
for absDir := range d.dirToKeys {
if pathWithinDir(event.Name, absDir) {
pendingMu.Lock()
pendingDirs[absDir] = true
pendingMu.Unlock()
}
}
d.mu.RUnlock()
if debounceTimer != nil {
debounceTimer.Stop()
}
debounceTimer = time.AfterFunc(debounceDuration, func() {
pendingMu.Lock()
dirs := make([]string, 0, len(pendingDirs))
for dir := range pendingDirs {
dirs = append(dirs, dir)
}
pendingDirs = make(map[string]bool)
pendingMu.Unlock()
for _, dir := range dirs {
d.reloadDir(dir)
}
})
case err, ok := <-d.watcher.Errors:
if !ok {
return
}
d.logger.Error("autoreload watcher error", zap.Error(err))
case <-d.stopCh:
if debounceTimer != nil {
debounceTimer.Stop()
}
return
}
}
}
func (d *DynamicApp) cancelPendingCleanups() {
d.cleanupMu.Lock()
for _, cancel := range d.pendingCleanups {
cancel()
}
d.pendingCleanups = nil
d.cleanupMu.Unlock()
}
// reloadDir evicts all apps associated with the given directory and
// cleans them up after a grace period.
func (d *DynamicApp) reloadDir(absDir string) {
d.logger.Info("reloading dynamic python apps due to file changes",
zap.String("working_dir", absDir),
)
d.mu.Lock()
keys, ok := d.dirToKeys[absDir]
if !ok {
d.mu.Unlock()
return
}
var oldApps []AppServer
for _, key := range keys {
if app := d.removeAppLocked(key); app != nil {
oldApps = append(oldApps, app)
}
delete(d.failed, key)
}
delete(d.dirToKeys, absDir)
d.mu.Unlock()
d.cancelPendingCleanups()
d.logger.Info("dynamic python apps evicted, will reimport on next request",
zap.String("working_dir", absDir),
zap.Int("apps_evicted", len(oldApps)),
)
d.cleanupAppsAsync(oldApps)
}
// HandleRequest resolves placeholders from the request, gets or creates the
// appropriate app, and forwards the request. The app is pinned for the
// duration of the handler so idle/LRU eviction cannot tear down workers
// serving long-lived connections (e.g. WebSockets).
func (d *DynamicApp) HandleRequest(w http.ResponseWriter, r *http.Request) error {
key, module, dir, venv, envFiles, envVars := d.resolve(r)
for {
app, err := d.getOrCreateApp(key, module, dir, venv, envFiles, envVars)
if err != nil {
if errors.Is(err, ErrDynamicAppCapacity) || errors.Is(err, ErrDynamicCreateLimit) {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return nil
}
if d.autoreload && d.exitOnReloadFailure != nil {
d.logger.Error("failed to load python app (autoreload); terminating",
zap.String("module", module),
zap.String("working_dir", dir),
zap.Error(err),
)
d.exitOnReloadFailure(1)
}
return err
}
d.mu.Lock()
if d.closed {
d.mu.Unlock()
return errors.New("dynamic app shutting down")
}
if cur, ok := d.apps[key]; ok && cur == app {
d.inUse[key]++
d.mu.Unlock()
// defer so a panic in the tenant handler cannot permanently pin the key
// out of idle/LRU eviction.
defer d.releaseApp(key)
return app.HandleRequest(w, r)
}
d.mu.Unlock()
// Evicted between lookup and pin; retry get-or-create.
}
}
// Cleanup frees all dynamically created apps and stops the autoreload watcher.
func (d *DynamicApp) Cleanup() error {
d.mu.Lock()
d.closed = true
for len(d.inflight) > 0 {
waits := make([]chan struct{}, 0, len(d.inflight))
for _, c := range d.inflight {
waits = append(waits, c.done)
}
d.mu.Unlock()
for _, done := range waits {
<-done
}
d.mu.Lock()
}
d.stopOnce.Do(func() {
if d.autoreload && d.stopCh != nil {
close(d.stopCh)
}
})
if d.autoreload && d.watcher != nil {
_ = d.watcher.Close()
}
d.cancelPendingCleanups()
var errs []error
for key, app := range d.apps {
if err := app.Cleanup(); err != nil {
errs = append(errs, err)
}
delete(d.apps, key)
delete(d.lastUsed, key)
delete(d.inUse, key)
delete(d.appDirs, key)
}
d.failed = make(map[string]failedAppCreate)
d.mu.Unlock()
return errors.Join(errs...)
}
package caddysnake
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
var validEnvVarName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// dangerousEnvVarNames are loader / dynamic-linker variables that must not be
// set via env_file or env_var (arbitrary code execution / library hijack).
var dangerousEnvVarNames = map[string]struct{}{
"LD_PRELOAD": {},
"LD_LIBRARY_PATH": {},
"LD_AUDIT": {},
"LD_DYNAMIC_WEAK": {},
"LD_ORIGIN_PATH": {},
"LD_USE_LOAD_BIAS": {},
"DYLD_INSERT_LIBRARIES": {},
"DYLD_LIBRARY_PATH": {},
"DYLD_FORCE_FLAT_NAMESPACE": {},
"DYLD_FALLBACK_LIBRARY_PATH": {},
"DYLD_IMAGE_SUFFIX": {},
"DYLD_PRINT_TO_FILE": {},
}
func validateEnvVarName(name string) error {
if !validEnvVarName.MatchString(name) {
return fmt.Errorf("must match [A-Za-z_][A-Za-z0-9_]*")
}
if name == "PYTHONUNBUFFERED" || strings.HasPrefix(name, "CADDYSNAKE_") {
return fmt.Errorf("reserved environment variable name")
}
if _, bad := dangerousEnvVarNames[name]; bad {
return fmt.Errorf("disallowed environment variable name")
}
// Catch other LD_* / DYLD_* variants without enumerating every platform quirk.
if strings.HasPrefix(name, "LD_") || strings.HasPrefix(name, "DYLD_") {
return fmt.Errorf("disallowed environment variable name")
}
return nil
}
func validateEnvVars(envVars map[string]string) error {
for name := range envVars {
if err := validateEnvVarName(name); err != nil {
return fmt.Errorf("invalid env_var name %q: %w", name, err)
}
}
return nil
}
func envMapContainsPlaceholder(envVars map[string]string) bool {
for _, v := range envVars {
if containsPlaceholder(v) {
return true
}
}
return false
}
func envFilesContainPlaceholder(paths []string) bool {
for _, p := range paths {
if containsPlaceholder(p) {
return true
}
}
return false
}
func cloneEnvVars(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func cloneEnvFiles(src []string) []string {
if len(src) == 0 {
return nil
}
dst := make([]string, len(src))
copy(dst, src)
return dst
}
func resolveEnvFilePath(workingDir, envFile string) (string, error) {
if envFile == "" {
return "", fmt.Errorf("env_file path is empty")
}
if hasDotDotSegment(envFile) {
return "", fmt.Errorf("env_file path contains path traversal: %q", envFile)
}
relative := !filepath.IsAbs(envFile)
path := envFile
base := workingDir
if relative {
if base == "" {
var err error
base, err = os.Getwd()
if err != nil {
return "", fmt.Errorf("resolve env_file base directory: %w", err)
}
}
path = filepath.Join(base, path)
}
abs, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("invalid env_file path: %w", err)
}
// Relative env_file paths must stay under the working directory even when
// the leaf is a symlink (same containment idea as tls.permission.python_dir).
if relative && base != "" {
if err := ensureEnvFileInsideWorkingDir(base, abs); err != nil {
return "", err
}
}
return abs, nil
}
func ensureEnvFileInsideWorkingDir(workingDir, envFileAbs string) error {
baseAbs, err := filepath.Abs(workingDir)
if err != nil {
return fmt.Errorf("resolve env_file working_dir: %w", err)
}
baseAbs = filepath.Clean(baseAbs)
baseEval, err := filepath.EvalSymlinks(baseAbs)
if err != nil {
// working_dir may not exist yet at validate time; fall back to cleaned abs.
baseEval = baseAbs
} else {
baseEval = filepath.Clean(baseEval)
}
fileAbs := filepath.Clean(envFileAbs)
fileEval, err := filepath.EvalSymlinks(fileAbs)
if err != nil {
// Missing path components: keep containment checks on one filesystem view.
// On macOS, EvalSymlinks(base) may yield /private/var/... while a missing
// child still has the logical /var/... prefix; rebuild under baseEval.
rel, relErr := filepath.Rel(baseAbs, fileAbs)
if relErr != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return fmt.Errorf("env_file %q resolves outside working_dir %q", envFileAbs, workingDir)
}
fileEval = filepath.Join(baseEval, rel)
} else {
fileEval = filepath.Clean(fileEval)
}
if !pythonDirTLSPermInsideRoot(baseEval, fileEval) {
return fmt.Errorf("env_file %q resolves outside working_dir %q", envFileAbs, workingDir)
}
return nil
}
func parseEnvFile(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
vars := make(map[string]string)
scanner := bufio.NewScanner(f)
for lineNum := 1; scanner.Scan(); lineNum++ {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "export ") {
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
}
key, value, ok := strings.Cut(line, "=")
if !ok {
return nil, fmt.Errorf("%s:%d: invalid line (expected KEY=VALUE)", path, lineNum)
}
key = strings.TrimSpace(key)
if key == "" {
return nil, fmt.Errorf("%s:%d: empty variable name", path, lineNum)
}
if err := validateEnvVarName(key); err != nil {
return nil, fmt.Errorf("%s:%d: invalid variable name %q: %w", path, lineNum, key, err)
}
value = strings.TrimSpace(value)
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
value = value[1 : len(value)-1]
}
}
vars[key] = value
}
if err := scanner.Err(); err != nil {
return nil, err
}
return vars, nil
}
func loadEnvFiles(workingDir string, paths []string) (map[string]string, error) {
merged := make(map[string]string)
for _, p := range paths {
if p == "" {
continue
}
if containsPlaceholder(p) {
return nil, fmt.Errorf("env_file path contains unresolved placeholder: %q", p)
}
abs, err := resolveEnvFilePath(workingDir, p)
if err != nil {
return nil, err
}
info, err := os.Stat(abs)
if err != nil {
return nil, fmt.Errorf("env_file %q: %w", abs, err)
}
if info.IsDir() {
return nil, fmt.Errorf("env_file %q is a directory", abs)
}
vars, err := parseEnvFile(abs)
if err != nil {
return nil, fmt.Errorf("env_file %q: %w", abs, err)
}
for k, v := range vars {
merged[k] = v
}
}
return merged, nil
}
func parseEnvSlice(base []string) map[string]string {
m := make(map[string]string, len(base))
for _, entry := range base {
key, value, ok := strings.Cut(entry, "=")
if !ok {
continue
}
if idx := strings.Index(key, "\x00"); idx >= 0 {
key = key[:idx]
}
m[key] = value
}
return m
}
func envMapToSlice(m map[string]string) []string {
out := make([]string, 0, len(m))
for k, v := range m {
out = append(out, k+"="+v)
}
return out
}
// buildWorkerEnv merges environment variables with precedence:
// base (process env) < fileVars < inlineVars < extra (internal vars).
func buildWorkerEnv(base []string, fileVars, inlineVars map[string]string, extra ...string) []string {
merged := parseEnvSlice(base)
for k, v := range fileVars {
merged[k] = v
}
for k, v := range inlineVars {
merged[k] = v
}
for _, entry := range extra {
key, value, ok := strings.Cut(entry, "=")
if !ok {
continue
}
merged[key] = value
}
return envMapToSlice(merged)
}
func workerInternalEnv(iface, cacheAddr, cacheToken, workerID, workerToken string) []string {
extra := []string{"PYTHONUNBUFFERED=1"}
if cacheAddr != "" {
extra = append(extra,
EnvCaddysnakeCacheAddr+"="+cacheAddr,
EnvCaddysnakeWorkerInterface+"="+iface,
EnvCaddysnakeCacheTimeoutSeconds+"="+strconv.Itoa(DefaultCacheClientTimeoutSec),
)
if cacheToken != "" {
extra = append(extra, EnvCaddysnakeCacheToken+"="+cacheToken)
}
if workerID != "" {
extra = append(extra, EnvCaddysnakeWorkerID+"="+workerID)
}
}
if workerToken != "" {
extra = append(extra, EnvCaddysnakeWorkerToken+"="+workerToken)
}
return extra
}
package caddysnake
import (
"context"
"fmt"
"net"
"os"
"strings"
"time"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"go.uber.org/zap"
)
const (
isolationBackendNone = "none"
isolationBackendDocker = "docker"
envCaddysnakeWorkerTCP = "CADDYSNAKE_WORKER_TCP"
envCaddysnakeWorkerTCPHost = "CADDYSNAKE_WORKER_TCP_HOST"
)
// IsolationConfig selects how Python workers are run.
type IsolationConfig struct {
Backend string `json:"backend,omitempty"`
Docker *DockerIsolationConfig `json:"docker,omitempty"`
}
// DockerIsolationConfig configures the Docker isolation backend.
type DockerIsolationConfig struct {
Image string `json:"image,omitempty"`
Network string `json:"network,omitempty"`
DockerHost string `json:"docker_host,omitempty"`
Memory string `json:"memory,omitempty"`
CPUs string `json:"cpus,omitempty"`
ReadOnly bool `json:"read_only,omitempty"`
Mounts []IsolationMount `json:"mounts,omitempty"`
}
// IsolationMount is an extra bind mount for Docker workers.
type IsolationMount struct {
Host string `json:"host"`
Container string `json:"container"`
Mode string `json:"mode,omitempty"` // ro or rw
}
func (ic *IsolationConfig) effectiveBackend() string {
if ic == nil || ic.Backend == "" {
return isolationBackendNone
}
return ic.Backend
}
func (ic *IsolationConfig) usesDocker() bool {
return ic != nil && ic.effectiveBackend() == isolationBackendDocker
}
func (ic *IsolationConfig) validate() error {
if ic == nil {
return nil
}
switch ic.effectiveBackend() {
case isolationBackendNone:
return nil
case isolationBackendDocker:
if ic.Docker == nil || strings.TrimSpace(ic.Docker.Image) == "" {
return fmt.Errorf("isolation docker requires image")
}
for i, m := range ic.Docker.Mounts {
if strings.TrimSpace(m.Host) == "" || strings.TrimSpace(m.Container) == "" {
return fmt.Errorf("isolation docker mount %d requires host and container paths", i)
}
mode := strings.ToLower(strings.TrimSpace(m.Mode))
if mode != "" && mode != "ro" && mode != "rw" {
return fmt.Errorf("isolation docker mount %d mode must be ro or rw", i)
}
}
return nil
default:
return fmt.Errorf("unknown isolation backend %q", ic.Backend)
}
}
func (m *CaddySnake) validateIsolation() error {
if m.Isolation == nil {
return nil
}
return m.Isolation.validate()
}
func parseIsolationCaddyfile(d *caddyfile.Dispenser, ic **IsolationConfig) error {
var backend string
if !d.Args(&backend) {
return d.Errf("expected isolation backend or 'none'")
}
backend = strings.ToLower(strings.TrimSpace(backend))
if backend == isolationBackendNone {
*ic = &IsolationConfig{Backend: isolationBackendNone}
return nil
}
cfg := &IsolationConfig{Backend: backend}
switch backend {
case isolationBackendDocker:
cfg.Docker = &DockerIsolationConfig{}
for sub := d.Nesting(); d.NextBlock(sub); {
switch d.Val() {
case "image":
if !d.Args(&cfg.Docker.Image) {
return d.Errf("expected exactly one argument for image")
}
case "network":
if !d.Args(&cfg.Docker.Network) {
return d.Errf("expected exactly one argument for network")
}
case "docker_host":
if !d.Args(&cfg.Docker.DockerHost) {
return d.Errf("expected exactly one argument for docker_host")
}
case "memory":
if !d.Args(&cfg.Docker.Memory) {
return d.Errf("expected exactly one argument for memory")
}
case "cpus":
if !d.Args(&cfg.Docker.CPUs) {
return d.Errf("expected exactly one argument for cpus")
}
case "read_only":
cfg.Docker.ReadOnly = true
case "mount":
var host, container, mode string
switch d.CountRemainingArgs() {
case 2:
if !d.Args(&host, &container) {
return d.ArgErr()
}
case 3:
if !d.Args(&host, &container, &mode) {
return d.ArgErr()
}
default:
return d.Errf("expected two or three arguments for mount: host container [ro|rw]")
}
cfg.Docker.Mounts = append(cfg.Docker.Mounts, IsolationMount{
Host: host,
Container: container,
Mode: mode,
})
default:
return d.Errf("unknown isolation docker subdirective: %s", d.Val())
}
}
default:
return d.Errf("unknown isolation backend %q", backend)
}
*ic = cfg
return nil
}
func cloneIsolationConfig(src *IsolationConfig) *IsolationConfig {
if src == nil {
return nil
}
dst := *src
if src.Docker != nil {
docker := *src.Docker
if len(src.Docker.Mounts) > 0 {
docker.Mounts = append([]IsolationMount(nil), src.Docker.Mounts...)
}
dst.Docker = &docker
}
return &dst
}
// WorkerSpec is the input to WorkerBackend.Start.
type WorkerSpec struct {
Interface string
App string
WorkingDir string
Venv string
Lifespan string
Runtime string
PythonBin string
ScriptPath string
ScriptDir string
EnvFiles []string
EnvVars map[string]string
InternalEnv []string
WorkerID string
CacheAddr string
CacheToken string
WorkerToken string
StartTimeout time.Duration
Isolation *IsolationConfig
Logger *zap.Logger
}
// WorkerHandle is a running isolated or local worker.
type WorkerHandle interface {
DialNetwork() string
DialAddress() string
Exited() <-chan error
}
// WorkerBackend starts and stops worker units.
type WorkerBackend interface {
Start(ctx context.Context, spec WorkerSpec) (WorkerHandle, error)
Stop(handle WorkerHandle, grace time.Duration) error
}
func newWorkerBackend(isolation *IsolationConfig) (WorkerBackend, error) {
backend := isolationBackendNone
if isolation != nil {
backend = isolation.effectiveBackend()
}
switch backend {
case isolationBackendNone:
return processBackend{}, nil
case isolationBackendDocker:
if isolation == nil || isolation.Docker == nil {
return nil, fmt.Errorf("docker isolation config is required")
}
return newDockerBackend(isolation.Docker)
default:
return nil, fmt.Errorf("unsupported isolation backend %q", backend)
}
}
func cacheAddrForContainer(cacheAddr string) string {
if cacheAddr == "" {
return ""
}
if strings.HasPrefix(cacheAddr, cacheAddrUnixScheme) {
return cacheAddr
}
host, port, err := net.SplitHostPort(cacheAddr)
if err != nil {
return cacheAddr
}
if host == "127.0.0.1" || host == "localhost" {
return net.JoinHostPort("host.docker.internal", port)
}
return cacheAddr
}
func workerInternalEnvForIsolation(iface, cacheAddr, cacheToken, workerID, workerToken string, isolated bool) []string {
addr := cacheAddr
if isolated {
addr = cacheAddrForContainer(cacheAddr)
}
extra := workerInternalEnv(iface, addr, cacheToken, workerID, workerToken)
if isolated {
// Bind all interfaces inside the container so Caddy can dial the container IP.
extra = append(extra, envCaddysnakeWorkerTCP+"=1", envCaddysnakeWorkerTCPHost+"=0.0.0.0")
}
return extra
}
func buildWorkerEnvForIsolation(spec WorkerSpec, fileVars map[string]string) []string {
isolated := spec.Isolation != nil && spec.Isolation.usesDocker()
internal := workerInternalEnvForIsolation(spec.Interface, spec.CacheAddr, spec.CacheToken, spec.WorkerID, spec.WorkerToken, isolated)
if isolated {
return buildWorkerEnv(nil, fileVars, spec.EnvVars, internal...)
}
return buildWorkerEnv(os.Environ(), fileVars, spec.EnvVars, internal...)
}
func buildIsolationFromCLI(backend, image, network, dockerHost, memory, cpus string, readOnly bool) (*IsolationConfig, error) {
backend = strings.ToLower(strings.TrimSpace(backend))
if backend == "" {
return nil, nil
}
cfg := &IsolationConfig{Backend: backend}
switch backend {
case isolationBackendNone:
return cfg, nil
case isolationBackendDocker:
cfg.Docker = &DockerIsolationConfig{
Image: strings.TrimSpace(image),
Network: strings.TrimSpace(network),
DockerHost: strings.TrimSpace(dockerHost),
Memory: strings.TrimSpace(memory),
CPUs: strings.TrimSpace(cpus),
ReadOnly: readOnly,
}
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
default:
return nil, fmt.Errorf("invalid --isolation %q (want none or docker)", backend)
}
}
package caddysnake
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
dockerWorkerLabel = "caddy-snake.worker"
dockerWorkerNamePrefix = "caddysnake-w-"
)
type dockerBackend struct {
cfg *DockerIsolationConfig
}
func newDockerBackend(cfg *DockerIsolationConfig) (WorkerBackend, error) {
if cfg == nil {
return nil, fmt.Errorf("docker isolation config is required")
}
return dockerBackend{cfg: cfg}, nil
}
type dockerWorkerHandle struct {
containerID string
portFile string
portDir string
dialNet string
dialAddr string
exited chan error
}
func (b dockerBackend) Start(ctx context.Context, spec WorkerSpec) (WorkerHandle, error) {
logger := spec.Logger
portDir, err := os.MkdirTemp("", "caddysnake-docker-*")
if err != nil {
return nil, err
}
if chErr := os.Chmod(portDir, 0o700); chErr != nil {
os.RemoveAll(portDir)
return nil, chErr
}
portFileHost := filepath.Join(portDir, "worker.port")
containerPortPath := "/run/caddysnake/worker.port"
workingDir := spec.WorkingDir
if workingDir == "" {
workingDir, _ = os.Getwd()
}
absWorkingDir, err := filepath.Abs(workingDir)
if err != nil {
os.RemoveAll(portDir)
return nil, err
}
scriptDir := spec.ScriptDir
if scriptDir == "" {
scriptDir = filepath.Dir(spec.ScriptPath)
}
absScriptDir, err := filepath.Abs(scriptDir)
if err != nil {
os.RemoveAll(portDir)
return nil, err
}
scriptName := filepath.Base(spec.ScriptPath)
containerScriptPath := filepath.Join("/opt/caddysnake", scriptName)
// Prefer the image interpreter; host-resolved absolute paths are not visible in the container.
pythonBin := "python3"
if spec.PythonBin != "" && !filepath.IsAbs(spec.PythonBin) {
pythonBin = spec.PythonBin
}
args := []string{
"run", "-d", "--rm",
"--user", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()),
"--label", dockerWorkerLabel + "=true",
"--label", "caddy-snake.worker.id=" + spec.WorkerID,
"--name", dockerWorkerContainerName(spec.WorkerID),
"--add-host", "host.docker.internal:host-gateway",
}
network := strings.TrimSpace(b.cfg.Network)
if network != "" {
args = append(args, "--network", network)
}
if b.cfg.ReadOnly {
args = append(args, "--read-only")
}
if mem := strings.TrimSpace(b.cfg.Memory); mem != "" {
args = append(args, "--memory", mem)
}
if cpus := strings.TrimSpace(b.cfg.CPUs); cpus != "" {
args = append(args, "--cpus", cpus)
}
args = append(args,
"-v", absWorkingDir+":"+absWorkingDir+":rw",
"-v", absScriptDir+":/opt/caddysnake:ro",
"-v", portDir+":/run/caddysnake:rw",
)
if spec.Venv != "" {
absVenv, vErr := filepath.Abs(spec.Venv)
if vErr != nil {
os.RemoveAll(portDir)
return nil, vErr
}
args = append(args, "-v", absVenv+":"+absVenv+":ro")
pythonBin = filepath.Join(absVenv, "bin", "python3")
}
for _, m := range b.cfg.Mounts {
mode := strings.ToLower(strings.TrimSpace(m.Mode))
if mode == "" {
mode = "ro"
}
args = append(args, "-v", m.Host+":"+m.Container+":"+mode)
}
fileVars, err := loadEnvFiles(spec.WorkingDir, spec.EnvFiles)
if err != nil {
os.RemoveAll(portDir)
return nil, err
}
for _, entry := range buildWorkerEnvForIsolation(spec, fileVars) {
args = append(args, "-e", entry)
}
runArgs := []string{
pythonBin,
containerScriptPath,
"--interface", spec.Interface,
"--app", spec.App,
"--socket", containerPortPath,
}
if absWorkingDir != "" {
runArgs = append(runArgs, "--working-dir", absWorkingDir)
}
if spec.Venv != "" {
absVenv, _ := filepath.Abs(spec.Venv)
runArgs = append(runArgs, "--venv", absVenv)
}
if spec.Lifespan != "" {
runArgs = append(runArgs, "--lifespan", spec.Lifespan)
}
if spec.Runtime != "" {
runArgs = append(runArgs, "--runtime", spec.Runtime)
}
args = append(args, strings.TrimSpace(b.cfg.Image))
args = append(args, runArgs...)
containerID, err := b.runDocker(ctx, args...)
if err != nil {
os.RemoveAll(portDir)
return nil, err
}
exited := make(chan error, 1)
go func() {
exited <- b.waitDocker(ctx, containerID)
}()
timeout := effectiveStartTimeout(spec.StartTimeout)
port, err := waitForPortFile(portFileHost, timeout, exited, logger)
if err != nil {
logs := b.containerLogs(ctx, containerID)
_ = b.removeContainer(ctx, containerID)
os.RemoveAll(portDir)
if logs != "" {
return nil, fmt.Errorf("waiting for docker worker port file: %w\ncontainer logs:\n%s", err, logs)
}
return nil, fmt.Errorf("waiting for docker worker port file: %w", err)
}
containerIP, err := b.containerIP(ctx, containerID)
if err != nil {
_ = b.removeContainer(ctx, containerID)
os.RemoveAll(portDir)
return nil, err
}
return &dockerWorkerHandle{
containerID: containerID,
portFile: portFileHost,
portDir: portDir,
dialNet: "tcp",
dialAddr: net.JoinHostPort(containerIP, strconv.Itoa(port)),
exited: exited,
}, nil
}
func (h *dockerWorkerHandle) DialNetwork() string { return h.dialNet }
func (h *dockerWorkerHandle) DialAddress() string { return h.dialAddr }
func (h *dockerWorkerHandle) Exited() <-chan error { return h.exited }
func (b dockerBackend) Stop(handle WorkerHandle, grace time.Duration) error {
h, ok := handle.(*dockerWorkerHandle)
if !ok {
return fmt.Errorf("docker backend: invalid handle type")
}
ctx, cancel := context.WithTimeout(context.Background(), grace+10*time.Second)
defer cancel()
_ = b.stopContainer(ctx, h.containerID, grace)
_ = b.removeContainer(ctx, h.containerID)
if h.portDir != "" {
_ = os.RemoveAll(h.portDir)
}
return nil
}
func dockerWorkerContainerName(workerID string) string {
buf := make([]byte, 4)
_, _ = rand.Read(buf)
return fmt.Sprintf("%s%s-%s", dockerWorkerNamePrefix, workerID, hex.EncodeToString(buf))
}
func (b dockerBackend) dockerEnv() []string {
if host := strings.TrimSpace(b.cfg.DockerHost); host != "" {
return []string{"DOCKER_HOST=" + host}
}
return nil
}
func (b dockerBackend) runDocker(ctx context.Context, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Env = append(os.Environ(), b.dockerEnv()...)
out, err := cmd.CombinedOutput()
if err != nil {
prefix := "docker run"
if len(args) > 0 {
prefix = "docker " + args[0]
}
return "", fmt.Errorf("%s: %w: %s", prefix, err, strings.TrimSpace(string(out)))
}
return strings.TrimSpace(string(out)), nil
}
func (b dockerBackend) waitDocker(ctx context.Context, containerID string) error {
cmd := exec.CommandContext(ctx, "docker", "wait", containerID)
cmd.Env = append(os.Environ(), b.dockerEnv()...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker wait: %w: %s", err, strings.TrimSpace(string(out)))
}
code, convErr := strconv.Atoi(strings.TrimSpace(string(out)))
if convErr != nil || code != 0 {
return fmt.Errorf("docker container exited with code %s", strings.TrimSpace(string(out)))
}
return nil
}
func (b dockerBackend) stopContainer(ctx context.Context, containerID string, grace time.Duration) error {
secs := int(grace.Seconds())
if secs < 1 {
secs = 1
}
cmd := exec.CommandContext(ctx, "docker", "stop", "-t", strconv.Itoa(secs), containerID)
cmd.Env = append(os.Environ(), b.dockerEnv()...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker stop: %w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func (b dockerBackend) removeContainer(ctx context.Context, containerID string) error {
cmd := exec.CommandContext(ctx, "docker", "rm", "-f", containerID)
cmd.Env = append(os.Environ(), b.dockerEnv()...)
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if strings.Contains(msg, "No such container") {
return nil
}
return fmt.Errorf("docker rm: %w: %s", err, msg)
}
return nil
}
func (b dockerBackend) containerIP(ctx context.Context, containerID string) (string, error) {
cmd := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", containerID)
cmd.Env = append(os.Environ(), b.dockerEnv()...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("docker inspect ip: %w: %s", err, strings.TrimSpace(string(out)))
}
ip := strings.TrimSpace(string(out))
if ip == "" {
return "", fmt.Errorf("docker container %s has no IP address", containerID)
}
return ip, nil
}
func (b dockerBackend) containerLogs(ctx context.Context, containerID string) string {
cmd := exec.CommandContext(ctx, "docker", "logs", "--tail", "200", containerID)
cmd.Env = append(os.Environ(), b.dockerEnv()...)
out, err := cmd.CombinedOutput()
if err != nil {
return strings.TrimSpace(string(out))
}
return strings.TrimSpace(string(out))
}
// ListDockerWorkerContainers returns container IDs for caddy-snake worker containers.
func ListDockerWorkerContainers(ctx context.Context, dockerHost string) ([]string, error) {
args := []string{"ps", "-aq", "--filter", "label=" + dockerWorkerLabel + "=true"}
cmd := exec.CommandContext(ctx, "docker", args...)
if strings.TrimSpace(dockerHost) != "" {
cmd.Env = append(os.Environ(), "DOCKER_HOST="+dockerHost)
}
out, err := cmd.Output()
if err != nil {
return nil, err
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
var ids []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
ids = append(ids, line)
}
}
return ids, nil
}
package caddysnake
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"syscall"
"time"
)
type processBackend struct{}
type processWorkerHandle struct {
cmd *exec.Cmd
cmdWaitCh chan error
dialNet string
dialAddr string
socketPath string
sockDir string
}
func (processBackend) Start(ctx context.Context, spec WorkerSpec) (WorkerHandle, error) {
_ = ctx
logger := spec.Logger
var socket *os.File
var sockDir string
var err error
if runtime.GOOS == "windows" {
socket, err = os.CreateTemp("", "caddysnake-worker.port*")
} else {
sockDir, err = os.MkdirTemp("", "caddysnake-*")
if err != nil {
return nil, err
}
if chErr := os.Chmod(sockDir, 0o700); chErr != nil {
os.RemoveAll(sockDir)
return nil, chErr
}
socket, err = os.Create(filepath.Join(sockDir, "worker.sock"))
}
if err != nil {
if sockDir != "" {
os.RemoveAll(sockDir)
}
return nil, err
}
path := socket.Name()
socket.Close()
dialNet := "unix"
dialAddr := path
useTCP := runtime.GOOS == "windows"
if useTCP {
dialNet = "tcp"
dialAddr = ""
}
workingDir := spec.WorkingDir
if workingDir == "" {
workingDir, _ = os.Getwd()
}
args := []string{
spec.ScriptPath,
"--interface", spec.Interface,
"--app", spec.App,
"--socket", path,
}
if workingDir != "" {
args = append(args, "--working-dir", workingDir)
}
if spec.Venv != "" {
args = append(args, "--venv", spec.Venv)
}
if spec.Lifespan != "" {
args = append(args, "--lifespan", spec.Lifespan)
}
if spec.Runtime != "" {
args = append(args, "--runtime", spec.Runtime)
}
fileVars, err := loadEnvFiles(spec.WorkingDir, spec.EnvFiles)
if err != nil {
if sockDir != "" {
os.RemoveAll(sockDir)
}
return nil, err
}
cmd := exec.Command(spec.PythonBin, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = buildWorkerEnvForIsolation(spec, fileVars)
setSysProcAttr(cmd)
if err := cmd.Start(); err != nil {
if sockDir != "" {
os.RemoveAll(sockDir)
}
os.Remove(path)
return nil, err
}
cmdWaitCh := make(chan error, 1)
go func() {
cmdWaitCh <- cmd.Wait()
}()
timeout := effectiveStartTimeout(spec.StartTimeout)
if useTCP {
port, err := waitForPortFile(path, timeout, cmdWaitCh, logger)
if err != nil {
_ = stopProcessWorker(cmd, cmdWaitCh, false)
os.Remove(path)
if sockDir != "" {
os.RemoveAll(sockDir)
}
return nil, fmt.Errorf("waiting for Python worker port file: %w", err)
}
dialAddr = "127.0.0.1:" + strconv.Itoa(port)
} else if err := waitForUnixSocket(path, timeout, cmdWaitCh, logger); err != nil {
_ = stopProcessWorker(cmd, cmdWaitCh, false)
if sockDir != "" {
os.RemoveAll(sockDir)
}
return nil, fmt.Errorf("waiting for Python worker socket: %w", err)
}
return &processWorkerHandle{
cmd: cmd,
cmdWaitCh: cmdWaitCh,
dialNet: dialNet,
dialAddr: dialAddr,
socketPath: path,
sockDir: sockDir,
}, nil
}
func (h *processWorkerHandle) DialNetwork() string { return h.dialNet }
func (h *processWorkerHandle) DialAddress() string { return h.dialAddr }
func (h *processWorkerHandle) Exited() <-chan error { return h.cmdWaitCh }
func (processBackend) Stop(handle WorkerHandle, grace time.Duration) error {
h, ok := handle.(*processWorkerHandle)
if !ok {
return fmt.Errorf("process backend: invalid handle type")
}
if h.cmd == nil || h.cmd.Process == nil {
return cleanupProcessHandlePaths(h)
}
err := stopProcessWorker(h.cmd, h.cmdWaitCh, true)
if pathErr := cleanupProcessHandlePaths(h); pathErr != nil {
if err != nil {
return fmt.Errorf("%w; %v", err, pathErr)
}
return pathErr
}
_ = grace
return err
}
func stopProcessWorker(cmd *exec.Cmd, cmdWaitCh chan error, graceful bool) error {
if cmd == nil || cmd.Process == nil {
return nil
}
if runtime.GOOS == "windows" || !graceful {
_ = cmd.Process.Kill()
} else {
_ = cmd.Process.Signal(syscall.SIGTERM)
}
if cmdWaitCh != nil {
select {
case <-cmdWaitCh:
case <-time.After(5 * time.Second):
_ = cmd.Process.Kill()
<-cmdWaitCh
}
}
return nil
}
func cleanupProcessHandlePaths(h *processWorkerHandle) error {
if h == nil {
return nil
}
if h.socketPath != "" {
_ = os.Remove(h.socketPath)
}
if h.sockDir != "" {
_ = os.RemoveAll(h.sockDir)
}
return nil
}
//go:build linux
package caddysnake
import (
"os/exec"
"syscall"
)
func setSysProcAttr(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
Pdeathsig: syscall.SIGTERM,
}
}
package caddysnake
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/modules/caddytls"
)
func init() {
caddy.RegisterModule(PermissionByPythonDir{})
}
// PermissionByPythonDir implements on-demand TLS permission by checking that the
// requested hostname is of the form {slug}.{domain_suffix} (exactly one label
// before the suffix), and that filepath.Join(root, slug) exists as a directory.
// Users can pair this with a dynamic python block using working_dir "{http.request.host.labels.2}/" when
// slug.appdomain.com uses labels.2 == slug for a three-part host.
//
// Implements [caddytls.OnDemandPermission] as tls.permission.python_dir.
type PermissionByPythonDir struct {
// Absolute base path containing one subdirectory per slug (branch name, tenant, etc.).
Root string `json:"root,omitempty"`
// Registered domain suffix, e.g. appdomain.com (no leading dot). Hostname must be {slug}.{domain_suffix}.
DomainSuffix string `json:"domain_suffix,omitempty"`
rootAbs string
suffixNorm string
}
// CaddyModule returns the Caddy module information.
func (PermissionByPythonDir) CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
ID: "tls.permission.python_dir",
New: func() caddy.Module { return new(PermissionByPythonDir) },
}
}
// Ungrouped names so a duplicate legacy file can't break the package build alongside this one.
var pythonDirTLSPermSlug = regexp.MustCompile(`^(?:[a-z0-9]|[a-z0-9][a-z0-9_-]*[a-z0-9])$`)
// Provision validates configuration and resolves Root to an absolute path.
func (p *PermissionByPythonDir) Provision(ctx caddy.Context) error {
_ = ctx
if strings.TrimSpace(p.Root) == "" {
return fmt.Errorf("tls.permission.python_dir: root is required")
}
if strings.TrimSpace(p.DomainSuffix) == "" {
return fmt.Errorf("tls.permission.python_dir: domain_suffix is required")
}
abs, err := filepath.Abs(p.Root)
if err != nil {
return fmt.Errorf("tls.permission.python_dir: resolving root: %w", err)
}
rootEval, err := filepath.EvalSymlinks(abs)
if err != nil {
return fmt.Errorf("tls.permission.python_dir: resolving root symlink: %w", err)
}
st, err := os.Stat(rootEval)
if err != nil {
return fmt.Errorf("tls.permission.python_dir: stat root %q: %w", rootEval, err)
}
if !st.IsDir() {
return fmt.Errorf("tls.permission.python_dir: root %q is not a directory", rootEval)
}
p.rootAbs = filepath.Clean(rootEval)
s := strings.ToLower(strings.TrimSpace(p.DomainSuffix))
s = strings.Trim(s, ".")
p.suffixNorm = s
return nil
}
func pythonDirTLSPermInsideRoot(rootCleanAbs, resolved string) bool {
rel, err := filepath.Rel(rootCleanAbs, resolved)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
}
// CertificateAllowed implements [caddytls.OnDemandPermission].
func (p *PermissionByPythonDir) CertificateAllowed(_ context.Context, name string) error {
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(name), "."))
if host == "" {
return fmt.Errorf("%w", caddytls.ErrPermissionDenied)
}
suffixNeedle := "." + p.suffixNorm
if !strings.HasSuffix(host, suffixNeedle) {
return fmt.Errorf("%w", caddytls.ErrPermissionDenied)
}
slug := strings.TrimSuffix(host, suffixNeedle)
if slug == "" || strings.Contains(slug, ".") {
return fmt.Errorf("%w", caddytls.ErrPermissionDenied)
}
if !pythonDirTLSPermSlug.MatchString(slug) {
return fmt.Errorf("%w", caddytls.ErrPermissionDenied)
}
dirPath := filepath.Join(p.rootAbs, slug)
dirPath = filepath.Clean(dirPath)
if !pythonDirTLSPermInsideRoot(p.rootAbs, dirPath) {
return fmt.Errorf("%w", caddytls.ErrPermissionDenied)
}
dirResolved, err := filepath.EvalSymlinks(dirPath)
if err != nil {
return fmt.Errorf("%w", caddytls.ErrPermissionDenied)
}
if !pythonDirTLSPermInsideRoot(p.rootAbs, filepath.Clean(dirResolved)) {
return fmt.Errorf("%w", caddytls.ErrPermissionDenied)
}
st, err := os.Stat(dirResolved)
if err != nil || !st.IsDir() {
return fmt.Errorf("%w", caddytls.ErrPermissionDenied)
}
return nil
}
// UnmarshalCaddyfile implements [caddyfile.Unmarshaler].
//
// The Caddy loader prepends the module short name (`python_dir`) before the `{ ... }` block,
// so the first token may be `python_dir` (see caddyfile.UnmarshalModule + NewFromNextSegment).
//
// Example block body:
//
// python_dir {
// root /home/server
// domain_suffix appdomain.com
// }
func (p *PermissionByPythonDir) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
for d.Next() {
switch d.Val() {
case "python_dir":
// optional first token — module name echoed at start of the segment
continue
case "{":
continue
case "}":
continue
case "root":
if !d.Args(&p.Root) {
return d.ArgErr()
}
case "domain_suffix":
if !d.Args(&p.DomainSuffix) {
return d.ArgErr()
}
default:
return d.Errf("unknown subdirective %q", d.Val())
}
}
return nil
}
// Interface guards
var (
_ caddytls.OnDemandPermission = (*PermissionByPythonDir)(nil)
_ caddy.Provisioner = (*PermissionByPythonDir)(nil)
_ caddyfile.Unmarshaler = (*PermissionByPythonDir)(nil)
)