Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
private.key
certificate.crt
test.sh

# Local build artifacts
/goup
/goup-dev
/goup-web
/goup-dns
public/node_test/app/node_modules
public/node_test/app/pnpm-lock.yaml
public/**/*.pyc
Expand Down
18 changes: 8 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,6 @@ GoUP! is a minimal, tweakable web server written in Go. You can use it to serve
- [DNS Server Guide](docs/dns.md) explanation of the DNS module configuration and usage.
- [Kamal Proxy Guide](docs/kamal-proxy.md) for blue/green deployments with kamal-proxy.

## Future Plans

- API for dynamic configuration changes
- Docker/Podman support for easy deployment


## API & Dashboard

GoUp includes a built-in REST API and a Web Dashboard for management.
Expand All @@ -39,7 +33,9 @@ To enable them, edit your global configuration file (`~/.config/goup/conf.global

### Authentication

Security is mandatory when enabling the API/Dashboard. GoUp uses:
Security is mandatory when enabling the API/Dashboard, and it is enforced:
GoUp refuses to start the API without an `api_token`, and refuses to start the
Dashboard without a `username` and `password_hash`. GoUp uses:
- **Basic Auth** for the Dashboard.
- **Token Auth** for the API.

Expand Down Expand Up @@ -71,22 +67,24 @@ GoUp handles compression automatically with a dual-layer strategy:

GoUp includes a built-in **SafeGuard** system that monitors memory usage and automatically restarts the process if it exceeds safety limits, ensuring long-term stability.

- **Enabled by Default**: Checks memory usage every 30 seconds.
- **Enabled by Default**: Checks resident memory (RSS) every 30 seconds.
- **Auto-Dump**: Saves a Pprof heap dump before restarting for easier debugging.
- **Seamless Restart**: Uses `syscall.Exec` to replace the process immediately.

Configuration (in `~/.config/goup/conf.global.json`):

```json
{
"safe_guard": {
"safeguard": {
"enable": true,
"max_memory_mb": 1024,
"check_interval": 30
"check_interval": "30s"
}
}
```

`check_interval` is a Go duration string (e.g. `"30s"`, `"1m"`).

## Installation

Go is required to build the software, ensure you have it installed on your system.
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ module github.com/mirkobrombin/goup

go 1.25.0

toolchain go1.26.5

require (
github.com/armon/go-radix v1.0.0
github.com/gorilla/mux v1.8.1
Expand Down
12 changes: 12 additions & 0 deletions internal/api/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ func updateConfigHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}

// Reject changes that would leave an admin surface enabled without
// credentials (which would otherwise disable authentication entirely).
if newConf.EnableAPI && newConf.Account.APIToken == "" {
http.Error(w, "Refusing to enable the API without account.api_token", http.StatusBadRequest)
return
}
if newConf.DashboardPort != 0 && (newConf.Account.Username == "" || newConf.Account.PasswordHash == "") {
http.Error(w, "Refusing to enable the dashboard without account.username and account.password_hash", http.StatusBadRequest)
return
}

config.GlobalConfMu.Lock()
config.GlobalConf = &newConf
config.GlobalConfMu.Unlock()
Expand Down
9 changes: 6 additions & 3 deletions internal/api/logs_explorer.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package api

import (
"io/fs"
"io/ioutil"
"net/http"
"os"
"path/filepath"
Expand Down Expand Up @@ -123,12 +122,16 @@ func getLogFileHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "fileName is required", http.StatusBadRequest)
return
}
fullPath := filepath.Join(config.GetLogDir(), fileName)
fullPath, err := config.SafeJoin(config.GetLogDir(), fileName)
if err != nil {
http.Error(w, "Invalid log file path", http.StatusBadRequest)
return
}
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
http.Error(w, "Log file not found", http.StatusNotFound)
return
}
data, err := ioutil.ReadFile(fullPath)
data, err := os.ReadFile(fullPath)
if err != nil {
http.Error(w, "Failed to read log file", http.StatusInternalServerError)
return
Expand Down
9 changes: 9 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ func StartAPIServer() *http.Server {
return nil
}

// Fail closed: the API exposes full administrative control (create/delete
// sites, rewrite the global config, restart). Refuse to start it without a
// token instead of serving an unauthenticated admin surface on the network.
if conf.Account.APIToken == "" {
fmt.Println("[API] Refusing to start: 'account.api_token' is not set. " +
"Set a token in the global config to enable the API.")
return nil
}

router := SetupRoutes()
port := conf.APIPort
var handler http.Handler = router
Expand Down
19 changes: 15 additions & 4 deletions internal/api/sites.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"sort"

"github.com/gorilla/mux"
Expand Down Expand Up @@ -48,7 +47,11 @@ func createSiteHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
path := filepath.Join(config.GetConfigDir(), site.Domain+".json")
path, err := config.SiteConfigPath(site.Domain)
if err != nil {
http.Error(w, "Invalid domain", http.StatusBadRequest)
return
}
if _, err := os.Stat(path); err == nil {
http.Error(w, "Site already exists", http.StatusBadRequest)
return
Expand Down Expand Up @@ -86,7 +89,11 @@ func updateSiteHandler(w http.ResponseWriter, r *http.Request) {
existing.RequestTimeout = updated.RequestTimeout
existing.PluginConfigs = updated.PluginConfigs

path := filepath.Join(config.GetConfigDir(), domain+".json")
path, err := config.SiteConfigPath(domain)
if err != nil {
http.Error(w, "Invalid domain", http.StatusBadRequest)
return
}
if err := existing.Save(path); err != nil {
http.Error(w, "Failed to save site config", http.StatusInternalServerError)
return
Expand All @@ -100,7 +107,11 @@ func updateSiteHandler(w http.ResponseWriter, r *http.Request) {
func deleteSiteHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
domain := vars["domain"]
path := filepath.Join(config.GetConfigDir(), domain+".json")
path, err := config.SiteConfigPath(domain)
if err != nil {
http.Error(w, "Invalid domain", http.StatusBadRequest)
return
}
if err := os.Remove(path); err != nil {
http.Error(w, "Failed to delete site config", http.StatusInternalServerError)
return
Expand Down
2 changes: 1 addition & 1 deletion internal/api/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func cleanupLogsHandler(w http.ResponseWriter, r *http.Request) {
zipWriter.Close()

backupFile := filepath.Join(logDir, "logs_backup_"+time.Now().Format("20060102_150405")+".zip")
err = os.WriteFile(backupFile, zipBuffer.Bytes(), 0644)
err = os.WriteFile(backupFile, zipBuffer.Bytes(), 0600)
if err != nil {
http.Error(w, "Error saving backup zip", http.StatusInternalServerError)
return
Expand Down
105 changes: 87 additions & 18 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,11 @@ var startDNSCmd = &cobra.Command{
}

func startDNS(cmd *cobra.Command, args []string) {
configs, _ := loadConfigs()
configs, err := loadConfigs()
if err != nil {
fmt.Printf("Error loading configurations: %v\n", err)
os.Exit(1)
}

if err := pidfile.Write(); err != nil {
fmt.Printf("Warning: could not write PID file: %v\n", err)
Expand Down Expand Up @@ -296,22 +300,42 @@ var stopCmd = &cobra.Command{
fmt.Printf("Error sending signal: %v\n", err)
os.Exit(1)
}
done := make(chan struct{})
go func() {
proc.Wait()
close(done)
}()
select {
case <-done:
if waitForExit(pid, 10*time.Second) {
fmt.Println("Server stopped.")
case <-time.After(10 * time.Second):
} else {
fmt.Println("Server did not stop in time, forcing...")
proc.Kill()
_ = proc.Kill()
waitForExit(pid, 5*time.Second)
}
pidfile.Remove()
},
}

// processAlive reports whether a process with the given PID is currently
// running. On Unix, signal 0 performs error checking without delivering a
// signal.
func processAlive(pid int) bool {
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
return proc.Signal(syscall.Signal(0)) == nil
}

// waitForExit polls until the process is gone or the timeout elapses. It
// returns true if the process exited. This works for non-child processes,
// unlike (*os.Process).Wait.
func waitForExit(pid int, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if !processAlive(pid) {
return true
}
time.Sleep(50 * time.Millisecond)
}
return !processAlive(pid)
}

var restartCmd = &cobra.Command{
Use: "restart",
Short: "Restart the server",
Expand All @@ -326,15 +350,36 @@ var restartCmd = &cobra.Command{
fmt.Printf("Error finding process %d: %v\n", pid, err)
os.Exit(1)
}
proc.Signal(syscall.SIGTERM)
time.Sleep(500 * time.Millisecond)
if err := proc.Signal(syscall.SIGTERM); err != nil {
fmt.Printf("Error sending signal: %v\n", err)
os.Exit(1)
}
// Wait for the old process to release its PID file, ports and
// listeners before starting a fresh instance, otherwise the two
// race over the PID file and the sockets.
if !waitForExit(pid, 15*time.Second) {
fmt.Println("Previous instance did not stop in time, forcing...")
_ = proc.Kill()
waitForExit(pid, 5*time.Second)
}

exe, err := os.Executable()
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
argsEnv := os.Environ()
if err := syscall.Exec(exe, os.Args, argsEnv); err != nil {

// Re-exec as "start" (not "restart"), preserving the config flags so
// the new process actually serves traffic instead of re-running the
// restart command against a now-stopped server.
newArgs := []string{exe, "start"}
if configPath != "" {
newArgs = append(newArgs, "--config", configPath)
}
if globalConfigPath != "" {
newArgs = append(newArgs, "--global-config", globalConfigPath)
}
if err := syscall.Exec(exe, newArgs, os.Environ()); err != nil {
fmt.Printf("Error restarting: %v\n", err)
os.Exit(1)
}
Expand All @@ -348,16 +393,40 @@ var validateCmd = &cobra.Command{
}

func validate(cmd *cobra.Command, args []string) {
configs, err := config.LoadAllConfigs()
configDir := config.GetConfigDir()
files, err := os.ReadDir(configDir)
if err != nil {
fmt.Printf("Error loading configurations: %v\n", err)
fmt.Printf("Error reading configuration directory %s: %v\n", configDir, err)
os.Exit(1)
}

fmt.Printf("Validating configurations in %s:\n", config.GetConfigDir())
for _, conf := range configs {
fmt.Printf("Validating configurations in %s:\n", configDir)
hasError := false
for _, file := range files {
if filepath.Ext(file.Name()) != ".json" {
continue
}
if file.Name() == "conf.global.json" {
continue
}
fullPath, err := config.SafeJoin(configDir, file.Name())
if err != nil {
fmt.Printf("- %s: INVALID PATH (%v)\n", file.Name(), err)
hasError = true
continue
}
conf, err := config.LoadConfig(fullPath)
if err != nil {
fmt.Printf("- %s: FAILED (%v)\n", file.Name(), err)
hasError = true
continue
}
fmt.Printf("- %s: OK\n", conf.Domain)
}

if hasError {
os.Exit(1)
}
}

var listCmd = &cobra.Command{
Expand Down
15 changes: 13 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,23 @@ func LoadAllConfigs() ([]SiteConfig, error) {
var wg sync.WaitGroup

for _, file := range files {
// Skip the global config file: it lives in the same directory but is
// not a site definition (loading it would create a phantom site with an
// empty domain on port 0).
if file.Name() == globalConfName {
continue
}
if filepath.Ext(file.Name()) == ".json" {
wg.Add(1)
go func(fname string) {
defer wg.Done()

conf, err := LoadConfig(filepath.Join(configDir, fname))
fullPath, err := SafeJoin(configDir, fname)
if err != nil {
fmt.Printf("Error loading config %s: %v\n", fname, err)
return
}
conf, err := LoadConfig(fullPath)
if err != nil {
fmt.Printf("Error loading config %s: %v\n", fname, err)
return
Expand All @@ -168,7 +179,7 @@ func (conf *SiteConfig) Save(filePath string) error {
if err != nil {
return err
}
return os.WriteFile(filePath, data, 0644)
return os.WriteFile(filePath, data, 0600)
}

// GetSiteConfigByHost returns the site configuration based on the host.
Expand Down
4 changes: 4 additions & 0 deletions internal/config/dns_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ type DNSConfig struct {
Port int `json:"port"` // Default: 53
UpstreamResolvers []string `json:"upstream_resolvers"` // Optional forwarding
Zones map[string][]DNSRecord `json:"zones"` // zone -> records
// AllowRecursionFrom lists CIDRs allowed to use the forwarder (recursion).
// When empty, recursion is restricted to loopback and private networks so
// the server is not an open resolver / amplification vector.
AllowRecursionFrom []string `json:"allow_recursion_from"`
}

// DefaultDNSConfig returns the default DNS configuration.
Expand Down
2 changes: 1 addition & 1 deletion internal/config/global_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,5 @@ func SaveGlobalConfig() error {
if err != nil {
return err
}
return os.WriteFile(configFile, data, 0644)
return os.WriteFile(configFile, data, 0600)
}
Loading
Loading