fix: 收口融合 QA 构建与验收安全基线
This commit is contained in:
1 parent
47bc6604e5
commit
5560a3ac04
28 files changed
+22947
-352
No files matched your search
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
readonly EXPECTED_SHA256="346d5602477ed472b19c5e25729fff5a58b1d83cec56162a56ac4b86d9546c32"
|
||||
readonly INSTALLED_JAR="${HOME}/.m2/repository/com/kingbase/kingbase8/8.6.0/kingbase8-8.6.0.jar"
|
||||
readonly EXPECTED_SHA256="e439bd1c48ce654748c4f333b874a61465702ceb18f29ee017c3f9bc47738c23"
|
||||
readonly INSTALLED_JAR="${HOME}/.m2/repository/cn/com/kingbase/kingbase8/9.0.0/kingbase8-9.0.0.jar"
|
||||
|
||||
sha256_file() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
@@ -16,7 +16,7 @@ sha256_file() {
|
||||
}
|
||||
|
||||
if [[ ! -f "${INSTALLED_JAR}" ]]; then
|
||||
echo "Kingbase JDBC 8.6.0 is not provisioned." >&2
|
||||
echo "Kingbase JDBC 9.0.0 is not provisioned." >&2
|
||||
echo "Run scripts/bootstrap/install-kingbase-jdbc.sh" >&2
|
||||
echo "with KINGBASE_JDBC_JAR pointing to the authorized vendor driver." >&2
|
||||
exit 1
|
||||
@@ -24,8 +24,8 @@ fi
|
||||
|
||||
installed_sha256="$(sha256_file "${INSTALLED_JAR}")"
|
||||
if [[ "${installed_sha256}" != "${EXPECTED_SHA256}" ]]; then
|
||||
echo "Kingbase JDBC 8.6.0 checksum mismatch." >&2
|
||||
echo "Kingbase JDBC 9.0.0 checksum mismatch." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Kingbase JDBC 8.6.0 preflight PASS."
|
||||
echo "Kingbase JDBC 9.0.0 preflight PASS."
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
readonly EXPECTED_SHA256="346d5602477ed472b19c5e25729fff5a58b1d83cec56162a56ac4b86d9546c32"
|
||||
readonly EXPECTED_SHA256="e439bd1c48ce654748c4f333b874a61465702ceb18f29ee017c3f9bc47738c23"
|
||||
readonly MAVEN_REPOSITORY="${HOME}/.m2/repository"
|
||||
readonly INSTALLED_JAR="${MAVEN_REPOSITORY}/com/kingbase/kingbase8/8.6.0/kingbase8-8.6.0.jar"
|
||||
readonly INSTALLED_JAR="${MAVEN_REPOSITORY}/cn/com/kingbase/kingbase8/9.0.0/kingbase8-9.0.0.jar"
|
||||
|
||||
sha256_file() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
@@ -18,7 +18,7 @@ sha256_file() {
|
||||
|
||||
if [[ -z "${KINGBASE_JDBC_JAR:-}" ]]; then
|
||||
echo "KINGBASE_JDBC_JAR is not set." >&2
|
||||
echo "Set it to the absolute path of the authorized Kingbase JDBC 8.6.0 vendor driver." >&2
|
||||
echo "Set it to the absolute path of the authorized Kingbase JDBC 9.0.0 vendor driver." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -29,28 +29,28 @@ fi
|
||||
|
||||
source_sha256="$(sha256_file "${KINGBASE_JDBC_JAR}")"
|
||||
if [[ "${source_sha256}" != "${EXPECTED_SHA256}" ]]; then
|
||||
echo "Kingbase JDBC 8.6.0 source checksum mismatch." >&2
|
||||
echo "Kingbase JDBC 9.0.0 source checksum mismatch." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mvn install:install-file \
|
||||
mvn org.apache.maven.plugins:maven-install-plugin:3.1.2:install-file \
|
||||
-Dfile="${KINGBASE_JDBC_JAR}" \
|
||||
-DgroupId=com.kingbase \
|
||||
-DgroupId=cn.com.kingbase \
|
||||
-DartifactId=kingbase8 \
|
||||
-Dversion=8.6.0 \
|
||||
-Dversion=9.0.0 \
|
||||
-Dpackaging=jar \
|
||||
-DgeneratePom=true \
|
||||
-Dmaven.repo.local="${MAVEN_REPOSITORY}"
|
||||
|
||||
if [[ ! -f "${INSTALLED_JAR}" ]]; then
|
||||
echo "Kingbase JDBC 8.6.0 installation failed: local Maven artifact is missing." >&2
|
||||
echo "Kingbase JDBC 9.0.0 installation failed: local Maven artifact is missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
installed_sha256="$(sha256_file "${INSTALLED_JAR}")"
|
||||
if [[ "${installed_sha256}" != "${EXPECTED_SHA256}" ]]; then
|
||||
echo "Kingbase JDBC 8.6.0 installed artifact checksum mismatch." >&2
|
||||
echo "Kingbase JDBC 9.0.0 installed artifact checksum mismatch." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Kingbase JDBC 8.6.0 provisioning PASS."
|
||||
echo "Kingbase JDBC 9.0.0 provisioning PASS."
|
||||
@@ -0,0 +1,87 @@
|
||||
import java.sql.*;
|
||||
import java.util.*;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import javax.sql.rowset.*;
|
||||
|
||||
/** Read-only identity/schema probe. Credentials only from process environment. */
|
||||
public class QaDatabaseProbe {
|
||||
static final String DATABASE = "railwaysystem_fusion_qa_20261013";
|
||||
static void query(Connection c, String label, String sql) throws SQLException {
|
||||
System.out.println("[" + label + "]");
|
||||
try (Statement s = c.createStatement()) {
|
||||
s.setQueryTimeout(15);
|
||||
try (ResultSet r = s.executeQuery(sql)) {
|
||||
int n = r.getMetaData().getColumnCount();
|
||||
while (r.next()) {
|
||||
List<String> row = new ArrayList<>();
|
||||
for (int i = 1; i <= n; i++) row.add(String.valueOf(r.getObject(i)));
|
||||
System.out.println(String.join(" | ", row));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
public static void main(String[] args) throws Exception {
|
||||
String url = System.getenv("QA_DB_URL");
|
||||
// Validate target before even opening a connection; no URL credentials permitted.
|
||||
if (url == null || !url.matches("jdbc:kingbase8://[A-Za-z0-9.:-]+/" + DATABASE + "(?:\\?.*)?")
|
||||
|| url.matches("(?i).*[?&](user|password)=.*"))
|
||||
throw new SecurityException("Refusing non-target URL or URL credentials");
|
||||
if (!"fusion_qa_app".equals(System.getenv("QA_DB_USER")))
|
||||
throw new SecurityException("Read-only probe requires fusion_qa_app");
|
||||
Class.forName("com.kingbase8.Driver");
|
||||
Properties p = new Properties(); p.setProperty("user", System.getenv("QA_DB_USER"));
|
||||
p.setProperty("password", Objects.requireNonNull(System.getenv("QA_DB_PASSWORD")));
|
||||
try (Connection c = DriverManager.getConnection(url, p)) {
|
||||
c.setAutoCommit(false);
|
||||
c.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
|
||||
try (Statement s = c.createStatement()) { s.execute("SET TRANSACTION READ ONLY"); }
|
||||
try (Statement s = c.createStatement(); ResultSet r = s.executeQuery("SELECT current_database()")) {
|
||||
r.next(); if (!DATABASE.equals(r.getString(1))) throw new SecurityException("Database mismatch");
|
||||
}
|
||||
query(c,"identity", "SELECT current_database(), current_user, current_schema(), version()");
|
||||
query(c,"search_path", "SHOW search_path");
|
||||
query(c,"schemas", "SELECT schema_name FROM information_schema.schemata ORDER BY schema_name");
|
||||
query(c,"columns", "SELECT table_schema,table_name,column_name,data_type,is_nullable,column_default "
|
||||
+ "FROM information_schema.columns WHERE (table_schema='iotlocks' AND table_name IN ('lock_auth','lock_command')) ORDER BY table_name,ordinal_position");
|
||||
query(c,"constraints", "SELECT table_schema,table_name,constraint_name,constraint_type FROM information_schema.table_constraints "
|
||||
+ "WHERE table_schema IN ('iotlocks','public') AND table_name IN ('lock_auth','lock_command') ORDER BY table_name,constraint_name");
|
||||
query(c,"counts", "SELECT 'lock_auth',count(*) FROM iotlocks.lock_auth UNION ALL SELECT 'lock_command',count(*) FROM iotlocks.lock_command");
|
||||
query(c,"indexes", "SELECT schemaname,tablename,indexname,indexdef FROM pg_indexes WHERE schemaname='iotlocks' AND tablename IN ('lock_auth','lock_command') ORDER BY tablename,indexname");
|
||||
query(c,"runtime_account", "SELECT rolname,rolsuper,rolcreatedb,rolcreaterole FROM pg_roles WHERE rolname=current_user");
|
||||
query(c,"schema_privileges", "SELECT nspname,has_schema_privilege(current_user,oid,'USAGE'),has_schema_privilege(current_user,oid,'CREATE') FROM pg_namespace WHERE nspname IN ('public','iotlocks') ORDER BY nspname");
|
||||
query(c,"table_owners", "SELECT schemaname,tablename,tableowner FROM pg_tables WHERE schemaname IN ('public','iotlocks') AND tablename IN ('sys_user','lock_auth','lock_command') ORDER BY schemaname,tablename");
|
||||
query(c,"table_grants", "SELECT table_schema,privilege_type,count(*) FROM information_schema.role_table_grants WHERE grantee=current_user AND table_schema IN ('public','iotlocks') GROUP BY table_schema,privilege_type ORDER BY table_schema,privilege_type");
|
||||
query(c,"extra_grant_review", "SELECT table_schema,table_name,privilege_type FROM information_schema.role_table_grants WHERE grantee=current_user AND table_schema IN ('public','iotlocks') AND privilege_type IN ('TRUNCATE','TRIGGER','REFERENCES') ORDER BY table_schema,table_name,privilege_type");
|
||||
query(c,"target_sessions", "SELECT usename,application_name,state,count(*) FROM pg_stat_activity WHERE datname=current_database() GROUP BY usename,application_name,state ORDER BY usename,application_name,state");
|
||||
if (args.length == 2 && "--backup".equals(args[0])) {
|
||||
Path directory = Paths.get(args[1]);
|
||||
Files.createDirectory(directory, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")));
|
||||
for (String table : List.of("lock_auth", "lock_command")) {
|
||||
Path destination = directory.resolve(table + ".xml");
|
||||
Files.createFile(destination, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
|
||||
try (Statement s = c.createStatement(); ResultSet rows = s.executeQuery("SELECT * FROM iotlocks." + table + " ORDER BY 1");
|
||||
WebRowSet backup = RowSetProvider.newFactory().createWebRowSet();
|
||||
java.io.Writer writer = Files.newBufferedWriter(destination)) {
|
||||
backup.populate(rows); backup.writeXml(writer);
|
||||
System.out.println("PRIVATE_TABLE_SNAPSHOT " + table + " rows=" + backup.size());
|
||||
}
|
||||
}
|
||||
System.out.println("TABLE_SNAPSHOT_ONLY: no native full-database dump or restore drill claimed");
|
||||
}
|
||||
if (Arrays.asList(args).contains("--require-migrated")) {
|
||||
String common = "'execution_mode','environment_id','intent_key','intent_version','execution_state','intent_action','intent_at'";
|
||||
try (Statement s = c.createStatement(); ResultSet r = s.executeQuery("SELECT count(*) FROM information_schema.columns WHERE table_schema='iotlocks' AND ((table_name='lock_auth' AND column_name IN (" + common + ")) OR (table_name='lock_command' AND column_name IN (" + common + ",'device_sn','attempt_started_at')))")) {
|
||||
r.next(); if (r.getInt(1) != 16) throw new SecurityException("MIGRATION_REQUIRED: runtime refused");
|
||||
}
|
||||
try (Statement s = c.createStatement(); ResultSet r = s.executeQuery("SELECT count(*) FROM pg_indexes WHERE schemaname='iotlocks' AND indexname IN ('uq_round5s_command_intent','uq_round5s_auth_intent','ix_round5s_command_recovery','ix_round5s_auth_recovery')")) {
|
||||
r.next(); if (r.getInt(1) != 4) throw new SecurityException("MIGRATION_INDEXES_REQUIRED: runtime refused");
|
||||
}
|
||||
}
|
||||
c.rollback(); System.out.println("READ_ONLY_ROLLBACK_COMPLETE");
|
||||
} catch (SQLException e) {
|
||||
// Server exceptions can contain values; expose SQLSTATE only.
|
||||
System.err.println("QA_DATABASE_PROBE_FAILED SQLSTATE=" + e.getSQLState()); System.exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local QA only. No DDL, hardware, account provisioning or shared-service management."""
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shlex
|
||||
import signal
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DATABASE = 'railwaysystem_fusion_qa_20261013'
|
||||
RUNTIME = ROOT / '.qa-runtime'
|
||||
JAR = Path.home() / '.m2/repository/cn/com/kingbase/kingbase8/9.0.0/kingbase8-9.0.0.jar'
|
||||
|
||||
|
||||
def load_config(path):
|
||||
path = path.expanduser()
|
||||
info = path.stat()
|
||||
if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077 or not stat.S_ISREG(info.st_mode):
|
||||
raise ValueError('Config must be an owner-only regular file (0600)')
|
||||
values = {}
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
key, value = line.removeprefix('export ').split('=', 1)
|
||||
tokens = shlex.split(value, comments=True)
|
||||
if len(tokens) != 1:
|
||||
raise ValueError('Config requires literal KEY=value; no shell expansion')
|
||||
values[key] = tokens[0]
|
||||
return values
|
||||
|
||||
|
||||
def environment(config):
|
||||
# Legacy QA_DB_URL deliberately NOT reused: it may point to a different QA database.
|
||||
url = config.get('FUSION_QA_DB_URL', 'jdbc:kingbase8://192.168.139.218:4321/' + DATABASE + '?connectTimeout=5&socketTimeout=15')
|
||||
parsed = urllib.parse.urlsplit(url.removeprefix('jdbc:'))
|
||||
if parsed.scheme != 'kingbase8' or parsed.path != '/' + DATABASE or parsed.username or parsed.password or parsed.fragment:
|
||||
raise ValueError('Refusing non-target database URL')
|
||||
if any(k.lower() in ('user', 'password') for k in urllib.parse.parse_qs(parsed.query)):
|
||||
raise ValueError('URL credentials forbidden')
|
||||
password = config.get('FUSION_QA_APP_PASSWORD', '')
|
||||
if not password:
|
||||
raise ValueError('Missing FUSION_QA_APP_PASSWORD')
|
||||
inherited = {key: value for key, value in os.environ.items()
|
||||
if not key.startswith(('SPRING_', 'IOT_', 'DMS_', 'QA_', 'TOKEN_', 'TRAIN_'))
|
||||
and key not in ['JAVA_TOOL_OPTIONS', 'JDK_JAVA_OPTIONS', '_JAVA_OPTIONS']}
|
||||
return dict(inherited, QA_DB_URL=url, QA_DB_USER='fusion_qa_app', QA_DB_PASSWORD=password)
|
||||
|
||||
|
||||
def verify_snapshot(directory):
|
||||
directory = directory.resolve()
|
||||
if not directory.is_relative_to(RUNTIME.resolve()):
|
||||
raise ValueError('Snapshot must stay inside this task private runtime directory')
|
||||
expected = json.loads((directory / 'sha256.json').read_text())
|
||||
for name in ['lock_auth.xml', 'lock_command.xml']:
|
||||
raw = (directory / name).read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest() != expected[name]:
|
||||
raise ValueError('Snapshot checksum mismatch')
|
||||
tree = ET.fromstring(raw)
|
||||
rows = sum(1 for node in tree.iter() if node.tag.rsplit('}', 1)[-1] in ['currentRow', 'insertRow'])
|
||||
print('SNAPSHOT_PARSE_SHA256_VERIFIED ' + name + ' rows=' + str(rows))
|
||||
print('NATIVE_DATABASE_RESTORE_NOT_VERIFIED')
|
||||
|
||||
|
||||
def private_directory():
|
||||
RUNTIME.mkdir(mode=0o700, exist_ok=True)
|
||||
if RUNTIME.is_symlink() or RUNTIME.stat().st_uid != os.getuid() or stat.S_IMODE(RUNTIME.stat().st_mode) & 0o077:
|
||||
raise ValueError('Unsafe runtime directory')
|
||||
|
||||
|
||||
def inspect(env, extra):
|
||||
command = ['java', '-cp', str(JAR), str(ROOT / 'scripts/qa/QaDatabaseProbe.java'), *extra]
|
||||
result = subprocess.run(command, env=env, text=True, capture_output=True, timeout=60)
|
||||
private_directory()
|
||||
path = RUNTIME / 'database-inspection.log'
|
||||
with open(path, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as stream:
|
||||
stream.write(result.stdout + result.stderr)
|
||||
# Probe reports only metadata/counts; SQL errors are reduced to SQLSTATE.
|
||||
print(result.stdout + result.stderr, end='')
|
||||
return result.returncode
|
||||
|
||||
|
||||
def available(port):
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', port))
|
||||
|
||||
|
||||
def launch(name, command, env):
|
||||
private_directory()
|
||||
ledger = RUNTIME / (name + '.json')
|
||||
if ledger.exists():
|
||||
raise ValueError('Runtime ledger exists; inspect/stop this task-owned process first')
|
||||
log = open(RUNTIME / (name + '.log'), 'w', opener=lambda p, f: os.open(p, f, 0o600))
|
||||
child = subprocess.Popen(command, cwd=ROOT, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
log.close()
|
||||
started = subprocess.run(['ps', '-p', str(child.pid), '-o', 'lstart='], text=True, capture_output=True).stdout.strip()
|
||||
ledger.write_text(json.dumps({'pid': child.pid, 'command': command, 'cwd': str(ROOT), 'started': started}))
|
||||
print(name + ' PID=' + str(child.pid) + ' LOG=' + str(RUNTIME / (name + '.log')))
|
||||
|
||||
|
||||
def start(config, env, backend_port, redis_port):
|
||||
if inspect(env, ['--require-migrated']):
|
||||
raise ValueError('Backend not started: QA migration preflight failed')
|
||||
available(backend_port)
|
||||
available(redis_port)
|
||||
private_directory()
|
||||
secret_path = RUNTIME / 'runtime-secrets.env'
|
||||
if not secret_path.exists():
|
||||
with open(secret_path, 'x', opener=lambda p, f: os.open(p, f, 0o600)) as f:
|
||||
f.write('TOKEN_SECRET=' + base64.b64encode(secrets.token_bytes(64)).decode() + '\n')
|
||||
f.write('QA_REDIS_PASSWORD=' + secrets.token_urlsafe(40) + '\n')
|
||||
local_secrets = load_config(secret_path)
|
||||
env.update(local_secrets)
|
||||
for key in ['TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY', 'TRAIN_STUDENT_IDENTITY_HASH_KEY']:
|
||||
if not config.get(key):
|
||||
raise ValueError('Missing required protected identity configuration')
|
||||
env[key] = config[key]
|
||||
env.update(SPRING_DATASOURCE_DRUID_MASTER_URL=env['QA_DB_URL'],
|
||||
SPRING_DATASOURCE_DRUID_MASTER_USERNAME='fusion_qa_app',
|
||||
SPRING_DATASOURCE_DRUID_MASTER_PASSWORD=env['QA_DB_PASSWORD'],
|
||||
SPRING_DATA_REDIS_PASSWORD=env['QA_REDIS_PASSWORD'])
|
||||
redis_config = RUNTIME / 'redis.conf'
|
||||
with open(redis_config, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as f:
|
||||
f.write('bind 127.0.0.1\nprotected-mode yes\nport ' + str(redis_port) + '\nsave ""\nappendonly no\nrequirepass ' + env['QA_REDIS_PASSWORD'] + '\n')
|
||||
command = ['java', '-jar', str(ROOT / 'RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar'),
|
||||
'--spring.profiles.active=druid',
|
||||
'--spring.datasource.druid.master.url=' + env['QA_DB_URL'],
|
||||
'--spring.datasource.druid.master.username=fusion_qa_app',
|
||||
'--server.address=127.0.0.1', '--server.port=' + str(backend_port),
|
||||
'--spring.data.redis.host=127.0.0.1', '--spring.data.redis.port=' + str(redis_port),
|
||||
'--spring.data.redis.database=0', '--spring.quartz.auto-startup=false',
|
||||
'--ruoyi.profile=' + str(RUNTIME / 'upload'), '--logging.file.path=' + str(RUNTIME / 'logs'),
|
||||
'--logging.level.com.ruoyi=INFO', '--iot.mqtt.mock=true', '--iot.mqtt.enabled=false',
|
||||
'--iot.mqtt.real-device-write-enabled=false', '--iot.mqtt.device-whitelist=',
|
||||
'--iot.mqtt.environment-id=round5v-local-mock', '--iot.mqtt.retry-task-enabled=false',
|
||||
'--iot.mqtt.protocol-verified=false', '--iot.qa-api-enabled=false', '--iot.openapi.enabled=false',
|
||||
'--iot.face.mock=true', '--iot.face.enabled=false', '--iot.face.real-http-enabled=false',
|
||||
'--iot.face.allowed-endpoints=', '--dms.enabled=false', '--dms.mock=true',
|
||||
'--spring.datasource.druid.stat-view-servlet.enabled=false']
|
||||
if not Path(command[2]).is_file():
|
||||
raise ValueError('Build backend jar first')
|
||||
launch('redis', ['redis-server', str(redis_config)], env)
|
||||
launch('backend', command, env)
|
||||
|
||||
|
||||
def stop():
|
||||
private_directory()
|
||||
for name in ['frontend', 'backend', 'redis']:
|
||||
path = RUNTIME / (name + '.json')
|
||||
if not path.exists():
|
||||
continue
|
||||
item = json.loads(path.read_text())
|
||||
actual = subprocess.run(['ps', '-p', str(item['pid']), '-o', 'command='], text=True, capture_output=True).stdout.strip()
|
||||
if actual:
|
||||
# macOS Python wrappers exec Python.app; Redis rewrites its process title.
|
||||
# Check task arguments + cwd + birth time, never select by port/name alone.
|
||||
suffix = ' '.join(item['command'][1:])
|
||||
cwd = subprocess.run(['lsof', '-a', '-p', str(item['pid']), '-d', 'cwd', '-Fn'], text=True, capture_output=True).stdout
|
||||
started = subprocess.run(['ps', '-p', str(item['pid']), '-o', 'lstart='], text=True, capture_output=True).stdout.strip()
|
||||
comm = subprocess.run(['ps', '-p', str(item['pid']), '-o', 'comm='], text=True, capture_output=True).stdout.strip()
|
||||
identity = actual.endswith(suffix) and suffix != ''
|
||||
if name == 'redis':
|
||||
identity = bool(item.get('started')) and Path(comm).name.startswith('redis-server')
|
||||
if not identity or ('n' + str(ROOT)) not in cwd.splitlines() or (item.get('started') and item['started'] != started):
|
||||
raise ValueError('PID identity mismatch; refusing to signal ' + name)
|
||||
os.kill(item['pid'], signal.SIGTERM)
|
||||
path.unlink()
|
||||
print('Stopped task-owned ' + name)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('action', choices=['inspect', 'snapshot', 'verify-snapshot', 'start', 'frontend', 'stop'])
|
||||
parser.add_argument('--config', type=Path, default=Path.home() / '.railwaysystem-qa.env')
|
||||
parser.add_argument('--backend-port', type=int, default=18096)
|
||||
parser.add_argument('--frontend-port', type=int, default=18088)
|
||||
parser.add_argument('--redis-port', type=int, default=16389)
|
||||
parser.add_argument('--snapshot-dir', type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.action == 'verify-snapshot':
|
||||
if not args.snapshot_dir:
|
||||
raise ValueError('Specify --snapshot-dir')
|
||||
verify_snapshot(args.snapshot_dir)
|
||||
return 0
|
||||
if args.action == 'stop':
|
||||
stop()
|
||||
return 0
|
||||
if args.action == 'frontend':
|
||||
available(args.frontend_port)
|
||||
launch('frontend', [sys.executable, str(ROOT / 'scripts/qa/serve_frontend.py'),
|
||||
'--port', str(args.frontend_port), '--backend-port', str(args.backend_port)], os.environ.copy())
|
||||
return 0
|
||||
config = load_config(args.config)
|
||||
env = environment(config)
|
||||
if args.action == 'inspect':
|
||||
return inspect(env, [])
|
||||
if args.action == 'snapshot':
|
||||
private_directory()
|
||||
path = RUNTIME / ('snapshot-' + secrets.token_hex(6))
|
||||
rc = inspect(env, ['--backup', str(path)])
|
||||
if rc == 0:
|
||||
hashes = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in path.glob('*.xml')}
|
||||
(path / 'sha256.json').write_text(json.dumps(hashes, indent=2))
|
||||
print('SNAPSHOT_DIRECTORY=' + str(path))
|
||||
return rc
|
||||
start(config, env, args.backend_port, args.redis_port)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
sys.exit(main())
|
||||
except (ValueError, OSError, subprocess.TimeoutExpired) as error:
|
||||
# Do not echo environment or configuration values.
|
||||
print('QA_OPERATION_REFUSED: ' + (str(error) if isinstance(error, ValueError) else type(error).__name__), file=sys.stderr)
|
||||
sys.exit(2)
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Serve production SPA and proxy only /prod-api to the task-owned loopback backend."""
|
||||
import argparse
|
||||
from functools import partial
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
DIST = Path(__file__).resolve().parents[2] / 'build_ui/dist'
|
||||
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
backend_port = 18096
|
||||
|
||||
def proxy(self):
|
||||
path = self.path[len('/prod-api'):]
|
||||
if not path.startswith('/') or path.startswith('//'):
|
||||
self.send_error(400)
|
||||
return
|
||||
headers = {k: v for k, v in self.headers.items()
|
||||
if k.lower() not in ['host', 'connection', 'content-length', 'transfer-encoding']}
|
||||
length = int(self.headers.get('Content-Length', '0'))
|
||||
if length < 0 or length > 10 * 1024 * 1024:
|
||||
self.send_error(413)
|
||||
return
|
||||
request = urllib.request.Request('http://127.0.0.1:' + str(self.backend_port) + path,
|
||||
data=self.rfile.read(length) if length else None, headers=headers, method=self.command)
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
# Ignore all system/environment HTTP proxies, never follow a backend redirect.
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
|
||||
try:
|
||||
response = opener.open(request, timeout=30)
|
||||
except urllib.error.HTTPError as error:
|
||||
response = error
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
self.send_error(502, 'QA backend unavailable; no substituted API response')
|
||||
return
|
||||
with response:
|
||||
data = response.read()
|
||||
self.send_response(response.code)
|
||||
for key, value in response.headers.items():
|
||||
if key.lower() not in ['connection', 'transfer-encoding', 'content-length']:
|
||||
self.send_header(key, value)
|
||||
self.send_header('Content-Length', str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith('/prod-api/'):
|
||||
return self.proxy()
|
||||
requested = self.path.split('?', 1)[0]
|
||||
if requested != '/' and not (DIST / requested.lstrip('/')).is_file() and '.' not in requested.rsplit('/', 1)[-1]:
|
||||
self.path = '/index.html'
|
||||
return super().do_GET()
|
||||
|
||||
def do_POST(self):
|
||||
return self.proxy() if self.path.startswith('/prod-api/') else self.send_error(405)
|
||||
|
||||
do_PUT = do_POST
|
||||
do_DELETE = do_POST
|
||||
|
||||
def log_message(self, *args):
|
||||
# No query strings, headers, payloads, tokens or personal identifiers in access logs.
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--port', type=int, default=18088)
|
||||
parser.add_argument('--backend-port', type=int, default=18096)
|
||||
args = parser.parse_args()
|
||||
if not (DIST / 'index.html').is_file():
|
||||
raise SystemExit('Build frontend before serving')
|
||||
Handler.backend_port = args.backend_port
|
||||
ThreadingHTTPServer(('127.0.0.1', args.port), partial(Handler, directory=str(DIST))).serve_forever()
|
||||
@@ -0,0 +1,106 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location('qa', Path(__file__).with_name('qa.py'))
|
||||
qa = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(qa)
|
||||
|
||||
|
||||
class QaToolsTest(unittest.TestCase):
|
||||
def test_defaults_never_use_old_database_url(self):
|
||||
env = qa.environment({'QA_DB_URL': 'jdbc:kingbase8://localhost/training', 'FUSION_QA_APP_PASSWORD': 'synthetic'})
|
||||
self.assertIn('/' + qa.DATABASE, env['QA_DB_URL'])
|
||||
self.assertEqual('fusion_qa_app', env['QA_DB_USER'])
|
||||
|
||||
def test_wrong_database_rejected(self):
|
||||
for database in ['training', 'iotlocks', 'railwaysystem_integrated_qa_20260916']:
|
||||
with self.assertRaises(ValueError):
|
||||
qa.environment({'FUSION_QA_DB_URL': 'jdbc:kingbase8://localhost/' + database, 'FUSION_QA_APP_PASSWORD': 'synthetic'})
|
||||
|
||||
def test_url_credentials_rejected(self):
|
||||
for url in ['jdbc:kingbase8://user:secret@localhost/' + qa.DATABASE,
|
||||
'jdbc:kingbase8://localhost/' + qa.DATABASE + '?password=synthetic']:
|
||||
with self.assertRaises(ValueError):
|
||||
qa.environment({'FUSION_QA_DB_URL': url, 'FUSION_QA_APP_PASSWORD': 'synthetic'})
|
||||
|
||||
def test_missing_password_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
qa.environment({})
|
||||
|
||||
def test_unsafe_inherited_overrides_removed(self):
|
||||
with patch.dict(os.environ, {'SPRING_APPLICATION_JSON': '{"danger":true}', 'IOT_MQTT_ENABLED': 'true', 'JAVA_TOOL_OPTIONS': '-Dunsafe=true'}):
|
||||
env = qa.environment({'FUSION_QA_APP_PASSWORD': 'synthetic'})
|
||||
self.assertNotIn('SPRING_APPLICATION_JSON', env)
|
||||
self.assertNotIn('IOT_MQTT_ENABLED', env)
|
||||
self.assertNotIn('JAVA_TOOL_OPTIONS', env)
|
||||
|
||||
def test_private_config_required(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'config.env'
|
||||
path.write_text('FUSION_QA_APP_PASSWORD=synthetic\n')
|
||||
path.chmod(0o644)
|
||||
with self.assertRaises(ValueError):
|
||||
qa.load_config(path)
|
||||
path.chmod(0o600)
|
||||
self.assertEqual('synthetic', qa.load_config(path)['FUSION_QA_APP_PASSWORD'])
|
||||
|
||||
def test_config_does_not_execute_shell(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'config.env'
|
||||
path.write_text("KEY='$(touch should-not-exist)'\n")
|
||||
path.chmod(0o600)
|
||||
self.assertEqual('$(touch should-not-exist)', qa.load_config(path)['KEY'])
|
||||
|
||||
def test_migration_preflight_prevents_all_processes(self):
|
||||
with patch.object(qa, 'inspect', return_value=2), patch.object(qa, 'launch') as launch:
|
||||
with self.assertRaises(ValueError):
|
||||
qa.start({}, {}, 18096, 16389)
|
||||
launch.assert_not_called()
|
||||
|
||||
def test_start_flags_and_secret_handling(self):
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(qa, 'RUNTIME', Path(directory)), \
|
||||
patch.object(qa, 'inspect', return_value=0), patch.object(qa, 'available'), patch.object(qa, 'launch') as launch:
|
||||
config = {'FUSION_QA_APP_PASSWORD':'synthetic', 'TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY':'synthetic', 'TRAIN_STUDENT_IDENTITY_HASH_KEY':'synthetic'}
|
||||
qa.start(config, qa.environment(config), 18096, 16389)
|
||||
self.assertEqual(2,launch.call_count)
|
||||
name, command, env = launch.call_args.args
|
||||
self.assertEqual('backend',name)
|
||||
for flag in ['--iot.mqtt.enabled=false','--iot.mqtt.mock=true', '--iot.mqtt.real-device-write-enabled=false',
|
||||
'--iot.mqtt.device-whitelist=', '--iot.mqtt.retry-task-enabled=false', '--iot.qa-api-enabled=false',
|
||||
'--iot.openapi.enabled=false','--iot.face.real-http-enabled=false','--dms.enabled=false',
|
||||
'--spring.quartz.auto-startup=false','--spring.datasource.druid.master.username=fusion_qa_app']:
|
||||
self.assertIn(flag,command)
|
||||
self.assertNotIn(env['TOKEN_SECRET'],' '.join(command))
|
||||
self.assertEqual(0o600,stat.S_IMODE((Path(directory)/'runtime-secrets.env').stat().st_mode))
|
||||
|
||||
def test_stop_rejects_reused_pid(self):
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(qa, 'RUNTIME', Path(directory)), \
|
||||
patch.object(qa.subprocess, 'run') as run, patch.object(qa.os, 'kill') as kill:
|
||||
(Path(directory)/'backend.json').write_text(json.dumps({'pid':123,'command':['task-owned-java']}))
|
||||
run.return_value.stdout='unrelated-user-process'
|
||||
with self.assertRaises(ValueError):
|
||||
qa.stop()
|
||||
kill.assert_not_called()
|
||||
|
||||
def test_stop_accepts_python_wrapper_only_with_task_identity(self):
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(qa, 'RUNTIME', Path(directory)), \
|
||||
patch.object(qa.subprocess, 'run') as run, patch.object(qa.os, 'kill') as kill:
|
||||
script = str(qa.ROOT / 'scripts/qa/serve_frontend.py')
|
||||
command = ['/usr/local/bin/python3', script, '--port', '18088']
|
||||
(Path(directory)/'frontend.json').write_text(json.dumps({'pid':123,'command':command,'started':'same-birth-time'}))
|
||||
run.side_effect = [SimpleNamespace(stdout='/Frameworks/Python.app/Python ' + ' '.join(command[1:])),
|
||||
SimpleNamespace(stdout='p123\nfcwd\nn' + str(qa.ROOT) + '\n'),
|
||||
SimpleNamespace(stdout='same-birth-time'), SimpleNamespace(stdout='/Frameworks/Python.app/Python')]
|
||||
qa.stop()
|
||||
kill.assert_called_once_with(123,qa.signal.SIGTERM)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user