+ New

fierj

Public

Tiny personal git forge

660e752ba40fd36ec9e33bd29b62185fb97e5416
diff --git a/Dockerfile b/Dockerfile
index a823423..7659e06 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -11,6 +11,8 @@ RUN apk add --no-cache git ca-certificates tzdata
 COPY --from=builder /fierj /usr/local/bin/fierj
 ENV FIERJ_ADDR=:8080
 ENV FIERJ_DIR=/data/repos
+ENV FIERJ_SSH_ADDR=:2222
+ENV FIERJ_SSH_HOST_KEY=/data/repos/.fierj_host_key
 VOLUME /data/repos
-EXPOSE 8080
+EXPOSE 8080 2222
 ENTRYPOINT ["fierj"]
diff --git a/config.go b/config.go
index b3ffa6e..2eeed51 100644
--- a/config.go
+++ b/config.go
@@ -10,17 +10,20 @@ import (
 const envPrefix = "FIERJ_"
 
 type Config struct {
-	Addr string `json:"addr" env:"ADDR"`
-	Host string `json:"host" env:"HOST"`
-	Dir  string `json:"dir" env:"DIR"`
+	Addr       string `json:"addr" env:"ADDR"`
+	Host       string `json:"host" env:"HOST"`
+	Dir        string `json:"dir" env:"DIR"`
+	SSHAddr    string `json:"ssh_addr" env:"SSH_ADDR"`
+	SSHHostKey string `json:"ssh_host_key" env:"SSH_HOST_KEY"`
 }
 
 func LoadConfig(path string) Config {
 	// default config
 	cfg := Config{
-		Addr: ":8080",
-		Host: "localhost:8080",
-		Dir:  "repos",
+		Addr:    ":8080",
+		Host:    "localhost:8080",
+		SSHAddr: ":2222",
+		Dir:     "repos",
 	}
 
 	// read file, log read/unmarshal errors, if any
diff --git a/go.mod b/go.mod
index d0ba93a..0dddb98 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,8 @@
 module github.com/zserge/fierj
 
 go 1.26.3
+
+require (
+	golang.org/x/crypto v0.54.0 // indirect
+	golang.org/x/sys v0.47.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..2f53f1f
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,4 @@
+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
diff --git a/handlers.go b/handlers.go
index da7ed5d..0be93de 100644
--- a/handlers.go
+++ b/handlers.go
@@ -1,13 +1,13 @@
 package main
 
 import (
-	"embed"
 	"fmt"
+	"html/template"
 	"log/slog"
+	"net"
 	"net/http"
 	"sort"
 	"strings"
-	"text/template"
 	"time"
 )
 
@@ -16,9 +16,6 @@ type BreadcrumbItem struct {
 	URL  string
 }
 
-//go:embed templates/*.html
-var templateFS embed.FS
-
 var funcMap = template.FuncMap{
 	"pathJoin": func(parts ...string) string {
 		var nonEmpty []string
@@ -128,14 +125,19 @@ func Tree(cfg Config, tmpl *template.Template) http.HandlerFunc {
 		cloneSSH := ""
 		cloneHTTPS := ""
 		if cfg.Host != "" {
-			cloneSSH = fmt.Sprintf("git@%s:%s.git", cfg.Host, git.Name)
 			cloneHTTPS = fmt.Sprintf("https://%s/%s.git", cfg.Host, git.Name)
+			// Include port in SSH URL if non-standard
+			if _, port, _ := net.SplitHostPort(cfg.SSHAddr); port != "" && port != "22" {
+				cloneSSH = fmt.Sprintf("ssh://git@%s:%s/%s.git", cfg.Host, port, git.Name)
+			} else {
+				cloneSSH = fmt.Sprintf("git@%s:%s.git", cfg.Host, git.Name)
+			}
 		}
-	if git.IsEmpty() {
+		if git.IsEmpty() {
 			tmpl.ExecuteTemplate(w, "tree.html", map[string]any{
-				"Repo":    git.Repo(),
-				"Ref":     ref,
-				"Path":    "",
+				"Repo":        git.Repo(),
+				"Ref":         ref,
+				"Path":        "",
 				"Entries":     []FileEntry{},
 				"CloneSSH":    cloneSSH,
 				"CloneHTTPS":  cloneHTTPS,
@@ -171,13 +173,13 @@ func Tree(cfg Config, tmpl *template.Template) http.HandlerFunc {
 		}
 		readme := git.Readme(ref, treePath)
 		tmpl.ExecuteTemplate(w, "tree.html", map[string]any{
-			"Repo":       git.Repo(),
-			"Ref":        ref,
-			"Path":       treePath,
-			"Breadcrumb": Breadcrumb(treePath),
-			"Entries":    fileEntries,
-			"CloneSSH":   cloneSSH,
-			"CloneHTTPS": cloneHTTPS,
+			"Repo":        git.Repo(),
+			"Ref":         ref,
+			"Path":        treePath,
+			"Breadcrumb":  Breadcrumb(treePath),
+			"Entries":     fileEntries,
+			"CloneSSH":    cloneSSH,
+			"CloneHTTPS":  cloneHTTPS,
 			"Description": git.Description(),
 			"Branches":    git.Branches(),
 			"Tags":        git.Tags(),
diff --git a/main.go b/main.go
index 35f03fa..ba8abdf 100644
--- a/main.go
+++ b/main.go
@@ -2,15 +2,19 @@ package main
 
 import (
 	"context"
+	"embed"
+	"html/template"
 	"log/slog"
 	"net/http"
 	"os"
 	"os/signal"
 	"syscall"
-	"text/template"
 	"time"
 )
 
+//go:embed templates/*.html
+var templateFS embed.FS
+
 func main() {
 	cfgPath := "config.json"
 	if len(os.Args) > 1 {
@@ -39,6 +43,20 @@ func main() {
 		Handler: mux,
 	}
 
+	// Start SSH server if configured
+	if cfg.SSHAddr != "" {
+		sshSrv, err := NewSSHServer(cfg.Dir, cfg.SSHHostKey)
+		if err != nil {
+			slog.Error("failed to create SSH server", "error", err)
+			os.Exit(1)
+		}
+		go func() {
+			if err := sshSrv.ListenAndServe(cfg.SSHAddr); err != nil {
+				slog.Error("ssh server failed", "error", err)
+			}
+		}()
+	}
+
 	go func() {
 		sig := make(chan os.Signal, 1)
 		signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
diff --git a/ssh.go b/ssh.go
new file mode 100644
index 0000000..52fe3bc
--- /dev/null
+++ b/ssh.go
@@ -0,0 +1,194 @@
+package main
+
+import (
+	"crypto/ed25519"
+	"crypto/rand"
+	"encoding/pem"
+	"fmt"
+	"io"
+	"log/slog"
+	"net"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"sync"
+
+	"golang.org/x/crypto/ssh"
+)
+
+type SSHServer struct {
+	reposDir string
+	config   *ssh.ServerConfig
+}
+
+func NewSSHServer(reposDir, hostKeyPath string) (*SSHServer, error) {
+	signer, err := loadOrGenerateHostKey(hostKeyPath)
+	if err != nil {
+		return nil, fmt.Errorf("host key: %w", err)
+	}
+
+	config := &ssh.ServerConfig{
+		PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
+			slog.Info("ssh connection accepted", "user", conn.User(), "remote", conn.RemoteAddr())
+			return &ssh.Permissions{
+				Extensions: map[string]string{"pubkey": string(ssh.MarshalAuthorizedKey(key))},
+			}, nil
+		},
+	}
+	config.AddHostKey(signer)
+
+	return &SSHServer{reposDir: reposDir, config: config}, nil
+}
+
+func (s *SSHServer) ListenAndServe(addr string) error {
+	listener, err := net.Listen("tcp", addr)
+	if err != nil {
+		return err
+	}
+	slog.Info("ssh server listening", "addr", addr)
+
+	for {
+		conn, err := listener.Accept()
+		if err != nil {
+			slog.Error("ssh accept error", "error", err)
+			continue
+		}
+		go s.handleConn(conn)
+	}
+}
+
+func (s *SSHServer) handleConn(conn net.Conn) {
+	defer conn.Close()
+
+	sshConn, chans, reqs, err := ssh.NewServerConn(conn, s.config)
+	if err != nil {
+		slog.Warn("ssh handshake failed", "error", err, "remote", conn.RemoteAddr())
+		return
+	}
+	defer sshConn.Close()
+
+	go ssh.DiscardRequests(reqs)
+
+	for ch := range chans {
+		if ch.ChannelType() != "session" {
+			ch.Reject(ssh.UnknownChannelType, "only session channels supported")
+			continue
+		}
+		channel, requests, err := ch.Accept()
+		if err != nil {
+			continue
+		}
+		go s.handleSession(channel, requests)
+	}
+}
+
+func (s *SSHServer) handleSession(channel ssh.Channel, requests <-chan *ssh.Request) {
+	defer channel.Close()
+
+	for req := range requests {
+		var execMsg struct{ Command string }
+		switch req.Type {
+		case "exec":
+			ssh.Unmarshal(req.Payload, &execMsg)
+			req.Reply(true, nil)
+			s.runGitCommand(channel, execMsg.Command)
+			return
+		case "env":
+			req.Reply(false, nil)
+		default:
+			req.Reply(false, nil)
+		}
+	}
+}
+
+func (s *SSHServer) runGitCommand(channel ssh.Channel, gitCmd string) {
+	parts := strings.SplitN(strings.TrimSpace(gitCmd), " ", 2)
+	if len(parts) != 2 {
+		io.WriteString(channel.Stderr(), "fatal: invalid git command\n")
+		return
+	}
+
+	subCmd := parts[0]
+	repoArg := strings.Trim(parts[1], "'\"")
+	repoName := strings.TrimSuffix(repoArg, ".git")
+	repoName = strings.TrimPrefix(repoName, "/")
+
+	if strings.Contains(repoName, "..") || strings.Contains(repoName, "/") {
+		io.WriteString(channel.Stderr(), "fatal: invalid repository path\n")
+		return
+	}
+
+	absRoot, err := filepath.Abs(s.reposDir)
+	if err != nil {
+		io.WriteString(channel.Stderr(), "fatal: internal error\n")
+		return
+	}
+	repoPath := filepath.Join(absRoot, repoName+".git")
+
+	if _, err := os.Stat(repoPath); os.IsNotExist(err) {
+		io.WriteString(channel.Stderr(), "fatal: repository not found\n")
+		return
+	}
+
+	switch subCmd {
+	case "git-upload-pack", "git-upload-archive", "git-receive-pack":
+	default:
+		io.WriteString(channel.Stderr(), "fatal: unknown git command: "+subCmd+"\n")
+		return
+	}
+
+	slog.Info("ssh git", "cmd", subCmd, "repo", repoName)
+
+	cmd := exec.Command(subCmd, ".")
+	cmd.Dir = repoPath
+
+	stdin, _ := cmd.StdinPipe()
+	stdout, _ := cmd.StdoutPipe()
+	cmd.Stderr = channel.Stderr()
+
+	if err := cmd.Start(); err != nil {
+		slog.Warn("start failed", "err", err)
+		return
+	}
+
+	// Stdout -> channel (must drain before we return).
+	// Stdin <- channel (will be unblocked by channel.Close in handleSession).
+	var wg sync.WaitGroup
+	wg.Add(1)
+	go func() { defer wg.Done(); io.Copy(channel, stdout) }()
+	go func() { io.Copy(stdin, channel); stdin.Close() }()
+
+	cmd.Wait()
+	wg.Wait() // drain stdout before returning
+}
+
+func loadOrGenerateHostKey(path string) (ssh.Signer, error) {
+	if path == "" {
+		path = "fierj_host_key"
+	}
+
+	if data, err := os.ReadFile(path); err == nil {
+		return ssh.ParsePrivateKey(data)
+	}
+
+	slog.Info("generating new SSH host key", "path", path)
+	_, priv, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		return nil, err
+	}
+
+	privBytes, err := ssh.MarshalPrivateKey(priv, "")
+	if err != nil {
+		return nil, err
+	}
+	if err := os.WriteFile(path, pem.EncodeToMemory(privBytes), 0600); err != nil {
+		return nil, err
+	}
+
+	signer, err := ssh.NewSignerFromKey(priv)
+	if err != nil {
+		return nil, err
+	}
+	return signer, nil
+}