diff --git a/.gitignore b/.gitignore index 7f48767177e75ab62894454f61761e963f03f43e..b857f93578768d29a5203fe589c21a0b77a0287c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ -cmd/soft/soft -.ssh -.repos +soft +soft-serve dist testdata completions/ diff --git a/cmd/soft/serve.go b/cmd/soft/serve.go index 8207fd0d9cce1a2e33244a6db7db4163598b99ba..577f0b5ab87071d5fb478f8b61059f538a8de14e 100644 --- a/cmd/soft/serve.go +++ b/cmd/soft/serve.go @@ -2,7 +2,6 @@ package main import ( "context" - "log" "os" "os/signal" "syscall" @@ -23,8 +22,6 @@ var ( cfg := config.DefaultConfig() s := server.NewServer(cfg) - log.Printf("Starting SSH server on %s:%d", cfg.BindAddr, cfg.Port) - done := make(chan os.Signal, 1) lch := make(chan error, 1) go func() { diff --git a/config/config.go b/config/config.go index 85408497baace36d2146a647aaa749f72d51ae4f..de96808c3b2a8133a2044dacfbf6602c4df6dc97 100644 --- a/config/config.go +++ b/config/config.go @@ -75,7 +75,7 @@ func NewConfig(cfg *config.Config) (*Config, error) { var yamlUsers string var displayHost string host := cfg.Host - port := cfg.Port + port := cfg.SSH.Port pks := make([]string, 0) for _, k := range cfg.InitialAdminKeys { @@ -94,7 +94,7 @@ func NewConfig(cfg *config.Config) (*Config, error) { pks = append(pks, pk) } - rs := NewRepoSource(cfg.RepoPath) + rs := NewRepoSource(cfg.RepoPath()) c := &Config{ Cfg: cfg, } @@ -259,7 +259,7 @@ func createFile(path string, content string) error { func (cfg *Config) createDefaultConfigRepo(yaml string) error { cn := defaultConfigRepo - rp := filepath.Join(cfg.Cfg.RepoPath, cn) + ".git" + rp := filepath.Join(cfg.Cfg.RepoPath(), cn) + ".git" rs := cfg.Source err := rs.LoadRepo(cn) if errors.Is(err, fs.ErrNotExist) { diff --git a/config/config_test.go b/config/config_test.go index 12ddd8c244a7a556f1498dace6b7da61a1e5d706..7616a3ca5cb488e2a4d92f1a1be33f559d5451e7 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -9,8 +9,7 @@ import ( func TestMultipleInitialKeys(t *testing.T) { cfg, err := NewConfig(&config.Config{ - RepoPath: t.TempDir(), - KeyPath: t.TempDir(), + DataPath: t.TempDir(), InitialAdminKeys: []string{ "testdata/k1.pub", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b", @@ -28,8 +27,7 @@ func TestMultipleInitialKeys(t *testing.T) { func TestEmptyInitialKeys(t *testing.T) { cfg, err := NewConfig(&config.Config{ - RepoPath: t.TempDir(), - KeyPath: t.TempDir(), + DataPath: t.TempDir(), }) is := is.New(t) is.NoErr(err) diff --git a/examples/setuid/main.go b/examples/setuid/main.go index 3138ebff1275f4f1a6b48649b093ebc53af144fa..328bb99bc4d89c7a7e05434ebb50cf6c53e8c7da 100644 --- a/examples/setuid/main.go +++ b/examples/setuid/main.go @@ -44,13 +44,13 @@ func main() { log.Fatalf("Setuid error: %s", err) } cfg := config.DefaultConfig() - cfg.Port = *port + cfg.SSH.Port = *port s := server.NewServer(cfg) done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) - log.Printf("Starting SSH server on %s:%d", cfg.BindAddr, cfg.Port) + log.Printf("Starting SSH server on %s:%d", cfg.Host, cfg.SSH.Port) go func() { if err := s.SSHServer.Serve(ls); err != nil { log.Fatalln(err) @@ -59,7 +59,7 @@ func main() { <-done - log.Printf("Stopping SSH server on %s:%d", cfg.BindAddr, cfg.Port) + log.Printf("Stopping SSH server on %s:%d", cfg.Host, cfg.SSH.Port) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer func() { cancel() }() if err := s.Shutdown(ctx); err != nil { diff --git a/server/cmd/middleware_test.go b/server/cmd/middleware_test.go index 68efef5a1bd5f82ea0956dba50a8d2b57764e7d6..3ccdad40151df5b04261c3d30b748bf84ba9546c 100644 --- a/server/cmd/middleware_test.go +++ b/server/cmd/middleware_test.go @@ -19,10 +19,11 @@ func TestMiddleware(t *testing.T) { }) is := is.New(t) appCfg, err := config.NewConfig(&sconfig.Config{ - Host: "localhost", - Port: 22223, - RepoPath: "testmiddleware/repos", - KeyPath: "testmiddleware/key", + Host: "localhost", + SSH: sconfig.SSHConfig{ + Port: 22223, + }, + DataPath: "testmiddleware", }) is.NoErr(err) _ = testsession.New(t, &ssh.Server{ diff --git a/server/config/config.go b/server/config/config.go index 577e205a7c70be254c0a72c75b8aca48f2321222..3deb57576b8eb864579d060ce093e2f79c6fa6ba 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -14,33 +14,75 @@ type Callbacks interface { Fetch(repo string) } +// SSHConfig is the SSH configuration for the server. +type SSHConfig struct { + Port int `env:"PORT" envDefault:"23231"` +} + +// GitConfig is the Git protocol configuration for the server. +type GitConfig struct { + Port int `env:"PORT" envDefault:"9418"` + MaxTimeout int `env:"MAX_TIMEOUT" envDefault:"300"` + // MaxReadTimeout is the maximum time a client can take to send a request. + MaxReadTimeout int `env:"MAX_READ_TIMEOUT" envDefault:"3"` + MaxConnections int `env:"SOFT_SERVE_GIT_MAX_CONNECTIONS" envDefault:"32"` +} + // Config is the configuration for Soft Serve. type Config struct { - BindAddr string `env:"SOFT_SERVE_BIND_ADDRESS" envDefault:""` - Host string `env:"SOFT_SERVE_HOST" envDefault:"localhost"` - Port int `env:"SOFT_SERVE_PORT" envDefault:"23231"` - GitPort int `env:"SOFT_SERVE_GIT_PORT" envDefault:"9418"` - GitMaxTimeout int `env:"SOFT_SERVE_GIT_MAX_TIMEOUT" envDefault:"300"` - // MaxReadTimeout is the maximum time a client can take to send a request. - GitMaxReadTimeout int `env:"SOFT_SERVE_GIT_MAX_READ_TIMEOUT" envDefault:"3"` - GitMaxConnections int `env:"SOFT_SERVE_GIT_MAX_CONNECTIONS" envDefault:"32"` - KeyPath string `env:"SOFT_SERVE_KEY_PATH"` - RepoPath string `env:"SOFT_SERVE_REPO_PATH" envDefault:".repos"` - InitialAdminKeys []string `env:"SOFT_SERVE_INITIAL_ADMIN_KEY" envSeparator:"\n"` - Callbacks Callbacks - ErrorLog *log.Logger + Host string `env:"HOST" envDefault:"localhost"` + SSH SSHConfig `env:"SSH" envPrefix:"SSH_"` + Git GitConfig `env:"GIT" envPrefix:"GIT_"` + + DataPath string `env:"DATA_PATH" envDefault:"soft-serve"` + + // Deprecated: use SOFT_SERVE_SSH_PORT instead. + Port int `env:"PORT"` + // Deprecated: use DataPath instead. + KeyPath string `env:"KEY_PATH"` + // Deprecated: use DataPath instead. + ReposPath string `env:"REPO_PATH"` + + InitialAdminKeys []string `env:"INITIAL_ADMIN_KEY" envSeparator:"\n"` + Callbacks Callbacks + ErrorLog *log.Logger +} + +// RepoPath returns the path to the repositories. +func (c *Config) RepoPath() string { + if c.ReposPath != "" { + log.Printf("warning: SOFT_SERVE_REPO_PATH is deprecated, use SOFT_SERVE_DATA_PATH instead") + return c.ReposPath + } + return filepath.Join(c.DataPath, "repos") +} + +// SSHPath returns the path to the SSH directory. +func (c *Config) SSHPath() string { + return filepath.Join(c.DataPath, "ssh") +} + +// PrivateKeyPath returns the path to the SSH key. +func (c *Config) PrivateKeyPath() string { + if c.KeyPath != "" { + log.Printf("warning: SOFT_SERVE_KEY_PATH is deprecated, use SOFT_SERVE_DATA_PATH instead") + return c.KeyPath + } + return filepath.Join(c.DataPath, "ssh", "soft_serve") } // DefaultConfig returns a Config with the values populated with the defaults // or specified environment variables. func DefaultConfig() *Config { cfg := &Config{ErrorLog: log.Default()} - if err := env.Parse(cfg); err != nil { + if err := env.Parse(cfg, env.Options{ + Prefix: "SOFT_SERVE_", + }); err != nil { log.Fatalln(err) } - if cfg.KeyPath == "" { - // NB: cross-platform-compatible path - cfg.KeyPath = filepath.Join(".ssh", "soft_serve_server_ed25519") + if cfg.Port != 0 { + log.Printf("warning: SOFT_SERVE_PORT is deprecated, use SOFT_SERVE_SSH_PORT instead") + cfg.SSH.Port = cfg.Port } return cfg.WithCallbacks(nil) } diff --git a/server/git/daemon/daemon.go b/server/git/daemon/daemon.go index 9d4247eee8228054a63a97b038894df51d8aabd8..1c350a0a623154d0a8837e5071089aed92bc8731 100644 --- a/server/git/daemon/daemon.go +++ b/server/git/daemon/daemon.go @@ -29,11 +29,12 @@ type Daemon struct { conns map[net.Conn]struct{} cfg *config.Config wg sync.WaitGroup + once sync.Once } // NewDaemon returns a new Git daemon. func NewDaemon(cfg *config.Config, auth git.Hooks) (*Daemon, error) { - addr := fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.GitPort) + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Git.Port) d := &Daemon{ addr: addr, auth: auth, @@ -53,7 +54,7 @@ func NewDaemon(cfg *config.Config, auth git.Hooks) (*Daemon, error) { // Start starts the Git TCP daemon. func (d *Daemon) Start() error { // set up channel on which to send accepted connections - listen := make(chan net.Conn, d.cfg.GitMaxConnections) + listen := make(chan net.Conn, d.cfg.Git.MaxConnections) go d.acceptConnection(d.listener, listen) // loop work cycle with accept connections or interrupt @@ -107,14 +108,14 @@ func (d *Daemon) handleClient(c net.Conn) { defer delete(d.conns, c) // Close connection if there are too many open connections. - if len(d.conns) >= d.cfg.GitMaxConnections { + if len(d.conns) >= d.cfg.Git.MaxConnections { log.Printf("git: max connections reached, closing %s", c.RemoteAddr()) fatal(c, git.ErrMaxConns) return } // Set connection timeout. - if err := c.SetDeadline(time.Now().Add(time.Duration(d.cfg.GitMaxTimeout) * time.Second)); err != nil { + if err := c.SetDeadline(time.Now().Add(time.Duration(d.cfg.Git.MaxTimeout) * time.Second)); err != nil { log.Printf("git: error setting deadline: %v", err) fatal(c, git.ErrSystemMalfunction) return @@ -123,7 +124,7 @@ func (d *Daemon) handleClient(c net.Conn) { readc := make(chan struct{}, 1) go func() { select { - case <-time.After(time.Duration(d.cfg.GitMaxReadTimeout) * time.Second): + case <-time.After(time.Duration(d.cfg.Git.MaxReadTimeout) * time.Second): log.Printf("git: read timeout from %s", c.RemoteAddr()) fatal(c, git.ErrMaxTimeout) case <-readc: @@ -168,11 +169,11 @@ func (d *Daemon) handleClient(c net.Conn) { repo += ".git" } - err := git.GitPack(c, c, c, cmd, d.cfg.RepoPath, repo) + err := git.GitPack(c, c, c, cmd, d.cfg.RepoPath(), repo) if err == git.ErrInvalidRepo { trimmed := strings.TrimSuffix(repo, ".git") log.Printf("git: invalid repo %q trying again %q", repo, trimmed) - err = git.GitPack(c, c, c, cmd, d.cfg.RepoPath, trimmed) + err = git.GitPack(c, c, c, cmd, d.cfg.RepoPath(), trimmed) } if err != nil { fatal(c, err) @@ -182,12 +183,13 @@ func (d *Daemon) handleClient(c net.Conn) { // Close closes the underlying listener. func (d *Daemon) Close() error { + d.once.Do(func() { close(d.exit) }) return d.listener.Close() } // Shutdown gracefully shuts down the daemon. func (d *Daemon) Shutdown(_ context.Context) error { - close(d.exit) + d.once.Do(func() { close(d.exit) }) d.wg.Wait() return nil } diff --git a/server/git/daemon/daemon_test.go b/server/git/daemon/daemon_test.go index 2e5d137328e0c4105760b50d89be284038046991..5de891ac8fbcf6e9f3e0b5785eda31683f5fc28d 100644 --- a/server/git/daemon/daemon_test.go +++ b/server/git/daemon/daemon_test.go @@ -2,7 +2,6 @@ package daemon import ( "bytes" - "context" "io" "log" "net" @@ -18,13 +17,21 @@ import ( var testDaemon *Daemon func TestMain(m *testing.M) { - cfg := config.DefaultConfig() - // Reduce the max connections to 3 so we can test the timeout. - cfg.GitMaxConnections = 3 - // Reduce the max timeout to 100 second so we can test the timeout. - cfg.GitMaxTimeout = 100 - // Reduce the max read timeout to 1 second so we can test the timeout. - cfg.GitMaxReadTimeout = 1 + testdata := "testdata" + defer os.RemoveAll(testdata) + cfg := &config.Config{ + Host: "", + DataPath: testdata, + Git: config.GitConfig{ + // Reduce the max timeout to 100 second so we can test the timeout. + MaxTimeout: 100, + // Reduce the max read timeout to 1 second so we can test the timeout. + MaxReadTimeout: 1, + // Reduce the max connections to 3 so we can test the timeout. + MaxConnections: 3, + Port: 9418, + }, + } ac, err := appCfg.NewConfig(cfg) if err != nil { log.Fatal(err) @@ -39,7 +46,7 @@ func TestMain(m *testing.M) { log.Fatal(err) } }() - defer d.Shutdown(context.Background()) + defer d.Close() os.Exit(m.Run()) } diff --git a/server/server.go b/server/server.go index bcbfcc55e643622f3da3e07aef6464e891c335d0..a7ce7276ca67964bfc24f98ae9765d0e1c790ab9 100644 --- a/server/server.go +++ b/server/server.go @@ -45,7 +45,7 @@ func NewServer(cfg *config.Config) *Server { // Command middleware must come after the git middleware. cm.Middleware(ac), // Git middleware. - gm.Middleware(cfg.RepoPath, ac), + gm.Middleware(cfg.RepoPath(), ac), // Logging middleware must be last to be executed first. lm.Middleware(), ), @@ -53,8 +53,8 @@ func NewServer(cfg *config.Config) *Server { s, err := wish.NewServer( ssh.PublicKeyAuth(ac.PublicKeyHandler), ssh.KeyboardInteractiveAuth(ac.KeyboardInteractiveHandler), - wish.WithAddress(fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port)), - wish.WithHostKeyPath(cfg.KeyPath), + wish.WithAddress(fmt.Sprintf("%s:%d", cfg.Host, cfg.SSH.Port)), + wish.WithHostKeyPath(cfg.PrivateKeyPath()), wish.WithMiddleware(mw...), ) if err != nil { @@ -81,14 +81,14 @@ func (s *Server) Reload() error { func (s *Server) Start() error { var errg errgroup.Group errg.Go(func() error { - log.Printf("Starting Git server on %s:%d", s.Config.BindAddr, s.Config.GitPort) + log.Printf("Starting Git server on %s:%d", s.Config.Host, s.Config.Git.Port) if err := s.GitServer.Start(); err != daemon.ErrServerClosed { return err } return nil }) errg.Go(func() error { - log.Printf("Starting SSH server on %s:%d", s.Config.BindAddr, s.Config.Port) + log.Printf("Starting SSH server on %s:%d", s.Config.Host, s.Config.SSH.Port) if err := s.SSHServer.ListenAndServe(); err != ssh.ErrServerClosed { return err } diff --git a/server/server_test.go b/server/server_test.go index 27ddddd3891cca6feaaa2b9e097d6f87d991de11..778a191fb301346586f989124ec3ac84c956fed9 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -19,9 +19,10 @@ import ( var ( cfg = &config.Config{ - BindAddr: "", - Host: "localhost", - Port: 22222, + Host: "", + SSH: config.SSHConfig{ + Port: 22222, + }, } ) @@ -54,7 +55,7 @@ func TestPushRepo(t *testing.T) { is.NoErr(err) _, err = r.CreateRemote(&gconfig.RemoteConfig{ Name: "origin", - URLs: []string{fmt.Sprintf("ssh://%s:%d/%s", cfg.Host, cfg.Port, "testrepo")}, + URLs: []string{fmt.Sprintf("ssh://%s:%d/%s", cfg.Host, cfg.SSH.Port, "testrepo")}, }) auth, err := gssh.NewPublicKeysFromFile("git", pkPath, "") is.NoErr(err) @@ -77,7 +78,7 @@ func TestCloneRepo(t *testing.T) { is.NoErr(err) dst := t.TempDir() - url := fmt.Sprintf("ssh://%s:%d/config", cfg.Host, cfg.Port) + url := fmt.Sprintf("ssh://%s:%d/config", cfg.Host, cfg.SSH.Port) err = ggit.Clone(url, dst, ggit.CloneOptions{ CommandOptions: ggit.CommandOptions{ Envs: []string{ @@ -91,8 +92,7 @@ func TestCloneRepo(t *testing.T) { func setupServer(t *testing.T) *Server { t.Helper() tmpdir := t.TempDir() - cfg.RepoPath = filepath.Join(tmpdir, "repos") - cfg.KeyPath = filepath.Join(tmpdir, "key") + cfg.DataPath = tmpdir s := NewServer(cfg) go func() { s.Start() diff --git a/server/session_test.go b/server/session_test.go index 3257be89c852af1612b7b8a8349fed8e2c4a0001..ac6b24645f33a7b9f7b0827c5c0f9388cdcdbee7 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -55,11 +55,10 @@ func TestSession(t *testing.T) { func setup(tb testing.TB) *gossh.Session { is := is.New(tb) tb.Helper() - cfg.RepoPath = tb.TempDir() + cfg.DataPath = tb.TempDir() ac, err := appCfg.NewConfig(&config.Config{ - Port: 22226, - KeyPath: tb.TempDir(), - RepoPath: tb.TempDir(), + SSH: config.SSHConfig{Port: 22226}, + DataPath: tb.TempDir(), InitialAdminKeys: []string{ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMJlb/qf2B2kMNdBxfpCQqI2ctPcsOkdZGVh5zTRhKtH", },