diff options
| -rw-r--r-- | Dockerfile | 29 | ||||
| -rw-r--r-- | cmd/server/main.go | 163 | ||||
| -rw-r--r-- | cmd/userkey/main.go | 40 | ||||
| -rw-r--r-- | go.mod | 10 | ||||
| -rw-r--r-- | go.sum | 5 |
5 files changed, 243 insertions, 4 deletions
diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..069309c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM marketplace.gcr.io/google/debian11 AS build + +ENV BUILD_DEPS 'curl git gcc patch libc6-dev ca-certificates build-essential pkg-config libmagickwand-dev libmagickwand-6-headers' +RUN apt-get update && apt-get install -y ${BUILD_DEPS} --no-install-recommends + +ENV GOPATH=/go +ENV GOROOT_BOOTSTRAP=/usr/local/go-bootstrap +RUN curl -sSL https://dl.google.com/go/go1.17.2.linux-amd64.tar.gz -o /tmp/go.tar.gz +RUN mkdir -p $GOROOT_BOOTSTRAP +RUN tar --strip=1 -C $GOROOT_BOOTSTRAP -xzf /tmp/go.tar.gz +RUN $GOROOT_BOOTSTRAP/bin/go install golang.org/dl/gotip@latest +#RUN /go/bin/gotip download + +RUN mkdir -p /workdir +WORKDIR /workdir + +COPY go.mod /workdir +COPY go.sum /workdir +RUN $GOROOT_BOOTSTRAP/bin/go mod download + +COPY . /workdir +RUN $GOROOT_BOOTSTRAP/bin/go build ./cmd/server + +ENV PORT=8080 + +RUN mkdir -p /app +RUN mv /workdir/server /app + +ENTRYPOINT /app/server diff --git a/cmd/server/main.go b/cmd/server/main.go index 4c8238c..35fbab2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,19 +2,34 @@ package main import ( "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" "errors" + "fmt" "io" "io/fs" + "io/ioutil" "log" + rand2 "math/rand" "mime" "net" "net/http" + "net/url" "os" "path" "strings" + "sync" + "time" + "cloud.google.com/go/compute/metadata" + "cloud.google.com/go/secretmanager/apiv1" "cloud.google.com/go/storage" "git.toothrot.net/i.dis.band/internal" + secretmanager2 "google.golang.org/genproto/googleapis/cloud/secretmanager/v1" + "gopkg.in/gographics/imagick.v2/imagick" ) func main() { @@ -31,11 +46,153 @@ func main() { log.Fatalf("storage.NewClient() = _, %v", err) } b := cl.Bucket("i-dis-band-east4") + http.Handle("/upload", upload(b)) http.Handle("/", fileServerHandler(internal.Static, image(b, http.HandlerFunc(home)))) log.Printf("Listening on %s\n", net.JoinHostPort(host, port)) log.Fatal(http.ListenAndServe(net.JoinHostPort(host, port), nil)) } +var sk []byte +var skOnce sync.Once + +func secretKey() []byte { + skOnce.Do(func() { + project, err := metadata.NumericProjectID() + if err != nil { + log.Printf("metadata.NumericProjectID() = %v, %v. Not on GCP?", project, err) + return + } + ctx := context.Background() + client, err := secretmanager.NewClient(ctx) + if err != nil { + log.Printf("secretmanager.NewClient() = _, %v", err) + return + } + defer client.Close() + name := fmt.Sprintf("projects/%s/secrets/i-dis-band-sk/versions/latest", project) + resp, err := client.AccessSecretVersion(ctx, &secretmanager2.AccessSecretVersionRequest{Name: name}) + if err != nil { + log.Printf("client.AccessSecretVersion(%q) = %v", name, err) + } + encoded := resp.GetPayload().GetData() + sk, err = hex.DecodeString(string(encoded)) + if err != nil { + log.Printf("hex.DecodeString() = %d, %v", len(sk), err) + sk = sk[:0] + return + } + }) + if len(sk) < 32 { + skOnce = sync.Once{} + } + return sk +} + +// ValidMAC reports whether messageMAC is a valid HMAC tag for message. +func ValidMAC(message, messageMAC, key []byte) bool { + mac := hmac.New(sha256.New, key) + mac.Write(message) + expectedMAC := mac.Sum(nil) + return hmac.Equal(messageMAC, expectedMAC) +} + +const ( + Byte = 1 << (10*iota) + KiB + MiB + GiB +) + +const maxUpload = 100*MiB + +func upload(b *storage.BucketHandle) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + secret := secretKey() + if len(secret) == 0 { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + user, key, ok := r.BasicAuth() + if !ok { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + decoded, err := hex.DecodeString(key) + if err != nil { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + if !ValidMAC([]byte(user), decoded, secret) { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + file, head, err := r.FormFile("file") + if err != nil { + log.Printf("r.FormFile(%q) = _, %v", "file", err) + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + if head.Size > maxUpload { + log.Printf("head.Size = %d, maxUpload = %d", head.Size, maxUpload) + http.Error(w, http.StatusText(http.StatusPreconditionFailed), http.StatusPreconditionFailed) + return + } + img, err := ioutil.ReadAll(file) + if err != nil { + log.Printf("ioutil.ReadAll(file) = _, %v", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + wand := imagick.NewMagickWand() + defer wand.Destroy() + if err := wand.ReadImageBlob(img); err != nil { + log.Printf("wand.ReadImageBlob(img) = %v", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + for _, prop := range wand.GetImageProperties("") { + wand.DeleteImageProperty(prop) + } + ct := head.Header.Get("content-type") + ext, err := mime.ExtensionsByType(ct) + if err != nil { + ext = []string{path.Ext(head.Filename)} + } + outname := fmt.Sprintf("%s%s", filename(), ext[len(ext)-1]) + log.Printf("trying to write %q", outname) + fw := b.Object(outname).NewWriter(r.Context()) + fw.ContentType = ct + n, err := fw.Write(wand.GetImageBlob()) + if err != nil || n == 0{ + log.Printf("fw.Write(wand.GetImageBlob()) = %d, %v", n, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if err := fw.Close(); err != nil { + log.Printf("fw.Close() = %v", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + u := url.URL{Scheme: "https", Host: "i.dis.band", Path: outname} + fmt.Fprintln(w, u.String()) + }) +} + +func filename() string { + b := make([]byte, 8) + n, err := rand.Read(b) + if err != nil || n != len(b) { + b = make([]byte, 8) + r := rand2.New(rand2.NewSource(time.Now().UnixNano())) + r.Read(b) + } + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(b) +} + func image(b *storage.BucketHandle, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { @@ -52,7 +209,11 @@ func image(b *storage.BucketHandle, next http.Handler) http.Handler { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(r.URL.Path))) + ct := o.Attrs.ContentType + if ct == "" || ct == "text/plain" { + ct = mime.TypeByExtension(path.Ext(r.URL.Path)) + } + w.Header().Set("Content-Type", ct) w.Header().Set("Cache-Control", "no-cache, private, max-age=0") io.Copy(w, o) }) diff --git a/cmd/userkey/main.go b/cmd/userkey/main.go new file mode 100644 index 0000000..5ecd5f1 --- /dev/null +++ b/cmd/userkey/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "io/ioutil" + "log" + "os" +) + +func main() { + user := os.Args[1] + if len(user) < 3 { + log.Fatalf("User name argument expected.") + } + encoded, err := ioutil.ReadAll(os.Stdin) + if err != nil || len(encoded) == 0 { + log.Fatalf("Expected exactly 1 secret") + } + sk, err := hex.DecodeString(string(encoded)) + if err != nil || len(sk) < 32 { + log.Fatalf("hex.DecodeString() = %d, %v", len(sk), err) + return + } + mac := hmac.New(sha256.New, sk) + mac.Write([]byte(user)) + fmt.Println(hex.EncodeToString(mac.Sum(nil))) +} + +// ValidMAC reports whether messageMAC is a valid HMAC tag for message. +func ValidMAC(message, messageMAC, key []byte) bool { + mac := hmac.New(sha256.New, key) + mac.Write(message) + expectedMAC := mac.Sum(nil) + return hmac.Equal(messageMAC, expectedMAC) +} + +var sk []byte @@ -2,10 +2,15 @@ module git.toothrot.net/i.dis.band go 1.17 -require cloud.google.com/go/storage v1.18.2 +require ( + cloud.google.com/go v0.97.0 + cloud.google.com/go/secretmanager v1.0.0 + cloud.google.com/go/storage v1.18.2 + google.golang.org/genproto v0.0.0-20211016002631-37fc39342514 + gopkg.in/gographics/imagick.v2 v2.6.0 +) require ( - cloud.google.com/go v0.97.0 // indirect github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/go-cmp v0.5.6 // indirect @@ -18,7 +23,6 @@ require ( golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/api v0.58.0 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20211016002631-37fc39342514 // indirect google.golang.org/grpc v1.40.0 // indirect google.golang.org/protobuf v1.27.1 // indirect ) @@ -38,6 +38,8 @@ cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2k cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/secretmanager v1.0.0 h1:Wbw6lsRrpatsE8GVpuwYqImn+sY5DmRjaEImYPwcSMY= +cloud.google.com/go/secretmanager v1.0.0/go.mod h1:+Qkm5qxIJ5mk74xxIXA+87fseaY1JLYBcFPQoc/GQxg= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= @@ -497,6 +499,7 @@ google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEc google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210921142501-181ce0d877f6/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211016002631-37fc39342514 h1:Rp1vYDPD4TdkMH5S/bZbopsGCsWhPcrLBUwOVhAQCxM= google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= @@ -544,6 +547,8 @@ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/gographics/imagick.v2 v2.6.0 h1:ewRsUQk3QkjGumERlndbFn/kTYRjyMaPY5gxwpuAhik= +gopkg.in/gographics/imagick.v2 v2.6.0/go.mod h1:/QVPLV/iKdNttRKthmDkeeGg+vdHurVEPc8zkU0XgBk= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
