From 426a06868086b1c4e1df7b257a68785375d6bc72 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 10 Feb 2022 16:55:52 -0500 Subject: [PATCH] feat: add http and git dumb server --- 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(-) create mode 100644 server/http.go create mode 100644 server/ssh.go diff --git a/cmd/soft/main.go b/cmd/soft/main.go index bb0954136e17dcc8628417098098f60677dc683a..604e044d97e24f04b1fc68cee643bb8accf7bccc 100644 --- a/cmd/soft/main.go +++ b/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 { diff --git a/config/config.go b/config/config.go index 580bcb479f53531e5155136e6b973c991e8fa451..a5d32c35be073c3187cdafbddb7be8bf5f9f0b79 100644 --- a/config/config.go +++ b/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 +} diff --git a/go.mod b/go.mod index fad69374307dd3260c3b4900262899e57d855e71..31a3be8c434a728fa7d3ae8465a192f863a2cacc 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index e81c7e4c75c1e2f0515d2541cc48d6429d2f9917..ff380864c79a72d01791852d7e7047598c46e8ed 100644 --- a/go.sum +++ b/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= diff --git a/internal/config/config.go b/internal/config/config.go index 2b1ad55c368bfb1e57b0ab0f5591910d02430de6..e5ffe9367d82a0488ca6bba5c94085067591e014 100644 --- a/internal/config/config.go +++ b/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 { diff --git a/server/http.go b/server/http.go new file mode 100644 index 0000000000000000000000000000000000000000..e9f7534d0a7820e116ec28e76c9275d14129311b --- /dev/null +++ b/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) +} diff --git a/server/server.go b/server/server.go index 7b72f6ca3705571f3c31248994dbde0e91a636b8..b9d6dd2c6bd0b94613efd8bc4669cfe7554bd46d 100644 --- a/server/server.go +++ b/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) } diff --git a/server/ssh.go b/server/ssh.go new file mode 100644 index 0000000000000000000000000000000000000000..b902b23c2e99d70976741b41e2218228881a29dc --- /dev/null +++ b/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) +}