diff options
author | Marc Pervaz Boocha <mboocha@sudomsg.com> | 2025-07-27 15:43:06 +0530 |
---|---|---|
committer | Marc Pervaz Boocha <mboocha@sudomsg.com> | 2025-07-27 15:43:06 +0530 |
commit | 41178f6bad1f2e1eaed462475cb7aa26185dc0ac (patch) | |
tree | 8c025140c6d1d35d02246ea413b135da93021532 | |
download | gopkgserver-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
-rw-r--r-- | .gitignore | 3 | ||||
-rw-r--r-- | LICENSE | 22 | ||||
-rw-r--r-- | README.md | 37 | ||||
-rw-r--r-- | go.mod | 5 | ||||
-rw-r--r-- | go.sum | 2 | ||||
-rw-r--r-- | logging/handler/handler.go | 75 | ||||
-rw-r--r-- | logging/log.go | 56 | ||||
-rw-r--r-- | main.go | 121 | ||||
-rw-r--r-- | repo/meta.html | 14 | ||||
-rw-r--r-- | repo/repo.go | 93 |
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 @@ -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}" +``` + @@ -0,0 +1,5 @@ +module go.sudomsg.com/gopkgserver + +go 1.24.1 + +require github.com/pelletier/go-toml/v2 v2.2.4 @@ -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)) +} @@ -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 +} |