357 lines
15 KiB
Python
357 lines
15 KiB
Python
import sys
|
|
|
|
sys.dont_write_bytecode = True
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import threading
|
|
|
|
import yaml
|
|
|
|
from migrate_runtime_safety import require_runtime_hardening_stopped
|
|
from db_backend import database_url_from_env, is_postgres_url
|
|
from paths import apply_path_config
|
|
from postgres_runtime import load_postgres_environment
|
|
from runtime_security import (
|
|
ClusterAuthorityLock,
|
|
canonical_path,
|
|
durable_replace,
|
|
harden_private_file,
|
|
PrivateFileLock,
|
|
private_file_ready,
|
|
require_private_directory,
|
|
require_private_file,
|
|
)
|
|
|
|
|
|
GITHUB_TOKEN_PREFIXES = ('ghp_', 'gho_', 'ghu_', 'ghs_', 'ghr_', 'github_pat_')
|
|
ACCEPTED_ALIVE_STATUSES = {'ALIVE', 'VALID', 'VALID_2FA'}
|
|
DOCKERHUB_TOKEN_RE = re.compile(r'^dckr_pat_[A-Za-z0-9_-]{27}$')
|
|
PROVIDER_DEFAULTS = {
|
|
'github': {
|
|
'alive_file': os.path.join('runtime', 'keychecks', 'github', 'githubAlive.txt'),
|
|
'pool': 'github_main',
|
|
'name_prefix': 'gh',
|
|
},
|
|
'dockerhub': {
|
|
'alive_file': os.path.join('runtime', 'keychecks', 'dockerhub', 'dockerhubAlive.txt'),
|
|
'pool': 'dockerhub_main',
|
|
'name_prefix': 'dockerhub',
|
|
},
|
|
}
|
|
|
|
|
|
def read_alive_tokens(path):
|
|
tokens = []
|
|
seen = set()
|
|
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
|
for line in f:
|
|
# Status files are TSV-like: token, status, message, extra.
|
|
fields = line.rstrip('\r\n').split('\t')
|
|
token = fields[0].strip() if fields else ''
|
|
if not token or not token.startswith(GITHUB_TOKEN_PREFIXES):
|
|
continue
|
|
if any(character.isspace() for character in token):
|
|
raise ValueError('alive token input contains whitespace in a token field')
|
|
status = fields[1].strip().upper() if len(fields) > 1 else ''
|
|
if status not in ACCEPTED_ALIVE_STATUSES:
|
|
raise ValueError(f'alive token input contains an unaccepted or missing status: {status or "(missing)"}')
|
|
if token in seen:
|
|
continue
|
|
seen.add(token)
|
|
tokens.append(token)
|
|
return tokens
|
|
|
|
|
|
def read_alive_credentials(path, provider):
|
|
provider = str(provider or 'github').strip().lower()
|
|
if provider == 'github':
|
|
return [{'token': token} for token in read_alive_tokens(path)], 0
|
|
if provider != 'dockerhub':
|
|
raise ValueError(f'unsupported alive credential provider: {provider}')
|
|
|
|
credentials = []
|
|
seen = {}
|
|
skipped_missing_username = 0
|
|
with open(path, 'r', encoding='utf-8', errors='replace') as handle:
|
|
for line in handle:
|
|
fields = line.rstrip('\r\n').split('\t')
|
|
identity = fields[0].strip() if fields else ''
|
|
if not identity:
|
|
continue
|
|
if ':' in identity:
|
|
username, token = identity.rsplit(':', 1)
|
|
username = username.strip()
|
|
token = token.strip()
|
|
else:
|
|
username = ''
|
|
token = identity
|
|
if not DOCKERHUB_TOKEN_RE.fullmatch(token):
|
|
continue
|
|
status = fields[1].strip().upper() if len(fields) > 1 else ''
|
|
if status not in {'VALID', 'VALID_2FA'}:
|
|
raise ValueError(f'alive DockerHub input contains an unaccepted or missing status: {status or "(missing)"}')
|
|
if not username:
|
|
skipped_missing_username += 1
|
|
continue
|
|
if len(username) > 256 or ':' in username or any(character.isspace() for character in username):
|
|
raise ValueError('alive DockerHub input contains an invalid username field')
|
|
previous = seen.get(token)
|
|
if previous is not None:
|
|
if previous.casefold() != username.casefold():
|
|
raise ValueError('alive DockerHub input contains conflicting usernames for one token')
|
|
continue
|
|
seen[token] = username
|
|
credentials.append({'username': username, 'token': token})
|
|
return credentials, skipped_missing_username
|
|
|
|
|
|
def next_name(existing_names, prefix):
|
|
pattern = re.compile(rf'^{re.escape(prefix)}_(\d+)$')
|
|
max_index = 0
|
|
for name in existing_names:
|
|
match = pattern.match(str(name or ''))
|
|
if match:
|
|
max_index = max(max_index, int(match.group(1)))
|
|
return f'{prefix}_{max_index + 1}'
|
|
|
|
|
|
def _atomic_write_private_yaml(path, value):
|
|
parent = require_private_directory(os.path.dirname(os.path.abspath(path)), create=False)
|
|
payload = yaml.safe_dump(value, allow_unicode=True, sort_keys=False, width=120).encode('utf-8')
|
|
temporary = f'{path}.{os.getpid()}.{threading.get_ident()}.tmp'
|
|
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_BINARY', 0) | getattr(os, 'O_NOFOLLOW', 0)
|
|
descriptor = os.open(temporary, flags, 0o600)
|
|
try:
|
|
os.close(descriptor)
|
|
descriptor = None
|
|
harden_private_file(temporary)
|
|
with open(temporary, 'wb') as handle:
|
|
handle.write(payload)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
if not private_file_ready(temporary):
|
|
raise OSError(f'private temporary secrets ACL changed: {temporary}')
|
|
durable_replace(temporary, path)
|
|
if not private_file_ready(path):
|
|
raise OSError(f'private secrets ACL changed during publication: {path}')
|
|
finally:
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
try:
|
|
if os.path.exists(temporary):
|
|
os.remove(temporary)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def sync_tokens(
|
|
secrets_path,
|
|
alive_path,
|
|
pool_name,
|
|
name_prefix,
|
|
apply=False,
|
|
*,
|
|
provider='github',
|
|
replace_conflicting_usernames=False,
|
|
canonical_secrets_path=None,
|
|
authority_lock=None,
|
|
stopped_verified=False,
|
|
):
|
|
if authority_lock is None or not getattr(authority_lock, 'acquired', False):
|
|
raise RuntimeError('alive-token sync requires an acquired cluster authority lock')
|
|
if stopped_verified is not True:
|
|
raise RuntimeError('alive-token sync requires verified stopped runtime proof')
|
|
if not canonical_secrets_path or canonical_path(secrets_path) != canonical_path(canonical_secrets_path):
|
|
raise RuntimeError('alive-token sync secrets path must exactly match canonical global.secrets_file')
|
|
if not os.path.exists(alive_path):
|
|
raise SystemExit(f'alive token file not found: {alive_path}')
|
|
if not os.path.exists(secrets_path):
|
|
raise SystemExit(f'secrets file not found: {secrets_path}')
|
|
|
|
require_private_file(secrets_path)
|
|
require_private_file(alive_path)
|
|
lock = PrivateFileLock(f'{secrets_path}.sync.lock').acquire()
|
|
try:
|
|
require_private_file(secrets_path)
|
|
require_private_file(alive_path)
|
|
with open(secrets_path, 'r', encoding='utf-8') as f:
|
|
secrets = yaml.safe_load(f) or {}
|
|
|
|
auth_pools = secrets.setdefault('auth_pools', {})
|
|
pool = auth_pools.setdefault(pool_name, [])
|
|
if not isinstance(pool, list):
|
|
raise SystemExit(f'auth_pools.{pool_name} must be a list')
|
|
existing_before = len(pool)
|
|
|
|
provider = str(provider or 'github').strip().lower()
|
|
if provider not in PROVIDER_DEFAULTS:
|
|
raise ValueError(f'unsupported alive credential provider: {provider}')
|
|
existing_tokens = set()
|
|
existing_entries = {}
|
|
existing_names = set()
|
|
normalized_existing = 0
|
|
normalized_usernames = 0
|
|
for entry in pool:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
if entry.get('name'):
|
|
existing_names.add(str(entry.get('name')))
|
|
token = str(entry.get('token') or '')
|
|
stripped = token.strip()
|
|
if stripped != token:
|
|
normalized_existing += 1
|
|
if apply:
|
|
entry['token'] = stripped
|
|
if stripped:
|
|
existing_tokens.add(stripped)
|
|
existing_entries.setdefault(stripped, []).append(entry)
|
|
if provider == 'dockerhub':
|
|
username = str(entry.get('username') or '')
|
|
stripped_username = username.strip()
|
|
if stripped_username != username:
|
|
normalized_usernames += 1
|
|
if apply:
|
|
entry['username'] = stripped_username
|
|
|
|
alive_credentials, skipped_missing_username = read_alive_credentials(alive_path, provider)
|
|
added = []
|
|
username_filled = 0
|
|
username_conflicts = 0
|
|
username_replaced = 0
|
|
for credential in alive_credentials:
|
|
token = credential['token']
|
|
if token in existing_tokens:
|
|
if provider == 'dockerhub':
|
|
entries = existing_entries.get(token, [])
|
|
usernames = {
|
|
str(entry.get('username') or '').strip().casefold()
|
|
for entry in entries if str(entry.get('username') or '').strip()
|
|
}
|
|
expected = credential['username'].casefold()
|
|
if len(usernames) > 1 or (usernames and expected not in usernames):
|
|
if replace_conflicting_usernames:
|
|
username_replaced += 1
|
|
if apply:
|
|
for entry in entries:
|
|
entry['username'] = credential['username']
|
|
else:
|
|
username_conflicts += 1
|
|
continue
|
|
if not usernames:
|
|
username_filled += 1
|
|
if apply:
|
|
for entry in entries:
|
|
entry['username'] = credential['username']
|
|
continue
|
|
name = next_name(existing_names, name_prefix)
|
|
existing_names.add(name)
|
|
existing_tokens.add(token)
|
|
new_entry = {'name': name}
|
|
if provider == 'dockerhub':
|
|
new_entry['username'] = credential['username']
|
|
new_entry['token'] = token
|
|
added.append(new_entry)
|
|
|
|
if apply and (
|
|
added or normalized_existing or normalized_usernames
|
|
or username_filled or username_replaced
|
|
):
|
|
pool.extend(added)
|
|
_atomic_write_private_yaml(secrets_path, secrets)
|
|
|
|
return {
|
|
'alive_unique': len(alive_credentials),
|
|
'existing_before': existing_before,
|
|
'added': len(added),
|
|
'normalized_existing': normalized_existing,
|
|
'normalized_usernames': normalized_usernames,
|
|
'username_filled': username_filled,
|
|
'username_conflicts': username_conflicts,
|
|
'username_replaced': username_replaced,
|
|
'skipped_missing_username': skipped_missing_username,
|
|
'pool_after': len(pool) + (0 if apply else len(added)),
|
|
}
|
|
finally:
|
|
lock.release()
|
|
|
|
|
|
def load_config(path):
|
|
with open(path, 'r', encoding='utf-8') as handle:
|
|
return apply_path_config(yaml.safe_load(handle) or {}, path)
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(description='Sync alive provider credentials into a private auth pool without printing them.')
|
|
parser.add_argument('--provider', choices=sorted(PROVIDER_DEFAULTS), default='github')
|
|
parser.add_argument('--secrets', help='Must exactly match global.secrets_file from --config')
|
|
parser.add_argument('--alive-file')
|
|
parser.add_argument('--pool')
|
|
parser.add_argument('--name-prefix')
|
|
parser.add_argument('--config', default=os.path.join('app', 'config.yaml'))
|
|
parser.add_argument('--dry-run', action='store_true')
|
|
parser.add_argument('--apply', action='store_true', help='Apply under verified offline maintenance authority')
|
|
parser.add_argument(
|
|
'--replace-conflicting-usernames', action='store_true',
|
|
help='Replace an existing DockerHub username only when the same token has an authoritative alive pair',
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
if args.apply and args.dry_run:
|
|
raise SystemExit('--apply and --dry-run are mutually exclusive')
|
|
config_path = canonical_path(args.config)
|
|
require_private_file(config_path)
|
|
config = load_config(config_path)
|
|
configured_value = (config.get('global') or {}).get('secrets_file')
|
|
if not configured_value:
|
|
raise SystemExit('global.secrets_file is required')
|
|
configured_secrets = canonical_path(configured_value)
|
|
requested_secrets = canonical_path(args.secrets) if args.secrets else configured_secrets
|
|
if requested_secrets != configured_secrets:
|
|
raise SystemExit('--secrets must exactly match canonical global.secrets_file')
|
|
load_postgres_environment(config_path, config)
|
|
endpoint_dsn = database_url_from_env() or (config.get('global') or {}).get('database_url')
|
|
if not is_postgres_url(endpoint_dsn):
|
|
raise SystemExit('A caller-selected canonical PostgreSQL DSN is required for maintenance authority')
|
|
provider = str(getattr(args, 'provider', 'github') or 'github').strip().lower()
|
|
defaults = PROVIDER_DEFAULTS.get(provider)
|
|
if defaults is None:
|
|
raise SystemExit(f'unsupported provider: {provider}')
|
|
alive_file = args.alive_file or defaults['alive_file']
|
|
pool_name = args.pool or defaults['pool']
|
|
name_prefix = args.name_prefix or defaults['name_prefix']
|
|
with ClusterAuthorityLock(config, endpoint_dsn=endpoint_dsn) as authority_lock:
|
|
require_runtime_hardening_stopped(config)
|
|
result = sync_tokens(
|
|
configured_secrets,
|
|
alive_file,
|
|
pool_name,
|
|
name_prefix,
|
|
apply=args.apply,
|
|
provider=provider,
|
|
replace_conflicting_usernames=bool(getattr(args, 'replace_conflicting_usernames', False)),
|
|
canonical_secrets_path=configured_secrets,
|
|
authority_lock=authority_lock,
|
|
stopped_verified=True,
|
|
)
|
|
mode = 'updated' if args.apply else 'dry_run'
|
|
print(
|
|
f"{mode}: provider={provider} pool={pool_name} alive_unique={result['alive_unique']} "
|
|
f"existing_before={result['existing_before']} added={result['added']} "
|
|
f"username_filled={result.get('username_filled', 0)} "
|
|
f"username_conflicts={result.get('username_conflicts', 0)} "
|
|
f"username_replaced={result.get('username_replaced', 0)} "
|
|
f"skipped_missing_username={result.get('skipped_missing_username', 0)} "
|
|
f"normalized_existing={result['normalized_existing']} "
|
|
f"normalized_usernames={result.get('normalized_usernames', 0)} pool_after={result['pool_after']}"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|