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