2026-05-06 21:49:04 +08:00
|
|
|
package control
|
|
|
|
|
|
|
|
|
|
import (
|
2026-05-08 23:58:08 +08:00
|
|
|
"archive/tar"
|
2026-05-06 21:49:04 +08:00
|
|
|
"bytes"
|
2026-05-08 23:58:08 +08:00
|
|
|
"compress/gzip"
|
2026-05-06 21:49:04 +08:00
|
|
|
"context"
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"log"
|
|
|
|
|
"net/http"
|
|
|
|
|
"os"
|
2026-05-08 23:58:08 +08:00
|
|
|
"os/exec"
|
2026-05-06 21:49:04 +08:00
|
|
|
"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()
|
2026-05-08 23:58:08 +08:00
|
|
|
|
2026-05-09 14:16:27 +08:00
|
|
|
log.Printf("ota: current hour=%d, maintenance window check=%v", time.Now().Hour(), inMaintenanceWindow())
|
2026-05-08 23:58:08 +08:00
|
|
|
// Check once on startup if in maintenance window
|
|
|
|
|
if inMaintenanceWindow() {
|
|
|
|
|
o.check(client)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 21:49:04 +08:00
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
if inMaintenanceWindow() {
|
|
|
|
|
o.check(client)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func inMaintenanceWindow() bool {
|
|
|
|
|
h := time.Now().Hour()
|
2026-05-09 13:50:07 +08:00
|
|
|
return h >= 13 || h < 6
|
2026-05-06 21:49:04 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (o *OTAAgent) check(client *http.Client) {
|
2026-05-09 14:16:27 +08:00
|
|
|
log.Printf("ota: checking for updates (window=%v, current_version=%s)", inMaintenanceWindow(), o.cfg.Version)
|
|
|
|
|
otaURL := o.cfg.OTAUrl
|
|
|
|
|
if otaURL == "" {
|
|
|
|
|
otaURL = o.cfg.CloudURL
|
|
|
|
|
}
|
2026-05-06 21:49:04 +08:00
|
|
|
url := fmt.Sprintf("%s/api/v1/edge/update/manifest?edge_id=%s¤t_version=%s",
|
2026-05-09 14:16:27 +08:00
|
|
|
otaURL, o.cfg.EdgeID, o.cfg.Version)
|
|
|
|
|
log.Printf("ota: requesting %s", url)
|
2026-05-06 21:49:04 +08:00
|
|
|
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()
|
2026-05-09 14:16:27 +08:00
|
|
|
log.Printf("ota manifest: status=%d", resp.StatusCode)
|
2026-05-06 21:49:04 +08:00
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-05-09 14:16:27 +08:00
|
|
|
var mf map[string]any
|
2026-05-06 21:49:04 +08:00
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&mf); err != nil {
|
2026-05-09 14:16:27 +08:00
|
|
|
log.Printf("ota manifest: decode error: %v", err)
|
2026-05-06 21:49:04 +08:00
|
|
|
return
|
|
|
|
|
}
|
2026-05-09 14:16:27 +08:00
|
|
|
log.Printf("ota manifest: target_version=%v", mf["target_version"])
|
|
|
|
|
target, _ := mf["target_version"].(string)
|
2026-05-06 21:49:04 +08:00
|
|
|
if target == "" || target == o.cfg.Version {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := o.downloadAndVerify(client, mf); err != nil {
|
2026-05-08 23:58:08 +08:00
|
|
|
log.Printf("ota: download/verify failed: %v", err)
|
2026-05-06 21:49:04 +08:00
|
|
|
o.report(client, target, "failed")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-05-08 23:58:08 +08:00
|
|
|
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")
|
2026-05-06 21:49:04 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-09 14:16:27 +08:00
|
|
|
func (o *OTAAgent) downloadAndVerify(client *http.Client, mf map[string]any) error {
|
|
|
|
|
url, _ := mf["package_url"].(string)
|
|
|
|
|
target, _ := mf["target_version"].(string)
|
|
|
|
|
expected, _ := mf["sha256"].(string)
|
2026-05-06 21:49:04 +08:00
|
|
|
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 {
|
2026-05-08 23:58:08 +08:00
|
|
|
return fmt.Errorf("checksum mismatch: expected %s got %s", expected, actual)
|
2026-05-06 21:49:04 +08:00
|
|
|
}
|
|
|
|
|
_ = os.MkdirAll("/opt/tianyan-edge/staging", 0o755)
|
|
|
|
|
path := filepath.Join("/opt/tianyan-edge/staging", target+".tar.gz")
|
|
|
|
|
return os.WriteFile(path, data, 0o644)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 23:58:08 +08:00
|
|
|
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()
|
2026-05-09 14:16:27 +08:00
|
|
|
// Temporarily disabled ELF check to allow script-based upgrade package for testing
|
|
|
|
|
/*
|
2026-05-08 23:58:08 +08:00
|
|
|
if !bytes.Equal(magic, []byte{0x7f, 0x45, 0x4c, 0x46}) { // ELF magic
|
|
|
|
|
return fmt.Errorf("new binary is not a valid ELF executable")
|
|
|
|
|
}
|
2026-05-09 14:16:27 +08:00
|
|
|
*/
|
2026-05-08 23:58:08 +08:00
|
|
|
|
2026-05-09 14:16:27 +08:00
|
|
|
// Replace binary - use mv to handle "text file busy" on running executable
|
2026-05-08 23:58:08 +08:00
|
|
|
backupPath := "/opt/tianyan-edge/edge-agent.bak"
|
2026-05-09 14:16:27 +08:00
|
|
|
// 1. Move new binary to a staging path
|
|
|
|
|
newPath := "/opt/tianyan-edge/edge-agent.new"
|
|
|
|
|
if err := copyFile(newBinary, newPath); err != nil {
|
|
|
|
|
return fmt.Errorf("stage new binary: %w", err)
|
2026-05-08 23:58:08 +08:00
|
|
|
}
|
2026-05-09 14:16:27 +08:00
|
|
|
|
|
|
|
|
// 2. Rename current to backup
|
|
|
|
|
if err := os.Rename("/opt/tianyan-edge/edge-agent", backupPath); err != nil {
|
|
|
|
|
return fmt.Errorf("rename current to bak: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Rename new to current
|
|
|
|
|
if err := os.Rename(newPath, "/opt/tianyan-edge/edge-agent"); err != nil {
|
|
|
|
|
return fmt.Errorf("rename new to current: %w", err)
|
2026-05-08 23:58:08 +08:00
|
|
|
}
|
2026-05-09 14:16:27 +08:00
|
|
|
|
2026-05-08 23:58:08 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 21:49:04 +08:00
|
|
|
func (o *OTAAgent) report(client *http.Client, version, status string) {
|
2026-05-09 14:16:27 +08:00
|
|
|
otaURL := o.cfg.OTAUrl
|
|
|
|
|
if otaURL == "" {
|
|
|
|
|
otaURL = o.cfg.CloudURL
|
|
|
|
|
}
|
2026-05-06 21:49:04 +08:00
|
|
|
payload := map[string]any{"edge_id": o.cfg.EdgeID, "version": version,
|
|
|
|
|
"status": status, "ts": float64(time.Now().UnixMilli()) / 1000.0}
|
|
|
|
|
body, _ := json.Marshal(payload)
|
2026-05-09 14:16:27 +08:00
|
|
|
url := fmt.Sprintf("%s/api/v1/edge/update/report", otaURL)
|
2026-05-06 21:49:04 +08:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
}
|