Lindenii Project Forge
Slight refactor on NewServer
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org> package main import ( "flag"
"log/slog" "os"
"go.lindenii.runxiyu.org/forge" ) func main() { configPath := flag.String( "config", "/etc/lindenii/forge.scfg", "path to configuration file", ) flag.Parse()
s := forge.Server{} s.Setup() if err := s.LoadConfig(*configPath); err != nil { slog.Error("loading configuration", "error", err) os.Exit(1) }
s := forge.NewServer(*configPath)
s.Run() }
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org> package forge import ( "bufio" "errors" "log/slog" "os" "go.lindenii.runxiyu.org/forge/internal/database" "go.lindenii.runxiyu.org/forge/internal/irc" "go.lindenii.runxiyu.org/forge/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"` } // 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) {
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 forge import ( "errors" "html/template" "io/fs" "log" "log/slog" "net" "net/http" "os" "os/exec" "path/filepath" "syscall" "time" "go.lindenii.runxiyu.org/forge/internal/database" "go.lindenii.runxiyu.org/forge/internal/irc" "go.lindenii.runxiyu.org/forge/internal/misc" "go.lindenii.runxiyu.org/lindenii-common/cmap" 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 (s *Server) Setup() {
func (s *Server) NewServer(configPath string) error { if err := s.loadConfig(configPath); err != nil { return err }
s.sourceHandler = http.StripPrefix( "/-/source/", http.FileServer(http.FS(embeddedSourceFS)), ) staticFS, err := fs.Sub(embeddedResourcesFS, "static") if err != nil {
panic(err)
return 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 } misc.NoneOrPanic(s.loadTemplates()) misc.NoneOrPanic(misc.DeployBinary(misc.FirstOrPanic(embeddedResourcesFS.Open("git2d/git2d")), s.config.Git.DaemonPath)) misc.NoneOrPanic(misc.DeployBinary(misc.FirstOrPanic(embeddedResourcesFS.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 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) } }() } // IRC bot go s.ircBot.ConnectLoop() select {} }