test release

This commit is contained in:
Gökhan Özdemir 2025-09-25 10:45:27 +03:00
commit a15ff9ba5f
14 changed files with 890 additions and 0 deletions

100
cmd/extractor/main.go Normal file
View File

@ -0,0 +1,100 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"kisekinopureya.com.tr/updater/internal/archive"
)
func sha256sum(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func main() {
if len(os.Args) != 3 {
fmt.Printf("Usage: %s <archive.tar.xz> <archive.tar.xz.sig>\n", os.Args[0])
os.Exit(1)
}
archiveFile := os.Args[1]
sig := os.Args[2]
// Step 1: verify GPG signature
cmd := exec.Command("/usr/bin/gpgv", "--keyring", "/etc/updates-public.gpg", sig, archiveFile)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "GPG verification failed: %v\n", err)
os.Exit(1)
}
fmt.Println("GPG signature verified.")
// Step 2: open archiveFile
f, err := os.Open(archiveFile)
if err != nil {
panic(err)
}
defer f.Close()
// Step 2: extract tar.xz using system tar
cmdTar := exec.Command("tar", "-xJf", archiveFile)
cmdTar.Stdout = os.Stdout
cmdTar.Stderr = os.Stderr
if err := cmdTar.Run(); err != nil {
panic(err)
}
metaFile := "metadata.json"
f, err = os.Open(metaFile)
if err != nil {
panic(err)
}
defer f.Close()
var meta archive.Output
if err := json.NewDecoder(f).Decode(&meta); err != nil {
panic(err)
}
// Step 4: check sha256
ok := true
for _, file := range meta.Files {
sum, err := sha256sum(file.Name)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to hash %s: %v\n", file.Name, err)
ok = false
continue
}
if sum != file.Sha256 {
fmt.Fprintf(os.Stderr, "Checksum mismatch for %s!\nExpected: %s\nGot: %s\n",
file.Name, file.Sha256, sum)
ok = false
} else {
fmt.Printf("%s checksum OK\n", file.Name)
}
}
if !ok {
os.Exit(1)
}
fmt.Println("All files verified successfully.")
}

View File

@ -0,0 +1,40 @@
package main
import (
"fmt"
"log"
"os"
"kisekinopureya.com.tr/updater/internal/logger"
"kisekinopureya.com.tr/updater/internal/version"
)
func main() {
logger.InitializeLogger("/tmp/os-select-branchlog")
log.Printf("%+v", os.Args)
if len(os.Args) != 2 {
fmt.Println("Usage: steamos-select-branch <-stable|unstable|testing>")
os.Exit(1)
}
branchPath := version.BranchPath
switch os.Args[1] {
case "-c":
branch := version.GetCurrentBranch()
fmt.Printf("%s\n", branch)
os.Exit(0)
case "-l":
fmt.Printf("%s\n%s\n%s\n", "stable", "unstable", "testing")
os.Exit(0)
case "stable", "unstable", "testing":
err := os.WriteFile(branchPath, []byte(os.Args[1]), 0644)
if err != nil {
log.Panic(err)
}
os.Exit(0)
default:
os.Exit(1)
}
}

25
cmd/packer/main.go Normal file
View File

@ -0,0 +1,25 @@
package main
import (
"fmt"
"os"
"kisekinopureya.com.tr/updater/internal/archive"
)
func main() {
if len(os.Args) != 5 {
fmt.Fprintf(os.Stderr, "usage: %s <rootfs.img> <dm-verity.img> <kernel.img> <output.tar.xz>\n", os.Args[0])
os.Exit(1)
}
rootfs := os.Args[1]
verity := os.Args[2]
kernel := os.Args[3]
output := os.Args[4]
if err := archive.Pack(rootfs, verity, kernel, output); err != nil {
fmt.Fprintf(os.Stderr, "packer error: %v\n", err)
os.Exit(1)
}
}

165
cmd/patcher/main.go Normal file
View File

@ -0,0 +1,165 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"kisekinopureya.com.tr/updater/internal/archive"
"kisekinopureya.com.tr/updater/internal/logger"
mountfilesystem "kisekinopureya.com.tr/updater/internal/mountFilesystem"
"kisekinopureya.com.tr/updater/internal/plymouth"
)
type BlockDevice struct {
Name string `json:"name"`
Type string `json:"type"`
PartType string `json:"parttype"`
Children []BlockDevice `json:"children,omitempty"`
}
type LSBLK struct {
BlockDevices []BlockDevice `json:"blockdevices"`
}
type partitionScheme struct {
EfiDevice string
RootfsDevice string
DmVerityDevice string
//VarDevice string
}
var efiUUID string = "c12a7328-f81f-11d2-ba4b-00a0c93ec93b"
var rootfsUUID string = "4f68bce3-e8cd-4db1-96e7-fbcaf984b709"
var dmverityUUID string = "2c7357ed-ebd2-46d9-aec1-23d437ec2bf5"
//var varUUID string = "4d21b016-b534-45c2-a9fb-5c16e091fd2d"
func findByPartType(devices []BlockDevice, target string) string {
for _, dev := range devices {
if dev.PartType != "" && dev.PartType == target {
return dev.Name
}
if len(dev.Children) > 0 {
if found := findByPartType(dev.Children, target); found != "" {
return found
}
}
}
return ""
}
func populatePartitionScheme() partitionScheme {
var generatedScheme partitionScheme = partitionScheme{}
cmd := exec.Command("lsblk", "-J", "-o", "NAME,TYPE,PARTTYPE")
blockDeviceTypes, err := cmd.Output()
if err != nil {
log.Panic(err)
}
var lsblk LSBLK
if err := json.Unmarshal(blockDeviceTypes, &lsblk); err != nil {
log.Panic(err)
}
generatedScheme.EfiDevice = findByPartType(lsblk.BlockDevices, efiUUID)
generatedScheme.RootfsDevice = findByPartType(lsblk.BlockDevices, rootfsUUID)
generatedScheme.DmVerityDevice = findByPartType(lsblk.BlockDevices, dmverityUUID)
return generatedScheme
}
func patchImage(imageFile string, partition string, name string) {
cmd := exec.Command("dd", "if="+imageFile, "of="+"/dev/"+partition, "bs=4M")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
plymouth.ShowPlymouthMessage("Patching " + name + " partition")
if err := cmd.Run(); err != nil {
log.Panic(err)
}
}
func CopyFile(source, destDir string) (string, error) {
if err := os.MkdirAll(destDir, 0755); err != nil {
return "", fmt.Errorf("mkdir failed: %w", err)
}
in, err := os.Open(source)
if err != nil {
return "", fmt.Errorf("open src failed: %w", err)
}
defer in.Close()
dstPath := filepath.Join(destDir, filepath.Base(source))
out, err := os.Create(dstPath)
if err != nil {
return "", fmt.Errorf("create dst failed: %w", err)
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return "", fmt.Errorf("copy failed: %w", err)
}
if err := out.Sync(); err != nil {
return "", fmt.Errorf("sync failed: %w", err)
}
return dstPath, nil
}
func main() {
logger.InitializeLogger("/mnt/var/log/patcher")
if len(os.Args) != 2 {
fmt.Printf("Usage: %s <metadata>\n", os.Args[0])
os.Exit(1)
}
f, err := os.Open(os.Args[1])
if err != nil {
panic(err)
}
defer f.Close()
var meta archive.Output
if err := json.NewDecoder(f).Decode(&meta); err != nil {
panic(err)
}
currentPartitionScheme := populatePartitionScheme()
fileCount := len(meta.Files)
// before 20 and after 80 will be filled by initramfs
leftProgress := 60/fileCount + 20
for _, f := range meta.Files {
plymouth.ShowPlymouthProgress(leftProgress)
leftProgress += leftProgress
imageFile := f.Name
imageType := f.Type
switch imageType {
case "rootfs":
patchImage(imageFile, currentPartitionScheme.RootfsDevice, imageType)
case "dm-verity":
patchImage(imageFile, currentPartitionScheme.DmVerityDevice, imageType)
case "kernel":
mountPath := "/updates-tmp/efi"
kernelDir := "/EFI/Linux"
mountfilesystem.MountFileSystem("/dev/"+currentPartitionScheme.EfiDevice, mountPath, "vfat", 0, "")
CopyFile(imageFile, mountPath+kernelDir)
mountfilesystem.UnmountFileSystem(mountPath)
default:
}
}
}

125
cmd/steamos-update/main.go Normal file
View File

@ -0,0 +1,125 @@
package main
import (
"bufio"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"kisekinopureya.com.tr/updater/internal/downloader"
"kisekinopureya.com.tr/updater/internal/logger"
"kisekinopureya.com.tr/updater/internal/version"
)
/*
The Steam client is known to call this program with the following parameter combinations:
steamos-update --supports-duplicate-detection -- should do nothing
steamos-update --enable-duplicate-detection check -- should check for update
steamos-update check -- should check for update
steamos-update --enable-duplicate-detection -- should perform an update
steamos-update -- should perform an update
*/
func main() {
logger.InitializeLogger("/tmp/steamos-updatelog")
log.Printf("%+v", os.Args)
args := os.Args[1:]
action := performUpdate
switch {
case len(args) == 1 && args[0] == "--supports-duplicate-detection":
os.Exit(7)
case len(args) >= 1 && args[0] == "--enable-duplicate-detection":
if len(args) >= 2 && args[1] == "check" {
action = checkForUpdate
}
case len(args) >= 1 && args[0] == "check":
action = checkForUpdate
}
action()
os.Exit(7)
}
func fetchIndex(url string) ([]string, error) {
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch index: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("bad status: %s", resp.Status)
}
var lines []string
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" {
lines = append(lines, line)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("reading response failed: %w", err)
}
return lines, nil
}
func checkForUpdate() {
branch := version.GetCurrentBranch()
currentVersion := version.DetermineCurrentVersion()
currentVersionInt, err := strconv.ParseInt(currentVersion, 10, 32)
if err != nil {
currentVersionInt = 0 //
}
indexFile, err := fetchIndex("http://10.26.1.3/updates/index")
if err != nil {
log.Panic(err)
}
for _, s := range indexFile {
if strings.HasPrefix(s, string(branch)) && !strings.HasSuffix(s, ".sig") {
parts := strings.Split(s, "/")
base := parts[len(parts)-1]
// Remove extension
base = strings.TrimSuffix(base, ".tar.xz")
// Split by dash
tokens := strings.Split(base, "-")
versionFromFile := tokens[len(tokens)-1]
versionFromFileInt, err := strconv.ParseInt(versionFromFile, 10, 32)
if err != nil {
log.Panic(err)
}
if currentVersionInt < versionFromFileInt {
fmt.Printf("Update available: %s\n", base)
os.Exit(0)
}
}
}
}
func performUpdate() {
branch := version.GetCurrentBranch()
err := downloader.DownloadFile("http://10.26.1.3/updates/"+branch+"/latest.tar.xz.sig", "/var/cache/updates/update.tar.xz.sig", true)
if err != nil {
log.Panic(err)
}
err = downloader.DownloadFile("http://10.26.1.3/updates/"+branch+"/latest.tar.xz", "/var/cache/updates/update.tar.xz", false)
if err != nil {
log.Panic(err)
}
os.Exit(0)
}

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module kisekinopureya.com.tr/updater
go 1.24.6
require golang.org/x/sys v0.36.0

2
go.sum Normal file
View File

@ -0,0 +1,2 @@
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=

207
internal/archive/packer.go Normal file
View File

@ -0,0 +1,207 @@
package archive
import (
"archive/tar"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"time"
"kisekinopureya.com.tr/updater/internal/version"
)
// Output metadata structure
type Output struct {
Metadata struct {
Date int64 `json:"date"`
Version string `json:"version"`
} `json:"metadata"`
Files []struct {
Name string `json:"name"`
Type string `json:"type"`
Sha256 string `json:"sha256"`
} `json:"files"`
}
type fileInfo struct {
Name string
Type string
Path string
Sha256 string
}
// Pack creates a tar.xz archive with metadata.json and the given images
func Pack(rootfsPath, verityPath, kernelPath, outPath string) error {
paths := []struct {
Path string
Type string
}{
{rootfsPath, "rootfs"},
{verityPath, "dm-verity"},
{kernelPath, "kernel"},
}
var infos []fileInfo
for _, p := range paths {
log.Printf("Hashing %s...", p.Path)
sum, err := sha256File(p.Path)
if err != nil {
return fmt.Errorf("hashing %s: %w", p.Path, err)
}
infos = append(infos, fileInfo{
Name: filepath.Base(p.Path),
Type: p.Type,
Path: p.Path,
Sha256: sum,
})
}
output, err := exec.Command("blkid", rootfsPath).Output()
if err != nil {
log.Fatal(err)
}
versionFromImage, err := version.Parse(string(output))
if err != nil {
log.Fatal()
}
var out Output
out.Metadata.Date = time.Now().Unix()
// NOTE: version should be filled by caller if needed
out.Metadata.Version = versionFromImage
for _, fi := range infos {
out.Files = append(out.Files, struct {
Name string `json:"name"`
Type string `json:"type"`
Sha256 string `json:"sha256"`
}{
Name: fi.Name,
Type: fi.Type,
Sha256: fi.Sha256,
})
}
metaBytes, err := json.MarshalIndent(out, "", " ")
if err != nil {
return fmt.Errorf("marshal metadata: %w", err)
}
log.Printf("Metadata: %+v\n", out)
if err := writeTarXz(outPath, metaBytes, infos); err != nil {
return fmt.Errorf("create tar.xz: %w", err)
}
sigFile := outPath + ".sig"
cmd := exec.Command("gpg", "--output", sigFile, "--detach-sign", "--armor", outPath)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
panic(fmt.Errorf("gpg signing failed: %w", err))
}
fmt.Printf("Created %s and signed as %s\n", outPath, sigFile)
return nil
}
func sha256File(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func writeTarXz(outPath string, metadata []byte, files []fileInfo) error {
outFile, err := os.Create(outPath)
if err != nil {
return fmt.Errorf("create output: %w", err)
}
defer outFile.Close()
cmd := exec.Command("xz", "-z", "-c")
cmd.Stdout = outFile
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
tw := tar.NewWriter(stdin)
// metadata.json
metaHeader := &tar.Header{
Name: "metadata.json",
Mode: 0644,
Size: int64(len(metadata)),
}
if err := tw.WriteHeader(metaHeader); err != nil {
return err
}
if _, err := tw.Write(metadata); err != nil {
return err
}
// files
for _, fi := range files {
if err := addFileToTar(tw, fi.Path, fi.Name); err != nil {
return err
}
}
if err := tw.Close(); err != nil {
return err
}
if err := stdin.Close(); err != nil {
return err
}
if err := cmd.Wait(); err != nil {
return err
}
return nil
}
func addFileToTar(tw *tar.Writer, srcPath, destName string) error {
f, err := os.Open(srcPath)
if err != nil {
return err
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return err
}
hdr := &tar.Header{
Name: destName,
Mode: int64(stat.Mode().Perm()),
Size: stat.Size(),
ModTime: stat.ModTime(),
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
_, err = io.Copy(tw, f)
return err
}

View File

@ -0,0 +1,81 @@
package downloader
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
)
func DownloadFile(url, filepath string, silent bool) error {
var start int64
if info, err := os.Stat(filepath); err == nil {
start = info.Size()
}
out, err := os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer out.Close()
if start > 0 {
if _, err := out.Seek(start, 0); err != nil {
return err
}
}
// HTTP request with Range header for resume
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
if start > 0 {
req.Header.Set("Range", "bytes="+strconv.FormatInt(start, 10)+"-")
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
os.Exit(1)
}
// Get total size for progress calculation
var total int64
if resp.StatusCode == http.StatusPartialContent {
contentRange := resp.Header.Get("Content-Range") // e.g., bytes 1234-9999/10000
var sizeStr string
fmt.Sscanf(contentRange, "bytes %*d-%*d/%s", &sizeStr)
total, _ = strconv.ParseInt(sizeStr, 10, 64)
} else if resp.ContentLength > 0 {
total = start + resp.ContentLength
}
buf := make([]byte, 32*1024)
var downloaded = start
for {
n, err := resp.Body.Read(buf)
if n > 0 {
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
return writeErr
}
downloaded += int64(n)
if total > 0 && !silent {
fmt.Printf("\r%.2f%%", float64(downloaded)*100/float64(total))
}
}
if err != nil {
if err == io.EOF {
break
}
return err
}
}
return nil
}

23
internal/logger/logger.go Normal file
View File

@ -0,0 +1,23 @@
package logger
import (
"log"
"os"
"sync"
)
var once sync.Once
func InitializeLogger(filePath string) error {
var err error
once.Do(func() {
logFile, e := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if e != nil {
err = e
return
}
log.SetOutput(logFile)
log.SetFlags(log.LstdFlags | log.Lshortfile)
})
return err
}

View File

@ -0,0 +1,34 @@
package mountfilesystem
import (
"fmt"
"log"
"os"
"golang.org/x/sys/unix"
"kisekinopureya.com.tr/updater/internal/logger"
)
func MountFileSystem(source string, target string, fsType string, flags int, data string) {
logger.InitializeLogger("/tmp/mountLog")
if err := unix.Mkdir(target, 0755); err != nil && !os.IsExist(err) {
log.Panic(err)
}
err := unix.Mount(source, target, fsType, uintptr(flags), data)
if err != nil {
log.Panic(fmt.Errorf("mount failed: %w", err))
}
fmt.Println("Mounted successfully at", target)
}
func UnmountFileSystem(target string) {
logger.InitializeLogger("/tmp/unmountLog")
err := unix.Unmount(target, 0)
if err != nil {
log.Panic(err)
}
fmt.Println("Unmounted successfully at", target)
}

View File

@ -0,0 +1,24 @@
package plymouth
import (
"fmt"
"os/exec"
"strconv"
)
func ShowPlymouthMessage(message string) {
cmd := exec.Command("plymouth", "update", "--status=\""+message+"\"")
if err := cmd.Run(); err != nil {
fmt.Println(message)
}
}
func ShowPlymouthProgress(progress int) {
if progress < 100 && progress > 0 {
progressStr := strconv.Itoa(progress)
cmd := exec.Command("plymouth", "system-update", "--progress="+progressStr+"\"")
if err := cmd.Run(); err != nil {
fmt.Printf("Progress: %s%%", progressStr)
}
}
}

View File

@ -0,0 +1,41 @@
package version
import (
"bufio"
"os"
"strings"
)
func GetCurrentBranch() string {
branch, err := os.ReadFile(BranchPath)
if err != nil {
return "stable"
}
return string(branch)
}
func DetermineCurrentVersion() string {
file, err := os.Open("/etc/os-release")
if err != nil {
panic(err)
}
defer file.Close()
var lastLine string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lastLine = scanner.Text()
}
if err := scanner.Err(); err != nil {
panic(err)
}
parts := strings.SplitN(lastLine, "=", 2)
if len(parts) != 2 {
panic("invalid format")
}
value := parts[1]
currentVersion := strings.Trim(value, "\"")
return currentVersion
}

18
internal/version/parse.go Normal file
View File

@ -0,0 +1,18 @@
package version
import (
"fmt"
"regexp"
)
var BranchPath = "/var/lib/os-branch"
// Parse extracts version from LABEL="rootfs-XXXX"
func Parse(label string) (string, error) {
re := regexp.MustCompile(`LABEL="rootfs-([0-9]+)"`)
m := re.FindStringSubmatch(label)
if len(m) < 2 {
return "", fmt.Errorf("version not found in: %s", label)
}
return m[1], nil
}