aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMarc Pervaz Boocha <mboocha@sudomsg.com>2025-07-27 15:43:06 +0530
committerMarc Pervaz Boocha <mboocha@sudomsg.com>2025-07-27 15:43:06 +0530
commit41178f6bad1f2e1eaed462475cb7aa26185dc0ac (patch)
tree8c025140c6d1d35d02246ea413b135da93021532
downloadgopkgserver-41178f6bad1f2e1eaed462475cb7aa26185dc0ac.tar
gopkgserver-41178f6bad1f2e1eaed462475cb7aa26185dc0ac.tar.gz
gopkgserver-41178f6bad1f2e1eaed462475cb7aa26185dc0ac.tar.bz2
gopkgserver-41178f6bad1f2e1eaed462475cb7aa26185dc0ac.tar.lz
gopkgserver-41178f6bad1f2e1eaed462475cb7aa26185dc0ac.tar.xz
gopkgserver-41178f6bad1f2e1eaed462475cb7aa26185dc0ac.tar.zst
gopkgserver-41178f6bad1f2e1eaed462475cb7aa26185dc0ac.zip
Initial Commit
Diffstat (limited to '')
-rw-r--r--.gitignore3
-rw-r--r--LICENSE22
-rw-r--r--README.md37
-rw-r--r--go.mod5
-rw-r--r--go.sum2
-rw-r--r--logging/handler/handler.go75
-rw-r--r--logging/log.go56
-rw-r--r--main.go121
-rw-r--r--repo/meta.html14
-rw-r--r--repo/repo.go93
10 files changed, 428 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4d0448a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+*.out
+vendor/
+gopkgserver
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..400e6e2
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright (c) 2025 Marc Pervaz Boocha
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..16f4b75
--- /dev/null
+++ b/README.md
@@ -0,0 +1,37 @@
+# GOPKGSERVER
+
+A minimal Go server for serving vanity URLs, configurable via TOML.
+
+## Status
+
+**Alpha** — functional but rough. No tests yet. Interface and config format may change.
+
+## Features
+
+- Serves vanity URLs for `go get`
+- Configurable with TOML
+- Supports VCS repositories (e.g., git)
+- Custom directory and file view URLs
+
+## Usage
+
+```bash
+gopkgserver -config /path/to/config.toml
+```
+
+## Configuration
+
+Configuration is passed via the -config flag as a TOML file. Example structure:
+
+``` toml
+[server]
+address = ":6000"
+
+[repo."go.example.com/cache"]
+repo = "https://git.example.com/mirror/cache.git"
+vcs = "git"
+home = "-"
+directory = "https://git.example.com/mirror/cache.git/tree/{/dir}"
+file = "https://git.example.com/cache/mirror/cache.git/tree/{/dir}/{file}#n{line}"
+```
+
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..a12fb29
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,5 @@
+module go.sudomsg.com/gopkgserver
+
+go 1.24.1
+
+require github.com/pelletier/go-toml/v2 v2.2.4
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..3cf50e1
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,2 @@
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
diff --git a/logging/handler/handler.go b/logging/handler/handler.go
new file mode 100644
index 0000000..1501715
--- /dev/null
+++ b/logging/handler/handler.go
@@ -0,0 +1,75 @@
+package handler
+
+import (
+ "go-pkg-server/logging"
+ "log/slog"
+ "net/http"
+ "time"
+)
+
+type traceWriter struct {
+ http.ResponseWriter
+ status int
+ bytes int
+}
+
+var _ http.ResponseWriter = &traceWriter{}
+
+func (t *traceWriter) Write(b []byte) (int, error) {
+ if t.status == 0 {
+ t.WriteHeader(http.StatusOK)
+ }
+ c, err := t.ResponseWriter.Write(b)
+ t.bytes += c
+ return c, err
+}
+
+func (tw *traceWriter) WriteHeader(statusCode int) {
+ if tw.status == 0 {
+ tw.status = http.StatusOK
+ }
+ tw.status = statusCode
+ tw.ResponseWriter.WriteHeader(statusCode)
+}
+
+func New(next http.Handler) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ logger := logging.FromContext(ctx)
+
+ defer func() {
+ err := recover()
+ if err != nil {
+ logging.RecoverLog(ctx, err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ }
+ }()
+
+ logger = logger.With("method", r.Method,
+ "host", r.Host,
+ "remote_address", r.RemoteAddr,
+ "path", r.RequestURI,
+ )
+
+ ctx = logging.WithLogger(ctx, logger)
+
+ r = r.WithContext(ctx)
+
+ tw := &traceWriter{ResponseWriter: w}
+
+ start := time.Now()
+ next.ServeHTTP(tw, r)
+ duration := time.Since(start)
+
+ logger.Log(ctx, slog.LevelInfo, "Request Handled",
+ "protocol", r.Proto,
+ "bytes_recieved", r.ContentLength,
+ "status", tw.status,
+ "time", duration,
+ "bytes_sent", tw.bytes,
+ "user_agent", r.UserAgent(),
+ "referer", r.Referer(),
+ )
+
+ }
+}
diff --git a/logging/log.go b/logging/log.go
new file mode 100644
index 0000000..8e3078f
--- /dev/null
+++ b/logging/log.go
@@ -0,0 +1,56 @@
+package logging
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "os"
+ "runtime"
+)
+
+type Config struct {
+ Level slog.Level `toml:"level"`
+ File string `toml:"file"`
+}
+
+func SetupLogger(cfg Config) *slog.Logger {
+ opts := &slog.HandlerOptions{Level: cfg.Level}
+ var handler slog.Handler
+
+ var writter io.Writer
+ writter = os.Stdout
+ if cfg.File != "" {
+ f, _ := os.OpenFile(cfg.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+ writter = f
+ }
+ handler = slog.NewJSONHandler(writter, opts)
+
+ logger := slog.New(handler)
+ slog.SetDefault(logger)
+ return logger
+}
+
+type contextKeyType struct{}
+
+var contextKey contextKeyType
+
+func WithLogger(ctx context.Context, logger *slog.Logger) context.Context {
+ return context.WithValue(ctx, contextKey, logger)
+}
+
+func FromContext(ctx context.Context) *slog.Logger {
+ if logger, ok := ctx.Value(contextKey).(*slog.Logger); ok {
+ return logger
+ }
+ return slog.Default()
+}
+
+func RecoverLog(ctx context.Context, err any) {
+ logger := FromContext(ctx)
+
+ const size = 64 << 10
+ buf := make([]byte, size)
+ buf = buf[:runtime.Stack(buf, false)]
+
+ logger.Log(ctx, slog.LevelError, "Program Paniced", "error", err, "stack", string(buf))
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..6404940
--- /dev/null
+++ b/main.go
@@ -0,0 +1,121 @@
+package gopkgserver
+
+import (
+ "context"
+ _ "embed"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "os"
+
+ "github.com/pelletier/go-toml/v2"
+
+ "go-pkg-server/logging"
+ "go-pkg-server/logging/handler"
+ "go-pkg-server/repo"
+)
+
+type ServerConfig struct {
+ Proto string `toml:"proto"`
+ Address string `toml:"address"`
+}
+
+type Config struct {
+ Server ServerConfig `toml:"server"`
+ Log logging.Config `toml:"log"`
+ Repos map[string]repo.Repo `toml:"repo"`
+}
+
+func Run(ctx context.Context, fs *flag.FlagSet, args []string) error {
+ var cfgFile string
+ fs.StringVar(&cfgFile, "config", "config.toml", "Path to config file")
+ fs.Parse(args)
+
+ cfg, err := LoadConfig(cfgFile)
+ if err != nil {
+ return err
+ }
+
+ logger := logging.SetupLogger(cfg.Log)
+ ctx = logging.WithLogger(ctx, logger)
+
+ h, err := repo.New(cfg.Repos)
+ if err != nil {
+ return fmt.Errorf(": %v", err)
+ }
+
+ mux := http.NewServeMux()
+ mux.Handle("GET /robots.txt", Robot())
+ mux.Handle("GET /", h)
+
+ return RunServer(ctx, handler.New(mux), cfg.Server)
+}
+
+func LoadConfig(cfgFile string) (Config, error) {
+ data, err := os.ReadFile(cfgFile)
+ if err != nil {
+ return Config{}, fmt.Errorf("read config: %w", err)
+ }
+
+ var cfg Config
+ if err := toml.Unmarshal(data, &cfg); err != nil {
+ return Config{}, fmt.Errorf("parse config: %w", err)
+ }
+
+ if cfg.Server.Proto == "" {
+ cfg.Server.Proto = "tcp"
+ }
+
+ return cfg, nil
+}
+
+func RunServer(ctx context.Context, mux http.Handler, config ServerConfig) error {
+ logger := logging.FromContext(ctx)
+
+ srv := &http.Server{
+ Handler: mux,
+ BaseContext: func(l net.Listener) context.Context {
+ logger := logger.With("address", l.Addr())
+ ctx = logging.WithLogger(ctx, logger)
+ return ctx
+ },
+ ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
+ }
+
+ ln, err := net.Listen(config.Proto, config.Address)
+ if err != nil {
+ return err
+ }
+
+ logger.Log(ctx, slog.LevelError, "Server Started on", "address", ln.Addr())
+
+ go func() {
+ <-ctx.Done()
+ shutdownCtx := context.Background()
+ // We received an interrupt signal, shut down.
+ if err := srv.Shutdown(shutdownCtx); err != nil {
+ slog.Log(shutdownCtx, slog.LevelError, "HTTP server Shutdown", "error", err)
+ }
+ }()
+
+ if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ return err
+ }
+
+ return nil
+}
+
+func Robot() http.HandlerFunc {
+ robots := `User-agent: *
+Disallow: /
+`
+ return func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "public, max-age=86400, immutable")
+ w.Header().Set("Content-Type", "text/plain")
+ io.WriteString(w, robots)
+ }
+}
diff --git a/repo/meta.html b/repo/meta.html
new file mode 100644
index 0000000..e5b3992
--- /dev/null
+++ b/repo/meta.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html lang="en" style="color-scheme: dark light;">
+
+<head>
+ <meta name="go-import" content="{{ .Package }} {{ .VCS }} {{ .Repository }}">
+ <meta name="go-source" content="{{ .Package }} {{ .Home }} {{ .Directory }} {{ .File }}">
+ <meta http-equiv="Refresh" content="0; url='https://pkg.go.dev/{{ .Package }}'" />
+</head>
+
+<body>
+ <a href="https://pkg.go.dev/{{ .Package }}">Redirecting to documentation...</a>
+</body>
+
+</html>
diff --git a/repo/repo.go b/repo/repo.go
new file mode 100644
index 0000000..2c052d6
--- /dev/null
+++ b/repo/repo.go
@@ -0,0 +1,93 @@
+package repo
+
+import (
+ "bytes"
+ _ "embed"
+ "fmt"
+ "html/template"
+ "net/http"
+ "strings"
+)
+
+//go:embed meta.html
+var tmplStr string
+
+type Repo struct {
+ VCS string `toml:"vcs"`
+ Repository string `toml:"repo"`
+ Home string `toml:"source,omitempty"`
+ Directory string `toml:"directory"`
+ File string `toml:"file"`
+}
+
+type RepoHandler struct {
+ Pages map[string][]byte
+}
+
+func New(repo map[string]Repo) (*RepoHandler, error) {
+ m, err := NewMeta()
+ if err != nil {
+ return nil, err
+ }
+ h := &RepoHandler{Pages: map[string][]byte{}}
+ for k, v := range repo {
+ if v.Home == "" {
+ v.Home = "_"
+ }
+
+ b, err := m.Exec(k, v)
+ if err != nil {
+ return nil, err
+ }
+ h.Pages[k] = b
+ }
+ return h, nil
+}
+
+func (h *RepoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ key := fmt.Sprintf("%s%s", r.Host, r.URL.Path)
+ key = strings.TrimSuffix(key, "/")
+
+ page, ok := h.Pages[key]
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+
+ if r.FormValue("go-get") == "1" {
+ w.Header().Set("Cache-Control", "public, max-age=86400, immutable")
+ w.Header().Set("Vary", "Host")
+ w.Header().Set("Content-Type", "text/html")
+ w.Write(page)
+ } else {
+ url := fmt.Sprintf("https://pkg.go.dev/%s", key)
+ http.Redirect(w, r, url, http.StatusFound)
+ }
+}
+
+type Meta struct {
+ tmpl *template.Template
+}
+
+func NewMeta() (*Meta, error) {
+ tmpl, err := template.New("meta").Parse(tmplStr)
+ if err != nil {
+ return nil, err
+ }
+ return &Meta{tmpl: tmpl}, nil
+}
+
+func (m *Meta) Exec(pkg string, repo Repo) ([]byte, error) {
+ var buf bytes.Buffer
+ data := struct {
+ Package string
+ Repo
+ }{
+ Package: pkg,
+ Repo: repo,
+ }
+ if err := m.tmpl.Execute(&buf, data); err != nil {
+ return nil, fmt.Errorf("exec template for %v: %w", pkg, err)
+ }
+ return buf.Bytes(), nil
+}