Files
chrome-monitor/main.go
2026-01-05 15:32:31 +08:00

346 lines
8.5 KiB
Go

package main
import (
"crypto/subtle"
"encoding/xml"
"html/template"
"io"
"log"
"math/rand"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
const (
downloadDir = "./chrome_versions"
checkInterval = 24 * time.Hour
maxRetries = 3
updateURL = "https://tools.google.com/service/update2"
)
var (
authToken string
authEnabled bool
port string
keepVersions int
)
type Version struct {
Filename string
Size int64
Time time.Time
SHA256 string
}
type UpdateResponse struct {
XMLName xml.Name `xml:"response"`
Protocol string `xml:"protocol,attr"`
App struct {
Status string `xml:"status,attr"`
UpdateCheck struct {
Status string `xml:"status,attr"`
URLs struct {
URL []struct {
Codebase string `xml:"codebase,attr"`
} `xml:"url"`
} `xml:"urls"`
Manifest struct {
Version string `xml:"version,attr"`
Packages struct {
Package struct {
Name string `xml:"name,attr"`
Required bool `xml:"required,attr"`
Size int64 `xml:"size,attr"`
Hash string `xml:"hash,attr"`
HashSHA256 string `xml:"hash_sha256,attr"`
} `xml:"package"`
} `xml:"packages"`
} `xml:"manifest"`
} `xml:"updatecheck"`
} `xml:"app"`
}
func init() {
authToken = getEnv("AUTH_TOKEN", "")
authEnabled = getEnv("AUTH_ENABLED", "false") == "true" || authToken != ""
port = getEnv("PORT", "8080")
keepVersions, _ = strconv.Atoi(getEnv("KEEP_VERSIONS", "3"))
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func main() {
os.MkdirAll(downloadDir, 0755)
log.Printf("Auth enabled: %v, Port: %s, Keep versions: %d", authEnabled, port, keepVersions)
log.Println("Performing initial check...")
checkAndDownload()
go monitor()
http.HandleFunc("/", authMiddleware(serveIndex))
http.Handle("/download/", authMiddleware(http.StripPrefix("/download/", http.FileServer(http.Dir(downloadDir))).ServeHTTP))
log.Printf("Server started on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
func monitor() {
rand.Seed(time.Now().UnixNano())
ticker := time.NewTicker(checkInterval)
defer ticker.Stop()
for range ticker.C {
delay := time.Duration(rand.Intn(3600)) * time.Second
time.Sleep(delay)
checkAndDownload()
}
}
func getLatestVersionInfo() (version, downloadURL, sha256 string, err error) {
reqBody := `<?xml version="1.0" encoding="UTF-8"?>
<request protocol="3.0" updater="Omaha" updaterversion="1.3.36.372" shell_version="1.3.36.352" ismachine="0" sessionid="{11111111-1111-1111-1111-111111111111}" installsource="taggedmi" requestid="{11111111-1111-1111-1111-111111111111}" dedup="cr" domainjoined="0">
<hw physmemory="16" sse="1" sse2="1" sse3="1" ssse3="1" sse41="1" sse42="1" avx="1"/>
<os platform="win" version="10.0.26100.1742" arch="x64"/>
<app version="" appid="{8A69D345-D564-463C-AFF1-A69D9E530F96}" ap="x64-stable">
<updatecheck/>
<data name="install" index="empty"/>
</app>
</request>`
resp, err := http.Post(updateURL, "application/xml", strings.NewReader(reqBody))
if err != nil {
return "", "", "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var updateResp UpdateResponse
if err := xml.Unmarshal(body, &updateResp); err != nil {
return "", "", "", err
}
version = updateResp.App.UpdateCheck.Manifest.Version
sha256 = updateResp.App.UpdateCheck.Manifest.Packages.Package.HashSHA256
packageName := updateResp.App.UpdateCheck.Manifest.Packages.Package.Name
if len(updateResp.App.UpdateCheck.URLs.URL) > 0 {
for _, url := range updateResp.App.UpdateCheck.URLs.URL {
if strings.HasPrefix(url.Codebase, "https://dl.google.com") {
downloadURL = url.Codebase + packageName
break
}
}
if downloadURL == "" {
downloadURL = updateResp.App.UpdateCheck.URLs.URL[0].Codebase + packageName
}
}
return version, downloadURL, sha256, nil
}
func checkAndDownload() {
version, downloadURL, sha256, err := getLatestVersionInfo()
if err != nil {
log.Printf("Failed to get version info: %v", err)
return
}
log.Printf("Latest Chrome version: %s", version)
log.Printf("Download URL: %s", downloadURL)
log.Printf("SHA256: %s", sha256)
if downloadURL == "" {
log.Println("No download URL found")
return
}
parts := strings.Split(downloadURL, "/")
filename := parts[len(parts)-1]
filePath := filepath.Join(downloadDir, filename)
sha256File := filePath + ".sha256"
if _, err := os.Stat(filePath); err == nil {
log.Printf("File %s already exists, skipping download", filename)
return
}
client := &http.Client{}
var downloaded bool
for i := 0; i < maxRetries; i++ {
resp, err := client.Get(downloadURL)
if err != nil {
log.Printf("Attempt %d failed: %v", i+1, err)
time.Sleep(time.Duration(i+1) * 10 * time.Second)
continue
}
tmpFile := filePath + ".tmp"
f, err := os.Create(tmpFile)
if err != nil {
resp.Body.Close()
log.Printf("Attempt %d: failed to create file: %v", i+1, err)
time.Sleep(time.Duration(i+1) * 10 * time.Second)
continue
}
written, err := io.Copy(f, resp.Body)
f.Close()
resp.Body.Close()
if err == nil && written > 10000000 {
os.Rename(tmpFile, filePath)
os.WriteFile(sha256File, []byte(sha256), 0644)
log.Printf("Downloaded: %s (%.2f MB)", filename, float64(written)/1024/1024)
downloaded = true
break
}
os.Remove(tmpFile)
log.Printf("Attempt %d: invalid response (size: %d bytes)", i+1, written)
time.Sleep(time.Duration(i+1) * 10 * time.Second)
}
if !downloaded {
log.Println("All download attempts failed")
return
}
cleanupOldVersions()
}
func cleanupOldVersions() {
files, _ := os.ReadDir(downloadDir)
if len(files) <= keepVersions*2 {
return
}
var versions []Version
for _, f := range files {
if strings.HasSuffix(f.Name(), ".exe") {
info, _ := f.Info()
versions = append(versions, Version{
Filename: f.Name(),
Time: info.ModTime(),
Size: info.Size(),
})
}
}
sort.Slice(versions, func(i, j int) bool {
return versions[i].Time.After(versions[j].Time)
})
for i := keepVersions; i < len(versions); i++ {
os.Remove(filepath.Join(downloadDir, versions[i].Filename))
os.Remove(filepath.Join(downloadDir, versions[i].Filename+".sha256"))
log.Printf("Removed old version: %s", versions[i].Filename)
}
}
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if authEnabled {
token := r.URL.Query().Get("token")
if subtle.ConstantTimeCompare([]byte(token), []byte(authToken)) != 1 {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
next(w, r)
}
}
func serveIndex(w http.ResponseWriter, r *http.Request) {
files, _ := os.ReadDir(downloadDir)
var versions []Version
for _, f := range files {
if strings.HasSuffix(f.Name(), ".exe") {
info, _ := f.Info()
sha256, _ := os.ReadFile(filepath.Join(downloadDir, f.Name()+".sha256"))
versions = append(versions, Version{
Filename: f.Name(),
Time: info.ModTime(),
Size: info.Size(),
SHA256: string(sha256),
})
}
}
sort.Slice(versions, func(i, j int) bool {
return versions[i].Time.After(versions[j].Time)
})
token := r.URL.Query().Get("token")
tmpl := template.Must(template.New("index").Parse(`
<!DOCTYPE html>
<html>
<head>
<title>Chrome Offline Installer</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #4CAF50; color: white; }
tr:nth-child(even) { background-color: #f2f2f2; }
.sha256 { font-family: monospace; font-size: 0.9em; word-break: break-all; }
</style>
</head>
<body>
<h1>Chrome Offline Versions (x64)</h1>
<table>
<tr>
<th>Filename</th>
<th>Size</th>
<th>Date</th>
<th>SHA256</th>
</tr>
{{range .Versions}}
<tr>
<td><a href="/download/{{.Filename}}{{if $.Token}}?token={{$.Token}}{{end}}">{{.Filename}}</a></td>
<td>{{printf "%.2f" .SizeMB}} MB</td>
<td>{{.Time.Format "2006-01-02 15:04"}}</td>
<td class="sha256">{{.SHA256}}</td>
</tr>
{{end}}
</table>
</body>
</html>
`))
type VersionDisplay struct {
Filename string
SizeMB float64
Time time.Time
SHA256 string
}
var displayVersions []VersionDisplay
for _, v := range versions {
displayVersions = append(displayVersions, VersionDisplay{
Filename: v.Filename,
SizeMB: float64(v.Size) / 1024 / 1024,
Time: v.Time,
SHA256: v.SHA256,
})
}
tmpl.Execute(w, struct {
Versions []VersionDisplay
Token string
}{displayVersions, token})
}