mirror of
https://github.com/wweir/sower.git
synced 2026-10-19 16:14:33 +00:00
feat: add sowerd install and startup validation
This commit is contained in:
+290
-72
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user