diff --git a/cmd/mirror.go b/cmd/mirror.go index 8f9cf32142..411336156d 100644 --- a/cmd/mirror.go +++ b/cmd/mirror.go @@ -14,15 +14,19 @@ package cmd import ( + "bytes" "encoding/json" "fmt" + "io/ioutil" "os" "path" "path/filepath" "runtime" "sort" + "strings" "time" + cjson "github.com/gibson042/canonicaljson-go" "github.com/pingcap/errors" "github.com/pingcap/tiup/pkg/cluster/spec" "github.com/pingcap/tiup/pkg/environment" @@ -33,6 +37,7 @@ import ( "github.com/pingcap/tiup/pkg/repository/v1manifest" "github.com/pingcap/tiup/pkg/set" "github.com/pingcap/tiup/pkg/utils" + "github.com/pingcap/tiup/server/rotate" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -64,6 +69,7 @@ of components or the repository itself.`, newMirrorSetCmd(), newMirrorModifyCmd(), newMirrorGrantCmd(), + newMirrorRotateCmd(), ) return cmd @@ -71,22 +77,54 @@ of components or the repository itself.`, // the `mirror sign` sub command func newMirrorSignCmd() *cobra.Command { + privPath := "" + timeout := 10 + cmd := &cobra.Command{ - Use: "sign [key-files]", + Use: "sign ", Short: "Add signatures to a manifest file", - Long: "Add signatures to a manifest file, if no key file specified, the ~/.tiup/keys/private.json will be used", + Long: fmt.Sprintf("Add signatures to a manifest file, if no key file specified, the ~/.tiup/keys/%s will be used", localdata.DefaultPrivateKeyName), RunE: func(cmd *cobra.Command, args []string) error { env := environment.GlobalEnv() if len(args) < 1 { return cmd.Help() } - if len(args) == 1 { - return v1manifest.SignManifestFile(args[0], env.Profile().Path(localdata.KeyInfoParentDir, "private.json")) + if privPath == "" { + privPath = env.Profile().Path(localdata.KeyInfoParentDir, localdata.DefaultPrivateKeyName) + } + + if !strings.HasPrefix(args[0], "http") { + return v1manifest.SignManifestFile(args[0], args[1:]...) + } + + client := utils.NewHTTPClient(time.Duration(timeout)*time.Second, nil) + data, err := client.Get(args[0]) + if err != nil { + return err + } + + m := &v1manifest.Manifest{Signed: &v1manifest.Root{}} + if err := cjson.Unmarshal(data, m); err != nil { + return errors.Annotate(err, "unmarshal response") } - return v1manifest.SignManifestFile(args[0], args[1:]...) + m, err = sign(privPath, m.Signed) + if err != nil { + return err + } + data, err = cjson.Marshal(m) + if err != nil { + return errors.Annotate(err, "marshal manifest") + } + + if _, err = client.Post(args[0], bytes.NewBuffer(data)); err != nil { + return err + } + return nil }, } + cmd.Flags().StringVarP(&privPath, "key", "k", "", "Specify the private key path") + cmd.Flags().IntVarP(&timeout, "timeout", "", timeout, "Specify the timeout when access the network") return cmd } @@ -237,10 +275,75 @@ func newMirrorModifyCmd() *cobra.Command { return cmd } +// the `mirror rotate` sub command +func newMirrorRotateCmd() *cobra.Command { + addr := "0.0.0.0:8080" + + cmd := &cobra.Command{ + Use: "rotate", + Short: "Rotate root.json", + Long: "Rotate root.json make it possible to modify root.json", + RunE: func(cmd *cobra.Command, args []string) error { + root, err := editLatestRootManifest() + if err != nil { + return err + } + + manifest, err := rotate.Serve(addr, root) + if err != nil { + return err + } + + return environment.GlobalEnv().V1Repository().Mirror().Rotate(manifest) + }, + } + cmd.Flags().StringVarP(&addr, "addr", "", addr, "listen address:port when starting the temp server for rotating") + + return cmd +} + +func editLatestRootManifest() (*v1manifest.Root, error) { + root, err := environment.GlobalEnv().V1Repository().FetchRootManfiest() + if err != nil { + return nil, err + } + + file, err := ioutil.TempFile(os.TempDir(), "*.root.json") + if err != nil { + return nil, errors.Annotate(err, "create temp file for root.json") + } + defer file.Close() + name := file.Name() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err := encoder.Encode(root); err != nil { + return nil, errors.Annotate(err, "encode root.json") + } + if err := file.Close(); err != nil { + return nil, errors.Annotatef(err, "close %s", name) + } + if err := utils.OpenFileInEditor(name); err != nil { + return nil, err + } + + root = &v1manifest.Root{} + file, err = os.Open(name) + if err != nil { + return nil, errors.Annotatef(err, "open %s", name) + } + defer file.Close() + if err := json.NewDecoder(file).Decode(root); err != nil { + return nil, errors.Annotatef(err, "decode %s", name) + } + + return root, nil +} + func loadPrivKey(privPath string) (*v1manifest.KeyInfo, error) { env := environment.GlobalEnv() if privPath == "" { - privPath = env.Profile().Path(localdata.KeyInfoParentDir, "private.json") + privPath = env.Profile().Path(localdata.KeyInfoParentDir, localdata.DefaultPrivateKeyName) } // Get the private key diff --git a/pkg/localdata/constant.go b/pkg/localdata/constant.go index 508c785da6..c80faa6e81 100644 --- a/pkg/localdata/constant.go +++ b/pkg/localdata/constant.go @@ -33,6 +33,9 @@ const ( // KeyInfoParentDir represent the parent directory of all keys KeyInfoParentDir = "keys" + // DefaultPrivateKeyName represents the default private key file stored in ${TIUP_HOME}/keys + DefaultPrivateKeyName = "private.json" + // DataParentDir represent the parent directory of all running instances DataParentDir = "data" diff --git a/pkg/repository/mirror.go b/pkg/repository/mirror.go index 3b1fdd63a7..3451c1685e 100644 --- a/pkg/repository/mirror.go +++ b/pkg/repository/mirror.go @@ -190,6 +190,21 @@ func (l *localFilesystem) Grant(id, name string, key *v1manifest.KeyInfo) error return nil } +// Rotate implements the model.Backend interface +func (l *localFilesystem) Rotate(m *v1manifest.Manifest) error { + txn, err := store.New(l.rootPath, l.upstream).Begin() + if err != nil { + return err + } + + if err := model.New(txn, l.keys).Rotate(m); err != nil { + _ = txn.Rollback() + return err + } + + return nil +} + // Download implements the Mirror interface func (l *localFilesystem) Download(resource, targetDir string) error { reader, err := l.Fetch(resource, 0) @@ -341,6 +356,39 @@ func (l *httpMirror) Grant(id, name string, key *v1manifest.KeyInfo) error { return errors.Errorf("introduce a fresher via the internet is not allowd, please set you mirror to a local one") } +// Rotate implements the model.Backend interface +func (l *httpMirror) Rotate(m *v1manifest.Manifest) error { + rotateAddr := fmt.Sprintf("%s/api/v1/rotate", l.Source()) + data, err := json.Marshal(m) + if err != nil { + return errors.Annotate(err, "marshal root manfiest") + } + + client := http.Client{Timeout: time.Minute} + resp, err := client.Post(rotateAddr, "text/json", bytes.NewBuffer(data)) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 300 { + return nil + } + switch resp.StatusCode { + case http.StatusConflict: + return errors.Errorf("The manifest has been modified after you fetch it, please try again") + case http.StatusBadRequest: + return errors.Errorf("The server rejected the manifest, please check if it's a valid root manifest") + default: + buf := new(strings.Builder) + if _, err := io.Copy(buf, resp.Body); err != nil { + return err + } + + return fmt.Errorf("Unknow error from server, response code: %d response body: %s", resp.StatusCode, buf.String()) + } +} + // Publish implements the model.Backend interface func (l *httpMirror) Publish(manifest *v1manifest.Manifest, info model.ComponentInfo) error { sid := uuid.New().String() @@ -506,6 +554,11 @@ func (l *MockMirror) Grant(id, name string, key *v1manifest.KeyInfo) error { return nil } +// Rotate implements the model.Backend interface +func (l *MockMirror) Rotate(m *v1manifest.Manifest) error { + return nil +} + // Publish implements the Mirror interface func (l *MockMirror) Publish(manifest *v1manifest.Manifest, info model.ComponentInfo) error { // Mock point for unit test diff --git a/pkg/repository/model/error.go b/pkg/repository/model/error.go index 07fafc4131..8bba7f49a9 100644 --- a/pkg/repository/model/error.go +++ b/pkg/repository/model/error.go @@ -26,4 +26,8 @@ var ( ErrorMissingOwner = errors.New("owner not found") // ErrorWrongSignature indicates that the signature is not correct ErrorWrongSignature = errors.New("the signature is not correct") + // ErrorWrongManifestType indicates that the manifest type is not expected + ErrorWrongManifestType = errors.New("the manifest type is not expected") + // ErrorWrongManifestVersion indicates that the manifest version is not expected + ErrorWrongManifestVersion = errors.New("the manifest version is not expected") ) diff --git a/pkg/repository/model/model.go b/pkg/repository/model/model.go index e594cfdf8a..d133bc12cb 100644 --- a/pkg/repository/model/model.go +++ b/pkg/repository/model/model.go @@ -22,6 +22,7 @@ import ( "github.com/pingcap/tiup/pkg/logger/log" "github.com/pingcap/tiup/pkg/repository/store" "github.com/pingcap/tiup/pkg/repository/v1manifest" + "github.com/pingcap/tiup/pkg/set" "github.com/pingcap/tiup/pkg/utils" ) @@ -31,6 +32,8 @@ type Backend interface { Publish(manifest *v1manifest.Manifest, info ComponentInfo) error // Introduce add a new owner to mirror Grant(id, name string, key *v1manifest.KeyInfo) error + // Rotate update root manifest + Rotate(manifest *v1manifest.Manifest) error } type model struct { @@ -93,7 +96,57 @@ func (m *model) Grant(id, name string, key *v1manifest.KeyInfo) error { if err := m.updateSnapshotManifest(initTime, func(om *v1manifest.Manifest) *v1manifest.Manifest { signed := om.Signed.(*v1manifest.Snapshot) if indexFileVersion != nil { - signed.Meta["/index.json"] = *indexFileVersion + signed.Meta[v1manifest.ManifestURLIndex] = *indexFileVersion + } + return om + }); err != nil { + return err + } + + // Update timestamp.json and signature + if err := m.updateTimestampManifest(initTime); err != nil { + return err + } + + return m.txn.Commit() + }, func(err error) bool { + return err == store.ErrorFsCommitConflict && m.txn.ResetManifest() == nil + }) +} + +// Rotate implements Backend +func (m *model) Rotate(manifest *v1manifest.Manifest) error { + initTime := time.Now() + root, ok := manifest.Signed.(*v1manifest.Root) + if !ok { + return ErrorWrongManifestType + } + + return utils.RetryUntil(func() error { + rm, err := m.readRootManifest() + if err != nil { + return err + } + + if err := verifyRootManifest(rm, manifest); err != nil { + return err + } + + manifestFilename := fmt.Sprintf("%d.root.json", root.Version) + if err := m.txn.WriteManifest(manifestFilename, manifest); err != nil { + return err + } + + fi, err := m.txn.Stat(manifestFilename) + if err != nil { + return err + } + + if err := m.updateSnapshotManifest(initTime, func(om *v1manifest.Manifest) *v1manifest.Manifest { + signed := om.Signed.(*v1manifest.Snapshot) + signed.Meta[v1manifest.ManifestURLRoot] = v1manifest.FileVersion{ + Version: root.Version, + Length: uint(fi.Size()), } return om }); err != nil { @@ -194,7 +247,7 @@ func (m *model) Publish(manifest *v1manifest.Manifest, info ComponentInfo) error manifestVersion := signed.Version signed := om.Signed.(*v1manifest.Snapshot) if indexFileVersion != nil { - signed.Meta["/index.json"] = *indexFileVersion + signed.Meta[v1manifest.ManifestURLIndex] = *indexFileVersion } signed.Meta[fmt.Sprintf("/%s.json", componentName)] = v1manifest.FileVersion{ Version: manifestVersion, @@ -300,9 +353,21 @@ func (m *model) readSnapshotManifest() (*v1manifest.Manifest, error) { return m.txn.ReadManifest(v1manifest.ManifestFilenameSnapshot, &v1manifest.Snapshot{}) } -// readRootManifest returns root.json +// readRootManifest returns the latest root.json func (m *model) readRootManifest() (*v1manifest.Manifest, error) { - return m.txn.ReadManifest(v1manifest.ManifestFilenameRoot, &v1manifest.Root{}) + root, err := m.txn.ReadManifest(v1manifest.ManifestFilenameRoot, &v1manifest.Root{}) + if err != nil { + return root, err + } + + for { + file := fmt.Sprintf("%d.%s", root.Signed.Base().Version+1, root.Signed.Filename()) + last, err := m.txn.ReadManifest(file, &v1manifest.Root{}) + if err != nil { + return root, nil + } + root = last + } } func (m *model) updateTimestampManifest(initTime time.Time) error { @@ -401,3 +466,39 @@ func verifyComponentManifest(owner *v1manifest.Owner, m *v1manifest.Manifest) er return ErrorWrongSignature } + +func verifyRootManifest(oldM *v1manifest.Manifest, newM *v1manifest.Manifest) error { + newRoot := newM.Signed.(*v1manifest.Root) + newKeys := set.NewStringSet() + payload, err := cjson.Marshal(newM.Signed) + if err != nil { + return err + } + for _, s := range newM.Signatures { + id := s.KeyID + k := newRoot.Roles[v1manifest.ManifestTypeRoot].Keys[id] + if err := k.Verify(payload, s.Sig); err == nil { + newKeys.Insert(s.KeyID) + } + } + + oldRoot := oldM.Signed.(*v1manifest.Root) + oldKeys := set.NewStringSet() + for id := range oldRoot.Roles[v1manifest.ManifestTypeRoot].Keys { + oldKeys.Insert(id) + } + + if len(oldKeys.Intersection(newKeys).Slice()) < int(oldRoot.Roles[v1manifest.ManifestTypeRoot].Threshold) { + return errors.Annotatef(ErrorWrongSignature, + "need %d valid signatures, only got %d", + oldRoot.Roles[v1manifest.ManifestTypeRoot].Threshold, + len(oldKeys.Intersection(newKeys).Slice()), + ) + } + + if newRoot.Version != oldRoot.Version+1 { + return errors.Annotatef(ErrorWrongManifestVersion, "expect %d, got %d", oldRoot.Version+1, newRoot.Version) + } + + return nil +} diff --git a/pkg/repository/store/txn.go b/pkg/repository/store/txn.go index d714c32fa3..58c6978455 100644 --- a/pkg/repository/store/txn.go +++ b/pkg/repository/store/txn.go @@ -14,10 +14,10 @@ package store import ( + "bytes" "fmt" "io" "io/ioutil" - "net/http" "os" "path" "time" @@ -25,7 +25,6 @@ import ( cjson "github.com/gibson042/canonicaljson-go" "github.com/pingcap/errors" "github.com/pingcap/tiup/pkg/localdata" - "github.com/pingcap/tiup/pkg/logger/log" "github.com/pingcap/tiup/pkg/repository/v1manifest" "github.com/pingcap/tiup/pkg/utils" ) @@ -154,25 +153,23 @@ func (t *localTxn) ReadManifest(filename string, role v1manifest.ValidManifest) if utils.IsExist(path.Join(t.root, filename)) { filepath = path.Join(t.root, filename) } - var wc io.ReadCloser + var wc io.Reader file, err := os.Open(filepath) switch { case err == nil: wc = file + defer file.Close() case os.IsNotExist(err) && t.store.upstream != "": url := fmt.Sprintf("%s/%s", t.store.upstream, filename) - client := http.Client{Timeout: time.Minute} - resp, err := client.Get(url) + client := utils.NewHTTPClient(time.Minute, nil) + body, err := client.Get(url) if err != nil { - resp.Body.Close() return nil, errors.Annotatef(err, "fetch %s", url) } - wc = resp.Body + wc = bytes.NewBuffer(body) default: - log.Errorf("Error on read manifest: %s, upstream: %s", err.Error(), t.store.upstream) - return nil, errors.Annotate(err, "open file") + return nil, errors.Annotatef(err, "error on read manifest: %s, upstream %s", err.Error(), t.store.upstream) } - defer wc.Close() return v1manifest.ReadNoVerify(wc, role) } diff --git a/pkg/repository/v1_repository.go b/pkg/repository/v1_repository.go index 16830c2f8a..171919987f 100644 --- a/pkg/repository/v1_repository.go +++ b/pkg/repository/v1_repository.go @@ -348,8 +348,8 @@ func (r *V1Repository) updateLocalRoot() error { keyStore := *r.local.KeyStore() var newManifest *v1manifest.Manifest - var newRoot v1manifest.Root for { + var newRoot v1manifest.Root url := FnameWithVersion(v1manifest.ManifestURLRoot, oldRoot.Version+1) nextManifest, err := r.fetchManifestWithKeyStore(url, &newRoot, maxRootSize, &keyStore) if err != nil { @@ -665,6 +665,26 @@ func (r *V1Repository) loadRoot() (*v1manifest.Root, error) { return root, nil } +// FetchRootManfiest fetch the root manifest. +func (r *V1Repository) FetchRootManfiest() (root *v1manifest.Root, err error) { + err = r.ensureManifests() + if err != nil { + return nil, err + } + + root = new(v1manifest.Root) + _, exists, err := r.local.LoadManifest(root) + if err != nil { + return nil, err + } + + if !exists { + return nil, errors.Errorf("no root manifest") + } + + return root, nil +} + // FetchIndexManifest fetch the index manifest. func (r *V1Repository) FetchIndexManifest() (index *v1manifest.Index, err error) { err = r.ensureManifests() diff --git a/server/handler/rotate.go b/server/handler/rotate.go new file mode 100644 index 0000000000..6917ede00a --- /dev/null +++ b/server/handler/rotate.go @@ -0,0 +1,61 @@ +// Copyright 2020 PingCAP, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// See the License for the specific language governing permissions and +// limitations under the License. + +package handler + +import ( + "encoding/json" + "net/http" + + "github.com/pingcap/fn" + "github.com/pingcap/tiup/pkg/logger/log" + "github.com/pingcap/tiup/pkg/repository" + "github.com/pingcap/tiup/pkg/repository/model" + "github.com/pingcap/tiup/pkg/repository/v1manifest" +) + +// RotateRoot handles requests to re-sign root manifest +func RotateRoot(mirror repository.Mirror) http.Handler { + return &rootSigner{mirror} +} + +type rootSigner struct { + mirror repository.Mirror +} + +func (h *rootSigner) ServeHTTP(w http.ResponseWriter, r *http.Request) { + fn.Wrap(h.sign).ServeHTTP(w, r) +} + +func (h *rootSigner) sign(m *v1manifest.RawManifest) (sr *simpleResponse, err statusError) { + root := v1manifest.Root{} + if err := json.Unmarshal(m.Signed, &root); err != nil { + log.Errorf("Unmarshal manifest %s", err.Error()) + return nil, ErrorInvalidManifest + } + + manifest := &v1manifest.Manifest{ + Signatures: m.Signatures, + Signed: &root, + } + + switch err := h.mirror.Rotate(manifest); err { + case model.ErrorConflict: + return nil, ErrorManifestConflict + case nil: + return nil, nil + default: + log.Errorf("Rotate root manifest: %s", err.Error()) + return nil, ErrorInternalError + } +} diff --git a/server/rotate/rotate_server.go b/server/rotate/rotate_server.go new file mode 100644 index 0000000000..640a3b80d7 --- /dev/null +++ b/server/rotate/rotate_server.go @@ -0,0 +1,130 @@ +package rotate + +import ( + "context" + "fmt" + "net" + "net/http" + "strings" + + cjson "github.com/gibson042/canonicaljson-go" + "github.com/gorilla/mux" + "github.com/pingcap/errors" + "github.com/pingcap/fn" + "github.com/pingcap/tiup/pkg/cliutil/progress" + "github.com/pingcap/tiup/pkg/logger/log" + "github.com/pingcap/tiup/pkg/repository/v1manifest" +) + +type statusRender struct { + mbar *progress.MultiBar + bars map[string]*progress.MultiBarItem +} + +func newStatusRender(manifest *v1manifest.Manifest, addr string) *statusRender { + ss := strings.Split(addr, ":") + if strings.Trim(ss[0], " ") == "" || strings.Trim(ss[0], " ") == "0.0.0.0" { + addrs, _ := net.InterfaceAddrs() + for _, addr := range addrs { + if ip, ok := addr.(*net.IPNet); ok && !ip.IP.IsLoopback() && ip.IP.To4() != nil { + ss[0] = ip.IP.To4().String() + break + } + } + } + + status := &statusRender{ + mbar: progress.NewMultiBar(fmt.Sprintf("Waiting all administrators to sign http://%s/rotate/root.json", strings.Join(ss, ":"))), + bars: make(map[string]*progress.MultiBarItem), + } + root := manifest.Signed.(*v1manifest.Root) + for key := range root.Roles[v1manifest.ManifestTypeRoot].Keys { + status.bars[key] = status.mbar.AddBar(fmt.Sprintf(" - Waiting key %s", key)) + } + status.mbar.StartRenderLoop() + return status +} + +func (s *statusRender) render(manifest *v1manifest.Manifest) { + for _, sig := range manifest.Signatures { + s.bars[sig.KeyID].UpdateDisplay(&progress.DisplayProps{ + Prefix: fmt.Sprintf(" - Waiting key %s", sig.KeyID), + Mode: progress.ModeDone, + }) + } +} + +func (s *statusRender) stop() { + s.mbar.StopRenderLoop() +} + +// Serve starts a temp server for receiving signatures from administrators +func Serve(addr string, root *v1manifest.Root) (*v1manifest.Manifest, error) { + r := mux.NewRouter() + + r.Handle("/rotate/root.json", fn.Wrap(func() (*v1manifest.Manifest, error) { + return &v1manifest.Manifest{Signed: root}, nil + })).Methods("GET") + + sigCh := make(chan v1manifest.Signature) + r.Handle("/rotate/root.json", fn.Wrap(func(m *v1manifest.RawManifest) (*v1manifest.Manifest /* always nil */, error) { + for _, sig := range m.Signatures { + if err := verifySig(sig, root); err != nil { + return nil, err + } + sigCh <- sig + } + return nil, nil + })).Methods("POST") + + srv := &http.Server{Addr: addr, Handler: r} + go func() { + if err := srv.ListenAndServe(); err != nil { + log.Errorf("server closed: %s", err.Error()) + } + close(sigCh) + }() + + manifest := &v1manifest.Manifest{Signed: root} + status := newStatusRender(manifest, addr) + defer status.stop() + +SIGLOOP: + for sig := range sigCh { + for _, s := range manifest.Signatures { + if s.KeyID == sig.KeyID { + // Duplicate signature + continue SIGLOOP + } + } + manifest.Signatures = append(manifest.Signatures, sig) + status.render(manifest) + if len(manifest.Signatures) == len(root.Roles[v1manifest.ManifestTypeRoot].Keys) { + _ = srv.Shutdown(context.Background()) + break + } + } + + if len(manifest.Signatures) != len(root.Roles[v1manifest.ManifestTypeRoot].Keys) { + return nil, errors.New("no enough signature collected before server shutdown") + } + return manifest, nil +} + +func verifySig(sig v1manifest.Signature, root *v1manifest.Root) error { + payload, err := cjson.Marshal(root) + if err != nil { + return fn.ErrorWithStatusCode(errors.Annotate(err, "marshal root manifest"), http.StatusInternalServerError) + } + + k := root.Roles[v1manifest.ManifestTypeRoot].Keys[sig.KeyID] + if k == nil { + // Received a signature signed by an invalid key + return fn.ErrorWithStatusCode(errors.New("the key is not valid"), http.StatusNotAcceptable) + } + if err := k.Verify(payload, sig.Sig); err != nil { + // Received an invalid signature + return fn.ErrorWithStatusCode(errors.New("the signature is not valid"), http.StatusNotAcceptable) + } + return nil +} diff --git a/server/router.go b/server/router.go index 393b7b5af4..f00c0398c5 100644 --- a/server/router.go +++ b/server/router.go @@ -48,6 +48,7 @@ func (s *server) router() http.Handler { r.Handle("/api/v1/tarball/{sid}", handler.UploadTarbal(s.sm)) r.Handle("/api/v1/component/{sid}/{name}", handler.SignComponent(s.sm, s.mirror)) + r.Handle("/api/v1/rotate", handler.RotateRoot(s.mirror)) r.PathPrefix("/").Handler(s.static("/", s.mirror.Source(), s.upstream)) return httpRequestMiddleware(r)