Skip to content

Commit 04749ef

Browse files
committed
A reference Go module proxy implementation
1 parent b30f9e7 commit 04749ef

File tree

3 files changed

+385
-0
lines changed

3 files changed

+385
-0
lines changed

go.mod

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module goproxy
2+
3+
go 1.16
4+
5+
require golang.org/x/mod v0.5.0

go.sum

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
2+
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
3+
golang.org/x/mod v0.5.0 h1:UG21uOlmZabA4fW5i7ZX6bjw1xELEGg/ZLgZq9auk/Q=
4+
golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
5+
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
6+
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
7+
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
8+
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
9+
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
10+
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
11+
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
12+
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
13+
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898 h1:/atklqdjdhuosWIl6AIbOeHJjicWYPqR9bpxqxYG2pA=
14+
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

goproxy.go

+366
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
// gomodproxy is a simple reference implementation of the core of a Go
2+
// module proxy (https://golang.org/ref/mod), for pedagogical purposes.
3+
// Each HTTP request is handled by directly executing the 'go' command.
4+
//
5+
// A realistic implementation would offer additional features, such as:
6+
//
7+
// - Caching, so that sequential requests for the same module do not
8+
// necessarily result in repeated execution of the go command.
9+
// - Duplicate suppression, so that concurrent requests for the same
10+
// module do not result in duplicate work.
11+
// - Replication and load balancing, so that the server can be run on
12+
// multiple hosts sharing persistent storage.
13+
// - Cache eviction, to prevent unbounded growth of storage.
14+
// - A checksum database, to avoid the need for "trust on first use".
15+
// - Transport-layer security, to prevent eavesdropping in the network.
16+
// - Authentication, so that only permitted users are served.
17+
// - Access control, so that authenticated users may only read permitted packages.
18+
// - Persistent storage, so that deletion or temporary downtime of a
19+
// repository does not break existing clients.
20+
// - A content-delivery network, so that large .zip files can be
21+
// served from caches closer in the network to the requesting user.
22+
// - Monitoring, logging, tracing, profiling, and other observability
23+
// features for maintainers.
24+
//
25+
// Examples of production-grade proxies are:
26+
// - The Go Module Mirror, https://proxy.golang.org/
27+
// - The Athens Project, https://docs.gomods.io/
28+
// - GoFig, https://gofig.dev/
29+
//
30+
//
31+
// The Go module proxy protocol (golang.org/ref/mod#goproxy-protocol) defines five endpoints:
32+
//
33+
// - MODULE/@v/VERSION.info
34+
// - MODULE/@v/VERSION.mod
35+
// - MODULE/@v/VERSION.zip
36+
//
37+
// These three endpoints accept version query (such as a semver or
38+
// branch name), and are implemented by a 'go mod download' command,
39+
// which resolves the version query, downloads the content of the
40+
// module from its version-control system (VCS) repository, and
41+
// saves its content (.zip, .mod) and metadata (.info) in separate
42+
// files in the cache directory.
43+
//
44+
// Although the client could extract the .mod file from the .zip
45+
// file, it is more efficient to request the .mod file alone during
46+
// the initial "minimum version selection" phase and then request
47+
// the complete .zip later only if needed.
48+
//
49+
// The results of these requests may be cached indefinitely, using
50+
// the pair (module, resolved version) as the key. The 'go mod
51+
// download' command effectively does this for us, storing previous
52+
// results in its cache directory.
53+
//
54+
// - MODULE/@v/list
55+
// - MODULE/@latest (optional)
56+
//
57+
// These two endpoints request information about the available
58+
// versions of a module, and are implemented by 'go list -m'
59+
// commands: /@v/list uses -versions to query the tags in the
60+
// version-control system that hosts the module, and /@latest uses
61+
// the query "module@latest" to obtain the current version.
62+
//
63+
// Because the set of versions may change at any moment, caching the
64+
// results of these queries inevitably results in the delivery of
65+
// stale information to some users at least some of the time.
66+
//
67+
//
68+
// To use this proxy:
69+
//
70+
// $ go run . &
71+
// $ export GOPROXY=http://localhost:8000/mod
72+
// $ go get <module>
73+
//
74+
package main
75+
76+
// TODO: when should we emit StatusGone? (see github.com/golang/go/issues/30134)
77+
78+
import (
79+
"bytes"
80+
"encoding/json"
81+
"fmt"
82+
"io"
83+
"io/ioutil"
84+
"log"
85+
"net/http"
86+
"os"
87+
"os/exec"
88+
"path/filepath"
89+
"strings"
90+
"time"
91+
92+
"golang.org/x/mod/module"
93+
)
94+
95+
var cachedir = filepath.Join(os.Getenv("HOME"), "gomodproxy-cache")
96+
97+
func main() {
98+
if err := os.MkdirAll(cachedir, 0755); err != nil {
99+
log.Fatalf("creating cache: %v", err)
100+
}
101+
http.HandleFunc("/mod/", handleMod)
102+
log.Fatal(http.ListenAndServe(":8000", nil))
103+
}
104+
105+
func handleMod(w http.ResponseWriter, req *http.Request) {
106+
path := strings.TrimPrefix(req.URL.Path, "/mod/")
107+
108+
// MODULE/@v/list
109+
if mod, ok := suffixed(path, "/@v/list"); ok {
110+
mod, err := module.UnescapePath(mod)
111+
if err != nil {
112+
http.Error(w, err.Error(), http.StatusBadRequest)
113+
return
114+
}
115+
116+
log.Println("list", mod)
117+
118+
versions, err := listVersions(mod)
119+
if err != nil {
120+
http.Error(w, err.Error(), http.StatusNotFound)
121+
return
122+
}
123+
124+
w.Header().Set("Cache-Control", "no-store")
125+
for _, v := range versions {
126+
fmt.Fprintln(w, v)
127+
}
128+
return
129+
}
130+
131+
// MODULE/@latest
132+
if mod, ok := suffixed(path, "/@latest"); ok {
133+
mod, err := module.UnescapePath(mod)
134+
if err != nil {
135+
http.Error(w, err.Error(), http.StatusBadRequest)
136+
return
137+
}
138+
139+
log.Println("latest", mod)
140+
141+
latest, err := resolve(mod, "latest")
142+
if err != nil {
143+
http.Error(w, err.Error(), http.StatusNotFound)
144+
return
145+
}
146+
147+
w.Header().Set("Cache-Control", "no-store")
148+
w.Header().Set("Content-Type", "application/json")
149+
info := InfoJSON{Version: latest.Version, Time: latest.Time}
150+
json.NewEncoder(w).Encode(info)
151+
return
152+
}
153+
154+
// MODULE/@v/VERSION.{info,mod,zip}
155+
if rest, ext, ok := lastCut(path, "."); ok && isOneOf(ext, "mod", "info", "zip") {
156+
if mod, version, ok := cut(rest, "/@v/"); ok {
157+
mod, err := module.UnescapePath(mod)
158+
if err != nil {
159+
http.Error(w, err.Error(), http.StatusBadRequest)
160+
return
161+
}
162+
version, err := module.UnescapeVersion(version)
163+
if err != nil {
164+
http.Error(w, err.Error(), http.StatusBadRequest)
165+
return
166+
}
167+
168+
log.Printf("%s %s@%s", ext, mod, version)
169+
170+
m, err := download(mod, version)
171+
if err != nil {
172+
http.Error(w, err.Error(), http.StatusNotFound)
173+
return
174+
}
175+
176+
// The version may be a query such as a branch name.
177+
// Branches move, so we suppress HTTP caching in that case.
178+
// (To avoid repeated calls to download, the proxy could use
179+
// the module name and resolved m.Version as a key in a cache.)
180+
if version != m.Version {
181+
w.Header().Set("Cache-Control", "no-store")
182+
log.Printf("%s %s@%s => %s", ext, mod, version, m.Version)
183+
}
184+
185+
// Return the relevant cached file.
186+
var filename, mimetype string
187+
switch ext {
188+
case "info":
189+
filename = m.Info
190+
mimetype = "application/json"
191+
case "mod":
192+
filename = m.GoMod
193+
mimetype = "text/plain; charset=UTF-8"
194+
case "zip":
195+
filename = m.Zip
196+
mimetype = "application/zip"
197+
}
198+
w.Header().Set("Content-Type", mimetype)
199+
if err := copyFile(w, filename); err != nil {
200+
http.Error(w, err.Error(), http.StatusInternalServerError)
201+
return
202+
}
203+
return
204+
}
205+
}
206+
207+
http.Error(w, "bad request", http.StatusBadRequest)
208+
}
209+
210+
// download runs 'go mod download' and returns information about a
211+
// specific module version. It also downloads the module's dependencies.
212+
func download(name, version string) (*ModuleDownloadJSON, error) {
213+
var mod ModuleDownloadJSON
214+
if err := runGo(&mod, "mod", "download", "-json", name+"@"+version); err != nil {
215+
return nil, err
216+
}
217+
if mod.Error != "" {
218+
return nil, fmt.Errorf("failed to download module %s: %v", name, mod.Error)
219+
}
220+
return &mod, nil
221+
}
222+
223+
// listVersions runs 'go list -m -versions' and returns an unordered list
224+
// of versions of the specified module.
225+
func listVersions(name string) ([]string, error) {
226+
var mod ModuleListJSON
227+
if err := runGo(&mod, "list", "-m", "-json", "-versions", name); err != nil {
228+
return nil, err
229+
}
230+
if mod.Error != nil {
231+
return nil, fmt.Errorf("failed to list module %s: %v", name, mod.Error.Err)
232+
}
233+
return mod.Versions, nil
234+
}
235+
236+
// resolve runs 'go list -m' to resolve a module version query to a specific version.
237+
func resolve(name, query string) (*ModuleListJSON, error) {
238+
var mod ModuleListJSON
239+
if err := runGo(&mod, "list", "-m", "-json", name+"@"+query); err != nil {
240+
return nil, err
241+
}
242+
if mod.Error != nil {
243+
return nil, fmt.Errorf("failed to list module %s: %v", name, mod.Error.Err)
244+
}
245+
return &mod, nil
246+
}
247+
248+
// runGo runs the Go command and decodes its JSON output into result.
249+
func runGo(result interface{}, args ...string) error {
250+
tmpdir, err := ioutil.TempDir("", "")
251+
if err != nil {
252+
return err
253+
}
254+
defer os.RemoveAll(tmpdir)
255+
256+
cmd := exec.Command("go", args...)
257+
cmd.Dir = tmpdir
258+
// Construct environment from scratch, for hygiene.
259+
cmd.Env = []string{
260+
"USER=" + os.Getenv("USER"),
261+
"PATH=" + os.Getenv("PATH"),
262+
"HOME=" + os.Getenv("HOME"),
263+
"NETRC=", // don't allow go command to read user's secrets
264+
"GOPROXY=direct",
265+
"GOCACHE=" + cachedir,
266+
"GOMODCACHE=" + cachedir,
267+
"GOSUMDB=",
268+
}
269+
cmd.Stdout = new(bytes.Buffer)
270+
cmd.Stderr = new(bytes.Buffer)
271+
if err := cmd.Run(); err != nil {
272+
return fmt.Errorf("%s failed: %v (stderr=<<%s>>)", cmd, err, cmd.Stderr)
273+
}
274+
if err := json.Unmarshal(cmd.Stdout.(*bytes.Buffer).Bytes(), result); err != nil {
275+
return fmt.Errorf("internal error decoding %s JSON output: %v", cmd, err)
276+
}
277+
return nil
278+
}
279+
280+
// -- JSON schemas --
281+
282+
// ModuleDownloadJSON is the JSON schema of the output of 'go help mod download'.
283+
type ModuleDownloadJSON struct {
284+
Path string // module path
285+
Version string // module version
286+
Error string // error loading module
287+
Info string // absolute path to cached .info file
288+
GoMod string // absolute path to cached .mod file
289+
Zip string // absolute path to cached .zip file
290+
Dir string // absolute path to cached source root directory
291+
Sum string // checksum for path, version (as in go.sum)
292+
GoModSum string // checksum for go.mod (as in go.sum)
293+
}
294+
295+
// ModuleListJSON is the JSON schema of the output of 'go help list'.
296+
type ModuleListJSON struct {
297+
Path string // module path
298+
Version string // module version
299+
Versions []string // available module versions (with -versions)
300+
Replace *ModuleListJSON // replaced by this module
301+
Time *time.Time // time version was created
302+
Update *ModuleListJSON // available update, if any (with -u)
303+
Main bool // is this the main module?
304+
Indirect bool // is this module only an indirect dependency of main module?
305+
Dir string // directory holding files for this module, if any
306+
GoMod string // path to go.mod file used when loading this module, if any
307+
GoVersion string // go version used in module
308+
Retracted string // retraction information, if any (with -retracted or -u)
309+
Error *ModuleError // error loading module
310+
}
311+
312+
type ModuleError struct {
313+
Err string // the error itself
314+
}
315+
316+
// InfoJSON is the JSON schema of the .info and @latest endpoints.
317+
type InfoJSON struct {
318+
Version string
319+
Time *time.Time
320+
}
321+
322+
// -- helpers --
323+
324+
// suffixed reports whether x has the specified suffix,
325+
// and returns the prefix.
326+
func suffixed(x, suffix string) (rest string, ok bool) {
327+
if y := strings.TrimSuffix(x, suffix); y != x {
328+
return y, true
329+
}
330+
return
331+
}
332+
333+
// See https://github.com/golang/go/issues/46336
334+
func cut(s, sep string) (before, after string, found bool) {
335+
if i := strings.Index(s, sep); i >= 0 {
336+
return s[:i], s[i+len(sep):], true
337+
}
338+
return s, "", false
339+
}
340+
341+
func lastCut(s, sep string) (before, after string, found bool) {
342+
if i := strings.LastIndex(s, sep); i >= 0 {
343+
return s[:i], s[i+len(sep):], true
344+
}
345+
return s, "", false
346+
}
347+
348+
// copyFile writes the content of the named file to dest.
349+
func copyFile(dest io.Writer, name string) error {
350+
f, err := os.Open(name)
351+
if err != nil {
352+
return err
353+
}
354+
defer f.Close()
355+
_, err = io.Copy(dest, f)
356+
return err
357+
}
358+
359+
func isOneOf(s string, items ...string) bool {
360+
for _, item := range items {
361+
if s == item {
362+
return true
363+
}
364+
}
365+
return false
366+
}

0 commit comments

Comments
 (0)