feat(server): http server

Ayman Bagabas created

* Git dumb transport for public repos
* Go Import response

Change summary

go.mod                     |   1 
go.sum                     |   2 
server/config/config.go    |  18 +++
server/daemon.go           |   4 
server/http.go             | 183 ++++++++++++++++++++++++++++++++++++++++
server/server.go           |  36 +++++--
server/ssh.go              |  32 +++++-
ui/common/utils.go         |   8 -
ui/pages/repo/empty.go     |  13 +-
ui/pages/repo/repo.go      |   6 
ui/pages/selection/item.go |   2 
ui/ui.go                   |   5 
12 files changed, 269 insertions(+), 41 deletions(-)

Detailed changes

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
 )

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=

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"`
 

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 {

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(`<!DOCTYPE html>
+<html lang="en">
+<head>
+	<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
+	<meta http-equiv="refresh" content="0; url=https://godoc.org/{{.ImportRoot}}/{{.Repo}}"
+ 	<meta name="go-import" content="{{.ImportRoot}}/{{.Repo}} git {{.Config.SSH.PublicURL}}/{{.Repo}}">
+</head>
+<body>
+Redirecting to docs at <a href="https://godoc.org/{{.ImportRoot}}/{{.Repo}}">godoc.org/{{.ImportRoot}}/{{.Repo}}</a>...
+</body>
+</html>`))
+
+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)
+}

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()
 }

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

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)
 }

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)
 }

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!"

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
 }
 

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,