Skip to content

Commit 9518aee

Browse files
committed
deploy: provision merkle log at UCLA
1 parent 3d883b2 commit 9518aee

14 files changed

Lines changed: 178 additions & 20 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ docker compose exec -e HOME=/testbed/root-ca-home master ndnsec list -c
6969

7070
Each node runs the pinned `ndnd-repo` container with identity `<default_prefix>/ndnd/repo` (e.g. `/ndn/edu/ucla/ndnd/repo`). Master generates a dedicated key, obtains a certificate from the local site CA, and installs the public chain selected by `globals.ndnd_repo_anchors`. The repo can write only its keychain and storage; it has no access to CA private keys or master renewal scripts.
7171

72+
## NDNd Merkle
73+
74+
Only UCLA runs the `ndnd-merkle` container, selected by `services.ndnd-merkle.hosts: [UCLA]` in `config.yml`, with identity `/ndn/edu/ucla/ndnd/merkle`. Master automatically provisions and renews its dedicated site-issued certificate using the same testbed anchors as the repo. Its trust schema permits testbed-certified users to append under their own identities and only the log key to sign responses. History persists in `dist/ndnd-merkle/storage`.
75+
7276
## Development
7377

7478
For debugging and development, you can define `DEBUG=1` in your `.env` file. This will prevent the `dist` folder from auto-rendering and disable git polling. You can then use docker compose as usual to manage the containers.

‎config.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ services:
3939
template_path: templates/ndnd-repo
4040
render_path: dist/ndnd-repo
4141

42+
ndnd-merkle:
43+
hosts: [UCLA]
44+
profile: merkle-log
45+
template_path: templates/ndnd-merkle
46+
render_path: dist/ndnd-merkle
47+
4248
ndncert:
4349
template_path: templates/ndncert
4450
render_path: dist/ndncert

‎docker-compose.yml‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,34 @@ services:
175175
logging:
176176
driver: local
177177

178+
ndnd-merkle:
179+
image: ghcr.io/named-data/ndnd:20261012
180+
pull_policy: always
181+
profiles: [merkle-log] # selected by config.yml through maintenance
182+
init: true
183+
volumes:
184+
- /run/nfd:/run/nfd:ro
185+
- ${PWD}/dist/ndnd-merkle/keys:/config/keys
186+
- ${PWD}/dist/ndnd-merkle/storage:/config/storage
187+
- ${PWD}/dist/ndnd-merkle/merkle.yml:/config/merkle.yml:ro
188+
- ${PWD}/dist/ndnd-merkle/management-schema.tlv:/config/management-schema.tlv:ro
189+
environment:
190+
NDN_CLIENT_TRANSPORT: unix:///run/nfd/nfd.sock
191+
command: [merkle, run, /config/merkle.yml]
192+
network_mode: none
193+
read_only: true
194+
cap_drop: [ALL]
195+
security_opt: [no-new-privileges:true]
196+
mem_limit: 2g
197+
cpus: 1
198+
pids_limit: 128
199+
restart: unless-stopped
200+
depends_on:
201+
nfd: { condition: service_healthy }
202+
serve-certs: { condition: service_started }
203+
logging:
204+
driver: local
205+
178206
ndncert:
179207
image: ghcr.io/ucla-irl/ndncert-ca:20250923
180208
init: true

‎framework/internal/compose.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import json
2+
import os
23
import subprocess
34
import sys
5+
from . import conf
46

57
STATUS_CACHE = {}
68

7-
def config() -> dict:
9+
def config(node: str = None) -> dict:
810
"""
911
Get the global configuration for the docker compose
1012
"""
1113
try:
14+
os.environ['COMPOSE_PROFILES'] = conf.compose_profiles(node)
1215
cmd = ['docker', 'compose', 'config', '--format', 'json']
1316
result = subprocess.run(cmd, stdout=subprocess.PIPE)
1417
return json.loads(result.stdout)

‎framework/internal/conf.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import yaml
2+
import shlex
23

34
from dataclasses import dataclass
5+
from os import getenv
46
from typing import Any
57

68
from .utils import read_dotenv
@@ -29,3 +31,26 @@ def get() -> Config:
2931
config.globals[rkey] = value
3032

3133
return config
34+
35+
36+
def service_enabled(service: dict, node: str) -> bool:
37+
return 'hosts' not in service or node in service['hosts']
38+
39+
40+
def compose_profiles(node: str = None) -> str:
41+
"""Apply tracked host profiles without replacing operator-managed profiles."""
42+
env = read_dotenv()
43+
node = node or getenv('MANAGED_HOST', env.get('MANAGED_HOST'))
44+
services = get().services.values()
45+
managed = {service['profile'] for service in services if 'profile' in service}
46+
default_profiles = ''.join(shlex.split(env.get('COMPOSE_PROFILES', ''), comments=True))
47+
requested = [p.strip() for p in getenv('COMPOSE_PROFILES', default_profiles).split(',')]
48+
if '*' in requested:
49+
with open('docker-compose.yml') as stream:
50+
compose = yaml.safe_load(stream)
51+
requested = [p for service in compose['services'].values() for p in service.get('profiles', [])]
52+
profiles = [p for p in requested
53+
if p and p not in managed]
54+
profiles.extend(service['profile'] for service in services
55+
if 'profile' in service and service_enabled(service, node))
56+
return ','.join(dict.fromkeys(profiles))

‎framework/internal/render.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def render(node_name: str, dry: bool = False) -> None:
3535
config = conf.get()
3636

3737
# Get the docker compose config
38-
compose_config = compose.config()
38+
compose_config = compose.config(node_name)
3939

4040
# Load environment and templates for each service. Set undefined to StrictUndefined to throw
4141
# a noisy error if a value that is present in the template is not passed in as a value.
@@ -66,7 +66,7 @@ def render(node_name: str, dry: bool = False) -> None:
6666
# For each service, render templates with the corresponding values
6767
for service, values in config.services.items():
6868
# Check if the service is enabled
69-
if service not in compose_config['services']:
69+
if not conf.service_enabled(values, node_name) or service not in compose_config['services']:
7070
continue
7171

7272
# Check if any file did change

‎framework/service-profiles.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Resolve tracked host selection for shell maintenance jobs."""
2+
import argparse
3+
from os import getenv
4+
5+
from internal import conf
6+
from internal.utils import read_dotenv
7+
8+
parser = argparse.ArgumentParser(description=__doc__)
9+
parser.add_argument('--service', choices=list(conf.get().services))
10+
args = parser.parse_args()
11+
if args.service:
12+
node = getenv('MANAGED_HOST', read_dotenv().get('MANAGED_HOST'))
13+
print(int(conf.service_enabled(conf.get().services[args.service], node)))
14+
else:
15+
print(conf.compose_profiles())

‎scripts/cert-renew.sh‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,28 @@ if [[ -f dist/ndnd-repo/.security-changed ]]; then
3535
fi
3636
rm -f dist/ndnd-repo/.security-changed
3737
fi
38+
39+
# Optional services run after the core renewals and retain their own retry state.
40+
MERKLE_ENABLED=$(python3 framework/service-profiles.py --service ndnd-merkle) || exit $?
41+
if [[ "${MERKLE_ENABLED}" == 1 ]]; then
42+
MERKLE_DIR=dist/ndnd-merkle
43+
if [[ -n "${FORCE}" ]]; then
44+
touch "${MERKLE_DIR}/.renew-pending"
45+
fi
46+
MERKLE_FORCE=""
47+
if [[ -f "${MERKLE_DIR}/.renew-pending" ]]; then
48+
MERKLE_FORCE="--force"
49+
fi
50+
if bash "${MERKLE_DIR}/renew.sh" ${MERKLE_FORCE:+"${MERKLE_FORCE}"}; then
51+
rm -f "${MERKLE_DIR}/.renew-pending"
52+
if [[ -f "${MERKLE_DIR}/.security-changed" ]]; then
53+
RUNNING_LOG=$(PWD="${ROOT_DIR:-$PWD}" docker compose ps --status running -q ndnd-merkle) || exit $?
54+
if [[ -n "${RUNNING_LOG}" ]]; then
55+
PWD="${ROOT_DIR:-$PWD}" docker compose restart ndnd-merkle || exit $?
56+
fi
57+
rm -f "${MERKLE_DIR}/.security-changed"
58+
fi
59+
else
60+
echo "NDNd Merkle credential renewal failed; core services renewed, log will retry next poll" >&2
61+
fi
62+
fi

‎scripts/cron-host.sh‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ fi
2424
# to recreate everything when master is updated
2525
docker compose up -d master --no-deps
2626

27+
# Resolve service profiles from Git configuration inside the existing master image.
28+
COMPOSE_PROFILES=$(docker compose exec -T master python3 framework/service-profiles.py) || exit $?
29+
export COMPOSE_PROFILES
30+
2731
# Ensure services are running
2832
docker compose up -d --remove-orphans
2933

30-
echo -e "Finished cron-host at $(date)" >&2
34+
echo -e "Finished cron-host at $(date)" >&2
Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -89,17 +89,17 @@ def get_key(directory, identity):
8989
keys = list(directory.glob('*.key'))
9090
if keys:
9191
if len(keys) != 1:
92-
raise ValueError('Expected exactly one private repo key')
92+
raise ValueError('Expected exactly one private service key')
9393
wire = decode_pem(keys[0].read_text(), 'KEY')
9494
name, meta, content, pointers = parse_data(wire)
9595
if (meta.content_type != 9 or len(name) != len(identity) + 2
9696
or Name.to_bytes(name[:-2]) != Name.to_bytes(identity)
9797
or bytes(name[-2]) != Component.from_str('KEY')):
98-
raise ValueError('Existing repo key has the wrong identity or format')
98+
raise ValueError('Existing service key has the wrong identity or format')
9999
key = ECC.import_key(bytes(content))
100100
keys[0].chmod(0o600)
101101
if not key.has_private() or not verify_ecdsa(key.public_key(), pointers):
102-
raise ValueError('Invalid private repo key')
102+
raise ValueError('Invalid private service key')
103103
return name, key, wire
104104

105105
key = ECC.generate(curve='P-256')
@@ -112,19 +112,23 @@ def get_key(directory, identity):
112112
return name, key, wire
113113

114114

115-
def provision(directory, site_home, site_prefix, anchors_path, force=False, renew_before_days=30):
115+
def provision(directory, site_home, site_prefix, anchors_path, force=False, renew_before_days=30, service='repo'):
116116
# Serialize overlapping cron/manual renewal runs without rotating the key.
117117
with (directory / '.security.lock').open('a') as lock:
118118
fcntl.flock(lock, fcntl.LOCK_EX)
119-
return provision_locked(directory, site_home, site_prefix, anchors_path, force, renew_before_days)
119+
return provision_locked(directory, site_home, site_prefix, anchors_path, force, renew_before_days, service)
120120

121121

122-
def provision_locked(directory, site_home, site_prefix, anchors_path, force, renew_before_days):
123-
with (directory / 'repo.yml').open() as stream:
124-
config = yaml.safe_load(stream)['repo']
122+
def provision_locked(directory, site_home, site_prefix, anchors_path, force, renew_before_days, service):
123+
if service not in ('repo', 'merkle'):
124+
raise ValueError('Unsupported NDNd service')
125+
if service == 'merkle' and site_prefix != '/ndn/edu/ucla':
126+
raise ValueError('Merkle log provisioning is restricted to UCLA')
127+
with (directory / f'{service}.yml').open() as stream:
128+
config = yaml.safe_load(stream)[service]
125129
identity = Name.normalize(config['name'])
126-
if Name.to_bytes(identity) != Name.to_bytes(Name.normalize(site_prefix + '/ndnd/repo')):
127-
raise ValueError('Repo identity must be <site-prefix>/ndnd/repo')
130+
if Name.to_bytes(identity) != Name.to_bytes(Name.normalize(site_prefix + '/ndnd/' + service)):
131+
raise ValueError(f'Identity must be <site-prefix>/ndnd/{service}')
128132

129133
now = datetime.now(timezone.utc)
130134
site_wire = decode_certificate(site_home / 'site.ndncert')
@@ -156,7 +160,7 @@ def provision_locked(directory, site_home, site_prefix, anchors_path, force, ren
156160
keys.mkdir(mode=0o700, exist_ok=True)
157161
keys.chmod(0o700)
158162
key_name, key, key_wire = get_key(keys, identity)
159-
cert_path = directory / 'repo.ndncert'
163+
cert_path = directory / f'{service}.ndncert'
160164
cert_wire = decode_certificate(cert_path) if cert_path.exists() else None
161165
margin = timedelta(days=renew_before_days)
162166
renew = force or cert_wire is None
@@ -174,7 +178,7 @@ def provision_locked(directory, site_home, site_prefix, anchors_path, force, ren
174178
before = max(now - timedelta(minutes=5), site_before, root_before).replace(microsecond=0)
175179
after = min(now + timedelta(days=90), site_after, root_after).replace(microsecond=0)
176180
if after <= now + margin:
177-
raise ValueError('Renew the site/root certificate before issuing a repo certificate')
181+
raise ValueError('Renew the site/root certificate before issuing a service certificate')
178182
signer = Sha256WithEcdsaSigner(key_name, key.export_key(format='DER', use_pkcs8=False))
179183
_, csr = new_cert(key_name, Component.from_str('self'),
180184
key.public_key().export_key(format='DER'), signer, before, after)
@@ -189,7 +193,7 @@ def provision_locked(directory, site_home, site_prefix, anchors_path, force, ren
189193
or bytes(cert.content) != key.public_key().export_key(format='DER')
190194
or not verify_certificate(cert_wire, site_wire)
191195
or not before <= actual_before <= now < actual_after <= after):
192-
raise ValueError('Site CA returned an invalid repo certificate')
196+
raise ValueError('Site CA returned an invalid service certificate')
193197

194198
# Prepare every credential before marking it for a service reload.
195199
changed = import_packet(keys, key_wire, 'KEY')
@@ -198,7 +202,7 @@ def provision_locked(directory, site_home, site_prefix, anchors_path, force, ren
198202
changed |= write_atomic(cert_path, base64.encodebytes(cert_wire))
199203
if changed:
200204
write_atomic(directory / '.security-changed', b'\n')
201-
print(f"NDNd repo {config['name']}: {'credentials updated' if changed else 'credentials current'}")
205+
print(f"NDNd {service} {config['name']}: {'credentials updated' if changed else 'credentials current'}")
202206
return changed
203207

204208

@@ -208,6 +212,7 @@ def provision_locked(directory, site_home, site_prefix, anchors_path, force, ren
208212
parser.add_argument('--site-home', type=Path, required=True)
209213
parser.add_argument('--site-prefix', required=True)
210214
parser.add_argument('--anchors', type=Path, required=True)
215+
parser.add_argument('--service', choices=('repo', 'merkle'), default='repo')
211216
parser.add_argument('--force', action='store_true')
212217
args = parser.parse_args()
213-
provision(args.directory, args.site_home, args.site_prefix, args.anchors, args.force)
218+
provision(args.directory, args.site_home, args.site_prefix, args.anchors, args.force, service=args.service)

0 commit comments

Comments
 (0)