Initial commit: QSafe Android 1.1.0 and Windows 1.0.4

This commit is contained in:
Курнат Андрей
2026-11-01 15:28:12 +03:00
commit 9845944bb8
91 changed files with 6496 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
#!/bin/bash
set -euo pipefail
umask 077
stage="$(cd "$(dirname "$0")" && pwd)"
mountpoint -q /mnt/data || { echo 'External disk is not mounted'; exit 1; }
findmnt -n -o OPTIONS /mnt/data | tr ',' '\n' | grep -qx rw
test -f "$stage/token.sha256"
if ! id qsafe >/dev/null 2>&1; then useradd --system --home-dir /nonexistent --shell /usr/sbin/nologin qsafe; fi
install -d -o root -g root -m 755 /opt/qsafe
install -d -o qsafe -g qsafe -m 700 /mnt/data/qsafe/data
install -d -o root -g qsafe -m 750 /etc/qsafe
install -d -o root -g root -m 700 /var/backups/qsafe /var/backups/qsafe-install
install -m 644 "$stage/server.py" "$stage/backup.py" /opt/qsafe/
if test -f /etc/qsafe/token.sha256; then
cmp -s "$stage/token.sha256" /etc/qsafe/token.sha256 || { echo 'Existing connection key differs; refusing to rotate it'; exit 1; }
else
install -o root -g qsafe -m 640 "$stage/token.sha256" /etc/qsafe/token.sha256
fi
cat > /etc/systemd/system/qsafe.service <<'EOF'
[Unit]
Description=QSafe encrypted vault synchronization
After=network.target
RequiresMountsFor=/mnt/data/qsafe/data
[Service]
Type=simple
User=qsafe
Group=qsafe
UMask=0077
ExecStart=/usr/bin/python3 /opt/qsafe/server.py --data /mnt/data/qsafe/data --token-hash-file /etc/qsafe/token.sha256
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/mnt/data/qsafe/data
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
MemoryMax=256M
TasksMax=48
[Install]
WantedBy=multi-user.target
EOF
cat > /etc/systemd/system/qsafe-backup.service <<'EOF'
[Unit]
Description=QSafe verified encrypted backup to SD card
RequiresMountsFor=/mnt/data/qsafe/data
[Service]
Type=oneshot
UMask=0077
ExecStart=/usr/bin/python3 /opt/qsafe/backup.py /mnt/data/qsafe/data/vault.sqlite3 /var/backups/qsafe
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/backups/qsafe /mnt/data/qsafe/data
EOF
cat > /etc/systemd/system/qsafe-backup.timer <<'EOF'
[Unit]
Description=Daily QSafe encrypted backup
[Timer]
OnCalendar=*-*-* 04:25:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
EOF
: "${QSAFE_LAN_IP:?Set the Pi private IPv4; public vault endpoints are forbidden}"
python3 "$stage/lan-only.py" --ip "$QSAFE_LAN_IP"
systemctl daemon-reload
systemctl enable --now qsafe.service qsafe-backup.timer
systemctl restart qsafe.service
for i in $(seq 1 20); do
if curl -fsS http://127.0.0.1:18792/health; then break; fi
sleep 1
done
systemctl start qsafe-backup.service
systemctl is-active qsafe.service caddy.service qsafe-backup.timer
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Migrate only QSafe to a private-IP TLS listener; preserve Argus updates and vault data."""
import argparse
import hashlib
import ipaddress
import json
import os
from pathlib import Path
import shutil
import subprocess
import time
def run(*args):
subprocess.run(args, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
def main():
p = argparse.ArgumentParser()
p.add_argument('--ip', required=True)
p.add_argument('--port', type=int, default=18443)
a = p.parse_args()
ip = ipaddress.IPv4Address(a.ip)
if not any(ip in n for n in map(ipaddress.IPv4Network, ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'])) or not 1024 <= a.port <= 65535:
raise SystemExit('A private LAN IPv4 and unprivileged TLS port are required')
links = json.loads(subprocess.check_output(['ip', '-j', '-4', 'address', 'show']))
match = [(link['ifname'], address['prefixlen']) for link in links for address in link.get('addr_info', []) if address.get('local') == str(ip)]
if len(match) != 1 or not match[0][0].startswith(('wl', 'eth', 'en')):
raise SystemExit('IP must belong to a physical LAN interface')
subnet = ipaddress.IPv4Network(f'{ip}/{match[0][1]}', strict=False)
if subnet.prefixlen < 16:
raise SystemExit('LAN prefix is too broad')
config = Path('/etc/caddy/Caddyfile')
site = Path('/etc/caddy/sites/qsafe-lan.caddy')
original = config.read_text()
if original.count('# BEGIN QSafe') == 0 and original.count('# END QSafe') == 0:
needle = 'argus.kusoft.xyz {\n'
if original.count(needle) != 1: raise SystemExit('Expected Argus site; no change made')
original = original.replace(needle, needle + '\t# BEGIN QSafe\n\t# END QSafe\n', 1)
if original.count('# BEGIN QSafe\n') != 1 or original.count('# END QSafe\n') != 1:
raise SystemExit('Expected exactly one owned QSafe block; no change made')
start = original.index('\t# BEGIN QSafe')
end = original.index('\n', original.index('# END QSafe'))
replacement = '''\t# BEGIN QSafe
\t@qsafe_disabled path /qsafe /qsafe/*
\thandle @qsafe_disabled {
\t\trespond 404
\t}
\t# END QSafe'''
backup = Path('/var/backups/qsafe-lan') / time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())
backup.mkdir(parents=True, mode=0o700)
shutil.copy2(config, backup / 'Caddyfile')
if site.exists(): shutil.copy2(site, backup / 'qsafe-lan.caddy')
tls_dir = Path('/etc/caddy/qsafe-lan')
tls_dir.mkdir(mode=0o750, exist_ok=True)
shutil.chown(tls_dir, user='root', group='caddy'); os.chmod(tls_dir, 0o750)
cert, key = tls_dir / 'lan.crt', tls_dir / 'lan.key'
if cert.exists() != key.exists(): raise SystemExit('Incomplete existing TLS identity; no change made')
if not cert.exists():
run('openssl', 'req', '-x509', '-newkey', 'rsa:3072', '-sha256', '-nodes', '-days', '3650', '-subj', '/CN=QSafe LAN', '-addext', f'subjectAltName=IP:{ip}', '-addext', 'extendedKeyUsage=serverAuth', '-keyout', str(key), '-out', str(cert))
run('openssl', 'x509', '-in', str(cert), '-noout', '-checkip', str(ip))
for file in (cert, key):
shutil.chown(file, user='root', group='caddy'); os.chmod(file, 0o640)
lan = f'''# QSafe: private LAN listener, no ACME or public hostname.
https://{ip}:{a.port} {{
bind {ip}
tls {cert} {key}
@foreign not remote_ip {subnet}
respond @foreign 403
handle_path /qsafe/* {{
request_body {{
max_size 32MB
}}
reverse_proxy 127.0.0.1:18792
}}
handle {{
respond 404
}}
}}
'''
try:
config.write_text(original[:start] + replacement + original[end:])
site.write_text(lan)
run('caddy', 'validate', '--config', str(config), '--adapter', 'caddyfile')
run('systemctl', 'reload', 'caddy')
except BaseException:
shutil.copy2(backup / 'Caddyfile', config)
if (backup / 'qsafe-lan.caddy').exists(): shutil.copy2(backup / 'qsafe-lan.caddy', site)
else: site.unlink(missing_ok=True)
raise
der = subprocess.check_output(['openssl', 'x509', '-in', str(cert), '-outform', 'DER'])
print(json.dumps({'url': f'https://{ip}:{a.port}/qsafe/', 'homeSubnet': str(subnet), 'certificateSha256': hashlib.sha256(der).hexdigest(), 'publicVaultRoute': 'disabled', 'argusUpdates': 'unchanged', 'backup': str(backup)}))
if __name__ == '__main__': main()
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Publish a reviewed QSafe APK into the existing Argus catalog, with rollback and identity guards."""
import argparse
from contextlib import closing
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import sqlite3
import tempfile
import uuid
ROOT = Path('/mnt/data/argus').resolve()
SLUG, PACKAGE = 'qsafe', 'ru.kusoft.qsafe'
def digest(path):
h = hashlib.sha256()
with path.open('rb') as f:
for chunk in iter(lambda: f.read(1024 * 1024), b''):
h.update(chunk)
return h.hexdigest()
def check(db):
if db.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
raise RuntimeError('SQLite integrity check failed')
if db.execute('PRAGMA foreign_key_check').fetchall():
raise RuntimeError('SQLite foreign key check failed')
def publish(source, metadata):
version, code, size, sha = (metadata[k] for k in ['version', 'versionCode', 'size', 'sha256'])
if not re.fullmatch(r'\d+\.\d+\.\d+', version) or not isinstance(code, int) or code < 1 or not isinstance(size, int) or size < 1 or not re.fullmatch('[0-9a-f]{64}', sha):
raise ValueError('Invalid reviewed metadata')
if not source.is_file() or source.stat().st_size != size or digest(source) != sha:
raise ValueError('Reviewed APK does not match source')
with closing(sqlite3.connect(ROOT / 'argus.db', timeout=30)) as db:
db.execute('PRAGMA foreign_keys=ON')
check(db)
existing = db.execute('SELECT Id,AndroidPackageName FROM Apps WHERE Slug=?', (SLUG,)).fetchone()
if existing and existing[1] != PACKAGE:
raise RuntimeError('Slug is owned by another package')
if db.execute('SELECT Slug FROM Apps WHERE AndroidPackageName=? AND Slug<>?', (PACKAGE, SLUG)).fetchall():
raise RuntimeError('Another slug owns this Android package')
if existing:
same = db.execute('SELECT Id,StoredRelativePath FROM Releases WHERE CatalogAppId=? AND Version=? AND AndroidVersionCode=? AND Sha256=?', (existing[0], version, code, sha)).fetchone()
if same:
file = (ROOT / 'Packages' / same[1]).resolve()
file.relative_to(ROOT / 'Packages')
if not file.is_file() or digest(file) != sha:
raise RuntimeError('Existing publication has an invalid artifact')
return {'alreadyPublished': True, 'slug': SLUG, 'releaseId': same[0], 'sha256': sha}
previous = db.execute('SELECT MAX(AndroidVersionCode) FROM Releases WHERE CatalogAppId=?', (existing[0],)).fetchone()[0]
if metadata.get('expectedPreviousVersionCode') != previous or (previous is not None and code <= previous):
raise RuntimeError('Catalog changed or versionCode did not increase')
elif metadata.get('expectedPreviousVersionCode') is not None:
raise RuntimeError('Expected previous release is missing')
stamp = datetime.now(timezone.utc)
now = stamp.isoformat(timespec='microseconds')
backup_dir = ROOT / 'backups' / ('qsafe-' + version + '-' + stamp.strftime('%Y%m%dT%H%M%S%fZ'))
backup_dir.mkdir(mode=0o700)
with closing(sqlite3.connect(backup_dir / 'argus.db')) as saved:
db.backup(saved)
check(saved)
if existing:
for relative, in db.execute('SELECT StoredRelativePath FROM Releases WHERE CatalogAppId=?', (existing[0],)):
old = (ROOT / 'Packages' / relative).resolve()
old.relative_to(ROOT / 'Packages')
if old.is_file():
dest = backup_dir / 'Packages' / relative
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(old, dest)
app_id = existing[0] if existing else str(uuid.uuid4()).upper()
release_id = str(uuid.uuid4()).upper()
package_dir = ROOT / 'Packages' / SLUG
if package_dir.is_symlink():
raise RuntimeError('Package directory may not be a symlink')
package_dir.mkdir(exist_ok=True)
package_dir.resolve().relative_to(ROOT / 'Packages')
relative = SLUG + '/' + stamp.strftime('%Y%m%d%H%M%S') + '-' + version + '-' + release_id.replace('-', '') + '.apk'
target = ROOT / 'Packages' / relative
with tempfile.NamedTemporaryFile(dir=package_dir, prefix='.upload-', delete=False) as output:
temp = Path(output.name)
with source.open('rb') as src:
shutil.copyfileobj(src, output)
output.flush()
os.fsync(output.fileno())
temp.chmod(0o644)
if digest(temp) != sha:
temp.unlink()
raise RuntimeError('Staging hash mismatch')
temp.replace(target)
def unrelated():
rows = [db.execute('SELECT * FROM Apps WHERE Id<>? ORDER BY Id', (app_id,)).fetchall(), db.execute('SELECT * FROM Releases WHERE CatalogAppId<>? ORDER BY Id', (app_id,)).fetchall()]
return hashlib.sha256(json.dumps(rows, ensure_ascii=False).encode()).hexdigest()
committed = False
try:
db.execute('BEGIN IMMEDIATE')
if db.execute('SELECT Id,AndroidPackageName FROM Apps WHERE Slug=?', (SLUG,)).fetchone() != existing:
raise RuntimeError('Application catalog changed during preparation')
before = unrelated()
if existing:
previous = db.execute('SELECT MAX(AndroidVersionCode) FROM Releases WHERE CatalogAppId=?', (app_id,)).fetchone()[0]
if metadata.get('expectedPreviousVersionCode') != previous:
raise RuntimeError('Previous release changed during preparation')
db.execute('UPDATE Apps SET Name=?,Summary=?,Description=?,IsListed=1,UpdatedAt=? WHERE Id=?', ('QSafe', metadata['summary'], metadata['description'], now, app_id))
else:
db.execute('INSERT INTO Apps VALUES(?,?,?,?,?,NULL,NULL,?,1,?,?)', (app_id, SLUG, 'QSafe', metadata['summary'], metadata['description'], PACKAGE, now, now))
db.execute('INSERT INTO Releases VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)', (release_id, app_id, version, 'stable', 'android', 'apk', code, source.name, relative, 'application/vnd.android.package-archive', size, sha, metadata['notes'], now))
if unrelated() != before:
raise RuntimeError('Unrelated catalog data changed')
if db.execute('SELECT COUNT(*) FROM Releases WHERE CatalogAppId=?', (app_id,)).fetchone()[0] != 1:
raise RuntimeError('Expected a single retained release')
if db.execute('PRAGMA foreign_key_check').fetchall():
raise RuntimeError('Foreign key check failed before commit')
db.commit()
committed = True
finally:
if not committed:
db.rollback()
target.unlink(missing_ok=True)
check(db)
report = {'slug': SLUG, 'version': version, 'versionCode': code, 'size': size, 'sha256': sha, 'releaseId': release_id,
'backup': str(backup_dir), 'integrity': 'ok', 'unrelatedCatalogRowsUnchanged': True,
'catalogUrl': 'https://argus.kusoft.xyz/?app=qsafe',
'manifestUrl': 'https://argus.kusoft.xyz/api/apps/qsafe/manifest?platform=android&channel=stable',
'downloadUrl': f'https://argus.kusoft.xyz/api/apps/qsafe/releases/{release_id}/download'}
(backup_dir / 'publication.json').write_text(json.dumps(report, ensure_ascii=False, indent=2))
return report
if __name__ == '__main__':
p = argparse.ArgumentParser()
p.add_argument('apk', type=Path)
p.add_argument('metadata', type=Path)
args = p.parse_args()
print(json.dumps(publish(args.apk, json.loads(args.metadata.read_text())), ensure_ascii=False))
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
set -euo pipefail
stage="$(cd "$(dirname "$0")" && pwd)"
test ! -e /mnt/data/qsafe/qa/vault.sqlite3 || { echo 'QA vault already exists'; exit 1; }
install -d -o qsafe -g qsafe -m 700 /mnt/data/qsafe/qa
install -o root -g qsafe -m 640 "$stage/qa-token.sha256" /etc/qsafe/qa-token.sha256
systemd-run --unit=qsafe-qa --property=User=qsafe --property=Group=qsafe --property=UMask=0077 \
/usr/bin/python3 /opt/qsafe/server.py --port 18793 --data /mnt/data/qsafe/qa --token-hash-file /etc/qsafe/qa-token.sha256
python3 - <<'PY'
from pathlib import Path
p = Path('/etc/caddy/sites/qsafe-lan.caddy')
t = p.read_text()
if '# BEGIN QSafe QA' in t:
raise SystemExit('QA route already present')
needle = ' handle_path /qsafe/* {\n'
block = '''\t# BEGIN QSafe QA
\thandle_path /qsafe-qa/* {
\t\treverse_proxy 127.0.0.1:18793
\t}
\t# END QSafe QA
'''
assert t.count(needle) == 1, 'LAN-only QSafe site must be installed first'
p.write_text(t.replace(needle, block + needle, 1))
PY
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null
systemctl reload caddy
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
p = Path('/etc/caddy/sites/qsafe-lan.caddy')
t = p.read_text()
start = t.find('\t# BEGIN QSafe QA\n')
endmarker = '\t# END QSafe QA\n'
end = t.find(endmarker, start)
if start >= 0 and end >= 0:
p.write_text(t[:start] + t[end + len(endmarker):])
PY
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null
systemctl reload caddy
systemctl stop qsafe-qa
rm -f /etc/qsafe/qa-token.sha256
+44
View File
@@ -0,0 +1,44 @@
import hashlib
import sqlite3
import tempfile
import unittest
from pathlib import Path
import publish_argus as publisher
class PublicationTests(unittest.TestCase):
def test_catalog_retains_latest_with_backup_and_other_apps_unchanged(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
publisher.ROOT = root
(root / 'backups').mkdir()
(root / 'Packages' / 'qsafe').mkdir(parents=True)
old = root / 'Packages/qsafe/old.apk'
old.write_bytes(b'old synthetic APK')
with sqlite3.connect(root / 'argus.db') as db:
db.executescript('''
CREATE TABLE Apps (Id TEXT PRIMARY KEY, Slug TEXT, Name TEXT, Summary TEXT, Description TEXT, Icon TEXT, Extra TEXT, AndroidPackageName TEXT, IsListed INTEGER, CreatedAt TEXT, UpdatedAt TEXT);
CREATE TABLE Releases (Id TEXT PRIMARY KEY, CatalogAppId TEXT REFERENCES Apps(Id), Version TEXT, Channel TEXT, Platform TEXT, PackageKind TEXT, AndroidVersionCode INTEGER, OriginalFileName TEXT, StoredRelativePath TEXT, ContentType TEXT, PackageSizeBytes INTEGER, Sha256 TEXT, Notes TEXT, PublishedAt TEXT);
CREATE TRIGGER Argus_RetainLatestAfterInsert AFTER INSERT ON Releases BEGIN
DELETE FROM Releases WHERE CatalogAppId=NEW.CatalogAppId AND rowid NOT IN (SELECT rowid FROM Releases WHERE CatalogAppId=NEW.CatalogAppId ORDER BY AndroidVersionCode DESC LIMIT 1);
END;
INSERT INTO Apps VALUES ('qsafe-id','qsafe','QSafe','old','old',NULL,NULL,'ru.kusoft.qsafe',1,'old','old');
INSERT INTO Apps VALUES ('other-id','other','Other','keep','keep',NULL,NULL,'other.package',1,'old','old');
''')
db.execute('INSERT INTO Releases VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)', ('old-release', 'qsafe-id', '1.0.0', 'stable', 'android', 'apk', 3, 'old.apk', 'qsafe/old.apk', 'application/vnd.android.package-archive', old.stat().st_size, hashlib.sha256(old.read_bytes()).hexdigest(), 'old', 'old'))
new = root / 'QSafe-1.0.1.apk'
new.write_bytes(b'new synthetic APK')
metadata = dict(version='1.0.1', versionCode=4, size=new.stat().st_size, sha256=hashlib.sha256(new.read_bytes()).hexdigest(), expectedPreviousVersionCode=3, summary='new', description='new', notes='LAN only vault; Argus updates')
result = publisher.publish(new, metadata)
self.assertTrue(result['unrelatedCatalogRowsUnchanged'])
self.assertEqual(b'old synthetic APK', old.read_bytes())
with sqlite3.connect(root / 'argus.db') as db:
self.assertEqual(1, db.execute("SELECT COUNT(*) FROM Releases WHERE CatalogAppId='qsafe-id'").fetchone()[0])
self.assertEqual(('keep', 'keep'), db.execute("SELECT Summary,Description FROM Apps WHERE Id='other-id'").fetchone())
self.assertTrue(publisher.publish(new, metadata)['alreadyPublished'])
self.assertEqual(b'old synthetic APK', (Path(result['backup']) / 'Packages/qsafe/old.apk').read_bytes())
wrong = dict(metadata, version='1.0.2', versionCode=5)
with self.assertRaises(RuntimeError): publisher.publish(new, wrong)
if __name__ == '__main__': unittest.main()