IQ.Pilot Prebuilt Release @ 27f668a
This commit is contained in:
250
iqpilot/common/git_creds.py
Normal file
250
iqpilot/common/git_creds.py
Normal file
@@ -0,0 +1,250 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from iqpilot.common.params import Params
|
||||
|
||||
PARAM = "GitAuthBlob"
|
||||
PARAMS_DIR = os.environ.get("PARAMS_DIR", "/data/params/d")
|
||||
KEY_DIR = "/data/konn3kt"
|
||||
KEY_PATH = os.path.join(KEY_DIR, "git_auth.key")
|
||||
HELPER_PATH = os.path.join(KEY_DIR, "git_credential_helper.py")
|
||||
DEFAULT_REPO_DIR = "/data/openpilot"
|
||||
CREDENTIAL_HOSTS = ("git.konn3kt.com", "gitlvb.teallvbs.xyz")
|
||||
|
||||
_HELPER_SCRIPT = '''#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
KEY_PATH = "{key_path}"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2 or sys.argv[1] != "get":
|
||||
return
|
||||
# drain git's request on stdin (terminated by a blank line)
|
||||
for line in sys.stdin:
|
||||
if not line.strip():
|
||||
break
|
||||
params_dir = os.environ.get("PARAMS_DIR", "/data/params/d")
|
||||
blob_path = os.path.join(params_dir, "GitAuthBlob")
|
||||
try:
|
||||
with open(KEY_PATH, "rb") as f:
|
||||
key = f.read().strip()
|
||||
with open(blob_path, "rb") as f:
|
||||
blob = f.read()
|
||||
if not blob:
|
||||
return
|
||||
from cryptography.fernet import Fernet
|
||||
data = json.loads(Fernet(key).decrypt(blob).decode())
|
||||
username = data.get("u", "")
|
||||
token = data.get("t", "")
|
||||
if username and token:
|
||||
sys.stdout.write("username=%s\\npassword=%s\\n" % (username, token))
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'''
|
||||
|
||||
|
||||
def _params_get(name: str) -> bytes | None:
|
||||
# Params -> cereal -> iqdbc: that chain is unavailable mid-bootstrap (this module's
|
||||
# callers install those very packages), so fall back to the params file directly,
|
||||
# exactly like the embedded credential helper does.
|
||||
try:
|
||||
return Params().get(name)
|
||||
except Exception:
|
||||
try:
|
||||
with open(os.path.join(PARAMS_DIR, name), "rb") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _params_put(name: str, value: bytes) -> None:
|
||||
try:
|
||||
Params().put(name, value)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
os.makedirs(PARAMS_DIR, exist_ok=True)
|
||||
tmp = os.path.join(PARAMS_DIR, f".tmp_{name}")
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(value)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, os.path.join(PARAMS_DIR, name))
|
||||
|
||||
|
||||
def _params_remove(name: str) -> None:
|
||||
try:
|
||||
Params().remove(name)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
os.unlink(os.path.join(PARAMS_DIR, name))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _load_or_create_key() -> bytes:
|
||||
from cryptography.fernet import Fernet
|
||||
try:
|
||||
with open(KEY_PATH, "rb") as f:
|
||||
return f.read().strip()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
key = Fernet.generate_key()
|
||||
os.makedirs(KEY_DIR, exist_ok=True)
|
||||
# write atomically with restrictive perms
|
||||
tmp = KEY_PATH + ".tmp"
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(key)
|
||||
os.replace(tmp, KEY_PATH)
|
||||
return key
|
||||
|
||||
|
||||
def set_credentials(username: str, token: str) -> None:
|
||||
"""Encrypt and store credentials. Empty username AND token clears them."""
|
||||
username = (username or "").strip()
|
||||
token = (token or "").strip()
|
||||
if not username and not token:
|
||||
clear_credentials()
|
||||
return
|
||||
from cryptography.fernet import Fernet
|
||||
blob = Fernet(_load_or_create_key()).encrypt(
|
||||
json.dumps({"u": username, "t": token}).encode()
|
||||
)
|
||||
_params_put(PARAM, blob)
|
||||
try:
|
||||
install_credential_helper(DEFAULT_REPO_DIR)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_credentials() -> tuple[str, str] | None:
|
||||
"""Return (username, token), or None if unset / unreadable."""
|
||||
blob = _params_get(PARAM)
|
||||
if not blob:
|
||||
return None
|
||||
try:
|
||||
from cryptography.fernet import Fernet
|
||||
data = json.loads(Fernet(_load_or_create_key()).decrypt(blob).decode())
|
||||
return data.get("u", ""), data.get("t", "")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def clear_credentials() -> None:
|
||||
_params_remove(PARAM)
|
||||
|
||||
|
||||
def has_credentials() -> bool:
|
||||
return get_credentials() is not None
|
||||
|
||||
|
||||
def _auth_header(username: str, token: str) -> str:
|
||||
return "Authorization: Basic " + base64.b64encode(f"{username}:{token}".encode()).decode()
|
||||
|
||||
|
||||
def ssh_to_https(url: str) -> str:
|
||||
"""Convert an SSH git URL to its HTTPS equivalent. Returns url unchanged if it
|
||||
is not an SSH URL. A leading ssh. host label is dropped (ssh.host -> host)."""
|
||||
url = url.strip()
|
||||
host = path = ""
|
||||
if url.startswith("ssh://"):
|
||||
rest = url[len("ssh://"):]
|
||||
rest = rest.split("@", 1)[-1] # drop user@
|
||||
hostport, _, path = rest.partition("/")
|
||||
host = hostport.split(":", 1)[0] # drop :port
|
||||
elif url.startswith("git@") or ("@" in url and ":" in url.split("@", 1)[-1] and "://" not in url):
|
||||
rest = url.split("@", 1)[-1] # host:owner/repo.git
|
||||
host, _, path = rest.partition(":")
|
||||
else:
|
||||
return url # already https/http or unrecognised
|
||||
if host.startswith("ssh."):
|
||||
host = host[len("ssh."):]
|
||||
return f"https://{host}/{path}"
|
||||
|
||||
|
||||
def install_credential_helper(repo_dir: str = DEFAULT_REPO_DIR) -> None:
|
||||
if get_credentials() is None:
|
||||
return
|
||||
|
||||
scopes = {f"https://{host}" for host in CREDENTIAL_HOSTS}
|
||||
origin = subprocess.run(
|
||||
["git", "-C", repo_dir, "config", "--get", "remote.origin.url"],
|
||||
capture_output=True, text=True, check=False,
|
||||
).stdout.strip()
|
||||
https = ssh_to_https(origin)
|
||||
if https.startswith("https://"):
|
||||
from urllib.parse import urlsplit
|
||||
parts = urlsplit(https)
|
||||
if parts.hostname:
|
||||
scopes.add(f"{parts.scheme}://{parts.hostname}")
|
||||
|
||||
try:
|
||||
os.makedirs(KEY_DIR, exist_ok=True)
|
||||
tmp = HELPER_PATH + ".tmp"
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o755)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(_HELPER_SCRIPT.format(key_path=KEY_PATH))
|
||||
os.replace(tmp, HELPER_PATH)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
helper_cmd = f"!/usr/bin/env python3 {HELPER_PATH}"
|
||||
for scope in scopes:
|
||||
subprocess.run(
|
||||
["git", "config", "--global", f"credential.{scope}.helper", helper_cmd],
|
||||
check=False, capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def configure(repo_dir: str) -> None:
|
||||
"""Apply on-device credentials to the git repo at repo_dir before a remote op.
|
||||
|
||||
No-op when no credentials are stored. If the origin is an SSH URL it is
|
||||
rewritten in-place to the HTTPS equivalent so the Basic-auth header applies.
|
||||
The header is injected via GIT_CONFIG_* env (never persisted to .git/config).
|
||||
Idempotent."""
|
||||
creds = get_credentials()
|
||||
if creds is None:
|
||||
return
|
||||
username, token = creds
|
||||
|
||||
try:
|
||||
install_credential_helper(repo_dir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
origin = subprocess.run(
|
||||
["git", "-C", repo_dir, "config", "--get", "remote.origin.url"],
|
||||
capture_output=True, text=True, check=False,
|
||||
).stdout.strip()
|
||||
if not origin:
|
||||
return
|
||||
|
||||
https = ssh_to_https(origin)
|
||||
if https != origin and https.startswith("https://"):
|
||||
subprocess.run(
|
||||
["git", "-C", repo_dir, "config", "remote.origin.url", https],
|
||||
check=False, capture_output=True,
|
||||
)
|
||||
|
||||
if not https.startswith("https://"):
|
||||
return # header auth only works over https
|
||||
|
||||
# scope to this exact repo URL prefix (trailing slash => component boundary)
|
||||
key = https if https.endswith("/") else https + "/"
|
||||
os.environ["GIT_CONFIG_COUNT"] = "1"
|
||||
os.environ["GIT_CONFIG_KEY_0"] = f"http.{key}.extraHeader"
|
||||
os.environ["GIT_CONFIG_VALUE_0"] = _auth_header(username, token)
|
||||
Reference in New Issue
Block a user