An AUR (Arch User Repository) mirror service written in Go

full working example

+27 -8
cmd/myaur/main.go
···
"log"
"os"
+
"github.com/haileyok/myaur/myaur/gitrepo"
"github.com/haileyok/myaur/myaur/populate"
"github.com/haileyok/myaur/myaur/server"
"github.com/urfave/cli/v2"
···
Usage: "path to store/update the AUR git mirror",
Value: "./aur-mirror",
},
+
&cli.StringFlag{
+
Name: "remote-repo-url",
+
Usage: "remote aur repo url",
+
Value: gitrepo.DefaultAurRepoUrl,
+
},
&cli.BoolFlag{
Name: "debug",
Usage: "flag to enable debug logs",
···
ctx := context.Background()
p, err := populate.New(&populate.Args{
-
DatabasePath: cmd.String("database-path"),
-
RepoPath: cmd.String("repo-path"),
-
Debug: cmd.Bool("debug"),
-
Concurrency: cmd.Int("concurrency"),
+
DatabasePath: cmd.String("database-path"),
+
RepoPath: cmd.String("repo-path"),
+
RemoteRepoUrl: cmd.String("remote-repo-url"),
+
Debug: cmd.Bool("debug"),
+
Concurrency: cmd.Int("concurrency"),
})
if err != nil {
return fmt.Errorf("failed to create populate client: %w", err)
···
Usage: "path to database file",
Value: "./myaur.db",
},
+
&cli.StringFlag{
+
Name: "remote-repo-url",
+
Usage: "remote aur repo url",
+
Value: gitrepo.DefaultAurRepoUrl,
+
},
+
&cli.StringFlag{
+
Name: "repo-path",
+
Usage: "path to store/update the AUR git mirror",
+
Value: "./aur-mirror",
+
},
&cli.BoolFlag{
Name: "debug",
Usage: "flag to enable debug logs",
···
ctx := context.Background()
s, err := server.New(&server.Args{
-
Addr: cmd.String("listen-addr"),
-
MetricsAddr: cmd.String("metrics-listen-addr"),
-
DatabasePath: cmd.String("database-path"),
-
Debug: cmd.Bool("debug"),
+
Addr: cmd.String("listen-addr"),
+
MetricsAddr: cmd.String("metrics-listen-addr"),
+
DatabasePath: cmd.String("database-path"),
+
RemoteRepoUrl: cmd.String("remote-repo-url"),
+
RepoPath: cmd.String("repo-path"),
+
Debug: cmd.Bool("debug"),
})
if err != nil {
return fmt.Errorf("failed to create new myaur server: %w", err)
+5
go.mod
···
require (
github.com/labstack/echo/v4 v4.13.4
github.com/labstack/gommon v0.4.2
+
github.com/samber/slog-echo v1.18.0
github.com/urfave/cli/v2 v2.27.7
golang.org/x/sync v0.14.0
gorm.io/driver/sqlite v1.6.0
···
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
+
github.com/samber/lo v1.51.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
+
go.opentelemetry.io/otel v1.29.0 // indirect
+
go.opentelemetry.io/otel/trace v1.29.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
+
golang.org/x/time v0.11.0 // indirect
)
+12
go.sum
···
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
···
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+
github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI=
+
github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
+
github.com/samber/slog-echo v1.18.0 h1:fnDeUhwqoAsQZxbmIizO0avwE0qjjoefAvhXoByxN3U=
+
github.com/samber/slog-echo v1.18.0/go.mod h1:4diugqPTk6iQdL7gZFJIyf6zGMLVMaGnCmNm+DBSMRU=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
···
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
+
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
+
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
+
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
+
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
···
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
+
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
+
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+8
myaur/database/database.go
···
}
return pkgs, nil
}
+
+
func (db *Database) GetPackagesByNames(names []string) ([]PackageInfo, error) {
+
var pkgs []PackageInfo
+
if err := db.db.Where("name IN ?", names).Find(&pkgs).Error; err != nil {
+
return nil, err
+
}
+
return pkgs, nil
+
}
+9 -4
myaur/database/models.go
···
return nil
}
-
bytes, ok := value.([]byte)
-
if !ok {
-
return fmt.Errorf("failed to unmarshal StringSlice value: %v", value)
+
var bytes []byte
+
switch v := value.(type) {
+
case []byte:
+
bytes = v
+
case string:
+
bytes = []byte(v)
+
default:
+
return fmt.Errorf("failed to unmarshal StringSlice value: %v (type: %T)", value, value)
}
return json.Unmarshal(bytes, s)
···
type PackageInfo struct {
Id int64 `gorm:"primaryKey;autoIncrement" json:"ID"`
Name string `gorm:"uniqueIndex;not null" json:"Name"`
-
PackageBaseID string `json:"PackageBaseID"`
+
PackageBaseID int64 `json:"PackageBaseID"`
PackageBase string `gorm:"index" json:"PackageBase"`
Version string `json:"Version"`
Description string `gorm:"index:idx_description" json:"Description"`
+8 -6
myaur/populate/populate.go
···
}
type Args struct {
-
DatabasePath string
-
RepoPath string
-
Debug bool
-
Concurrency int
+
DatabasePath string
+
RepoPath string
+
RemoteRepoUrl string
+
Debug bool
+
Concurrency int
}
func New(args *Args) (*Populate, error) {
···
}
repo, err := gitrepo.New(&gitrepo.Args{
-
RepoPath: args.RepoPath,
-
Debug: args.Debug,
+
RepoPath: args.RepoPath,
+
AurRepoUrl: args.RemoteRepoUrl,
+
Debug: args.Debug,
})
if err != nil {
return nil, fmt.Errorf("failed to create repo client: %w", err)
+29 -1
myaur/server/handle_get_info.go
···
import "github.com/labstack/echo/v4"
+
type GetInfoInput struct {
+
Arg []string `query:"arg"`
+
}
+
func (s *Server) handleGetInfo(e echo.Context) error {
-
return nil
+
logger := s.logger.With("route", "getInfo")
+
+
// HACK: probably better way to do this...
+
queryParams := e.QueryParams()
+
args := queryParams["arg[]"]
+
if len(args) == 0 {
+
args = queryParams["arg"]
+
}
+
+
if len(args) == 0 {
+
return e.JSON(400, makeErrJson("Missing `arg` parameter"))
+
}
+
+
pkgs, err := s.db.GetPackagesByNames(args)
+
if err != nil {
+
logger.Error("failed to lookup packages", "err", err)
+
return e.JSON(500, makeErrJson("Failed to search for packages"))
+
}
+
+
return e.JSON(200, GetSearchOutput{
+
Version: 5,
+
Type: "search",
+
ResultCount: len(pkgs),
+
Results: pkgs,
+
})
}
+44
myaur/server/handle_get_rpc.go
···
+
package server
+
+
import (
+
"net/http"
+
+
"github.com/labstack/echo/v4"
+
)
+
+
func (s *Server) handleRpc(e echo.Context) error {
+
queryParams := e.QueryParams()
+
rpcType := e.QueryParam("type")
+
version := e.QueryParam("v")
+
+
if version == "" {
+
version = "5"
+
}
+
+
// HACK: there's probably a better way to do this with echo...
+
args := queryParams["arg[]"]
+
if len(args) == 0 {
+
args = queryParams["arg"]
+
}
+
+
switch rpcType {
+
case "search":
+
// there will be a single arg in search, since it does a `like` match
+
if len(args) == 0 {
+
return e.JSON(http.StatusBadRequest, makeErrJson("Missing `arg` parameter"))
+
}
+
e.SetPath("/rpc/v5/search/:term")
+
e.SetParamNames("term")
+
e.SetParamValues(args[0])
+
return s.handleGetSearch(e)
+
case "info", "query":
+
// there whould be an array, i.e. arg[]=discord-canary&arg[]=slack
+
if len(args) == 0 {
+
return e.JSON(http.StatusBadRequest, makeErrJson("Missing `arg` parameter"))
+
}
+
e.SetPath("/rpc/v5/info")
+
return s.handleGetInfo(e)
+
default:
+
return e.JSON(http.StatusBadRequest, makeErrJson("Missing or invalid `type` parameter"))
+
}
+
}
+11 -13
myaur/server/handle_get_search.go
···
package server
import (
-
"strings"
-
"github.com/haileyok/myaur/myaur/database"
"github.com/labstack/echo/v4"
)
···
ResultCount int `json:"resultcount"`
Results []database.PackageInfo `json:"results"`
Error *string `json:"error,omitempty"`
+
}
+
+
func makeErrJson(error string) GetSearchOutput {
+
return GetSearchOutput{
+
Version: 5,
+
Type: "error",
+
ResultCount: 0,
+
Results: []database.PackageInfo{},
+
Error: &error,
+
}
}
// Depending on what the `by` parameter is, should receive one of the following as path:
···
// `optdepends`: search for packages that optdepends on a keyword
// `checkdepends`: search for packages that checkdepends on a keyword
func (s *Server) handleGetSearch(e echo.Context) error {
-
makeErrJson := func(error string) GetSearchOutput {
-
return GetSearchOutput{
-
Version: 5,
-
Type: "error",
-
ResultCount: 0,
-
Results: []database.PackageInfo{},
-
Error: &error,
-
}
-
}
logger := s.logger.With("handler", "getSearch")
var input GetSearchInput
···
input.By = "name"
}
-
termPts := strings.Split(e.Request().URL.Path, "/")
-
term := termPts[len(termPts)-1]
+
term := e.Param("term")
var pkgs []database.PackageInfo
var err error
+136
myaur/server/handle_git.go
···
+
package server
+
+
import (
+
"bytes"
+
"fmt"
+
"io"
+
"os/exec"
+
"strings"
+
+
"github.com/labstack/echo/v4"
+
)
+
+
func (s *Server) handleGit(e echo.Context) error {
+
if s.repoPath == "" {
+
return e.String(503, "Git operations require local mirror")
+
}
+
+
// strip off any leading slash from the path
+
path := e.Request().URL.Path
+
if after, ok := strings.CutPrefix(path, "/"); ok {
+
path = after
+
}
+
+
// get the name and git path from the url path
+
pts := strings.SplitN(path, "/", 2)
+
if len(pts) == 0 {
+
return e.String(404, "Not Found")
+
}
+
+
packageName := strings.TrimSuffix(pts[0], ".git")
+
+
var gitPath string
+
if len(pts) > 1 {
+
gitPath = pts[1]
+
}
+
+
// return a symref pointing to master for head requests
+
if gitPath == "HEAD" {
+
e.Response().Header().Set("Content-Type", "text/plain")
+
return e.String(200, "ref: refs/heads/master\n")
+
}
+
+
// handle info/refs request
+
if gitPath == "info/refs" && strings.Contains(e.Request().URL.RawQuery, "service=git-upload-pack") {
+
return s.serveInfoRefs(e, packageName)
+
}
+
+
// handle git-upload-pack request
+
if gitPath == "git-upload-pack" {
+
return s.serveUploadPack(e, packageName)
+
}
+
+
return e.String(404, "Not Found")
+
}
+
+
func (s *Server) serveInfoRefs(e echo.Context, packageName string) error {
+
logger := s.logger.With("route", "handleGit", "git-component", "serveInfoRefs", "package-name", packageName)
+
+
cmd := exec.Command("git", "-C", s.repoPath, "show-ref", fmt.Sprintf("refs/heads/%s", packageName))
+
output, err := cmd.Output()
+
if err != nil {
+
logger.Error("branch not found", "err", err)
+
return e.String(404, "Package not found")
+
}
+
+
refLine := strings.TrimSpace(string(output))
+
refParts := strings.Fields(refLine)
+
if len(refParts) != 2 {
+
logger.Error("invalid ref format", "output", refLine)
+
return e.String(500, "Invalid ref format")
+
}
+
+
commitHash := refParts[0]
+
+
// WARNING: SLOP CODE
+
// claude apparently knows how to create these smart HTPP responses for git. it works on my machine,
+
// but...lol
+
var buf bytes.Buffer
+
+
// Write packet: service announcement
+
service := "# service=git-upload-pack\n"
+
buf.WriteString(fmt.Sprintf("%04x%s", len(service)+4, service))
+
buf.WriteString("0000") // flush packet
+
+
// Advertise HEAD and master branch
+
// Format: hash + SP + ref + NULL + capabilities + LF
+
// NOTE: these were the ones claude kept adding until yay didn't yell at me anymore. not sure if they are all needed though
+
capabilities := "multi_ack multi_ack_detailed thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag symref=HEAD:refs/heads/master"
+
+
// Advertise HEAD first with symref capability
+
headRef := fmt.Sprintf("%s HEAD\x00%s\n", commitHash, capabilities)
+
buf.WriteString(fmt.Sprintf("%04x%s", len(headRef)+4, headRef))
+
+
// because aur usually has a single repo for each package, but we have a single repo with individual
+
// branches for each package, we need to spoof this to show that the ref is refs/heads/master
+
masterRef := fmt.Sprintf("%s refs/heads/master\n", commitHash)
+
buf.WriteString(fmt.Sprintf("%04x%s", len(masterRef)+4, masterRef))
+
+
// claude says this is the flush packet
+
buf.WriteString("0000")
+
+
e.Response().Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
+
e.Response().Header().Set("Cache-Control", "no-cache")
+
return e.Blob(200, "application/x-git-upload-pack-advertisement", buf.Bytes())
+
}
+
+
func (s *Server) serveUploadPack(e echo.Context, packageName string) error {
+
logger := s.logger.With("route", "handleGit", "git-component", "serveUploadPack", "package-name", packageName)
+
+
bodyBytes, err := io.ReadAll(e.Request().Body)
+
if err != nil {
+
logger.Error("failed to read upload-pack request", "err", err)
+
return e.String(400, "Failed to read request")
+
}
+
+
// because aur usually has a single repo for each package, but we have a single repo with individual
+
// branches for each package, we need to spoof this to show that the ref is refs/heads/master
+
modifiedBody := bytes.ReplaceAll(bodyBytes, []byte("refs/heads/master"), fmt.Appendf(nil, "refs/heads/%s", packageName))
+
+
// then we can use upload-pack to serve the pack file
+
cmd := exec.Command("git", "upload-pack", "--stateless-rpc", s.repoPath)
+
cmd.Stdin = bytes.NewReader(modifiedBody)
+
+
var stdout, stderr bytes.Buffer
+
cmd.Stdout = &stdout
+
cmd.Stderr = &stderr
+
+
if err := cmd.Run(); err != nil {
+
logger.Error("upload-pack failed", "err", err, "stderr", stderr.String(), "package", packageName)
+
return e.String(500, fmt.Sprintf("upload pack failed: %s", stderr.String()))
+
}
+
+
e.Response().Header().Set("Content-Type", "application/x-git-upload-pack-result")
+
e.Response().Header().Set("Cache-Control", "no-cache")
+
return e.Blob(200, "application/x-git-upload-pack-result", stdout.Bytes())
+
}
+35 -24
myaur/server/server.go
···
"time"
"github.com/haileyok/myaur/myaur/database"
+
"github.com/haileyok/myaur/myaur/gitrepo"
"github.com/labstack/echo/v4"
+
"github.com/labstack/echo/v4/middleware"
"github.com/labstack/gommon/log"
+
slogecho "github.com/samber/slog-echo"
)
type Server struct {
-
logger *slog.Logger
-
echo *echo.Echo
-
httpd *http.Server
-
metricsHttpd *http.Server
-
db *database.Database
+
logger *slog.Logger
+
echo *echo.Echo
+
httpd *http.Server
+
metricsHttpd *http.Server
+
db *database.Database
+
remoteRepoUrl string
+
repoPath string
}
type Args struct {
-
Addr string
-
MetricsAddr string
-
DatabasePath string
-
Debug bool
+
Addr string
+
MetricsAddr string
+
DatabasePath string
+
RemoteRepoUrl string
+
RepoPath string
+
Debug bool
}
func New(args *Args) (*Server, error) {
···
level = slog.LevelDebug
}
+
if args.RemoteRepoUrl == "" {
+
args.RemoteRepoUrl = gitrepo.DefaultAurRepoUrl
+
}
+
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
}))
e := echo.New()
+
+
e.Use(middleware.Recover())
+
e.Use(slogecho.New(logger))
httpd := http.Server{
Addr: args.Addr,
···
}
s := Server{
-
echo: e,
-
httpd: &httpd,
-
metricsHttpd: &metricsHttpd,
-
db: db,
-
logger: logger,
+
echo: e,
+
httpd: &httpd,
+
metricsHttpd: &metricsHttpd,
+
db: db,
+
logger: logger,
+
remoteRepoUrl: args.RemoteRepoUrl,
+
repoPath: args.RepoPath,
}
return &s, nil
···
}
func (s *Server) addRoutes() {
+
s.echo.GET("/rpc", s.handleRpc)
s.echo.GET("/rpc/v5/info", s.handleGetInfo)
-
s.echo.GET("/rpc/v5/search", s.handleGetSearch)
-
}
+
s.echo.GET("/rpc/v5/search/:term", s.handleGetSearch)
-
func makeErrorJson(error string, message string) map[string]string {
-
jsonMap := map[string]string{
-
"error": error,
-
}
-
if message != "" {
-
jsonMap["message"] = message
-
}
-
return jsonMap
+
// git will make both get and post requests
+
s.echo.GET("/*", s.handleGit)
+
s.echo.POST("/*", s.handleGit)
}