Lindenii Project Forge
Login

server

Lindenii Forge’s main backend daemon
Commit info
ID
1344fc974d77d890d5b1c9721538e0911854fc0c
Author
Runxi Yu <me@runxiyu.org>
Author date
Fri, 02 May 2025 17:57:56 +0800
Committer
Runxi Yu <me@runxiyu.org>
Committer date
Fri, 02 May 2025 17:57:56 +0800
Actions
Add pprof endpoint
http {
	# What network transport should we listen on?
	# Examples: tcp tcp4 tcp6 unix
	net tcp

	# What address to listen on?
	# Examples for net tcp*: 127.0.0.1:8080 :80
	# Example for unix: /var/run/lindenii/forge/http.sock
	addr :8080

	# How many seconds should cookies be remembered before they are purged?
	cookie_expiry 604800

	# What is the canonical URL of the web root?
	root https://forge.example.org

	# General HTTP server context timeout settings. It's recommended to
	# set them slightly higher than usual as Git operations over large
	# repos may take a long time.
	read_timeout 120
	write_timeout 1800
	idle_timeout 120

	# Are we running behind a reverse proxy? If so, we will trust
	# X-Forwarded-For headers.
	reverse_proxy true
}

irc {
	tls true
	net tcp
	addr irc.runxiyu.org:6697
	sendq 6000
	nick forge-test
	user forge
	gecos "Lindenii Forge Test"
}

git {
	# Where should newly-created Git repositories be stored?
	repo_dir /var/lib/lindenii/forge/repos

	# Where should git2d listen on?
	socket /var/run/lindenii/forge/git2d.sock

	# Where should we put git2d?
	daemon_path /usr/libexec/lindenii/forge/git2d
}

ssh {
	# What network transport should we listen on?
	# This should be "tcp" in almost all cases.
	net tcp

	# What address to listen on?
	addr :22

	# What is the path to the SSH host key? Generate it with ssh-keygen.
	# The key must have an empty password.
	key /etc/lindenii/ssh_host_ed25519_key

	# What is the canonical SSH URL?
	root ssh://forge.example.org
}

general {
	title "Test Forge"
}

db {
	# What type of database are we connecting to?
	# Currently only "postgres" is supported.
	type postgres

	# What is the connection string?
	conn postgresql:///lindenii-forge?host=/var/run/postgresql
}

hooks {
	# On which UNIX domain socket should we listen for hook callbacks on?
	socket /var/run/lindenii/forge/hooks.sock

	# Where should hook executables be put?
	execs /usr/libexec/lindenii/forge/hooks
}

lmtp {
	# On which UNIX domain socket should we listen for LMTP on?
	socket /var/run/lindenii/forge/lmtp.sock

	# What's the maximum acceptable message size?
	max_size 1000000

	# What is our domainpart?
	domain forge.example.org

	# General timeouts
	read_timeout 300
	write_timeout 300
}

pprof {
	# What network to listen on for pprof?
	net tcp

	# What address to listen on?
	addr localhost:28471
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package unsorted

import (
	"bufio"
	"errors"
	"log/slog"
	"os"

	"go.lindenii.runxiyu.org/forge/forged/internal/database"
	"go.lindenii.runxiyu.org/forge/forged/internal/irc"
	"go.lindenii.runxiyu.org/forge/forged/internal/scfg"
)

type Config struct {
	HTTP struct {
		Net          string `scfg:"net"`
		Addr         string `scfg:"addr"`
		CookieExpiry int    `scfg:"cookie_expiry"`
		Root         string `scfg:"root"`
		ReadTimeout  uint32 `scfg:"read_timeout"`
		WriteTimeout uint32 `scfg:"write_timeout"`
		IdleTimeout  uint32 `scfg:"idle_timeout"`
		ReverseProxy bool   `scfg:"reverse_proxy"`
	} `scfg:"http"`
	Hooks struct {
		Socket string `scfg:"socket"`
		Execs  string `scfg:"execs"`
	} `scfg:"hooks"`
	LMTP struct {
		Socket       string `scfg:"socket"`
		Domain       string `scfg:"domain"`
		MaxSize      int64  `scfg:"max_size"`
		WriteTimeout uint32 `scfg:"write_timeout"`
		ReadTimeout  uint32 `scfg:"read_timeout"`
	} `scfg:"lmtp"`
	Git struct {
		RepoDir    string `scfg:"repo_dir"`
		Socket     string `scfg:"socket"`
		DaemonPath string `scfg:"daemon_path"`
	} `scfg:"git"`
	SSH struct {
		Net  string `scfg:"net"`
		Addr string `scfg:"addr"`
		Key  string `scfg:"key"`
		Root string `scfg:"root"`
	} `scfg:"ssh"`
	IRC     irc.Config `scfg:"irc"`
	General struct {
		Title string `scfg:"title"`
	} `scfg:"general"`
	DB struct {
		Type string `scfg:"type"`
		Conn string `scfg:"conn"`
	} `scfg:"db"`
	Pprof struct {
		Net  string `scfg:"net"`
		Addr string `scfg:"addr"`
	} `scfg:"pprof"`
}

// LoadConfig loads a configuration file from the specified path and unmarshals
// it to the global [config] struct. This may race with concurrent reads from
// [config]; additional synchronization is necessary if the configuration is to
// be made reloadable.
func (s *Server) loadConfig(path string) (err error) {
	var configFile *os.File
	if configFile, err = os.Open(path); err != nil {
		return err
	}
	defer configFile.Close()

	decoder := scfg.NewDecoder(bufio.NewReader(configFile))
	if err = decoder.Decode(&s.config); err != nil {
		return err
	}
	for _, u := range decoder.UnknownDirectives() {
		slog.Warn("unknown configuration directive", "directive", u)
	}

	if s.config.DB.Type != "postgres" {
		return errors.New("unsupported database type")
	}

	if s.database, err = database.Open(s.config.DB.Conn); err != nil {
		return err
	}

	s.globalData["forge_title"] = s.config.General.Title

	return nil
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package unsorted

import (
	"errors"
	"html/template"
	"io/fs"
	"log"
	"log/slog"
	"net"
	"net/http"
	_ "net/http/pprof"
	"os"
	"os/exec"
	"path/filepath"
	"syscall"
	"time"

	"go.lindenii.runxiyu.org/forge/forged/internal/cmap"
	"go.lindenii.runxiyu.org/forge/forged/internal/database"
	"go.lindenii.runxiyu.org/forge/forged/internal/embed"
	"go.lindenii.runxiyu.org/forge/forged/internal/irc"
	"go.lindenii.runxiyu.org/forge/forged/internal/misc"
	goSSH "golang.org/x/crypto/ssh"
)

type Server struct {
	config Config

	database database.Database

	sourceHandler http.Handler
	staticHandler http.Handler

	// globalData is passed as "global" when rendering HTML templates.
	globalData map[string]any

	serverPubkeyString string
	serverPubkeyFP     string
	serverPubkey       goSSH.PublicKey

	// packPasses contains hook cookies mapped to their packPass.
	packPasses cmap.Map[string, packPass]

	templates *template.Template

	ircBot *irc.Bot

	ready bool
}

func NewServer(configPath string) (*Server, error) {
	s := &Server{
		globalData: make(map[string]any),
	} //exhaustruct:ignore

	s.sourceHandler = http.StripPrefix(
		"/-/source/",
		http.FileServer(http.FS(embed.Source)),
	)
	staticFS, err := fs.Sub(embed.Resources, "forged/static")
	if err != nil {
		return s, err
	}
	s.staticHandler = http.StripPrefix("/-/static/", http.FileServer(http.FS(staticFS)))
	s.globalData = map[string]any{
		"server_public_key_string":      &s.serverPubkeyString,
		"server_public_key_fingerprint": &s.serverPubkeyFP,
		"forge_version":                 version,
		// Some other ones are populated after config parsing
	}

	if err := s.loadConfig(configPath); err != nil {
		return s, err
	}

	misc.NoneOrPanic(s.loadTemplates())
	misc.NoneOrPanic(misc.DeployBinary(misc.FirstOrPanic(embed.Resources.Open("git2d/git2d")), s.config.Git.DaemonPath))
	misc.NoneOrPanic(misc.DeployBinary(misc.FirstOrPanic(embed.Resources.Open("hookc/hookc")), filepath.Join(s.config.Hooks.Execs, "pre-receive")))
	misc.NoneOrPanic(os.Chmod(filepath.Join(s.config.Hooks.Execs, "pre-receive"), 0o755))

	s.ready = true

	return s, nil
}

func (s *Server) Run() error {
	if !s.ready {
		return errors.New("not ready")
	}

	// Launch Git2D
	go func() {
		cmd := exec.Command(s.config.Git.DaemonPath, s.config.Git.Socket) //#nosec G204
		cmd.Stderr = log.Writer()
		cmd.Stdout = log.Writer()
		if err := cmd.Run(); err != nil {
			panic(err)
		}
	}()

	// UNIX socket listener for hooks
	{
		hooksListener, err := net.Listen("unix", s.config.Hooks.Socket)
		if errors.Is(err, syscall.EADDRINUSE) {
			slog.Warn("removing existing socket", "path", s.config.Hooks.Socket)
			if err = syscall.Unlink(s.config.Hooks.Socket); err != nil {
				slog.Error("removing existing socket", "path", s.config.Hooks.Socket, "error", err)
				os.Exit(1)
			}
			if hooksListener, err = net.Listen("unix", s.config.Hooks.Socket); err != nil {
				slog.Error("listening hooks", "error", err)
				os.Exit(1)
			}
		} else if err != nil {
			slog.Error("listening hooks", "error", err)
			os.Exit(1)
		}
		slog.Info("listening hooks on unix", "path", s.config.Hooks.Socket)
		go func() {
			if err = s.serveGitHooks(hooksListener); err != nil {
				slog.Error("serving hooks", "error", err)
				os.Exit(1)
			}
		}()
	}

	// UNIX socket listener for LMTP
	{
		lmtpListener, err := net.Listen("unix", s.config.LMTP.Socket)
		if errors.Is(err, syscall.EADDRINUSE) {
			slog.Warn("removing existing socket", "path", s.config.LMTP.Socket)
			if err = syscall.Unlink(s.config.LMTP.Socket); err != nil {
				slog.Error("removing existing socket", "path", s.config.LMTP.Socket, "error", err)
				os.Exit(1)
			}
			if lmtpListener, err = net.Listen("unix", s.config.LMTP.Socket); err != nil {
				slog.Error("listening LMTP", "error", err)
				os.Exit(1)
			}
		} else if err != nil {
			slog.Error("listening LMTP", "error", err)
			os.Exit(1)
		}
		slog.Info("listening LMTP on unix", "path", s.config.LMTP.Socket)
		go func() {
			if err = s.serveLMTP(lmtpListener); err != nil {
				slog.Error("serving LMTP", "error", err)
				os.Exit(1)
			}
		}()
	}

	// SSH listener
	{
		sshListener, err := net.Listen(s.config.SSH.Net, s.config.SSH.Addr)
		if errors.Is(err, syscall.EADDRINUSE) && s.config.SSH.Net == "unix" {
			slog.Warn("removing existing socket", "path", s.config.SSH.Addr)
			if err = syscall.Unlink(s.config.SSH.Addr); err != nil {
				slog.Error("removing existing socket", "path", s.config.SSH.Addr, "error", err)
				os.Exit(1)
			}
			if sshListener, err = net.Listen(s.config.SSH.Net, s.config.SSH.Addr); err != nil {
				slog.Error("listening SSH", "error", err)
				os.Exit(1)
			}
		} else if err != nil {
			slog.Error("listening SSH", "error", err)
			os.Exit(1)
		}
		slog.Info("listening SSH on", "net", s.config.SSH.Net, "addr", s.config.SSH.Addr)
		go func() {
			if err = s.serveSSH(sshListener); err != nil {
				slog.Error("serving SSH", "error", err)
				os.Exit(1)
			}
		}()
	}

	// HTTP listener
	{
		httpListener, err := net.Listen(s.config.HTTP.Net, s.config.HTTP.Addr)
		if errors.Is(err, syscall.EADDRINUSE) && s.config.HTTP.Net == "unix" {
			slog.Warn("removing existing socket", "path", s.config.HTTP.Addr)
			if err = syscall.Unlink(s.config.HTTP.Addr); err != nil {
				slog.Error("removing existing socket", "path", s.config.HTTP.Addr, "error", err)
				os.Exit(1)
			}
			if httpListener, err = net.Listen(s.config.HTTP.Net, s.config.HTTP.Addr); err != nil {
				slog.Error("listening HTTP", "error", err)
				os.Exit(1)
			}
		} else if err != nil {
			slog.Error("listening HTTP", "error", err)
			os.Exit(1)
		}
		server := http.Server{
			Handler:      s,
			ReadTimeout:  time.Duration(s.config.HTTP.ReadTimeout) * time.Second,
			WriteTimeout: time.Duration(s.config.HTTP.ReadTimeout) * time.Second,
			IdleTimeout:  time.Duration(s.config.HTTP.ReadTimeout) * time.Second,
		} //exhaustruct:ignore
		slog.Info("listening HTTP on", "net", s.config.HTTP.Net, "addr", s.config.HTTP.Addr)
		go func() {
			if err = server.Serve(httpListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
				slog.Error("serving HTTP", "error", err)
				os.Exit(1)
			}
		}()
	}

	// Pprof listener
	{
		pprofListener, err := net.Listen(s.config.Pprof.Net, s.config.Pprof.Addr)
		if err != nil {
			slog.Error("listening pprof", "error", err)
			os.Exit(1)
		}

		slog.Info("listening pprof on", "net", s.config.Pprof.Net, "addr", s.config.Pprof.Addr)
		go func() {
			if err := http.Serve(pprofListener, nil); err != nil {
				slog.Error("serving pprof", "error", err)
				os.Exit(1)
			}
		}()
	}

	s.ircBot = irc.NewBot(&s.config.IRC)
	// IRC bot
	go s.ircBot.ConnectLoop()

	select {}
}