fierj
PublicTiny 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
+}