diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..20912ac --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -0,0 +1,51 @@ +name: release-tag +on: + push: + branches: + - 'main' +jobs: + release-image: + runs-on: ubuntu-fast + env: + DOCKER_ORG: ${{ vars.DOCKER_ORG }} + DOCKER_LATEST: latest + RUNNER_TOOL_CACHE: /toolcache + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v2 + with: # replace it with your local IP + config-inline: | + [registry."${{ vars.DOCKER_REGISTRY }}"] + http = true + insecure = true + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: ${{ vars.DOCKER_REGISTRY }} # replace it with your local IP + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b8f89b3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# ---------- Build stage ---------- +FROM golang:1.22-alpine AS build +WORKDIR /src +# optional, aber nett für reproduzierbare Builds: +ENV CGO_ENABLED=0 +# Falls du private CAs brauchst, sonst weglassen: +RUN apk add --no-cache ca-certificates +COPY go.mod ./ +COPY main.go ./ +RUN go build -o /out/mirrorweb -trimpath -ldflags="-s -w" + +# ---------- Runtime stage ---------- +FROM alpine:3.20 +# Für TLS (falls du Traefik mal nicht davor hast) und saubere Zeit: +RUN apk add --no-cache ca-certificates tzdata && update-ca-certificates +# Non-root User +RUN addgroup -S app && adduser -S -G app app +USER app +WORKDIR / +VOLUME ["/data"] # hier hängt dein Mirror-Volume read-only +EXPOSE 8080 +COPY --from=build /out/mirrorweb /mirrorweb +ENTRYPOINT ["/mirrorweb"] diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..a78e9dd --- /dev/null +++ b/compose.yml @@ -0,0 +1,52 @@ +services: + updater: + image: git.send.nrw/sendnrw/go-ubuntu-mirror:latest # unverändert vom vorherigen Setup + container_name: ubuntu-mirror-updater + restart: unless-stopped + volumes: + - mirror-data:/var/spool/apt-mirror + + web: + build: + context: . + dockerfile: Dockerfile # <- die Alpine-Datei oben + container_name: ubuntu-mirror-web + restart: unless-stopped + depends_on: [updater] + volumes: + - mirror-data:/data:ro + command: > + -archive=/data/mirror/archive.ubuntu.com/ubuntu + -security=/data/mirror/security.ubuntu.com/ubuntu + -old=/data/mirror/old-releases.ubuntu.com/ubuntu + -autoindex=true + -cache=600 + -addr=:8080 + labels: + - traefik.enable=true + # HTTPS Router + - traefik.http.routers.ubuntu_mirror.rule=Host(`ubuntu-24-04.send.nrw`) + - traefik.http.routers.ubuntu_mirror.entrypoints=websecure + - traefik.http.routers.ubuntu_mirror.tls=true + - traefik.http.routers.ubuntu_mirror.tls.certresolver=letsencrypt + - traefik.http.routers.ubuntu_mirror.service=ubuntu_mirror_svc + # HTTP -> HTTPS Redirect + - traefik.http.routers.ubuntu_mirror_http.rule=Host(`ubuntu-24-04.send.nrw`) + - traefik.http.routers.ubuntu_mirror_http.entrypoints=web + - traefik.http.routers.ubuntu_mirror_http.middlewares=to-https + - traefik.http.routers.ubuntu_mirror_http.service=ubuntu_mirror_svc + - traefik.http.middlewares.to-https.redirectscheme.scheme=https + - traefik.http.middlewares.to-https.redirectscheme.permanent=true + # Service-Ziel (Go-Server auf 8080) + - traefik.http.services.ubuntu_mirror_svc.loadbalancer.server.port=8080 + - traefik.http.services.ubuntu_mirror_svc.loadbalancer.server.scheme=http + # Optional: externes Traefik-Netz + # - traefik.docker.network=traefik_proxy + # networks: + # - traefik_proxy + +volumes: + mirror-data: +# networks: +# traefik_proxy: +# external: true diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2743289 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.send.nrw/sendnrw/go-ubuntu-mirror + +go 1.24.4 diff --git a/main.go b/main.go new file mode 100644 index 0000000..1b604d0 --- /dev/null +++ b/main.go @@ -0,0 +1,193 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "log" + "mime" + "net/http" + "os" + "path" + "path/filepath" + "strings" + "time" +) + +// unionFS tries multiple http.FileSystem roots in order. +type unionFS struct{ roots []http.FileSystem } + +func (u unionFS) Open(name string) (http.File, error) { + for _, fs := range u.roots { + f, err := fs.Open(name) + if err == nil { + return f, nil + } + } + return nil, os.ErrNotExist +} + +// fileHandler serves files from an http.FileSystem with nice directory listing +type fileHandler struct { + fs http.FileSystem + autoIndex bool + cacheMaxAge time.Duration +} + +func (h fileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Normalize path + upath := r.URL.Path + if !strings.HasPrefix(upath, "/") { + upath = "/" + upath + } + upath = path.Clean(upath) // prevents path traversal + + // Open + f, err := h.fs.Open(upath) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + http.NotFound(w, r) + return + } + + // Directories + if fi.IsDir() { + // redirect to slash-terminated path (as net/http does) + if !strings.HasSuffix(r.URL.Path, "/") { + http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently) + return + } + // If index.html exists, serve it + indexPath := path.Join(upath, "index.html") + if ff, err := h.fs.Open(indexPath); err == nil { + defer ff.Close() + info, _ := ff.Stat() + h.serveFile(w, r, indexPath, ff, info) + return + } + // Otherwise: simple autoindex (for debugging in browser) + if h.autoIndex { + h.serveDirList(w, r, f) + return + } + http.NotFound(w, r) + return + } + + // Files + h.serveFile(w, r, upath, f, fi) +} + +func (h fileHandler) serveFile(w http.ResponseWriter, r *http.Request, name string, f http.File, fi os.FileInfo) { + // Content-Type by extension (fallback: octet-stream) + ctype := mime.TypeByExtension(strings.ToLower(filepath.Ext(name))) + if ctype == "" { + ctype = "application/octet-stream" + } + w.Header().Set("Content-Type", ctype) + + // Conservative cache (APT macht eigene Validierungen über InRelease/Release) + if h.cacheMaxAge > 0 { + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(h.cacheMaxAge.Seconds()))) + } + + // ServeContent enables Range requests + sets Last-Modified + // Use name without leading slash to avoid special-case in ServeContent + http.ServeContent(w, r, strings.TrimPrefix(name, "/"), fi.ModTime(), f) +} + +func (h fileHandler) serveDirList(w http.ResponseWriter, r *http.Request, d http.File) { + entries, err := d.Readdir(-1) + if err != nil { + http.Error(w, "cannot read directory", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, "Index of %s

Index of %s

") +} + +func main() { + var ( + addr = flag.String("addr", ":8080", "listen address") + // Point these to the *ubuntu* directories inside your mirror volume + archiveRoot = flag.String("archive", "/data/mirror/archive.ubuntu.com/ubuntu", "archive ubuntu root") + securityRoot = flag.String("security", "/data/mirror/security.ubuntu.com/ubuntu", "security ubuntu root") + oldReleases = flag.String("old", "/data/mirror/old-releases.ubuntu.com/ubuntu", "old-releases ubuntu root") + autoIndex = flag.Bool("autoindex", true, "enable directory listings") + cacheSeconds = flag.Int("cache", 600, "Cache-Control max-age seconds (0 to disable)") + ) + flag.Parse() + + roots := []http.FileSystem{} + for _, p := range []string{*archiveRoot, *securityRoot, *oldReleases} { + if p == "" { + continue + } + if st, err := os.Stat(p); err == nil && st.IsDir() { + roots = append(roots, http.Dir(p)) + log.Printf("added root: %s", p) + } else { + if errors.Is(err, os.ErrNotExist) { + log.Printf("warn: root does not exist (skipping): %s", p) + } else if err != nil { + log.Printf("warn: cannot stat %s: %v", p, err) + } + } + } + if len(roots) == 0 { + log.Fatal("no valid roots found") + } + + union := unionFS{roots: roots} + handler := fileHandler{ + fs: union, + autoIndex: *autoIndex, + cacheMaxAge: time.Duration(*cacheSeconds) * time.Second, + } + + mux := http.NewServeMux() + // Serve under /ubuntu/... to match client sources.list + mux.Handle("/ubuntu/", http.StripPrefix("/ubuntu", handler)) + // health endpoint + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + + srv := &http.Server{ + Addr: *addr, + Handler: mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 10 * time.Minute, // big files + IdleTimeout: 120 * time.Second, + ReadHeaderTimeout: 10 * time.Second, + MaxHeaderBytes: 1 << 20, + } + + log.Printf("listening on %s", *addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } +}