feat: add sowerd install and startup validation

This commit is contained in:
wweir
2026-03-30 23:02:54 +08:00
parent 2b072584e6
commit 6ccdcc131b
8 changed files with 881 additions and 75 deletions
+290 -72
View File
@@ -1,56 +1,195 @@
package main
import (
"bufio"
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/cristalhq/aconfig"
"github.com/cristalhq/aconfig/aconfigtoml"
"github.com/cristalhq/aconfig/aconfigyaml"
"github.com/lmittmann/tint"
"github.com/sower-proxy/conns/relay"
"github.com/sower-proxy/conns/reread"
"github.com/sower-proxy/deferlog/v2"
"github.com/sower-proxy/sower/config"
"github.com/sower-proxy/sower/transport/sower"
"github.com/sower-proxy/sower/internal/install"
transportSower "github.com/sower-proxy/sower/transport/sower"
"github.com/sower-proxy/sower/transport/trojan"
"golang.org/x/crypto/acme/autocert"
)
const (
httpShutdownTimeout = 5 * time.Second
probeTimeout = 10 * time.Second
)
var (
version, date string
conf config.SowerdConfig
)
func init() {
fi, _ := os.Stdout.Stat()
noColor := (fi.Mode() & os.ModeCharDevice) == 0
deferlog.SetDefault(slog.New(tint.NewHandler(os.Stdout,
&tint.Options{AddSource: true, NoColor: noColor})))
setLogger(slog.LevelInfo)
}
err := aconfig.LoaderFor(&conf, aconfig.Config{}).Load()
func main() {
if hasInstallFlag(os.Args[1:]) {
if err := install.InstallService(stdinConfirm); err != nil {
slog.Error("install service", "error", err)
os.Exit(1)
}
return
}
conf, err := loadConfig()
if err != nil {
slog.Error("load config", "error", err)
os.Exit(1)
}
slog.Info("load config", "version", version, "date", date, "config", conf)
}
func main() {
cacheDir, _ := os.UserCacheDir()
cacheDir = filepath.Join(cacheDir, "sower")
if err := os.MkdirAll(cacheDir, 0o600); err != nil {
slog.Error("create cache dir", "error", err)
setLogger(conf.LogLevel)
slog.Info("load config",
"version", version,
"date", date,
"config", sanitizeConfig(conf))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, conf); err != nil {
slog.Error("sowerd exited", "error", err)
os.Exit(1)
}
}
certManager := autocert.Manager{
func loadConfig() (config.SowerdConfig, error) {
var conf config.SowerdConfig
if err := aconfig.LoaderFor(&conf, aconfig.Config{
AllowUnknownFlags: false,
AllowUnknownFields: false,
FileFlag: "c",
Files: []string{
"sowerd.toml",
"sowerd.yaml",
"sowerd.yml",
"config/sowerd.toml",
"config/sowerd.yaml",
"config/sowerd.yml",
"/etc/sower/sowerd.toml",
"/etc/sower/sowerd.yaml",
"/etc/sower/sowerd.yml",
},
FileDecoders: map[string]aconfig.FileDecoder{
".yml": aconfigyaml.New(),
".yaml": aconfigyaml.New(),
".toml": aconfigtoml.New(),
},
}).Load(); err != nil {
return config.SowerdConfig{}, err
}
if err := conf.Validate(); err != nil {
return config.SowerdConfig{}, err
}
return conf, nil
}
func run(ctx context.Context, conf config.SowerdConfig) error {
cacheDir, err := cacheDir()
if err != nil {
return err
}
certManager, tlsConf, err := buildTLSConfig(cacheDir, conf)
if err != nil {
return err
}
fakeSite, dirServer, err := prepareFakeSite(conf.FakeSite)
if err != nil {
return err
}
httpAddr := net.JoinHostPort(conf.ServeIP, "80")
httpServer := &http.Server{
Addr: httpAddr,
Handler: certManager.HTTPHandler(fakeSiteHandler(dirServer)),
}
httpErrCh := make(chan error, 1)
go func() {
err := httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
httpErrCh <- fmt.Errorf("listen http %s: %w", httpAddr, err)
}
close(httpErrCh)
}()
httpsAddr := net.JoinHostPort(conf.ServeIP, "443")
ln, err := tls.Listen("tcp", httpsAddr, tlsConf)
if err != nil {
_ = shutdownHTTP(context.Background(), httpServer)
return fmt.Errorf("listen https %s: %w", httpsAddr, err)
}
defer ln.Close()
httpsErrCh := make(chan error, 1)
go func() {
httpsErrCh <- serve443(ctx, ln, fakeSite, transportSower.New(conf.Password), trojan.New(conf.Password))
close(httpsErrCh)
}()
select {
case err := <-httpErrCh:
if err != nil {
_ = ln.Close()
return err
}
case err := <-httpsErrCh:
if err != nil {
_ = shutdownHTTP(context.Background(), httpServer)
return err
}
case <-ctx.Done():
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), httpShutdownTimeout)
defer cancel()
_ = ln.Close()
if err := shutdownHTTP(shutdownCtx, httpServer); err != nil {
return err
}
return nil
}
func cacheDir() (string, error) {
base, err := os.UserCacheDir()
if err != nil {
return "", fmt.Errorf("get user cache dir: %w", err)
}
dir := filepath.Join(base, "sower")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("create cache dir: %w", err)
}
return dir, nil
}
func buildTLSConfig(cacheDir string, cfg config.SowerdConfig) (*autocert.Manager, *tls.Config, error) {
certManager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
Email: conf.Cert.Email,
Email: cfg.Cert.Email,
Cache: autocert.DirCache(cacheDir),
}
@@ -59,80 +198,159 @@ func main() {
MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1", "h2"},
}
if conf.Cert.Cert != "" || conf.Cert.Key != "" {
cert, err := tls.LoadX509KeyPair(conf.Cert.Cert, conf.Cert.Key)
if err != nil {
slog.Error("load cert", "error", err)
os.Exit(1)
}
tlsConf.GetCertificate = nil
tlsConf.Certificates = []tls.Certificate{cert}
if cfg.Cert.Cert == "" {
return certManager, tlsConf, nil
}
// Redirect 80 to 443, and serve fake site if it's a directory.
addr := net.JoinHostPort(conf.ServeIP, "80")
var dirServer http.Handler
if si, err := os.Stat(conf.FakeSite); err == nil && si.IsDir() {
slog.Info("serve fake site on http", "dir", conf.FakeSite)
dirServer = http.FileServer(http.Dir(conf.FakeSite))
conf.FakeSite = "127.0.0.1:80"
}
go http.ListenAndServe(addr, certManager.HTTPHandler(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if dirServer != nil && strings.HasPrefix(r.RemoteAddr, "127.0.0.1:") {
dirServer.ServeHTTP(w, r)
} else {
target := "https://" + r.Host + r.URL.RequestURI()
http.Redirect(w, r, target, http.StatusPermanentRedirect)
}
})))
ln, err := tls.Listen("tcp", net.JoinHostPort(conf.ServeIP, "443"), tlsConf)
cert, err := tls.LoadX509KeyPair(cfg.Cert.Cert, cfg.Cert.Key)
if err != nil {
slog.Error("listen https", "error", err)
os.Exit(1)
return nil, nil, fmt.Errorf("load cert pair: %w", err)
}
go serve443(ln, conf.FakeSite, sower.New(conf.Password), trojan.New(conf.Password))
select {}
tlsConf.GetCertificate = nil
tlsConf.Certificates = []tls.Certificate{cert}
return certManager, tlsConf, nil
}
func serve443(ln net.Listener, fakeSite string, sower *sower.Sower, trojan *trojan.Trojan) {
conn, err := ln.Accept()
if err != nil {
slog.Error("serve 443 port", "error", err)
os.Exit(1)
func prepareFakeSite(fakeSite string) (string, http.Handler, error) {
si, err := os.Stat(fakeSite)
if err == nil && si.IsDir() {
slog.Info("serve fake site on http", "dir", fakeSite)
return "127.0.0.1:80", http.FileServer(http.Dir(fakeSite)), nil
}
go serve443(ln, fakeSite, sower, trojan)
reread := reread.New(conn)
defer reread.Close()
if err != nil && !os.IsNotExist(err) {
return "", nil, fmt.Errorf("stat fake site: %w", err)
}
return fakeSite, nil, nil
}
var addr net.Addr
var dur time.Duration
func fakeSiteHandler(dirServer http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if dirServer != nil && isLoopbackRemoteAddr(r.RemoteAddr) {
dirServer.ServeHTTP(w, r)
return
}
target := "https://" + r.Host + r.URL.RequestURI()
http.Redirect(w, r, target, http.StatusPermanentRedirect)
})
}
func serve443(ctx context.Context, ln net.Listener, fakeSite string, sower *transportSower.Sower, trojan *trojan.Trojan) error {
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
return nil
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Temporary() {
slog.Warn("temporary accept error", "error", err)
time.Sleep(100 * time.Millisecond)
continue
}
return fmt.Errorf("accept tls connection: %w", err)
}
go handleConn(conn, fakeSite, sower, trojan)
}
}
func handleConn(conn net.Conn, fakeSite string, sower *transportSower.Sower, trojan *trojan.Trojan) {
rereadConn := reread.New(conn)
defer rereadConn.Close()
_ = rereadConn.SetReadDeadline(time.Now().Add(probeTimeout))
var (
addr net.Addr
dur time.Duration
err error
)
defer func() {
deferlog.DebugWarn(err, "relay conn", "took", dur, "addr", addr)
}()
// 1. detect if it's a sower underlaying connection
reread.Reread()
if addr, err = sower.Unwrap(reread); err == nil {
reread.Stop()
dur, err = relay.RelayTo(reread, addr.String())
rereadConn.Reread()
if addr, err = sower.Unwrap(rereadConn); err == nil {
rereadConn.Stop()
_ = rereadConn.SetReadDeadline(time.Time{})
dur, err = relay.RelayTo(rereadConn, addr.String())
return
}
// 2. detect if it's a trojan underlaying connection
reread.Reread()
if addr, err = trojan.Unwrap(reread); err == nil {
reread.Stop()
dur, err = relay.RelayTo(reread, addr.String())
rereadConn.Reread()
if addr, err = trojan.Unwrap(rereadConn); err == nil {
rereadConn.Stop()
_ = rereadConn.SetReadDeadline(time.Time{})
dur, err = relay.RelayTo(rereadConn, addr.String())
return
}
// 3. fallback to fake site
reread.Stop().Reread()
dur, err = relay.RelayTo(reread, fakeSite)
rereadConn.Stop().Reread()
_ = rereadConn.SetReadDeadline(time.Time{})
dur, err = relay.RelayTo(rereadConn, fakeSite)
}
func sanitizeConfig(cfg config.SowerdConfig) map[string]any {
return map[string]any{
"log_level": cfg.LogLevel.String(),
"serve_ip": cfg.ServeIP,
"fake_site": cfg.FakeSite,
"cert": map[string]any{
"email": cfg.Cert.Email,
"cert_config": cfg.Cert.Cert != "",
"key_config": cfg.Cert.Key != "",
},
}
}
func hasInstallFlag(args []string) bool {
for _, arg := range args {
switch arg {
case "-i", "--install":
return true
}
}
return false
}
func stdinConfirm(label string) bool {
fmt.Printf("%s [y/N]: ", label)
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
return false
}
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
return answer == "y" || answer == "yes"
}
func isLoopbackRemoteAddr(remoteAddr string) bool {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
host = strings.Trim(host, "[]")
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func setLogger(level slog.Level) {
fi, err := os.Stdout.Stat()
noColor := err != nil || (fi.Mode()&os.ModeCharDevice) == 0
deferlog.SetDefault(slog.New(tint.NewHandler(os.Stdout, &tint.Options{
AddSource: true,
NoColor: noColor,
Level: level,
})))
}
func shutdownHTTP(ctx context.Context, server *http.Server) error {
if err := server.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("shutdown http server: %w", err)
}
return nil
}
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"log/slog"
"testing"
"github.com/sower-proxy/sower/config"
)
func TestSanitizeConfig(t *testing.T) {
t.Parallel()
cfg := config.SowerdConfig{
LogLevel: slog.LevelDebug,
ServeIP: "0.0.0.0",
Password: "secret",
FakeSite: "127.0.0.1:8080",
}
cfg.Cert.Email = "ops@example.com"
cfg.Cert.Cert = "/tmp/cert.pem"
cfg.Cert.Key = "/tmp/key.pem"
got := sanitizeConfig(cfg)
if got["fake_site"] != cfg.FakeSite {
t.Fatalf("unexpected fake_site: %#v", got["fake_site"])
}
if _, ok := got["password"]; ok {
t.Fatal("password must not be logged")
}
}
func TestIsLoopbackRemoteAddr(t *testing.T) {
t.Parallel()
tests := []struct {
addr string
want bool
}{
{addr: "127.0.0.1:1234", want: true},
{addr: "[::1]:443", want: true},
{addr: "10.0.0.1:1234", want: false},
{addr: "not-an-addr", want: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.addr, func(t *testing.T) {
t.Parallel()
if got := isLoopbackRemoteAddr(tt.addr); got != tt.want {
t.Fatalf("isLoopbackRemoteAddr(%q) = %v, want %v", tt.addr, got, tt.want)
}
})
}
}
func TestHasInstallFlag(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
want bool
}{
{name: "short flag", args: []string{"-i"}, want: true},
{name: "long flag", args: []string{"--install"}, want: true},
{name: "missing flag", args: []string{"-c", "/etc/sower/sowerd.toml"}, want: false},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := hasInstallFlag(tt.args); got != tt.want {
t.Fatalf("hasInstallFlag(%q) = %v, want %v", tt.args, got, tt.want)
}
})
}
}
+54 -1
View File
@@ -1,9 +1,30 @@
package config
import (
"fmt"
"log/slog"
"net"
"os"
)
const ExampleSowerdConfigTOML = `# Sowerd configuration example (TOML format)
# This is the daemon configuration for the sower server
# Logging configuration
logLevel = "info" # Log level: debug, info, warn, error
# Server configuration
serveIP = "0.0.0.0" # IP address to listen on for ports 80 and 443
password = "change_me" # Password for client authentication
fakeSite = "/var/www" # Fake site directory or address (default: /var/www)
# SSL/TLS certificate configuration
[cert]
email = "your-email@example.com" # Email for Let's Encrypt certificate
cert = "" # Path to custom certificate file (optional)
key = "" # Path to custom private key file (optional)
`
// SowerdConfig represents the configuration for sowerd daemon
type SowerdConfig struct {
LogLevel slog.Level `default:"info" usage:"log level: debug, info, warn, error"`
@@ -20,6 +41,38 @@ type SowerdConfig struct {
// Validate implements the validation interface for SowerdConfig
func (c *SowerdConfig) Validate() error {
// TODO: Add validation logic if needed
if c.ServeIP != "" && net.ParseIP(c.ServeIP) == nil {
return fmt.Errorf("invalid serve ip: %q", c.ServeIP)
}
if c.Password == "" {
return fmt.Errorf("password is required")
}
if c.FakeSite == "" {
return fmt.Errorf("fake site is required")
}
if (c.Cert.Cert == "") != (c.Cert.Key == "") {
return fmt.Errorf("cert and key must be configured together")
}
if c.Cert.Cert != "" {
if _, err := os.Stat(c.Cert.Cert); err != nil {
return fmt.Errorf("stat cert file: %w", err)
}
if _, err := os.Stat(c.Cert.Key); err != nil {
return fmt.Errorf("stat key file: %w", err)
}
}
if _, err := os.Stat(c.FakeSite); err == nil {
return nil
} else if !os.IsNotExist(err) {
return fmt.Errorf("stat fake site: %w", err)
}
if _, _, err := net.SplitHostPort(c.FakeSite); err != nil {
return fmt.Errorf("fake site must be an existing directory or host:port: %w", err)
}
return nil
}
+1 -1
View File
@@ -6,7 +6,7 @@ logLevel = "info" # Log level: debug, info, warn, error
# Server configuration
serveIP = "0.0.0.0" # IP address to listen on for ports 80 and 443
password = "your_secure_password" # Password for client authentication
password = "change_me" # Password for client authentication
fakeSite = "/var/www" # Fake site directory or address (default: /var/www)
# SSL/TLS certificate configuration
+1 -1
View File
@@ -6,7 +6,7 @@ logLevel: "info" # Log level: debug, info, warn, error
# Server configuration
serveIP: "0.0.0.0" # IP address to listen on for ports 80 and 443
password: "your_secure_password" # Password for client authentication
password: "change_me" # Password for client authentication
fakeSite: "/var/www" # Fake site directory or address (default: /var/www)
# SSL/TLS certificate configuration
+76
View File
@@ -0,0 +1,76 @@
package config
import "testing"
func TestSowerdConfigValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cfg SowerdConfig
wantErr bool
}{
{
name: "valid remote fake site",
cfg: SowerdConfig{
ServeIP: "0.0.0.0",
Password: "secret",
FakeSite: "127.0.0.1:8080",
},
},
{
name: "invalid serve ip",
cfg: SowerdConfig{
ServeIP: "bad-ip",
Password: "secret",
FakeSite: "127.0.0.1:8080",
},
wantErr: true,
},
{
name: "missing password",
cfg: SowerdConfig{
FakeSite: "127.0.0.1:8080",
},
wantErr: true,
},
{
name: "invalid fake site address",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "not-an-address",
},
wantErr: true,
},
{
name: "partial cert config",
cfg: SowerdConfig{
Password: "secret",
FakeSite: "127.0.0.1:8080",
Cert: struct {
Email string
Cert string
Key string
}{
Cert: "/tmp/cert.pem",
},
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.cfg.Validate()
if tt.wantErr && err == nil {
t.Fatal("expected error")
}
if !tt.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
+204
View File
@@ -0,0 +1,204 @@
package install
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/sower-proxy/sower/config"
)
type ConfirmFunc func(label string) bool
type installOptions struct {
confirm ConfirmFunc
geteuid func() int
executable func() (string, error)
evalSymlinks func(string) (string, error)
serviceIsActive func() bool
systemctl func(args ...string) error
targetBinaryPath string
servicePath string
configPath string
defaultFakeSite string
defaultConfigTOML string
}
func InstallService(confirm ConfirmFunc) error {
return installService(installOptions{
confirm: confirm,
geteuid: os.Geteuid,
executable: os.Executable,
evalSymlinks: filepath.EvalSymlinks,
serviceIsActive: serviceIsActive,
systemctl: systemctl,
targetBinaryPath: "/usr/local/bin/sowerd",
servicePath: "/etc/systemd/system/sowerd.service",
configPath: "/etc/sower/sowerd.toml",
defaultFakeSite: "/var/www",
defaultConfigTOML: config.ExampleSowerdConfigTOML,
})
}
func installService(opts installOptions) error {
if opts.geteuid() != 0 {
return fmt.Errorf("installing systemd service requires root privileges, try running with sudo")
}
execPath, err := opts.executable()
if err != nil {
return fmt.Errorf("get executable path: %w", err)
}
execPath, err = opts.evalSymlinks(execPath)
if err != nil {
return fmt.Errorf("resolve executable symlink: %w", err)
}
isUpdate := opts.serviceIsActive()
if execPath != opts.targetBinaryPath {
label := fmt.Sprintf("Copy binary to %s?", opts.targetBinaryPath)
if isUpdate {
label = fmt.Sprintf("Update binary at %s?", opts.targetBinaryPath)
}
if opts.confirm != nil && opts.confirm(label) {
if err := copyFile(execPath, opts.targetBinaryPath, 0o755); err != nil {
return err
}
if isUpdate {
_ = opts.systemctl("stop", "sowerd")
}
fmt.Printf(" OK binary copied to %s\n", opts.targetBinaryPath)
execPath = opts.targetBinaryPath
}
}
if err := writeServiceFile(opts.servicePath, execPath, opts.configPath); err != nil {
return err
}
fmt.Printf(" OK service file installed: %s\n", opts.servicePath)
if err := ensureDefaultFiles(opts.configPath, opts.defaultFakeSite, opts.defaultConfigTOML); err != nil {
return err
}
if err := opts.systemctl("daemon-reload"); err != nil {
return err
}
fmt.Println(" OK systemd daemon reloaded")
if isUpdate {
return finishUpdate(opts.confirm, opts.systemctl)
}
return finishInstall(opts.confirm, opts.systemctl)
}
func finishInstall(confirm ConfirmFunc, systemctlFn func(args ...string) error) error {
if confirm == nil || !confirm("Start and enable sowerd service now?") {
fmt.Println()
fmt.Println("sowerd service installed. Next steps:")
fmt.Println(" 1. Edit /etc/sower/sowerd.toml")
fmt.Println(" 2. sudo systemctl start sowerd")
fmt.Println(" 3. sudo systemctl enable sowerd")
return nil
}
if err := systemctlFn("enable", "sowerd"); err != nil {
return err
}
if err := systemctlFn("start", "sowerd"); err != nil {
return err
}
fmt.Println(" OK service enabled and started")
return nil
}
func finishUpdate(confirm ConfirmFunc, systemctlFn func(args ...string) error) error {
if confirm == nil || !confirm("Restart sowerd service now?") {
fmt.Println()
fmt.Println("sowerd service updated. To apply:")
fmt.Println(" sudo systemctl restart sowerd")
return nil
}
if err := systemctlFn("restart", "sowerd"); err != nil {
return err
}
fmt.Println(" OK service restarted")
return nil
}
func serviceIsActive() bool {
cmd := exec.Command("systemctl", "is-active", "--quiet", "sowerd")
return cmd.Run() == nil
}
func systemctl(args ...string) error {
cmd := exec.Command("systemctl", args...)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("systemctl %s: %w - %s", strings.Join(args, " "), err, strings.TrimSpace(string(output)))
}
return nil
}
func copyFile(src, dst string, mode os.FileMode) error {
data, err := os.ReadFile(src)
if err != nil {
return fmt.Errorf("read binary %s: %w", src, err)
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("create binary directory: %w", err)
}
if err := os.WriteFile(dst, data, mode); err != nil {
return fmt.Errorf("write binary to %s: %w", dst, err)
}
return nil
}
func writeServiceFile(servicePath, execPath, configPath string) error {
if err := os.MkdirAll(filepath.Dir(servicePath), 0o755); err != nil {
return fmt.Errorf("create service directory: %w", err)
}
serviceContent := fmt.Sprintf(`[Unit]
Description=sower server service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=%s -c %s
Restart=always
RestartSec=5s
[Install]
WantedBy=multi-user.target
`, execPath, configPath)
if err := os.WriteFile(servicePath, []byte(serviceContent), 0o644); err != nil {
return fmt.Errorf("write service file: %w", err)
}
return nil
}
func ensureDefaultFiles(configPath, fakeSiteDir, defaultConfigTOML string) error {
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return fmt.Errorf("create config directory: %w", err)
}
if err := os.MkdirAll(fakeSiteDir, 0o755); err != nil {
return fmt.Errorf("create fake site directory: %w", err)
}
if _, err := os.Stat(configPath); err == nil {
return nil
} else if !os.IsNotExist(err) {
return fmt.Errorf("stat default config path: %w", err)
}
if err := os.WriteFile(configPath, []byte(defaultConfigTOML), 0o644); err != nil {
return fmt.Errorf("write default config: %w", err)
}
fmt.Printf(" OK default config written to %s\n", configPath)
return nil
}
+177
View File
@@ -0,0 +1,177 @@
package install
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestInstallServiceRequiresRoot(t *testing.T) {
t.Parallel()
err := installService(installOptions{
geteuid: func() int { return 1000 },
})
if err == nil || !strings.Contains(err.Error(), "root privileges") {
t.Fatalf("installService() error = %v, want root privileges error", err)
}
}
func TestInstallServiceFreshInstall(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
execPath := filepath.Join(tmpDir, "sowerd")
if err := os.WriteFile(execPath, []byte("binary"), 0o755); err != nil {
t.Fatalf("write executable: %v", err)
}
targetBinaryPath := filepath.Join(tmpDir, "usr", "local", "bin", "sowerd")
servicePath := filepath.Join(tmpDir, "etc", "systemd", "system", "sowerd.service")
configPath := filepath.Join(tmpDir, "etc", "sower", "sowerd.toml")
fakeSiteDir := filepath.Join(tmpDir, "var", "www")
var prompts []string
var systemctlCalls []string
confirmAnswers := map[string]bool{
"Copy binary to " + targetBinaryPath + "?": true,
"Start and enable sowerd service now?": true,
}
err := installService(installOptions{
confirm: func(label string) bool {
prompts = append(prompts, label)
return confirmAnswers[label]
},
geteuid: func() int { return 0 },
executable: func() (string, error) { return execPath, nil },
evalSymlinks: func(path string) (string, error) { return path, nil },
serviceIsActive: func() bool { return false },
systemctl: func(args ...string) error {
systemctlCalls = append(systemctlCalls, strings.Join(args, " "))
return nil
},
targetBinaryPath: targetBinaryPath,
servicePath: servicePath,
configPath: configPath,
defaultFakeSite: fakeSiteDir,
defaultConfigTOML: `password = "change_me"` + "\n" + `fakeSite = "/var/www"` + "\n",
})
if err != nil {
t.Fatalf("installService() error = %v", err)
}
gotBinary, err := os.ReadFile(targetBinaryPath)
if err != nil {
t.Fatalf("read target binary: %v", err)
}
if string(gotBinary) != "binary" {
t.Fatalf("target binary = %q, want %q", string(gotBinary), "binary")
}
serviceContent, err := os.ReadFile(servicePath)
if err != nil {
t.Fatalf("read service file: %v", err)
}
serviceText := string(serviceContent)
if !strings.Contains(serviceText, "ExecStart="+targetBinaryPath+" -c "+configPath) {
t.Fatalf("service file missing ExecStart, content = %s", serviceText)
}
configContent, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read config file: %v", err)
}
if string(configContent) == "" {
t.Fatal("config file is empty")
}
if info, err := os.Stat(fakeSiteDir); err != nil || !info.IsDir() {
t.Fatalf("fake site dir stat err = %v, isDir = %v", err, err == nil && info.IsDir())
}
wantPrompts := []string{
"Copy binary to " + targetBinaryPath + "?",
"Start and enable sowerd service now?",
}
if !reflect.DeepEqual(prompts, wantPrompts) {
t.Fatalf("prompts = %#v, want %#v", prompts, wantPrompts)
}
wantSystemctlCalls := []string{
"daemon-reload",
"enable sowerd",
"start sowerd",
}
if !reflect.DeepEqual(systemctlCalls, wantSystemctlCalls) {
t.Fatalf("systemctl calls = %#v, want %#v", systemctlCalls, wantSystemctlCalls)
}
}
func TestInstallServiceUpdateWithoutRestart(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
targetBinaryPath := filepath.Join(tmpDir, "usr", "local", "bin", "sowerd")
if err := os.MkdirAll(filepath.Dir(targetBinaryPath), 0o755); err != nil {
t.Fatalf("mkdir target binary dir: %v", err)
}
if err := os.WriteFile(targetBinaryPath, []byte("binary"), 0o755); err != nil {
t.Fatalf("write target binary: %v", err)
}
servicePath := filepath.Join(tmpDir, "etc", "systemd", "system", "sowerd.service")
configPath := filepath.Join(tmpDir, "etc", "sower", "sowerd.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
if err := os.WriteFile(configPath, []byte("password = \"existing\"\n"), 0o644); err != nil {
t.Fatalf("write existing config: %v", err)
}
var prompts []string
var systemctlCalls []string
err := installService(installOptions{
confirm: func(label string) bool {
prompts = append(prompts, label)
return false
},
geteuid: func() int { return 0 },
executable: func() (string, error) { return targetBinaryPath, nil },
evalSymlinks: func(path string) (string, error) { return path, nil },
serviceIsActive: func() bool { return true },
systemctl: func(args ...string) error {
systemctlCalls = append(systemctlCalls, strings.Join(args, " "))
return nil
},
targetBinaryPath: targetBinaryPath,
servicePath: servicePath,
configPath: configPath,
defaultFakeSite: filepath.Join(tmpDir, "var", "www"),
defaultConfigTOML: "password = \"change_me\"\n",
})
if err != nil {
t.Fatalf("installService() error = %v", err)
}
configContent, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read config file: %v", err)
}
if string(configContent) != "password = \"existing\"\n" {
t.Fatalf("config content = %q, want existing config to be preserved", string(configContent))
}
wantPrompts := []string{"Restart sowerd service now?"}
if !reflect.DeepEqual(prompts, wantPrompts) {
t.Fatalf("prompts = %#v, want %#v", prompts, wantPrompts)
}
wantSystemctlCalls := []string{"daemon-reload"}
if !reflect.DeepEqual(systemctlCalls, wantSystemctlCalls) {
t.Fatalf("systemctl calls = %#v, want %#v", systemctlCalls, wantSystemctlCalls)
}
}