From a549bfae66f6bd752306aae774bb335e9f5d8786 Mon Sep 17 00:00:00 2001 From: ayyghost Date: Tue, 19 Dec 2023 16:43:03 +0000 Subject: [PATCH] first commit --- .gitignore | 2 + go.mod | 14 +++ go.sum | 12 +++ updater.go | 308 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 336 insertions(+) create mode 100644 .gitignore create mode 100644 go.mod create mode 100644 go.sum create mode 100644 updater.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01a3850 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +config.json +updater diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1cc3f84 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/cryptodog/updater + +go 1.20 + +require ( + github.com/google/go-github/v57 v57.0.0 + github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 +) + +require ( + github.com/google/go-querystring v1.1.0 // indirect + golang.org/x/crypto v0.12.0 // indirect + golang.org/x/sys v0.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..95e9f35 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-github/v57 v57.0.0 h1:L+Y3UPTY8ALM8x+TV0lg+IEBI+upibemtBD8Q9u7zHs= +github.com/google/go-github/v57 v57.0.0/go.mod h1:s0omdnye0hvK/ecLvpsGfJMiRt85PimQh4oygmLIxHw= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 h1:TMtDYDHKYY15rFihtRfck/bfFqNfvcabqvXAFQfAUpY= +github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267/go.mod h1:h1nSAbGFqGVzn6Jyl1R/iCcBUHN4g+gW1u9CoBTrb9E= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/updater.go b/updater.go new file mode 100644 index 0000000..bc2a290 --- /dev/null +++ b/updater.go @@ -0,0 +1,308 @@ +//go:debug tarinsecurepath=0 + +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/google/go-github/v57/github" + "github.com/jedisct1/go-minisign" +) + +type Target struct { + Name string + Owner string + Repo string +} + +type Config struct { + MetadataDir string `json:"metadata_dir"` + DeployDir string `json:"deploy_dir"` + Targets []*Target `json:"targets"` + PublicSigningKey string `json:"public_signing_key"` + UnsafeSkipSignatureVerification bool `json:"unsafe_skip_signature_verification"` + UpdateInterval int `json:"update_interval"` +} + +func main() { + configFile := flag.String("config", "config.json", "path to config file") + flag.Parse() + + b, err := os.ReadFile(*configFile) + if err != nil { + log.Fatal(err) + } + + config := Config{} + err = json.Unmarshal(b, &config) + if err != nil { + log.Fatal(err) + } + + err = validateConfig(&config) + if err != nil { + log.Fatal(err) + } + + err = os.MkdirAll(config.MetadataDir, 0750) + if err != nil { + log.Fatal(err) + } + + githubAPIToken, ok := os.LookupEnv("GITHUB_API_TOKEN") + if !ok { + log.Fatal("GITHUB_API_TOKEN environment variable must be set") + } + client := github.NewClient(nil).WithAuthToken(githubAPIToken) + + for { + for _, target := range config.Targets { + log.Printf("%s: checking for update...", target.Name) + + ctx := context.Background() + release, _, err := client.Repositories.GetLatestRelease(ctx, target.Owner, target.Repo) + if err != nil { + log.Printf("%s: %v", target.Name, err) + continue + } + + releaseID := strconv.FormatInt(*release.ID, 10) + lastReleaseFile := filepath.Join(config.MetadataDir, fmt.Sprintf("%v_last_release_id", target.Name)) + missingLastRelease := false + lastReleaseID, err := os.ReadFile(lastReleaseFile) + if err != nil { + if os.IsNotExist(err) { + missingLastRelease = true + } else { + log.Fatal(err) + } + } + if !missingLastRelease && string(lastReleaseID) == releaseID { + log.Printf("%s: already at latest release", target.Name) + continue + } + + log.Printf("%s: update found", target.Name) + tarGzBytes, sigBytes, err := downloadReleaseAssets(target, release) + if err != nil { + log.Printf("%s: update failed: %v", target.Name, err) + continue + } + + if !config.UnsafeSkipSignatureVerification { + ok, err := verifySignature(config.PublicSigningKey, tarGzBytes, sigBytes) + if !ok { + log.Printf("%s: update failed: %v", target.Name, err) + continue + } + } else { + log.Printf("%s: skipping signature verification!", target.Name) + } + + err = deploy(config.DeployDir, target.Name, releaseID, tarGzBytes, lastReleaseFile, string(lastReleaseID)) + if err != nil { + log.Printf("%s update failed: %v", target.Name, err) + continue + } + log.Printf("%s: update successful", target.Name) + } + time.Sleep(time.Duration(config.UpdateInterval) * time.Second) + } +} + +func validateConfig(config *Config) error { + if config.MetadataDir == "" { + return fmt.Errorf("metadata directory must be set") + } + if config.DeployDir == "" { + return fmt.Errorf("deploy directory must be set") + } + if config.UpdateInterval <= 0 { + return fmt.Errorf("update interval must be >0") + } + if !config.UnsafeSkipSignatureVerification && config.PublicSigningKey == "" { + return fmt.Errorf("public signing key must be set if signature verification is enabled") + } + if len(config.Targets) == 0 { + return fmt.Errorf("at least one target must be set") + } + return nil +} + +func downloadReleaseAssets(target *Target, release *github.RepositoryRelease) (tarGzBytes, sigBytes []byte, err error) { + if len(release.Assets) < 2 { + err = fmt.Errorf("release needs at least 2 assets (have %v)", len(release.Assets)) + return + } + + const tarGzRegexFmt = `^%s-[\w.]+\.tar\.gz$` + const sigRegexFmt = `^%s-[\w.]+\.minisig$` + tarGzRegex := regexp.MustCompile(fmt.Sprintf(tarGzRegexFmt, target.Name)) + sigRegex := regexp.MustCompile(fmt.Sprintf(sigRegexFmt, target.Name)) + + if !(tarGzRegex.MatchString(*release.Assets[0].Name)) { + err = fmt.Errorf("first asset doesn't have expected name (%v)", *release.Assets[0].Name) + return + } + if !(sigRegex.MatchString(*release.Assets[1].Name)) { + err = fmt.Errorf("second asset doesn't have expected name (%v)", *release.Assets[1].Name) + return + } + + tarGzDownloadUrl := release.Assets[0].GetBrowserDownloadURL() + if err = validateAssetURL(tarGzDownloadUrl); err != nil { + err = fmt.Errorf("tar.gz URL validation failed: %v", err) + return + } + sigDownloadURL := release.Assets[1].GetBrowserDownloadURL() + if err = validateAssetURL(sigDownloadURL); err != nil { + err = fmt.Errorf("signature URL validation failed: %v", err) + return + } + + tarGzBytes, err = downloadAsset(tarGzDownloadUrl) + if err != nil { + err = fmt.Errorf("tar.gz download failed: %v", err) + return + } + + sigBytes, err = downloadAsset(sigDownloadURL) + if err != nil { + err = fmt.Errorf("signature download failed: %v", err) + return + } + return +} + +func validateAssetURL(assetUrl string) error { + parsedURL, err := url.Parse(assetUrl) + if err != nil { + return err + } + if parsedURL.Hostname() != "github.com" { + return fmt.Errorf("asset has non-GitHub URL (%v)", assetUrl) + } + return nil +} + +func downloadAsset(assetUrl string) ([]byte, error) { + resp, err := http.Get(assetUrl) + if err != nil { + return nil, err + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) +} + +func verifySignature(publicSigningKey string, tarGzBytes, sigBytes []byte) (bool, error) { + pk, err := minisign.DecodePublicKey(publicSigningKey) + if err != nil { + return false, err + } + sig, err := minisign.DecodeSignature(string(sigBytes)) + if err != nil { + return false, err + } + return pk.Verify(tarGzBytes, sig) +} + +func deploy(deployDir, targetName, releaseID string, tarGzBytes []byte, lastReleaseFile, lastReleaseID string) error { + extractDir := filepath.Join(deployDir, targetName) + "-" + releaseID + if err := os.Mkdir(extractDir, 0755); err != nil { + return err + } + if err := extractTarGz(tarGzBytes, extractDir, 1); err != nil { + return err + } + if err := os.Symlink(extractDir, extractDir+".tmp"); err != nil { + return err + } + if err := os.Rename(extractDir+".tmp", filepath.Join(deployDir, targetName)); err != nil { + return err + } + if err := os.WriteFile(lastReleaseFile, []byte(releaseID), 0640); err != nil { + return err + } + + // clean up old release dir + return os.RemoveAll(filepath.Join(deployDir, targetName) + "-" + lastReleaseID) +} + +func extractTarGz(tarGzData []byte, destination string, stripComponents int) error { + buf := bytes.NewBuffer(tarGzData) + gzipReader, err := gzip.NewReader(buf) + if err != nil { + return err + } + defer gzipReader.Close() + tarReader := tar.NewReader(gzipReader) + + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + // Skip pax_global_header entries + if header.Name == "pax_global_header" { + continue + } + + // Calculate the target path by stripping components + target := header.Name + if stripComponents > 0 { + components := strings.SplitN(target, string(filepath.Separator), stripComponents+1) + if len(components) > stripComponents { + target = strings.Join(components[stripComponents:], string(filepath.Separator)) + } else { + target = "" + } + } + + // Get the full path for the file + target = filepath.Join(destination, target) + + switch header.Typeflag { + case tar.TypeDir: + // Create directory if it doesn't exist + if err := os.MkdirAll(target, os.ModePerm); err != nil { + return err + } + + case tar.TypeReg: + // Create file + file, err := os.Create(target) + if err != nil { + return err + } + defer file.Close() + + if _, err := io.Copy(file, tarReader); err != nil { + return err + } + + default: + return fmt.Errorf("unsupported file type: %v in %v", header.Typeflag, header.Name) + } + } + + return nil +}