package control import ( "bytes" "context" "encoding/json" "fmt" "io" "log" "net" "net/http" "os" "path/filepath" "strings" "time" ) type upgradePayload struct { UpgradeID string `json:"upgradeId"` URL string `json:"url"` } func (c *Client) doUpgrade(payload json.RawMessage) { var p upgradePayload if err := json.Unmarshal(payload, &p); err != nil || p.URL == "" || p.UpgradeID == "" { c.sendUpgradeAck("", false, "ongeldig upgrade-verzoek") return } if err := c.runUpgrade(p.URL); err != nil { log.Printf("UPGRADE failed: %v", err) c.sendUpgradeAck(p.UpgradeID, false, err.Error()) return } log.Printf("UPGRADE ok, herstarten (upgradeId=%s)", p.UpgradeID) c.sendUpgradeAck(p.UpgradeID, true, "") time.Sleep(500 * time.Millisecond) c.doRestart() } func (c *Client) sendUpgradeAck(upgradeID string, ok bool, errMsg string) { ack := controlMessage("UPGRADE_ACK", map[string]interface{}{ "upgradeId": upgradeID, "ok": ok, "version": c.version, "error": errMsg, }) if err := c.write(ack); err != nil { log.Printf("failed to send UPGRADE_ACK: %v", err) } } func (c *Client) runUpgrade(downloadURL string) error { dataDir := strings.TrimSpace(os.Getenv("MEDIA_NODE_DATA")) if dataDir == "" { dataDir = "/var/lib/media-node" } stagingDir := filepath.Join(dataDir, "upgrade") stagingFile := filepath.Join(stagingDir, "media-node") if err := os.MkdirAll(stagingDir, 0o755); err != nil { return fmt.Errorf("staging map: %w", err) } if err := downloadFile(downloadURL, stagingFile); err != nil { return err } if err := os.Chmod(stagingFile, 0o755); err != nil { return fmt.Errorf("chmod staging: %w", err) } // Prefer directory bind-mount (MEDIA_NODE_BIN_DIR). Replace via .new + rename // so we never overwrite the running executable (ETXTBSY) and need no docker.sock // (Synology: sock is root-only). var errs []string targets := upgradeBinaryTargets() for _, dest := range targets { if err := installBinaryReplace(stagingFile, dest); err != nil { errs = append(errs, fmt.Sprintf("%s: %v", dest, err)) continue } log.Printf("UPGRADE: binary geplaatst op %s", dest) return nil } if _, err := os.Stat("/var/run/docker.sock"); err != nil { return fmt.Errorf( "binary niet te vervangen (%s). Op Synology: eenmalig via SSH upgraden én bin-mount herstellen:\n"+ " curl -fsSL \"$MASTER/install/upgrade-node.sh\" | bash -s -- --appdata=\n"+ "(script zet /opt/media-node-bin + MEDIA_NODE_BIN_DIR zodat Admin→Upgrade daarna werkt)", strings.Join(errs, "; "), ) } containerName := strings.TrimSpace(os.Getenv("MEDIA_NODE_CONTAINER_NAME")) if containerName == "" { containerName = "media-node" } binHostDir, dataHostDir, err := dockerContainerBindDirs(containerName) if err != nil { return fmt.Errorf( "%w — eerdere pogingen: %s (Synology: docker.sock is vaak root-only; gebruik upgrade-node.sh via SSH)", err, strings.Join(errs, "; "), ) } stagingHost := filepath.Join(dataHostDir, "upgrade", "media-node") shellCmd := "cp /staging/media-node /target/media-node && chmod +x /target/media-node" binds := []string{ binHostDir + ":/target:rw", filepath.Dir(stagingHost) + ":/staging:ro", } if err := dockerRunOnce("alpine:3.20", shellCmd, binds); err != nil { return fmt.Errorf("binary installeren via docker: %w", err) } return nil } func upgradeBinaryTargets() []string { seen := map[string]bool{} var out []string add := func(p string) { p = filepath.Clean(p) if p == "" || p == "." || seen[p] { return } seen[p] = true out = append(out, p) } if binDir := strings.TrimSpace(os.Getenv("MEDIA_NODE_BIN_DIR")); binDir != "" { add(filepath.Join(binDir, "media-node")) } // Install-scripts mounten $APPDATA/bin hier — ook proberen als env op oude containers ontbreekt. if st, err := os.Stat("/opt/media-node-bin"); err == nil && st.IsDir() { add("/opt/media-node-bin/media-node") } // File bind-mount van de entrypoint — .new ernaast faalt vaak (map is root-owned). add("/usr/local/bin/media-node") return out } // installBinaryReplace writes dest.new then replaces dest (unlink+rename). // Unlink of a running Linux binary is allowed; open(O_WRONLY) on it is not (ETXTBSY). // .new must live in the same directory as dest (directory bind-mount like /opt/media-node-bin). func installBinaryReplace(src, dest string) error { if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return fmt.Errorf("bin-map: %w", err) } newPath := dest + ".new" _ = os.Remove(newPath) if err := copyFile(src, newPath, 0o755); err != nil { return fmt.Errorf("schrijven %s: %w", newPath, err) } // Drop old name (ok while process still runs), then put new binary in place. if err := os.Remove(dest); err != nil && !os.IsNotExist(err) { // Some file-mounts refuse remove; try rename-over as fallback. if renErr := os.Rename(newPath, dest); renErr != nil { _ = os.Remove(newPath) return fmt.Errorf("vervangen %s: remove=%v rename=%v", dest, err, renErr) } return nil } if err := os.Rename(newPath, dest); err != nil { _ = os.Remove(newPath) return fmt.Errorf("rename naar %s: %w", dest, err) } return nil } func copyFile(src, dest string, mode os.FileMode) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) if err != nil { return err } if _, err := io.Copy(out, in); err != nil { out.Close() _ = os.Remove(dest) return err } if err := out.Close(); err != nil { _ = os.Remove(dest) return err } return os.Chmod(dest, mode) } func downloadFile(url, dest string) error { log.Printf("UPGRADE: download %s", url) tmp := dest + ".download" client := &http.Client{Timeout: 5 * time.Minute} res, err := client.Get(url) if err != nil { return fmt.Errorf("download: %w", err) } defer res.Body.Close() if res.StatusCode >= 300 { return fmt.Errorf("download HTTP %d", res.StatusCode) } out, err := os.Create(tmp) if err != nil { return fmt.Errorf("schrijven staging: %w", err) } if _, err := io.Copy(out, res.Body); err != nil { out.Close() _ = os.Remove(tmp) return fmt.Errorf("download schrijven: %w", err) } if err := out.Close(); err != nil { _ = os.Remove(tmp) return err } if err := os.Rename(tmp, dest); err != nil { _ = os.Remove(tmp) return fmt.Errorf("staging afronden: %w", err) } return nil } type dockerMount struct { Source string `json:"Source"` Destination string `json:"Destination"` } type dockerInspect struct { Mounts []dockerMount `json:"Mounts"` } func dockerContainerBindDirs(containerName string) (binHostDir, dataHostDir string, err error) { body, err := dockerAPIRequest(http.MethodGet, "/containers/"+containerName+"/json", nil) if err != nil { return "", "", fmt.Errorf("container inspect: %w", err) } var info dockerInspect if err := json.Unmarshal(body, &info); err != nil { return "", "", fmt.Errorf("inspect parse: %w", err) } for _, m := range info.Mounts { switch m.Destination { case "/usr/local/bin/media-node": binHostDir = filepath.Dir(m.Source) case "/opt/media-node-bin": binHostDir = m.Source case "/var/lib/media-node": dataHostDir = m.Source } } if binHostDir == "" { return "", "", fmt.Errorf("binary-mount niet gevonden op container %s", containerName) } if dataHostDir == "" { return "", "", fmt.Errorf("data-mount niet gevonden op container %s", containerName) } return binHostDir, dataHostDir, nil } func dockerRunOnce(image, shellCmd string, binds []string) error { createBody := map[string]interface{}{ "Image": image, "Cmd": []string{"sh", "-c", shellCmd}, "HostConfig": map[string]interface{}{ "Binds": binds, "AutoRemove": true, }, } raw, err := json.Marshal(createBody) if err != nil { return err } created, err := dockerAPIRequest(http.MethodPost, "/containers/create", raw) if err != nil { return fmt.Errorf("container create: %w", err) } var createdResp struct { ID string `json:"Id"` } if err := json.Unmarshal(created, &createdResp); err != nil || createdResp.ID == "" { return fmt.Errorf("container create parse: %w", err) } if _, err := dockerAPIRequest(http.MethodPost, "/containers/"+createdResp.ID+"/start", nil); err != nil { return fmt.Errorf("container start: %w", err) } waitCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() for { select { case <-waitCtx.Done(): return fmt.Errorf("container wait timeout") default: } body, err := dockerAPIRequest(http.MethodGet, "/containers/"+createdResp.ID+"/json", nil) if err != nil { return err } var state struct { State struct { Status string `json:"Status"` ExitCode int `json:"ExitCode"` Running bool `json:"Running"` FinishedAt string `json:"FinishedAt"` } `json:"State"` } if err := json.Unmarshal(body, &state); err != nil { return err } if state.State.Running { time.Sleep(200 * time.Millisecond) continue } if state.State.ExitCode != 0 { return fmt.Errorf("install-container exit %d", state.State.ExitCode) } return nil } } func dockerAPIRequest(method, path string, body []byte) ([]byte, error) { httpc := http.Client{ Transport: &http.Transport{ DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { return net.Dial("unix", "/var/run/docker.sock") }, }, Timeout: 2 * time.Minute, } var reqBody io.Reader if body != nil { reqBody = bytes.NewReader(body) } req, err := http.NewRequest(method, "http://localhost"+path, reqBody) if err != nil { return nil, err } if body != nil { req.Header.Set("Content-Type", "application/json") } res, err := httpc.Do(req) if err != nil { return nil, err } defer res.Body.Close() out, err := io.ReadAll(res.Body) if err != nil { return nil, err } if res.StatusCode >= 300 { return nil, fmt.Errorf("docker API %s %s: %d %s", method, path, res.StatusCode, strings.TrimSpace(string(out))) } return out, nil }