Initial version

This commit is contained in:
Tom Marshall 2020-07-16 23:38:47 +02:00
parent 1b3fe72e6b
commit 21425b4b3a
2 changed files with 292 additions and 0 deletions

76
README.md Normal file
View File

@ -0,0 +1,76 @@
## This is a bare minimum OTA server for Lineage
## Quickstart
1. Create a configuration file named `.lineageupdaterrc` in the home
directory of the web server user, usually `/var/www`. This is a
simple key=value file. Required keys are:
* _directory_: the local directory where OTA zip files are stored.
* _baseurl_: the web accessible URL to the same directory.
2. Create a rewrite rule on the web server to translate the "pretty"
path to CGI parameters. This works for lighttpd:
$HTTP["host"] == "ota.example.com" {
url.rewrite = (
"^/api/v1/([^/]+)/([^/]+)/([^/]+)$" => "/cgi-bin/lineage-updater?device=$1&type=$2&incr=$3"
)
}
## Usage
The script expects OTA files to be named as follows:
_project_\__device_-ota-_version_-_buildtype_-_incremental_.zip
Where:
* _project_ is the project/ROM name, eg. "lineage".
* _device_ is the device name, eg. "mako".
* _version_ is the project/ROM version, eg. "16.0".
* _buildtype_ is the buildtype, eg. "unofficial".
* _incremental_ is the incremental version, eg. "eng.user.20200807.162000".
The default OTA and target-files file names include all of these fields
except _incremental_. This is the value of `ro.build.version.incremental`
in the system build properties. It is different from `ro.build.date.utc`.
They are __not__ interchangeable.
Example: lineage_mako-ota-16.0-unofficial-eng.user.20200807.162000.zip
The OTA directory may contain subdirectories of arbitrary depth.
## Time stamps
Clients determine whether an update is newer than the currently running
version by comparing the local `ro.build.date.utc` property with the
`datetime` on each OTA file. The script uses the file modification
time as the `datetime`. Therefore, you should ensure that these values
match for each available OTA file.
One way to do this is to set the file modification time on your build
server as shown below. You then only need to ensure that the file
modification time is preserved when you copy it to its destination on
your web server.
t=$(grep "ro.build.date.utc" "out/target/product/$device/system/build.prop" | cut -d'=' -f2)
touch -d "@$t" $filename
Another way to do this is to set the file modification time after copying
to your web server.
t=$(unzip -c $filename "META-INF/com/android/metadata" | grep "^post-timestamp=" | cut -d"=" -f2)
touch -d "@$t" $filename
## Caching
The script creates and uses a cache file named `.cache` in the OTA
directory. This is a simple JSON file describing the results of the
last walk through the OTA directory. If the script does not have
permission to write to this file, caching will fail and the script will
be exceptionally slow.
The cache entry for a given OTA file is updated if the file size or
modification time changes.
Cache entries for files that no longer exist are pruned.
The cache file is updated at most once per minute. If you do not see
new files immediately, wait 60 seconds and try again.
The cache file is locked during access to ensure its integrity.

216
lineage-updater Executable file
View File

@ -0,0 +1,216 @@
#!/usr/bin/python
import os
import sys
import pwd
import getopt
import re
import cgi
import hashlib
import json
import time
import fcntl
def die(msg):
sys.stdout.write("X-Error: %s\n" % (msg))
sys.exit(0)
def dbg(msg):
sys.stdout.write("X-Debug: %s\n" % (msg))
def cgi_method():
return os.environ.get('REQUEST_METHOD')
def write_response(obj):
headers = ""
headers += "Content-Type: application/json\n"
content = json.dumps(obj, indent=2)
sys.stdout.write("%s\n%s\n" % (headers, content))
def config_load(pathname):
required_keys = ['baseurl', 'directory']
if not os.path.exists(pathname):
raise RuntimeError("Configuration file %s does not exist" % (pathname))
config['debug'] = False
config['verbose'] = False
with open(pathname, 'r') as f:
for line in f:
line = line.rstrip('\n')
if len(line) == 0 or line.startswith('#'):
continue
(k, v) = line.split('=', 1)
config[k] = v
for k in required_keys:
if not k in config:
raise RuntimeError("Configuration missing required key \"%s\"" % (k))
def args_parse():
args = dict()
try:
values = cgi.parse()
except Exception as e:
raise RuntimeError(str(e))
for k in ['device', 'type', 'incr']:
arg = values.get(k, [])
if len(arg) != 1:
raise RuntimeError("No %s specified" % (k))
args[k] = arg[0]
return args
def file_hash(pathname):
f = open(pathname)
buf = f.read()
f.close()
hasher = hashlib.sha1()
hasher.update(buf)
return hasher.hexdigest()
def filename_properties(pathname):
filename = os.path.basename(pathname)
if not filename.endswith('.zip'):
raise ValueError("Not an OTA file")
fields = filename[:-4].split('-')
if len(fields) != 5:
raise ValueError("Incorrect number of fields in filename")
if fields[1] != 'ota':
raise ValueError("Not an OTA file")
(project, device) = fields[0].split('_', 1)
props = dict()
props['project'] = project
props['device'] = device
props['version'] = fields[2]
props['buildtype'] = fields[3]
props['incremental'] = fields[4]
return props
def incremental_stamp(incremental):
(tag, user, datestamp, timestamp) = incremental.split('.')
return "%s.%s" % (datestamp, timestamp)
def cache_refresh(cache):
stale = False
topdir = config['directory']
for root, dirs, files in os.walk(topdir):
relpath = ''
if root != topdir:
relpath = "%s" % (root[len(topdir):])
for filename in files:
pathname = "%s/%s" % (root, filename)
try:
props = filename_properties(pathname)
except ValueError:
continue
st = os.stat(pathname)
if (not pathname in cache or
st.st_size != cache[pathname]['size'] or
int(st.st_mtime) != cache[pathname]['datetime']):
hash = file_hash(pathname)
obj = dict()
obj['datetime'] = int(st.st_mtime)
obj['filename'] = filename
obj['id'] = hash
obj['romtype'] = props['buildtype']
obj['size'] = st.st_size
obj['url'] = "%s%s/%s" % (config['baseurl'], relpath, filename)
obj['version'] = props['version']
cache[pathname] = obj
stale = True
for k in list(cache):
if not os.path.exists(k):
del cache[k]
stale = True
return stale
def find_roms(device=None, buildtype=None, incremental=None):
cache = dict()
cf = None
try:
cn = "%s/.cache" % (config['directory'])
ct = os.stat(cn).st_mtime
cf = open(cn, 'r+')
fcntl.lockf(cf, fcntl.LOCK_EX)
except:
dbg("Failed to open cache file")
pass
update_cache = False
try:
cache = json.load(cf)
except:
dbg("Failed to read cache file")
update_cache = True
if cf is None or ct < time.time() - 60:
update_cache = cache_refresh(cache)
if cf is not None:
if update_cache:
cf.seek(0, 0)
try:
json.dump(cache, cf)
except:
pass
fcntl.lockf(cf, fcntl.LOCK_UN)
roms = []
for k, v in cache.items():
try:
props = filename_properties(k)
except ValueError:
dbg("Failed to parse cache filename")
continue
if device and props['device'] != device:
continue
if buildtype and props['buildtype'] != buildtype:
continue
if incremental:
try:
req_incr_stamp = incremental_stamp(incremental)
except ValueError:
continue
try:
ota_incr_stamp = incremental_stamp(props['incremental'])
except ValueError:
dbg("Failed to parse cache incremental field")
continue
if ota_incr_timestamp <= req_incr_timestamp:
continue
roms.append(v)
return roms
### Begin main script ###
uid = os.getuid()
pwent = pwd.getpwuid(uid)
home = pwent[5]
config = dict()
try:
config_load("%s/.lineageupdaterrc" % (home))
except RuntimeError as e:
die("Failed to load configuration: %s" % (str(e)))
try:
args = args_parse()
except RuntimeError as e:
die("Failed to parse arguments: %s\n" % (str(e)))
if cgi_method() != "GET":
die("Invalid method")
try:
obj = dict()
obj['response'] = find_roms(args['device'], args['type'], args['incr'])
write_response(obj)
except BaseException as e:
die("Failed: %s" % (str(e)))