diff --git a/go.mod b/go.mod index 28f483877d5e5cefe4c48df2d8313ba9e8a97950..f2088542e92c527d1979083e70d2c4994907d09f 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/muesli/mango-cobra v1.2.0 github.com/muesli/roff v0.1.0 github.com/spf13/cobra v1.6.1 + goji.io v2.0.2+incompatible golang.org/x/crypto v0.7.0 golang.org/x/sync v0.1.0 ) diff --git a/go.sum b/go.sum index 626d920b1c3db6dedfe4381536bd047e47879539..04e8a16f6787ffec10cbf2d570ba0392ee91e189 100644 --- a/go.sum +++ b/go.sum @@ -180,6 +180,8 @@ github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU= github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= +goji.io v2.0.2+incompatible h1:uIssv/elbKRLznFUy3Xj4+2Mz/qKhek/9aZQDUMae7c= +goji.io v2.0.2+incompatible/go.mod h1:sbqFwrtqZACxLBTQcdgVjFh54yGVCvwq8+w49MVMMIk= golang.org/x/arch v0.1.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= diff --git a/server/config/config.go b/server/config/config.go index 8a3de7e1c5313333245a07b0fde349a1281d1e66..e005cdea7a879bbe6711a0f98d785f48465dad8a 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -14,6 +14,9 @@ type SSHConfig struct { // ListenAddr is the address on which the SSH server will listen. ListenAddr string `env:"LISTEN_ADDR" envDefault:":23231"` + // PublicURL is the public URL of the SSH server. + PublicURL string `env:"PUBLIC_URL" envDefault:"ssh://localhost:23231"` + // KeyPath is the path to the SSH server's private key. KeyPath string `env:"KEY_PATH"` @@ -39,14 +42,29 @@ type GitConfig struct { MaxConnections int `env:"MAX_CONNECTIONS" envDefault:"32"` } +// HTTPConfig is the HTTP configuration for the server. +type HTTPConfig struct { + // ListenAddr is the address on which the HTTP server will listen. + ListenAddr string `env:"LISTEN_ADDR" envDefault:":8080"` + + // PublicURL is the public URL of the HTTP server. + PublicURL string `env:"PUBLIC_URL" envDefault:"http://localhost:8080"` +} + // Config is the configuration for Soft Serve. type Config struct { + // Name is the name of the server. + Name string `env:"NAME" envDefault:"Soft Serve"` + // SSH is the configuration for the SSH server. SSH SSHConfig `envPrefix:"SSH_"` // Git is the configuration for the Git daemon. Git GitConfig `envPrefix:"GIT_"` + // HTTP is the configuration for the HTTP server. + HTTP HTTPConfig `envPrefix:"HTTP_"` + // InitialAdminKeys is a list of public keys that will be added to the list of admins. InitialAdminKeys []string `env:"INITIAL_ADMIN_KEY" envSeparator:"\n"` diff --git a/server/daemon.go b/server/daemon.go index d1f0c438724dabc7b7dc04acc4261a2761d85907..789f1e9bd39b2adc7fd4ce7152d3b1844e2f4195 100644 --- a/server/daemon.go +++ b/server/daemon.go @@ -3,7 +3,7 @@ package server import ( "bytes" "context" - "errors" + "fmt" "io" "net" "path/filepath" @@ -16,7 +16,7 @@ import ( ) // ErrServerClosed indicates that the server has been closed. -var ErrServerClosed = errors.New("git: Server closed") +var ErrServerClosed = fmt.Errorf("git: %w", net.ErrClosed) // connections synchronizes access to to a net.Conn pool. type connections struct { diff --git a/server/http.go b/server/http.go new file mode 100644 index 0000000000000000000000000000000000000000..fdc58c551f394e920852a1fe47cb337501b956ef --- /dev/null +++ b/server/http.go @@ -0,0 +1,183 @@ +package server + +import ( + "context" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "text/template" + "time" + + "github.com/charmbracelet/soft-serve/server/backend" + "github.com/charmbracelet/soft-serve/server/config" + "github.com/dustin/go-humanize" + "goji.io" + "goji.io/pat" + "goji.io/pattern" +) + +// logWriter is a wrapper around http.ResponseWriter that allows us to capture +// the HTTP status code and bytes written to the response. +type logWriter struct { + http.ResponseWriter + code, bytes int +} + +func (r *logWriter) Write(p []byte) (int, error) { + written, err := r.ResponseWriter.Write(p) + r.bytes += written + return written, err +} + +// Note this is generally only called when sending an HTTP error, so it's +// important to set the `code` value to 200 as a default +func (r *logWriter) WriteHeader(code int) { + r.code = code + r.ResponseWriter.WriteHeader(code) +} + +func loggingMiddleware(next http.Handler) http.Handler { + logger := logger.WithPrefix("server.http") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + writer := &logWriter{code: http.StatusOK, ResponseWriter: w} + logger.Debug("request", + "method", r.Method, + "uri", r.RequestURI, + "addr", r.RemoteAddr) + next.ServeHTTP(writer, r) + elapsed := time.Since(start) + logger.Debug("response", + "status", fmt.Sprintf("%d %s", writer.code, http.StatusText(writer.code)), + "bytes", humanize.Bytes(uint64(writer.bytes)), + "time", elapsed) + }) +} + +// HTTPServer is an http server. +type HTTPServer struct { + cfg *config.Config + server *http.Server + dirHandler http.Handler +} + +func NewHTTPServer(cfg *config.Config) (*HTTPServer, error) { + mux := goji.NewMux() + s := &HTTPServer{ + cfg: cfg, + dirHandler: http.FileServer(http.Dir(cfg.Backend.RepositoryStorePath())), + server: &http.Server{ + Addr: cfg.HTTP.ListenAddr, + Handler: mux, + ReadHeaderTimeout: time.Second * 10, + ReadTimeout: time.Second * 10, + WriteTimeout: time.Second * 10, + MaxHeaderBytes: http.DefaultMaxHeaderBytes, + }, + } + + mux.Use(loggingMiddleware) + mux.HandleFunc(pat.Get("/:repo"), s.repoIndexHandler) + mux.HandleFunc(pat.Get("/:repo/*"), s.dumbGitHandler) + return s, nil +} + +// Close closes the HTTP server. +func (s *HTTPServer) Close() error { + return s.server.Close() +} + +// ListenAndServe starts the HTTP server. +func (s *HTTPServer) ListenAndServe() error { + return s.server.ListenAndServe() +} + +// Shutdown gracefully shuts down the HTTP server. +func (s *HTTPServer) Shutdown(ctx context.Context) error { + return s.server.Shutdown(ctx) +} + +var repoIndexHTMLTpl = template.Must(template.New("index").Parse(` + + + + + + +Redirecting to docs at godoc.org/{{.ImportRoot}}/{{.Repo}}... + +`)) + +func (s *HTTPServer) repoIndexHandler(w http.ResponseWriter, r *http.Request) { + repo := pat.Param(r, "repo") + repo = sanitizeRepoName(repo) + + // Only respond to go-get requests + if r.URL.Query().Get("go-get") != "1" { + http.NotFound(w, r) + return + } + + access := s.cfg.Access.AccessLevel(repo, nil) + if access < backend.ReadOnlyAccess { + http.NotFound(w, r) + return + } + + importRoot, err := url.Parse(s.cfg.HTTP.PublicURL) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + + if err := repoIndexHTMLTpl.Execute(w, struct { + Repo string + Config *config.Config + ImportRoot string + }{ + Repo: repo, + Config: s.cfg, + ImportRoot: importRoot.Host, + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func (s *HTTPServer) dumbGitHandler(w http.ResponseWriter, r *http.Request) { + repo := pat.Param(r, "repo") + repo = sanitizeRepoName(repo) + ".git" + + access := s.cfg.Access.AccessLevel(repo, nil) + if access < backend.ReadOnlyAccess || !s.cfg.Backend.AllowKeyless() { + httpStatusError(w, http.StatusUnauthorized) + return + } + + path := pattern.Path(r.Context()) + stat, err := os.Stat(filepath.Join(s.cfg.Backend.RepositoryStorePath(), repo, path)) + // Restrict access to files + if err != nil || stat.IsDir() { + http.NotFound(w, r) + return + } + + // Don't allow access to non-git clients + ua := r.Header.Get("User-Agent") + if !strings.HasPrefix(strings.ToLower(ua), "git") { + httpStatusError(w, http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + r.URL.Path = fmt.Sprintf("/%s/%s", repo, path) + s.dirHandler.ServeHTTP(w, r) +} + +func httpStatusError(w http.ResponseWriter, status int) { + http.Error(w, fmt.Sprintf("%d %s", status, http.StatusText(status)), status) +} diff --git a/server/server.go b/server/server.go index b52d09f28c08f5c71f3621055f0f2166307cc1ca..f1c703af2a1e195c530a13a87db7d369e052b22a 100644 --- a/server/server.go +++ b/server/server.go @@ -2,6 +2,7 @@ package server import ( "context" + "net/http" "github.com/charmbracelet/log" @@ -17,11 +18,12 @@ var ( // Server is the Soft Serve server. type Server struct { - SSHServer *SSHServer - GitDaemon *GitDaemon - Config *config.Config - Backend backend.Backend - Access backend.AccessMethod + SSHServer *SSHServer + GitDaemon *GitDaemon + HTTPServer *HTTPServer + Config *config.Config + Backend backend.Backend + Access backend.AccessMethod } // NewServer returns a new *ssh.Server configured to serve Soft Serve. The SSH @@ -46,6 +48,11 @@ func NewServer(cfg *config.Config) (*Server, error) { return nil, err } + srv.HTTPServer, err = NewHTTPServer(cfg) + if err != nil { + return nil, err + } + return srv, nil } @@ -59,6 +66,13 @@ func (s *Server) Start() error { } return nil }) + errg.Go(func() error { + log.Print("Starting HTTP server", "addr", s.Config.HTTP.ListenAddr) + if err := s.HTTPServer.ListenAndServe(); err != http.ErrServerClosed { + return err + } + return nil + }) errg.Go(func() error { log.Print("Starting SSH server", "addr", s.Config.SSH.ListenAddr) if err := s.SSHServer.ListenAndServe(); err != ssh.ErrServerClosed { @@ -75,6 +89,9 @@ func (s *Server) Shutdown(ctx context.Context) error { errg.Go(func() error { return s.GitDaemon.Shutdown(ctx) }) + errg.Go(func() error { + return s.HTTPServer.Shutdown(ctx) + }) errg.Go(func() error { return s.SSHServer.Shutdown(ctx) }) @@ -84,11 +101,8 @@ func (s *Server) Shutdown(ctx context.Context) error { // Close closes the SSH server. func (s *Server) Close() error { var errg errgroup.Group - errg.Go(func() error { - return s.SSHServer.Close() - }) - errg.Go(func() error { - return s.GitDaemon.Close() - }) + errg.Go(s.GitDaemon.Close) + errg.Go(s.HTTPServer.Close) + errg.Go(s.SSHServer.Close) return errg.Wait() } diff --git a/server/ssh.go b/server/ssh.go index c0f3529fd25958b3b5a827ee6a56d16542154a29..25dcdaed86d01b6e9c3d18263f27bab7037aeafd 100644 --- a/server/ssh.go +++ b/server/ssh.go @@ -1,7 +1,9 @@ package server import ( + "context" "errors" + "net" "path/filepath" "strings" "time" @@ -21,7 +23,7 @@ import ( // SSHServer is a SSH server that implements the git protocol. type SSHServer struct { - *ssh.Server + srv *ssh.Server cfg *config.Config } @@ -35,7 +37,7 @@ func NewSSHServer(cfg *config.Config) (*SSHServer, error) { logger, // BubbleTea middleware. bm.MiddlewareWithProgramHandler(SessionHandler(cfg), termenv.ANSI256), - // Command middleware must come after the git middleware. + // CLI middleware. cm.Middleware(cfg), // Git middleware. s.Middleware(cfg), @@ -43,7 +45,7 @@ func NewSSHServer(cfg *config.Config) (*SSHServer, error) { lm.MiddlewareWithLogger(logger), ), } - s.Server, err = wish.NewServer( + s.srv, err = wish.NewServer( ssh.PublicKeyAuth(s.PublicKeyHandler), ssh.KeyboardInteractiveAuth(s.KeyboardInteractiveHandler), wish.WithAddress(cfg.SSH.ListenAddr), @@ -55,15 +57,35 @@ func NewSSHServer(cfg *config.Config) (*SSHServer, error) { } if cfg.SSH.MaxTimeout > 0 { - s.Server.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second + s.srv.MaxTimeout = time.Duration(cfg.SSH.MaxTimeout) * time.Second } if cfg.SSH.IdleTimeout > 0 { - s.Server.IdleTimeout = time.Duration(cfg.SSH.IdleTimeout) * time.Second + s.srv.IdleTimeout = time.Duration(cfg.SSH.IdleTimeout) * time.Second } return s, nil } +// ListenAndServe starts the SSH server. +func (s *SSHServer) ListenAndServe() error { + return s.srv.ListenAndServe() +} + +// Serve starts the SSH server on the given net.Listener. +func (s *SSHServer) Serve(l net.Listener) error { + return s.srv.Serve(l) +} + +// Close closes the SSH server. +func (s *SSHServer) Close() error { + return s.srv.Close() +} + +// Shutdown gracefully shuts down the SSH server. +func (s *SSHServer) Shutdown(ctx context.Context) error { + return s.srv.Shutdown(ctx) +} + // PublicKeyAuthHandler handles public key authentication. func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool { return s.cfg.Access.AccessLevel("", pk) > backend.NoAccess diff --git a/ui/common/utils.go b/ui/common/utils.go index de8fbe8a6316795c5f581fae471f3287122d1894..ae7c3839a6197e3245b96a5d703fd94594005f85 100644 --- a/ui/common/utils.go +++ b/ui/common/utils.go @@ -15,10 +15,6 @@ func TruncateString(s string, max int) string { } // RepoURL returns the URL of the repository. -func RepoURL(host string, port string, name string) string { - p := "" - if port != "22" { - p += ":" + port - } - return fmt.Sprintf("git clone ssh://%s/%s", host+p, name) +func RepoURL(publicURL, name string) string { + return fmt.Sprintf("git clone %s/%s", publicURL, name) } diff --git a/ui/pages/repo/empty.go b/ui/pages/repo/empty.go index bddab29f15fc476c9f7b974ecc877c6e294dcacc..d5230c42a5da1179bf4d2e5ef6e1c6abfeb83838 100644 --- a/ui/pages/repo/empty.go +++ b/ui/pages/repo/empty.go @@ -8,10 +8,7 @@ import ( ) func defaultEmptyRepoMsg(cfg *config.Config, repo string) string { - host := cfg.Backend.ServerHost() - if cfg.Backend.ServerPort() != "22" { - host = fmt.Sprintf("%s:%s", host, cfg.Backend.ServerPort()) - } + publicURL := cfg.SSH.PublicURL repo = strings.TrimSuffix(repo, ".git") return fmt.Sprintf(`# Quick Start @@ -20,7 +17,7 @@ Get started by cloning this repository, add your files, commit, and push. ## Clone this repository. `+"```"+`sh -git clone ssh://%[1]s/%[2]s.git +git clone %[1]s/%[2]s.git `+"```"+` ## Creating a new repository on the command line @@ -31,15 +28,15 @@ git init git add README.md git branch -M main git commit -m "first commit" -git remote add origin ssh://%[1]s/%[2]s.git +git remote add origin %[1]s/%[2]s.git git push -u origin main `+"```"+` ## Pushing an existing repository from the command line `+"```"+`sh -git remote add origin ssh://%[1]s/%[2]s.git +git remote add origin %[1]s/%[2]s.git git push -u origin main `+"```"+` -`, host, repo) +`, publicURL, repo) } diff --git a/ui/pages/repo/repo.go b/ui/pages/repo/repo.go index 16c2af44fb00434276618356071e7cf24c04a500..75f3ac38ad302cc3af82ef9633141fe11479ffbb 100644 --- a/ui/pages/repo/repo.go +++ b/ui/pages/repo/repo.go @@ -235,10 +235,8 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case CopyURLMsg: if cfg := r.common.Config(); cfg != nil { - host := cfg.Backend.ServerHost() - port := cfg.Backend.ServerPort() r.common.Copy.Copy( - common.RepoURL(host, port, r.selectedRepo.Name()), + common.RepoURL(cfg.SSH.PublicURL, r.selectedRepo.Name()), ) } case ResetURLMsg: @@ -342,7 +340,7 @@ func (r *Repo) headerView() string { Align(lipgloss.Right) var url string if cfg := r.common.Config(); cfg != nil { - url = common.RepoURL(cfg.Backend.ServerHost(), cfg.Backend.ServerPort(), r.selectedRepo.Name()) + url = common.RepoURL(cfg.SSH.PublicURL, r.selectedRepo.Name()) } if !r.copyURL.IsZero() && r.copyURL.Add(time.Second).After(time.Now()) { url = "copied!" diff --git a/ui/pages/selection/item.go b/ui/pages/selection/item.go index 596ddcef2a287768cc19ccfbc17562e8086da33a..83bf3e6c3cdb17f60d1580de113059ea1c15f078 100644 --- a/ui/pages/selection/item.go +++ b/ui/pages/selection/item.go @@ -68,7 +68,7 @@ func NewItem(repo backend.Repository, cfg *config.Config) (Item, error) { return Item{ repo: repo, lastUpdate: lastUpdate, - cmd: common.RepoURL(cfg.Backend.ServerHost(), cfg.Backend.ServerPort(), repo.Name()), + cmd: common.RepoURL(cfg.SSH.PublicURL, repo.Name()), }, nil } diff --git a/ui/ui.go b/ui/ui.go index 29f4cde45d7097ecf56f1beb826bb2f9fb9b9189..78b92b8434249be1791defa69f5c0f85a097c764 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -52,10 +52,7 @@ type UI struct { // New returns a new UI model. func New(c common.Common, initialRepo string) *UI { - var serverName string - if cfg := c.Config(); cfg != nil { - serverName = cfg.Backend.ServerName() - } + serverName := c.Config().Name h := header.New(c, serverName) ui := &UI{ serverName: serverName,