Compare commits

..

23 Commits

Author SHA1 Message Date
6b2118b53f Update extra/product.mk
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/syncbuild Build is passing
continuous-integration/drone Build is passing
2024-11-11 06:25:50 -07:00
a09647cbaf go 1.23 isn't even out yet!
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2024-06-28 12:00:56 -06:00
9b3302dcf3 move from deadbeef.codes to code.stevenpolley.net
Some checks failed
continuous-integration/drone/push Build is failing
2024-06-28 11:55:35 -06:00
fb5b9864d9 Update README.md
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is failing
2023-09-11 17:38:31 +00:00
f72a33148d Update README.md
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-11 17:37:53 +00:00
5bdda0259a Add configurable baseURL (required for multi-device support)
All checks were successful
continuous-integration/drone/push Build is passing
2023-08-21 19:47:49 -06:00
0b5eb36b5a Update docker compose example for device specific folder
All checks were successful
continuous-integration/drone/push Build is passing
2023-08-19 21:58:25 -06:00
2921f5a76f update readme for device-specific ota URLs
All checks were successful
continuous-integration/drone/push Build is passing
2023-08-19 21:52:24 -06:00
1f3209f55e fix string formatting bug causing mangled id's
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-10 18:08:34 -06:00
238dab70fe more appropriate function name
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-30 20:14:34 -06:00
75668ad531 use simple type conversion instead fmt package 2023-06-30 20:12:44 -06:00
f308e4351a update comments 2023-06-30 20:11:49 -06:00
120d61d1b6 use read locks wherever we can
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-30 19:59:51 -06:00
21220b692f Update comment - needs to be RW lock to be effective
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-30 19:59:12 -06:00
b6e03db1ea more appropriate function name
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-30 19:56:25 -06:00
375e468a8e separate file handling functions from main file
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-30 19:50:47 -06:00
4e09ac34f2 the filename is used as the key, not the hash!
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-26 10:45:55 -06:00
aaf0abd08e fix concurrency race condition resulting in null json file
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-26 10:34:18 -06:00
3c5c99e340 add romCache file
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-24 13:36:23 -06:00
d17f7c50d8 .jpg
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-09 17:04:54 -06:00
12aed0fa26 add logo
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-09 17:04:15 -06:00
3e5bea532f Merge branch 'main' of https://deadbeef.codes/steven/lineageos-ota-server
All checks were successful
continuous-integration/drone/push Build is passing
2023-06-09 08:55:17 -06:00
c79519849a update comments 2023-06-09 08:55:11 -06:00
6 changed files with 198 additions and 133 deletions

View File

@ -3,7 +3,7 @@ name: default
workspace: workspace:
base: /go base: /go
path: src/deadbeef.codes/steven/lineageos-ota-server path: src/code.stevenpolley.net/steven/lineageos-ota-server
steps: steps:
@ -22,4 +22,4 @@ steps:
- name: package in docker container - name: package in docker container
image: plugins/docker image: plugins/docker
settings: settings:
repo: registry.deadbeef.codes/lineageos-ota-server repo: registry.stevenpolley.net/lineageos-ota-server

View File

@ -4,6 +4,11 @@
A highly-scalable and lightweight OTA Server for LineageOS. The OTA server supports a single device model (eg: Google Pixel), if you intend to have OTA's available for a variety of device models, just create an additional OTA server instance for each device. A highly-scalable and lightweight OTA Server for LineageOS. The OTA server supports a single device model (eg: Google Pixel), if you intend to have OTA's available for a variety of device models, just create an additional OTA server instance for each device.
![alt text][logo]
[logo]: https://deadbeef.codes/steven/lineageos-ota-server/raw/branch/main/ota-logo.jpg "LineageOS OTA Server"
### docker compose example ### docker compose example
The service listens on port 8080 by default. Mount the output directory for the builds of the device you wish to serve to the /out folder. Also, mount a persistent public directory which will be served publicly. The public folder can be shared across multiple OTA server instances if you wish. The service listens on port 8080 by default. Mount the output directory for the builds of the device you wish to serve to the /out folder. Also, mount a persistent public directory which will be served publicly. The public folder can be shared across multiple OTA server instances if you wish.
@ -19,7 +24,7 @@ version: '3.8'
- "8080" - "8080"
volumes: volumes:
- /data/android/lineage/out/target/product/sunfish:/out - /data/android/lineage/out/target/product/sunfish:/out
- /data/android/public:/public - /data/android/public/sunfish:/public
``` ```
@ -29,21 +34,17 @@ The recommended way is to include the configuration inside your build of the ROM
Create new file /data/android/lineage/vendor/lineage/build/core/deadbeef-ota.mk Create new file /data/android/lineage/vendor/lineage/build/core/deadbeef-ota.mk
```makefile
# deadbeef.codes LineageOS OTA update server - replace with your own URL
ADDITIONAL_SYSTEM_PROPERTIES += \
lineage.updater.uri=https://lineageos-ota.deadbeef.codes
```
Edit /data/android/lineage/vendor/lineage/build/core/main.mk to include deadbeef-ota.mk Edit (create if not exist) /data/android/lineage/vendor/extra/product.mk
```makefile ```makefile
# Include LineageOS versions
include $(TOPDIR)vendor/lineage/build/core/main_version.mk
# Include deadbeef.codes OTA server # Include deadbeef.codes OTA server
include $(TOPDIR)vendor/lineage/build/core/deadbeef-ota.mk PRODUCT_SYSTEM_DEFAULT_PROPERTIES += \
lineage.updater.uri=https://lineageos-ota-{device}.stevenpolley.net
# Default ADB shell
PRODUCT_SYSTEM_DEFAULT_PROPERTIES += \
persist.sys.adb.shell=/system_ext/bin/bash
``` ```

2
go.mod
View File

@ -1,3 +1,3 @@
module deadbeef.codes/steven/lineageos-ota-server module deadbeef.codes/steven/lineageos-ota-server
go 1.20 go 1.22

179
main.go
View File

@ -1,15 +1,12 @@
package main package main
import ( import (
"crypto/sha256"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
"os" "os"
"strings"
"sync" "sync"
) )
@ -18,9 +15,9 @@ type LineageOSROM struct {
Datetime int `json:"datetime"` // Unix timestamp - i.e. 1685907926 Datetime int `json:"datetime"` // Unix timestamp - i.e. 1685907926
Filename string `json:"filename"` // .zip filename - i.e. lineage-20.0-20230604-UNOFFICIAL-sunfish.zip Filename string `json:"filename"` // .zip filename - i.e. lineage-20.0-20230604-UNOFFICIAL-sunfish.zip
ID string `json:"id"` // A unique identifier such as a SHA256 hash of the .zip - i.e. 603bfc02e403e5fd1bf9ed74383f1d6c9ec7fb228d03c4b37753033d79488e93 ID string `json:"id"` // A unique identifier such as a SHA256 hash of the .zip - i.e. 603bfc02e403e5fd1bf9ed74383f1d6c9ec7fb228d03c4b37753033d79488e93
Romtype string `json:"romtype"` // i.e. nightly Romtype string `json:"romtype"` // i.e. NIGHTLY or UNOFFICIAL
Size int `json:"size"` // size of .zip file in bytes Size int `json:"size"` // size of .zip file in bytes
URL string `json:"url"` // public URL where client could download the .zip file URL string `json:"url"` // Accessible URL where client could download the .zip file
Version string `json:"version"` // LineageOS version - i.e. 20.0 Version string `json:"version"` // LineageOS version - i.e. 20.0
} }
@ -31,25 +28,51 @@ type HTTPResponseJSON struct {
// Caches data about available ROMs in memory so we don't need to reference the filesystem for each request // Caches data about available ROMs in memory so we don't need to reference the filesystem for each request
type ROMCache struct { type ROMCache struct {
ROMs []LineageOSROM ROMs []LineageOSROM `json:"roms"`
Cached map[string]bool // to quickly lookup if a file is already cached Cached map[string]bool `json:"-"` // to quickly lookup if a file is already cached
sync.Mutex // We have multiple goroutines that may be accessing this data simultaneously, so we much lock / unlock it to prevent race conditions sync.RWMutex `json:"-"` // We have multiple goroutines that may be accessing this data simultaneously, so we much lock / unlock it to prevent race conditions
} }
const ( const (
romDirectory = "public" // directory where ROMs are available for download romDirectory = "public" // directory where ROMs are available for download
buildOutDirectory = "out" // directory from build system containing artifacts which we can move to romDirectory buildOutDirectory = "out" // directory from build system containing artifacts which we can move to romDirectory
cacheFile = "public/romcache.json" // persistence between server restarts so we don't have to rehash all the ROM files each time the program starts
) )
var ( // evil global variable var ( // evil global variables
romCache ROMCache romCache ROMCache
baseURL string
) )
func init() { func init() {
// intialize and load ROMCache from file - so we don't have to rehash all the big files again
romCache = ROMCache{} romCache = ROMCache{}
baseURL = os.Getenv("baseurl")
if len(baseURL) < 1 {
log.Fatalf("required environment variable 'baseurl' is not set.")
}
romCacheJson, err := os.ReadFile(cacheFile)
if err != nil {
if err != os.ErrNotExist { // don't care if it doesn't exist, just skip it
log.Printf("failed to read romCache file : %v", err)
}
} else { // if opening the file's successful, then load the contents
err = json.Unmarshal(romCacheJson, &romCache)
if err != nil {
log.Printf("failed to unmarshal romCacheJson to romCache struct: %v", err)
}
}
romCache.Cached = make(map[string]bool) romCache.Cached = make(map[string]bool)
// Check if any new build artifacts and preload the romCache for _, rom := range romCache.ROMs {
romCache.Cached[rom.Filename] = true
log.Printf("loaded cached file: %s", rom.Filename)
}
// Check if any new build artifacts and load any new files into the romCache
moveBuildArtifacts() moveBuildArtifacts()
go updateROMCache() go updateROMCache()
} }
@ -84,22 +107,26 @@ func updateROMCache() {
return return
} }
wg := sync.WaitGroup{}
for i, v := range files { for i, v := range files {
isLineageROM, splitName := isLineageROMZip(v) isLineageROM, splitName := parseROMFileName(v)
if !isLineageROM { if !isLineageROM {
continue continue
} }
// skip already cached files // skip already cached files
romCache.Lock() romCache.RLock()
if _, ok := romCache.Cached[v.Name()]; ok { if _, ok := romCache.Cached[v.Name()]; ok {
romCache.Unlock() romCache.RUnlock()
continue continue
} }
romCache.Unlock() romCache.RUnlock()
wg.Add(1)
go func(v fs.DirEntry) { go func(v fs.DirEntry) {
defer wg.Done()
fInfo, err := v.Info() fInfo, err := v.Info()
if err != nil { if err != nil {
log.Printf("failed to get file info '%s': %v", v.Name(), err) log.Printf("failed to get file info '%s': %v", v.Name(), err)
@ -118,7 +145,7 @@ func updateROMCache() {
ID: fileHash, ID: fileHash,
Romtype: splitName[3], // UNOFFICIAL Romtype: splitName[3], // UNOFFICIAL
Size: int(fInfo.Size()), Size: int(fInfo.Size()),
URL: fmt.Sprintf("https://lineageos-ota.deadbeef.codes/public/%s", v.Name()), URL: fmt.Sprintf("%s/public/%s", baseURL, v.Name()),
Version: splitName[1], Version: splitName[1],
} }
@ -130,75 +157,47 @@ func updateROMCache() {
romCache.ROMs = append(romCache.ROMs, lineageOSROM) romCache.ROMs = append(romCache.ROMs, lineageOSROM)
romCache.Cached[v.Name()] = true romCache.Cached[v.Name()] = true
romCache.Unlock() romCache.Unlock()
}(files[i]) }(files[i])
} }
}
// returns true if new builds were moved // save file to disk for next startup so we don't have to rehash all the files again
func moveBuildArtifacts() bool { wg.Wait()
romCache.RLock()
f, err := os.Open(buildOutDirectory) romCacheJson, err := json.Marshal(romCache)
romCache.RUnlock()
if err != nil { if err != nil {
log.Printf("failed to open ROM directory: %v", err) log.Printf("failed to marshal romCache to json: %v", err)
return false return
} }
defer f.Close()
files, err := f.ReadDir(0) err = os.WriteFile(cacheFile, romCacheJson, 0644)
if err != nil { if err != nil {
log.Printf("failed to read files in directory: %v", err) log.Printf("failed to write '%s' file: %v", cacheFile, err)
return false log.Printf("attempting to remove '%s' to ensure integrity during next program startup...", cacheFile)
} err = os.Remove(cacheFile)
var newROMs bool
for _, v := range files {
if isLineageROM, _ := isLineageROMZip(v); !isLineageROM { // skip files that aren't LineageOS ROMs
continue
}
newROMs = true
log.Printf("new build found - moving file %s", v.Name())
romCache.Lock() // lock to prevent multiple concurrent goroutines moving the same file
err := moveBuildFile(fmt.Sprintf("%s/%s", buildOutDirectory, v.Name()), fmt.Sprintf("%s/%s", romDirectory, v.Name()))
romCache.Unlock()
if err != nil { if err != nil {
log.Printf("failed to move file '%s' from out to rom directory: %v", v.Name(), err) log.Printf("failed to remove file '%s': %v", cacheFile, err)
continue
} }
return
} }
return newROMs
}
// false if a file is not a LineageOS ROM .zip file
// no formal validation is performed - only file naming convention is checked
// also returns a lineage ROM's filename sliced and delimited by -'s
// Example filename: lineage-20.0-20230604-UNOFFICIAL-sunfish.zip
func isLineageROMZip(v fs.DirEntry) (bool, []string) {
// skip directories, non .zip files and files that don't begin with lineage-
if v.Type().IsDir() || !strings.HasSuffix(v.Name(), ".zip") || !strings.HasPrefix(v.Name(), "lineage-") {
return false, nil
}
splitName := strings.Split(v.Name(), "-")
// expect 5 dashes
return len(splitName) == 5, splitName
} }
// http - GET / // http - GET /
// Writes JSON response for the updater app to know what versions are available to download // The LineageOS updater app needs a JSON array of type LineageOSROM
// Marshal's romCache.ROMs to JSON to serve as the response body
func lineageOSROMListHandler(w http.ResponseWriter, r *http.Request) { func lineageOSROMListHandler(w http.ResponseWriter, r *http.Request) {
go func() { go func() { // Also checks for new builds - TBD need a better method as the first request will return no new updates. inotify?
newBuilds := moveBuildArtifacts() newBuilds := moveBuildArtifacts()
if newBuilds { if newBuilds {
updateROMCache() updateROMCache()
} }
}() }()
romCache.Lock() romCache.RLock()
httpResponseJSON := &HTTPResponseJSON{Response: romCache.ROMs} httpResponseJSON := &HTTPResponseJSON{Response: romCache.ROMs}
romCache.Unlock() romCache.RUnlock()
b, err := json.Marshal(httpResponseJSON) b, err := json.Marshal(httpResponseJSON)
if err != nil { if err != nil {
@ -209,55 +208,3 @@ func lineageOSROMListHandler(w http.ResponseWriter, r *http.Request) {
w.Write(b) w.Write(b)
} }
// Returns a sha256 hash of a file located at the path provided
func hashFile(filename string) (string, error) {
f, err := os.Open(filename)
if err != nil {
return "", fmt.Errorf("failed to open file '%s': %v: ", filename, err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", fmt.Errorf("failed to copy data from file to hash function: %v", err)
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
// A custom "move file" function because in docker container the mounted folders are different overlay filesystems
// Instead of os.Rename, we must copy and delete
func moveBuildFile(src, dst string) error {
sourceFileStat, err := os.Stat(src)
if err != nil {
return err
}
if !sourceFileStat.Mode().IsRegular() {
return fmt.Errorf("%s is not a regular file", src)
}
source, err := os.Open(src)
if err != nil {
return err
}
defer source.Close()
destination, err := os.Create(dst)
if err != nil {
return err
}
defer destination.Close()
_, err = io.Copy(destination, source)
if err != nil {
return fmt.Errorf("failed to copy file: %v", err)
}
err = os.Remove(src)
if err != nil {
return fmt.Errorf("failed to delete source file after copy: %v", err)
}
return nil
}

BIN
ota-logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

117
processFiles.go Normal file
View File

@ -0,0 +1,117 @@
package main
import (
"crypto/sha256"
"fmt"
"io"
"io/fs"
"log"
"os"
"strings"
)
// Searches the build toolchain output directory for new LineageOS builds.
// Any new builds are moved into the public http server directory.
// Returns true if new builds were moved
func moveBuildArtifacts() bool {
f, err := os.Open(buildOutDirectory)
if err != nil {
log.Printf("failed to open ROM directory: %v", err)
return false
}
defer f.Close()
files, err := f.ReadDir(0)
if err != nil {
log.Printf("failed to read files in directory: %v", err)
return false
}
var newROMs bool
for _, v := range files {
if isLineageROM, _ := parseROMFileName(v); !isLineageROM { // skip files that aren't LineageOS ROMs
continue
}
newROMs = true
log.Printf("new build found - moving file %s", v.Name())
romCache.Lock() // RW lock to prevent multiple concurrent goroutines moving the same file
err := copyThenDeleteFile(fmt.Sprintf("%s/%s", buildOutDirectory, v.Name()), fmt.Sprintf("%s/%s", romDirectory, v.Name()))
romCache.Unlock()
if err != nil {
log.Printf("failed to move file '%s' from out to rom directory: %v", v.Name(), err)
continue
}
}
return newROMs
}
// Returns a sha256 hash of a file located at the path provided
func hashFile(filename string) (string, error) {
f, err := os.Open(filename)
if err != nil {
return "", fmt.Errorf("failed to open file '%s': %v: ", filename, err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", fmt.Errorf("failed to copy data from file to hash function: %v", err)
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
// false if a file is not a LineageOS ROM .zip file
// no formal validation is performed - only file naming convention is checked
// also returns a lineage ROM's filename sliced and delimited by -'s
// Example filename: lineage-20.0-20230604-UNOFFICIAL-sunfish.zip
func parseROMFileName(v fs.DirEntry) (bool, []string) {
// skip directories, non .zip files and files that don't begin with lineage-
if v.Type().IsDir() || !strings.HasSuffix(v.Name(), ".zip") || !strings.HasPrefix(v.Name(), "lineage-") {
return false, nil
}
splitName := strings.Split(v.Name(), "-")
// expect 5 dashes
return len(splitName) == 5, splitName
}
// A custom "move file" function because in docker container the mounted folders are different overlay filesystems
// Instead of os.Rename, we must copy and delete
func copyThenDeleteFile(src, dst string) error {
sourceFileStat, err := os.Stat(src)
if err != nil {
return err
}
if !sourceFileStat.Mode().IsRegular() {
return fmt.Errorf("%s is not a regular file", src)
}
source, err := os.Open(src)
if err != nil {
return err
}
defer source.Close()
destination, err := os.Create(dst)
if err != nil {
return err
}
defer destination.Close()
_, err = io.Copy(destination, source)
if err != nil {
return fmt.Errorf("failed to copy file: %v", err)
}
err = os.Remove(src)
if err != nil {
return fmt.Errorf("failed to delete source file after copy: %v", err)
}
return nil
}