Skip to content

Commit 15915c2

Browse files
committed
feat(client): add Chelon client tools for RPM and repomd signing
- Add chelon_client.py: shared library for API communication - Handles authentication, SSL/mTLS, configuration - Supports CHELON_TOKEN, CHELON_URL, CHELON_CERT_DIR env vars - Supports --insecure flag for testing - Add chelon-sign-repomd: sign repository metadata files - Creates detached GPG signatures (.asc files) - Tested successfully with repomd.xml - Add chelon-sign-rpm: sign RPM packages - Creates detached GPG signatures - Simplified approach (no bash wrapper complexity) - Future: integrate with rpm-sign library for embedded sigs - Update chelon-admin: use journalctl for audit logs - Migrated from file-based to journald-based logging - Filters for AUDIT_ENTRY prefix in journal - Update AuditLogger: unified logging to journald/syslog - Removed manual file writing - Uses logging.getLogger('chelon.audit') - All logs now in systemd journal Resolves: Chelon client tooling for efficient signing Related: Unified logging strategy implementation Signed-off-by: Scott R. Shinn <scott@atomicorp.com>
1 parent c57441d commit 15915c2

5 files changed

Lines changed: 531 additions & 39 deletions

File tree

server/audit.py

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Tracks all signing operations for security and compliance
44
"""
55

6+
import os
67
import json
78
import logging
89
from datetime import datetime, UTC
@@ -14,12 +15,11 @@
1415
class AuditLogger:
1516
"""Audit logging for signing operations"""
1617

17-
def __init__(self, log_dir: str = '/var/lib/chelon'):
18+
def __init__(self, config_file: str = None, log_dir: str = None):
1819
"""Initialize audit logger"""
19-
self.log_dir = Path(log_dir)
20-
self.log_dir.mkdir(parents=True, exist_ok=True)
21-
self.audit_file = self.log_dir / 'audit.log'
22-
logger.info(f"Audit logging to: {self.audit_file}")
20+
# We use a specific logger for audit events so they can be filtered
21+
self.logger = logging.getLogger('chelon.audit')
22+
self.logger.info("Audit logger initialized (logging to journald/syslog)")
2323

2424
def log_signing(self, token_id: str, operation: str, key_used: str,
2525
data_hash: str, success: bool, client_ip: str,
@@ -59,24 +59,17 @@ def log_signing(self, token_id: str, operation: str, key_used: str,
5959
if error:
6060
log_entry['error'] = error
6161

62-
# Write to audit log
63-
try:
64-
with open(self.audit_file, 'a') as f:
65-
f.write(json.dumps(log_entry) + '\n')
66-
except Exception as e:
67-
logger.error(f"Failed to write audit log: {e}")
62+
# Log to standard logging system with a prefix for easy grep
63+
# Using comma separator for prefix to make it robust
64+
self.logger.info(f"AUDIT_ENTRY: {json.dumps(log_entry)}")
6865

6966
def get_recent_logs(self, limit: int = 100) -> list:
70-
"""Get recent audit log entries"""
71-
if not self.audit_file.exists():
72-
return []
73-
74-
logs = []
75-
try:
76-
with open(self.audit_file, 'r') as f:
77-
for line in f:
78-
logs.append(json.loads(line.strip()))
79-
except Exception as e:
80-
logger.error(f"Failed to read audit log: {e}")
81-
82-
return logs[-limit:]
67+
"""
68+
Get recent audit log entries
69+
Deprecated for direct file access. Returns empty list.
70+
Consumers should use journalctl.
71+
"""
72+
# This function is now deprecated in favor of journalctl
73+
# We return empty here to avoid breaking callers instantly,
74+
# but the admin tool will be updated to fetch from journal.
75+
return []

tools/chelon-admin

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import os
88
import sys
99
import argparse
1010
import json
11+
import subprocess
1112
from pathlib import Path
1213

1314
# Add server directory to path
@@ -24,7 +25,7 @@ from audit import AuditLogger
2425

2526
def generate_token(args):
2627
"""Generate a new API token"""
27-
auth = TokenAuth()
28+
auth = TokenAuth(config_file=args.config)
2829
permissions = args.permissions.split(',')
2930
token = auth.generate_token(args.name, permissions, args.rate_limit)
3031
print(f"Generated token for '{args.name}':")
@@ -37,7 +38,7 @@ def generate_token(args):
3738

3839
def list_tokens(args):
3940
"""List all tokens"""
40-
auth = TokenAuth()
41+
auth = TokenAuth(config_file=args.config)
4142
tokens = auth.list_tokens()
4243

4344
if not tokens:
@@ -55,29 +56,69 @@ def list_tokens(args):
5556

5657
def revoke_token(args):
5758
"""Revoke a token"""
58-
auth = TokenAuth()
59+
auth = TokenAuth(config_file=args.config)
5960
auth.revoke_token(args.name)
6061
print(f"Revoked token: {args.name}")
6162

6263

6364
def view_audit(args):
64-
"""View audit logs"""
65-
audit = AuditLogger()
66-
logs = audit.get_recent_logs(args.limit)
65+
"""View audit logs from journald"""
66+
# Use journalctl to fetch logs
67+
# -u chelon: Service unit
68+
# -o json: Output as JSON stream
69+
# -n limit: Limit number of lines (we fetch more and filter)
70+
# We grep for AUDIT_ENTRY in the message since Python logging doesn't set syslog identifier
71+
cmd = [
72+
'journalctl',
73+
'-u', 'chelon',
74+
'-o', 'json',
75+
'-n', str(args.limit * 10), # Fetch more to account for non-audit logs
76+
'--no-pager'
77+
]
6778

68-
if not logs:
69-
print("No audit logs found")
70-
return
71-
72-
for log in logs:
73-
status = "✓" if log['success'] else "✗"
74-
print(f"{status} {log['timestamp']} | {log['token_id']} | {log['operation']} | {log['key_used']}")
75-
if not log['success'] and 'error' in log:
76-
print(f" Error: {log['error']}")
79+
try:
80+
result = subprocess.run(cmd, capture_output=True, text=True)
81+
if result.returncode != 0:
82+
print(f"Error fetching logs: {result.stderr}")
83+
# Fallback (maybe not running as root or journald issue)
84+
print("Tip: Ensure you are running with sufficient permissions (sudo) to read journal logs.")
85+
return
86+
87+
logs = []
88+
for line in result.stdout.splitlines():
89+
try:
90+
journal_entry = json.loads(line)
91+
message = journal_entry.get('MESSAGE', '')
92+
# Message format is "AUDIT_ENTRY: {json}"
93+
if message.startswith('AUDIT_ENTRY: '):
94+
audit_json = message[len('AUDIT_ENTRY: '):]
95+
logs.append(json.loads(audit_json))
96+
except Exception:
97+
continue
98+
99+
# Limit to requested number
100+
logs = logs[-args.limit:]
101+
102+
if not logs:
103+
print("No audit logs found in journal")
104+
return
105+
106+
for log in logs:
107+
status = "✓" if log.get('success') else "✗"
108+
print(f"{status} {log.get('timestamp')} | {log.get('token_id')} | {log.get('operation')} | {log.get('key_used', 'N/A')}")
109+
if not log.get('success') and 'error' in log:
110+
print(f" Error: {log['error']}")
111+
112+
except FileNotFoundError:
113+
print("Error: 'journalctl' command not found. Is systemd installed?")
114+
except Exception as e:
115+
print(f"Error viewing audit logs: {e}")
77116

78117

79118
def main():
80119
parser = argparse.ArgumentParser(description='Chelon Administration Tool')
120+
parser.add_argument('--config', default=os.environ.get('CHELON_CONFIG', '/etc/chelon/chelon.conf'),
121+
help='Path to configuration file')
81122
subparsers = parser.add_subparsers(dest='command', help='Command to execute')
82123

83124
# Generate token

tools/chelon-sign-repomd

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Chelon Sign Repomd
4+
5+
Sign RPM repository metadata files (repomd.xml) using Chelon service.
6+
Creates detached GPG signatures (.asc files).
7+
"""
8+
9+
import os
10+
import sys
11+
import argparse
12+
from pathlib import Path
13+
14+
# Add tools directory to path for chelon_client import
15+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
16+
17+
from chelon_client import get_client, ChelonClientError
18+
19+
20+
def sign_repomd(repomd_path: str, output_path: Optional[str] = None,
21+
key_type: str = 'modern', verbose: bool = False) -> str:
22+
"""
23+
Sign a repomd.xml file
24+
25+
Args:
26+
repomd_path: Path to repomd.xml file
27+
output_path: Path for signature file (default: repomd_path + '.asc')
28+
key_type: GPG key type to use
29+
verbose: Print verbose output
30+
31+
Returns:
32+
Path to signature file
33+
"""
34+
repomd_file = Path(repomd_path)
35+
36+
if not repomd_file.exists():
37+
raise FileNotFoundError(f"Repomd file not found: {repomd_path}")
38+
39+
if output_path is None:
40+
output_path = str(repomd_file) + '.asc'
41+
42+
if verbose:
43+
print(f"Signing: {repomd_path}")
44+
print(f"Output: {output_path}")
45+
print(f"Key type: {key_type}")
46+
47+
# Get client
48+
try:
49+
client = get_client()
50+
except ChelonClientError as e:
51+
print(f"Error initializing client: {e}", file=sys.stderr)
52+
sys.exit(1)
53+
54+
# Sign the file
55+
try:
56+
if verbose:
57+
print("Sending signing request...")
58+
59+
response = client.sign_file(str(repomd_file), key_type=key_type, operation='repodata')
60+
61+
if verbose:
62+
print(f"✓ Signed successfully")
63+
print(f" Request ID: {response.get('request_id')}")
64+
print(f" Key ID: {response.get('key_id')}")
65+
print(f" Key Fingerprint: {response.get('key_fingerprint')}")
66+
67+
# Write signature to file
68+
signature = response['signature']
69+
with open(output_path, 'w') as f:
70+
f.write(signature)
71+
72+
if verbose:
73+
print(f"✓ Signature written to: {output_path}")
74+
75+
return output_path
76+
77+
except ChelonClientError as e:
78+
print(f"Error signing file: {e}", file=sys.stderr)
79+
sys.exit(1)
80+
except Exception as e:
81+
print(f"Unexpected error: {e}", file=sys.stderr)
82+
sys.exit(1)
83+
84+
85+
def main():
86+
parser = argparse.ArgumentParser(
87+
description='Sign RPM repository metadata using Chelon service',
88+
epilog='Environment variables: CHELON_URL, CHELON_TOKEN, CHELON_CERT_DIR'
89+
)
90+
91+
parser.add_argument('repomd_file', help='Path to repomd.xml file')
92+
parser.add_argument('-o', '--output', help='Output signature file (default: <repomd_file>.asc)')
93+
parser.add_argument('-k', '--key-type', choices=['legacy', 'modern'], default='modern',
94+
help='GPG key type to use (default: modern)')
95+
parser.add_argument('--insecure', action='store_true', help='Disable SSL certificate verification')
96+
parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output')
97+
98+
args = parser.parse_args()
99+
100+
# Set verify_ssl based on --insecure flag
101+
if args.insecure:
102+
os.environ['CHELON_VERIFY_SSL'] = 'false'
103+
104+
try:
105+
output_file = sign_repomd(
106+
args.repomd_file,
107+
output_path=args.output,
108+
key_type=args.key_type,
109+
verbose=args.verbose
110+
)
111+
112+
if not args.verbose:
113+
print(output_file)
114+
115+
return 0
116+
117+
except KeyboardInterrupt:
118+
print("\nInterrupted", file=sys.stderr)
119+
return 130
120+
except Exception as e:
121+
print(f"Error: {e}", file=sys.stderr)
122+
return 1
123+
124+
125+
if __name__ == '__main__':
126+
sys.exit(main())

0 commit comments

Comments
 (0)