feat: add http and git dumb server

Ayman Bagabas created

Change summary

cmd/soft/main.go          |  6 --
config/config.go          | 10 ++++
go.mod                    |  2 +
go.sum                    |  2 +
internal/config/config.go |  2 
server/http.go            | 77 +++++++++++++++++++++++++++++++++++++++++
server/server.go          | 69 ++++++++++++++---------------------
server/ssh.go             | 65 ++++++++++++++++++++++++++++++++++
8 files changed, 185 insertions(+), 48 deletions(-)

Detailed changes

cmd/soft/main.go 🔗

@@ -57,16 +57,12 @@ func main() {
 	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)
 	go func() {
-		if err := s.Start(); err != nil {
-			log.Fatalln(err)
-		}
+		s.Start()
 	}()
 
 	<-done
 
-	log.Printf("Stopping SSH server on %s:%d", cfg.BindAddr, cfg.Port)
 	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 	defer func() { cancel() }()
 	if err := s.Shutdown(ctx); err != nil {

config/config.go 🔗

@@ -1,6 +1,7 @@
 package config
 
 import (
+	"crypto/tls"
 	"log"
 	"path/filepath"
 
@@ -17,12 +18,14 @@ type Callbacks interface {
 // Config is the configuration for Soft Serve.
 type Config struct {
 	BindAddr         string   `env:"SOFT_SERVE_BIND_ADDRESS" envDefault:""`
-	Port             int      `env:"SOFT_SERVE_PORT" envDefault:"23231"`
+	SSHPort          int      `env:"SOFT_SERVE_SSH_PORT" envDefault:"23231"`
+	HTTPPort         int      `env:"SOFT_SERVE_HTTP_PORT" envDefault:"23232"`
 	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
+	TLSConfig        *tls.Config
 }
 
 // DefaultConfig returns a Config with the values populated with the defaults
@@ -50,3 +53,8 @@ func (c *Config) WithErrorLogger(logger *log.Logger) *Config {
 	c.ErrorLog = logger
 	return c
 }
+
+func (c *Config) WithTLSConfig(t *tls.Config) *Config {
+	c.TLSConfig = t
+	return c
+}

go.mod 🔗

@@ -22,6 +22,8 @@ require (
 	gopkg.in/yaml.v2 v2.4.0
 )
 
+require goji.io v2.0.2+incompatible
+
 require (
 	github.com/Microsoft/go-winio v0.5.1 // indirect
 	github.com/ProtonMail/go-crypto v0.0.0-20220113124808-70ae35bab23f // indirect

go.sum 🔗

@@ -141,6 +141,8 @@ github.com/yuin/goldmark v1.4.4 h1:zNWRjYUW32G9KirMXYHQHVNFkXvMI7LpgNW2AgYAoIs=
 github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
 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/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
 golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=

internal/config/config.go 🔗

@@ -52,7 +52,7 @@ func NewConfig(cfg *config.Config) (*Config, error) {
 	var yamlUsers string
 	var displayHost string
 	host := cfg.BindAddr
-	port := cfg.Port
+	port := cfg.SSHPort
 
 	pks := make([]string, 0)
 	for _, k := range cfg.InitialAdminKeys {

server/http.go 🔗

@@ -0,0 +1,77 @@
+package server
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/charmbracelet/soft-serve/config"
+	appCfg "github.com/charmbracelet/soft-serve/internal/config"
+	"github.com/charmbracelet/wish/git"
+	"goji.io"
+	"goji.io/pat"
+	"goji.io/pattern"
+)
+
+type HTTPServer struct {
+	server     *http.Server
+	gitHandler http.Handler
+	cfg        *config.Config
+	ac         *appCfg.Config
+}
+
+func NewHTTPServer(cfg *config.Config, ac *appCfg.Config) *HTTPServer {
+	h := goji.NewMux()
+	s := &HTTPServer{
+		cfg:        cfg,
+		ac:         ac,
+		gitHandler: http.FileServer(http.Dir(cfg.RepoPath)),
+		server: &http.Server{
+			Addr:      fmt.Sprintf(":%d", cfg.HTTPPort),
+			Handler:   h,
+			TLSConfig: cfg.TLSConfig,
+		},
+	}
+	h.HandleFunc(pat.Get("/:repo"), s.handleGit)
+	h.HandleFunc(pat.Get("/:repo/*"), s.handleGit)
+	return s
+}
+
+func (s *HTTPServer) Start() error {
+	if s.cfg.TLSConfig != nil {
+		return s.server.ListenAndServeTLS("", "")
+	}
+	return s.server.ListenAndServe()
+}
+
+func (s *HTTPServer) Shutdown(ctx context.Context) error {
+	return s.server.Shutdown(ctx)
+}
+
+func (s *HTTPServer) handleGit(w http.ResponseWriter, r *http.Request) {
+	ua := r.Header.Get("User-Agent")
+	repo := pat.Param(r, "repo")
+	access := s.ac.AuthRepo(repo, nil)
+	path := pattern.Path(r.Context())
+	stat, err := os.Stat(filepath.Join(s.cfg.RepoPath, repo, path))
+	// Restrict access to files
+	if err != nil || stat.IsDir() {
+		http.NotFound(w, r)
+		return
+	}
+	if !strings.HasPrefix(strings.ToLower(ua), "git") {
+		http.Error(w, fmt.Sprintf("%d Bad Request", http.StatusBadRequest), http.StatusBadRequest)
+		return
+	}
+	if access < git.ReadOnlyAccess || !s.ac.AllowKeyless {
+		http.Error(w, fmt.Sprintf("%d Unauthorized", http.StatusUnauthorized), http.StatusUnauthorized)
+		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.gitHandler.ServeHTTP(w, r)
+}

server/server.go 🔗

@@ -2,26 +2,18 @@ package server
 
 import (
 	"context"
-	"fmt"
 	"log"
 
 	"github.com/charmbracelet/soft-serve/config"
 	appCfg "github.com/charmbracelet/soft-serve/internal/config"
-	"github.com/charmbracelet/soft-serve/internal/tui"
-
-	"github.com/charmbracelet/wish"
-	bm "github.com/charmbracelet/wish/bubbletea"
-	gm "github.com/charmbracelet/wish/git"
-	lm "github.com/charmbracelet/wish/logging"
-	rm "github.com/charmbracelet/wish/recover"
-	"github.com/gliderlabs/ssh"
 )
 
 // Server is the Soft Serve server.
 type Server struct {
-	SSHServer *ssh.Server
-	Config    *config.Config
-	config    *appCfg.Config
+	HTTPServer *HTTPServer
+	SSHServer  *SSHServer
+	Cfg        *config.Config
+	ac         *appCfg.Config
 }
 
 // NewServer returns a new *ssh.Server configured to serve Soft Serve. The SSH
@@ -34,43 +26,38 @@ func NewServer(cfg *config.Config) *Server {
 	if err != nil {
 		log.Fatal(err)
 	}
-	mw := []wish.Middleware{
-		rm.MiddlewareWithLogger(
-			cfg.ErrorLog,
-			softServeMiddleware(ac),
-			bm.Middleware(tui.SessionHandler(ac)),
-			gm.Middleware(cfg.RepoPath, ac),
-			lm.Middleware(),
-		),
-	}
-	s, err := wish.NewServer(
-		ssh.PublicKeyAuth(ac.PublicKeyHandler),
-		ssh.PasswordAuth(ac.PasswordHandler),
-		wish.WithAddress(fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.Port)),
-		wish.WithHostKeyPath(cfg.KeyPath),
-		wish.WithMiddleware(mw...),
-	)
-	if err != nil {
-		log.Fatalln(err)
-	}
 	return &Server{
-		SSHServer: s,
-		Config:    cfg,
-		config:    ac,
+		HTTPServer: NewHTTPServer(cfg, ac),
+		SSHServer:  NewSSHServer(cfg, ac),
+		Cfg:        cfg,
+		ac:         ac,
 	}
 }
 
 // Reload reloads the server configuration.
 func (srv *Server) Reload() error {
-	return srv.config.Reload()
+	return srv.ac.Reload()
 }
 
-// Start starts the SSH server.
-func (srv *Server) Start() error {
-	return srv.SSHServer.ListenAndServe()
+func (s *Server) Start() {
+	go func() {
+		log.Printf("Starting HTTP server on %s:%d", s.Cfg.BindAddr, s.Cfg.HTTPPort)
+		if err := s.HTTPServer.Start(); err != nil {
+			log.Fatal(err)
+		}
+	}()
+	log.Printf("Starting SSH server on %s:%d", s.Cfg.BindAddr, s.Cfg.SSHPort)
+	if err := s.SSHServer.Start(); err != nil {
+		log.Fatal(err)
+	}
 }
 
-// Shutdown lets the server gracefully shutdown.
-func (srv *Server) Shutdown(ctx context.Context) error {
-	return srv.SSHServer.Shutdown(ctx)
+func (s *Server) Shutdown(ctx context.Context) error {
+	log.Printf("Stopping SSH server on %s:%d", s.Cfg.BindAddr, s.Cfg.SSHPort)
+	err := s.SSHServer.Shutdown(ctx)
+	if err != nil {
+		return err
+	}
+	log.Printf("Stopping HTTP server on %s:%d", s.Cfg.BindAddr, s.Cfg.SSHPort)
+	return s.HTTPServer.Shutdown(ctx)
 }

server/ssh.go 🔗

@@ -0,0 +1,65 @@
+package server
+
+import (
+	"context"
+	"fmt"
+	"log"
+
+	"github.com/charmbracelet/soft-serve/config"
+	appCfg "github.com/charmbracelet/soft-serve/internal/config"
+	"github.com/charmbracelet/soft-serve/internal/tui"
+	"github.com/charmbracelet/wish"
+	bm "github.com/charmbracelet/wish/bubbletea"
+	gm "github.com/charmbracelet/wish/git"
+	lm "github.com/charmbracelet/wish/logging"
+	rm "github.com/charmbracelet/wish/recover"
+	"github.com/gliderlabs/ssh"
+)
+
+type SSHServer struct {
+	server *ssh.Server
+	cfg    *config.Config
+	ac     *appCfg.Config
+}
+
+// NewServer returns a new *ssh.Server configured to serve Soft Serve. The SSH
+// server key-pair will be created if none exists. An initial admin SSH public
+// key can be provided with authKey. If authKey is provided, access will be
+// restricted to that key. If authKey is not provided, the server will be
+// publicly writable until configured otherwise by cloning the `config` repo.
+func NewSSHServer(cfg *config.Config, ac *appCfg.Config) *SSHServer {
+	mw := []wish.Middleware{
+		rm.MiddlewareWithLogger(
+			cfg.ErrorLog,
+			softServeMiddleware(ac),
+			bm.Middleware(tui.SessionHandler(ac)),
+			gm.Middleware(cfg.RepoPath, ac),
+			lm.Middleware(),
+		),
+	}
+	s, err := wish.NewServer(
+		ssh.PublicKeyAuth(ac.PublicKeyHandler),
+		ssh.PasswordAuth(ac.PasswordHandler),
+		wish.WithAddress(fmt.Sprintf("%s:%d", cfg.BindAddr, cfg.SSHPort)),
+		wish.WithHostKeyPath(cfg.KeyPath),
+		wish.WithMiddleware(mw...),
+	)
+	if err != nil {
+		log.Fatalln(err)
+	}
+	return &SSHServer{
+		server: s,
+		cfg:    cfg,
+		ac:     ac,
+	}
+}
+
+// Start starts the SSH server.
+func (s *SSHServer) Start() error {
+	return s.server.ListenAndServe()
+}
+
+// Shutdown lets the server gracefully shutdown.
+func (s *SSHServer) Shutdown(ctx context.Context) error {
+	return s.server.Shutdown(ctx)
+}