package control import ( "archive/tar" "bytes" "compress/gzip" "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "log" "net/http" "os" "os/exec" "path/filepath" "time" "tianyan-edge/internal/config" ) type OTAAgent struct { cfg *config.Config } func NewOTAAgent(cfg *config.Config) *OTAAgent { return &OTAAgent{cfg: cfg} } func (o *OTAAgent) Run(ctx context.Context) { client := &http.Client{Timeout: 30 * time.Second} ticker := time.NewTicker(10 * time.Minute) defer ticker.Stop() // Check once on startup if in maintenance window if inMaintenanceWindow() { o.check(client) } for { select { case <-ctx.Done(): return case <-ticker.C: if inMaintenanceWindow() { o.check(client) } } } } func inMaintenanceWindow() bool { h := time.Now().Hour() return h >= 22 || h < 6 } func (o *OTAAgent) check(client *http.Client) { url := fmt.Sprintf("%s/api/v1/edge/update/manifest?edge_id=%s¤t_version=%s", o.cfg.CloudURL, o.cfg.EdgeID, o.cfg.Version) req, _ := http.NewRequest(http.MethodGet, url, nil) req.Header.Set("Authorization", "Bearer "+o.cfg.EdgeToken) resp, err := client.Do(req) if err != nil { log.Printf("ota manifest: %v", err) return } defer resp.Body.Close() if resp.StatusCode != 200 { return } var mf map[string]string if err := json.NewDecoder(resp.Body).Decode(&mf); err != nil { return } target := mf["target_version"] if target == "" || target == o.cfg.Version { return } if err := o.downloadAndVerify(client, mf); err != nil { log.Printf("ota: download/verify failed: %v", err) o.report(client, target, "failed") return } if err := o.install(target); err != nil { log.Printf("ota: install failed: %v", err) o.report(client, target, "install_failed") return } o.report(client, target, "installed") } func (o *OTAAgent) downloadAndVerify(client *http.Client, mf map[string]string) error { url, target := mf["package_url"], mf["target_version"] expected := mf["sha256"] if url == "" || expected == "" { return fmt.Errorf("invalid manifest") } resp, err := client.Get(url) if err != nil { return err } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return err } sum := sha256.Sum256(data) actual := hex.EncodeToString(sum[:]) if actual != expected { return fmt.Errorf("checksum mismatch: expected %s got %s", expected, actual) } _ = os.MkdirAll("/opt/tianyan-edge/staging", 0o755) path := filepath.Join("/opt/tianyan-edge/staging", target+".tar.gz") return os.WriteFile(path, data, 0o644) } func (o *OTAAgent) install(target string) error { stagingPath := filepath.Join("/opt/tianyan-edge/staging", target+".tar.gz") data, err := os.ReadFile(stagingPath) if err != nil { return fmt.Errorf("read staging file: %w", err) } // Extract tar.gz into a temp directory tmpDir, err := os.MkdirTemp("/opt/tianyan-edge/staging", "ota-"+target) if err != nil { return fmt.Errorf("create temp dir: %w", err) } defer os.RemoveAll(tmpDir) if err := extractTarGz(bytes.NewReader(data), tmpDir); err != nil { return fmt.Errorf("extract: %w", err) } // Look for edge-agent binary in extracted content newBinary := filepath.Join(tmpDir, "edge-agent") if _, err := os.Stat(newBinary); os.IsNotExist(err) { // Try finding it recursively found := "" var stopWalk = fmt.Errorf("found") filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } if info.Name() == "edge-agent" && !info.IsDir() { found = path return stopWalk } return nil }) if found == "" { return fmt.Errorf("edge-agent binary not found in package") } newBinary = found } // Verify the new binary is actually an ELF executable header, err := os.Open(newBinary) if err != nil { return fmt.Errorf("open new binary: %w", err) } magic := make([]byte, 4) header.Read(magic) header.Close() if !bytes.Equal(magic, []byte{0x7f, 0x45, 0x4c, 0x46}) { // ELF magic return fmt.Errorf("new binary is not a valid ELF executable") } // Backup current binary backupPath := "/opt/tianyan-edge/edge-agent.bak" if err := copyFile("/opt/tianyan-edge/edge-agent", backupPath); err != nil { log.Printf("ota: backup failed (non-fatal): %v", err) } // Replace binary if err := copyFile(newBinary, "/opt/tianyan-edge/edge-agent"); err != nil { return fmt.Errorf("replace binary: %w", err) } if err := os.Chmod("/opt/tianyan-edge/edge-agent", 0755); err != nil { return fmt.Errorf("chmod: %w", err) } // Update version in config o.cfg.Version = target _ = o.cfg.Save() // Clean up staging os.Remove(stagingPath) // Restart service via systemd log.Println("ota: binary replaced, restarting service...") cmd := exec.Command("systemctl", "restart", "edge-agent") if err := cmd.Run(); err != nil { log.Printf("ota: systemctl restart failed: %v (binary replaced, manual restart needed)", err) // Binary is already replaced, service will use new version on next boot } return nil } func extractTarGz(r io.Reader, dest string) error { gzr, err := gzip.NewReader(r) if err != nil { return err } defer gzr.Close() tr := tar.NewReader(gzr) for { hdr, err := tr.Next() if err == io.EOF { break } if err != nil { return err } target := filepath.Join(dest, hdr.Name) switch hdr.Typeflag { case tar.TypeDir: if err := os.MkdirAll(target, os.FileMode(hdr.Mode)); err != nil { return err } case tar.TypeReg: os.MkdirAll(filepath.Dir(target), 0755) f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY, os.FileMode(hdr.Mode)) if err != nil { return err } if _, err := io.Copy(f, tr); err != nil { f.Close() return err } f.Close() } } return nil } func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() _, err = io.Copy(out, in) return err } func (o *OTAAgent) report(client *http.Client, version, status string) { payload := map[string]any{"edge_id": o.cfg.EdgeID, "version": version, "status": status, "ts": float64(time.Now().UnixMilli()) / 1000.0} body, _ := json.Marshal(payload) url := fmt.Sprintf("%s/api/v1/edge/update/report", o.cfg.CloudURL) req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+o.cfg.EdgeToken) resp, err := client.Do(req) if err == nil { resp.Body.Close() } }