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
)
Ayman Bagabas created
* Git dumb transport for public repos
* Go Import response
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(-)
@@ -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
)
@@ -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=
@@ -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"`
@@ -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 {
@@ -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)
+}
@@ -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()
}
@@ -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
@@ -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)
}
@@ -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)
}
@@ -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!"
@@ -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
}
@@ -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,