]> Cypherpunks.ru repositories - gocheese.git/blob - gocheese.go
URL fixes
[gocheese.git] / gocheese.go
1 /*
2 GoCheese -- Python private package repository and caching proxy
3 Copyright (C) 2019-2020 Sergey Matveev <stargrave@stargrave.org>
4               2019-2020 Elena Balakhonova <balakhonova_e@riseup.net>
5
6 This program is free software: you can redistribute it and/or modify
7 it under the terms of the GNU General Public License as published by
8 the Free Software Foundation, version 3 of the License.
9
10 This program is distributed in the hope that it will be useful,
11 but WITHOUT ANY WARRANTY; without even the implied warranty of
12 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 GNU General Public License for more details.
14
15 You should have received a copy of the GNU General Public License
16 along with this program.  If not, see <http://www.gnu.org/licenses/>.
17 */
18
19 // Python private package repository and caching proxy
20 package main
21
22 import (
23         "bytes"
24         "context"
25         "encoding/hex"
26         "flag"
27         "fmt"
28         "io/ioutil"
29         "log"
30         "net"
31         "net/http"
32         "net/url"
33         "os"
34         "os/signal"
35         "path/filepath"
36         "regexp"
37         "runtime"
38         "strings"
39         "syscall"
40         "time"
41
42         "golang.org/x/net/netutil"
43 )
44
45 const (
46         HTMLBegin = `<!DOCTYPE html>
47 <html>
48   <head>
49     <title>Links for %s</title>
50   </head>
51   <body>
52 `
53         HTMLEnd      = "  </body>\n</html>\n"
54         HTMLElement  = "    <a href=\"%s\"%s>%s</a><br/>\n"
55         InternalFlag = ".internal"
56         GPGSigExt    = ".asc"
57
58         Warranty = `This program is free software: you can redistribute it and/or modify
59 it under the terms of the GNU General Public License as published by
60 the Free Software Foundation, version 3 of the License.
61
62 This program is distributed in the hope that it will be useful,
63 but WITHOUT ANY WARRANTY; without even the implied warranty of
64 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
65 GNU General Public License for more details.
66
67 You should have received a copy of the GNU General Public License
68 along with this program.  If not, see <http://www.gnu.org/licenses/>.`
69 )
70
71 var (
72         pkgPyPI         = regexp.MustCompile(`^.*<a href="([^"]+)"[^>]*>(.+)</a><br/>.*$`)
73         normalizationRe = regexp.MustCompilePOSIX("[-_.]+")
74
75         HashAlgoSHA256              = "sha256"
76         HashAlgoBLAKE2b256          = "blake2_256"
77         HashAlgoSHA512              = "sha512"
78         HashAlgoMD5                 = "md5"
79         knownHashAlgos     []string = []string{
80                 HashAlgoSHA256,
81                 HashAlgoBLAKE2b256,
82                 HashAlgoSHA512,
83                 HashAlgoMD5,
84         }
85
86         root             = flag.String("root", "./packages", "Path to packages directory")
87         bind             = flag.String("bind", "[::]:8080", "Address to bind to")
88         tlsCert          = flag.String("tls-cert", "", "Path to TLS X.509 certificate")
89         tlsKey           = flag.String("tls-key", "", "Path to TLS X.509 private key")
90         norefreshURLPath = flag.String("norefresh", "/norefresh/", "Non-refreshing URL path")
91         refreshURLPath   = flag.String("refresh", "/simple/", "Auto-refreshing URL path")
92         gpgUpdateURLPath = flag.String("gpgupdate", "/gpgupdate/", "GPG forceful refreshing URL path")
93         pypiURL          = flag.String("pypi", "https://pypi.org/simple/", "Upstream PyPI URL")
94         passwdPath       = flag.String("passwd", "passwd", "Path to file with authenticators")
95         passwdCheck      = flag.Bool("passwd-check", false, "Test the -passwd file for syntax errors and exit")
96         fsck             = flag.Bool("fsck", false, "Check integrity of all packages (errors are in stderr)")
97         maxClients       = flag.Int("maxclients", 128, "Maximal amount of simultaneous clients")
98         version          = flag.Bool("version", false, "Print version information")
99         warranty         = flag.Bool("warranty", false, "Print warranty information")
100
101         Version       string = "UNKNOWN"
102         killed        bool
103         pypiURLParsed *url.URL
104 )
105
106 func mkdirForPkg(w http.ResponseWriter, r *http.Request, pkgName string) bool {
107         path := filepath.Join(*root, pkgName)
108         if _, err := os.Stat(path); os.IsNotExist(err) {
109                 if err = os.Mkdir(path, os.FileMode(0777)); err != nil {
110                         log.Println("error", r.RemoteAddr, "mkdir", pkgName, err)
111                         http.Error(w, err.Error(), http.StatusInternalServerError)
112                         return false
113                 }
114                 log.Println(r.RemoteAddr, "mkdir", pkgName)
115         }
116         return true
117 }
118
119 func listRoot(w http.ResponseWriter, r *http.Request) {
120         files, err := ioutil.ReadDir(*root)
121         if err != nil {
122                 log.Println("error", r.RemoteAddr, "root", err)
123                 http.Error(w, err.Error(), http.StatusInternalServerError)
124                 return
125         }
126         var result bytes.Buffer
127         result.WriteString(fmt.Sprintf(HTMLBegin, "root"))
128         for _, file := range files {
129                 if file.Mode().IsDir() {
130                         result.WriteString(fmt.Sprintf(
131                                 HTMLElement,
132                                 *refreshURLPath+file.Name()+"/",
133                                 "", file.Name(),
134                         ))
135                 }
136         }
137         result.WriteString(HTMLEnd)
138         w.Write(result.Bytes())
139 }
140
141 func listDir(
142         w http.ResponseWriter,
143         r *http.Request,
144         pkgName string,
145         autorefresh, gpgUpdate bool,
146 ) {
147         dirPath := filepath.Join(*root, pkgName)
148         if autorefresh {
149                 if !refreshDir(w, r, pkgName, "", gpgUpdate) {
150                         return
151                 }
152         } else if _, err := os.Stat(dirPath); os.IsNotExist(err) && !refreshDir(w, r, pkgName, "", false) {
153                 return
154         }
155         fis, err := ioutil.ReadDir(dirPath)
156         if err != nil {
157                 log.Println("error", r.RemoteAddr, "list", pkgName, err)
158                 http.Error(w, err.Error(), http.StatusInternalServerError)
159                 return
160         }
161         files := make(map[string]struct{}, len(fis)/2)
162         for _, fi := range fis {
163                 files[fi.Name()] = struct{}{}
164         }
165         var result bytes.Buffer
166         result.WriteString(fmt.Sprintf(HTMLBegin, pkgName))
167         for _, algo := range knownHashAlgos {
168                 for fn, _ := range files {
169                         if killed {
170                                 // Skip expensive I/O when shutting down
171                                 http.Error(w, "shutting down", http.StatusInternalServerError)
172                                 return
173                         }
174                         if !strings.HasSuffix(fn, "."+algo) {
175                                 continue
176                         }
177                         delete(files, fn)
178                         digest, err := ioutil.ReadFile(filepath.Join(dirPath, fn))
179                         if err != nil {
180                                 log.Println("error", r.RemoteAddr, "list", fn, err)
181                                 http.Error(w, err.Error(), http.StatusInternalServerError)
182                                 return
183                         }
184                         fnClean := strings.TrimSuffix(fn, "."+algo)
185                         delete(files, fnClean)
186                         gpgSigAttr := ""
187                         if _, err = os.Stat(filepath.Join(dirPath, fnClean+GPGSigExt)); err == nil {
188                                 gpgSigAttr = " data-gpg-sig=true"
189                                 delete(files, fnClean+GPGSigExt)
190                         }
191                         result.WriteString(fmt.Sprintf(
192                                 HTMLElement,
193                                 strings.Join([]string{
194                                         *refreshURLPath, pkgName, "/", fnClean,
195                                         "#", algo, "=", hex.EncodeToString(digest),
196                                 }, ""),
197                                 gpgSigAttr,
198                                 fnClean,
199                         ))
200                 }
201         }
202         result.WriteString(HTMLEnd)
203         w.Write(result.Bytes())
204 }
205
206 func servePkg(w http.ResponseWriter, r *http.Request, pkgName, filename string) {
207         log.Println(r.RemoteAddr, "get", filename)
208         path := filepath.Join(*root, pkgName, filename)
209         if _, err := os.Stat(path); os.IsNotExist(err) {
210                 if !refreshDir(w, r, pkgName, filename, false) {
211                         return
212                 }
213         }
214         http.ServeFile(w, r, path)
215 }
216
217 func handler(w http.ResponseWriter, r *http.Request) {
218         switch r.Method {
219         case "GET":
220                 var path string
221                 var autorefresh bool
222                 var gpgUpdate bool
223                 if strings.HasPrefix(r.URL.Path, *norefreshURLPath) {
224                         path = strings.TrimPrefix(r.URL.Path, *norefreshURLPath)
225                 } else if strings.HasPrefix(r.URL.Path, *refreshURLPath) {
226                         path = strings.TrimPrefix(r.URL.Path, *refreshURLPath)
227                         autorefresh = true
228                 } else if strings.HasPrefix(r.URL.Path, *gpgUpdateURLPath) {
229                         path = strings.TrimPrefix(r.URL.Path, *gpgUpdateURLPath)
230                         autorefresh = true
231                         gpgUpdate = true
232                 } else {
233                         http.Error(w, "unknown action", http.StatusBadRequest)
234                         return
235                 }
236                 parts := strings.Split(strings.TrimSuffix(path, "/"), "/")
237                 if len(parts) > 2 {
238                         http.Error(w, "invalid path", http.StatusBadRequest)
239                         return
240                 }
241                 if len(parts) == 1 {
242                         if parts[0] == "" {
243                                 listRoot(w, r)
244                         } else {
245                                 listDir(w, r, parts[0], autorefresh, gpgUpdate)
246                         }
247                 } else {
248                         servePkg(w, r, parts[0], parts[1])
249                 }
250         case "POST":
251                 serveUpload(w, r)
252         default:
253                 http.Error(w, "unknown action", http.StatusBadRequest)
254         }
255 }
256
257 func main() {
258         flag.Parse()
259         if *warranty {
260                 fmt.Println(Warranty)
261                 return
262         }
263         if *version {
264                 fmt.Println("GoCheese version " + Version + " built with " + runtime.Version())
265                 return
266         }
267         if *fsck {
268                 if !goodIntegrity() {
269                         os.Exit(1)
270                 }
271                 return
272         }
273         if *passwdCheck {
274                 refreshPasswd()
275                 return
276         }
277         if (*tlsCert != "" && *tlsKey == "") || (*tlsCert == "" && *tlsKey != "") {
278                 log.Fatalln("Both -tls-cert and -tls-key are required")
279         }
280         var err error
281         pypiURLParsed, err = url.Parse(*pypiURL)
282         if err != nil {
283                 log.Fatalln(err)
284         }
285         refreshPasswd()
286         log.Println("root:", *root)
287         log.Println("bind:", *bind)
288         log.Println("pypi:", *pypiURL)
289
290         ln, err := net.Listen("tcp", *bind)
291         if err != nil {
292                 log.Fatal(err)
293         }
294         ln = netutil.LimitListener(ln, *maxClients)
295         server := &http.Server{
296                 ReadTimeout:  time.Minute,
297                 WriteTimeout: time.Minute,
298         }
299         http.HandleFunc(*norefreshURLPath, handler)
300         http.HandleFunc(*refreshURLPath, handler)
301         if *gpgUpdateURLPath != "" {
302                 http.HandleFunc(*gpgUpdateURLPath, handler)
303         }
304
305         needsRefreshPasswd := make(chan os.Signal, 0)
306         needsShutdown := make(chan os.Signal, 0)
307         exitErr := make(chan error, 0)
308         signal.Notify(needsRefreshPasswd, syscall.SIGHUP)
309         signal.Notify(needsShutdown, syscall.SIGTERM, syscall.SIGINT)
310         go func() {
311                 for range needsRefreshPasswd {
312                         log.Println("refreshing passwords")
313                         refreshPasswd()
314                 }
315         }()
316         go func(s *http.Server) {
317                 <-needsShutdown
318                 killed = true
319                 log.Println("shutting down")
320                 ctx, cancel := context.WithTimeout(context.TODO(), time.Minute)
321                 exitErr <- s.Shutdown(ctx)
322                 cancel()
323         }(server)
324
325         if *tlsCert == "" {
326                 err = server.Serve(ln)
327         } else {
328                 err = server.ServeTLS(ln, *tlsCert, *tlsKey)
329         }
330         if err != http.ErrServerClosed {
331                 log.Fatal(err)
332         }
333         if err := <-exitErr; err != nil {
334                 log.Fatal(err)
335         }
336 }