Local dev stack on macOS — runbook with captured output¶
Runs the whole IronShep POC on one Mac: TimescaleDB in Docker, plus
ironshep-core and ironshep-edge as local processes talking real
gRPC/mTLS over loopback. Useful for development and demos without the three
RHEL VMs in 01–03.
Every command below was executed on 2026-08-23 and the output shown is what it actually printed — not illustrative filler. Where something failed or behaved surprisingly, that's captured too.
Environment this was run on
macOS: 26.6.2
bash: GNU bash, version 3.2.57(1)-release (arm64-apple-darwin25)
cargo: cargo 1.98.0 (797e8a9bc 2026-08-05)
rustc: rustc 1.98.0 (88d9e12ae 2026-08-18)
Docker: Docker version 29.2.1, build a5c7197
Python: Python 3.9.6 · pymodbus 3.8.6
OpenSSL: LibreSSL 3.3.6
How this differs from the RHEL deployment
RHEL 3-host (01–03) |
This Mac runbook | |
|---|---|---|
| Database | PostgreSQL 16 + TimescaleDB via dnf |
timescale/timescaledb-ha:pg16 container on port 5433 |
| Processes | systemd services | foreground/background processes |
| Ports | 5514 / 5162 / 47808 | 15514 / 15162 / 17808 (non-privileged, no sudo) |
| Config | /etc/ironshep/*.toml |
config/*.local.toml in each repo |
| Certs | scp'd between hosts | generated once, copied between the two repo dirs |
| Health check | edge_healthcheck.sh via cron |
not applicable — it's systemd-based (see step 9) |
0. Prerequisites (one time)¶
# Rust toolchain
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
# Docker Desktop — install, then launch it and approve the privileged-helper
# prompt. `docker info` must succeed before step 1.
open -a Docker
# Python simulator dependency (only for the Modbus pump sim in step 6)
pip3 install --user pymodbus
No protoc needed anywhere — the gRPC contract compiles with a pure-Rust
build step (protox).
1. Start the database¶
docker run --name ironshep-db -d \
-e POSTGRES_USER=ironshep \
-e POSTGRES_PASSWORD=ironshep_pw_change_me \
-e POSTGRES_DB=ironshep \
-p 5433:5432 \
timescale/timescaledb-ha:pg16
Wait for readiness rather than guessing at a sleep:
until docker exec ironshep-db pg_isready -U ironshep >/dev/null 2>&1; do sleep 1; done
echo "postgres ready"
Confirm the extensions are present:
List of installed extensions
Name | Version | Schema | Description
---------------------+---------+------------+---------------------------------------------------------------------------------------
plpgsql | 1.0 | pg_catalog | PL/pgSQL procedural language
timescaledb | 2.29.2 | public | Enables scalable inserts and complex queries for time-series data (Community Edition)
timescaledb_toolkit | 1.25.0 | public | Library of analytical hyperfunctions, time-series pipelining, and other SQL utilities
(3 rows)
vector (pgvector) isn't listed yet — it ships in the image but is created
by schema.sql in step 4. It shows up there.
2. Build and test both repos¶
source "$HOME/.cargo/env"
cd ~/Documents/Drive/Projects/Company_Apps/Ironshep/Code/ironshep-core
cargo test --locked
running 9 tests
test model::tests::empty_capture_is_none ... ok
test notify::tests::merge_replaces_present_secret ... ok
test notify::tests::redact_hides_secrets ... ok
test notify::tests::merge_keeps_blank_secret ... ok
test model::tests::bad_attributes_preserved ... ok
test auth::tests::bad_stored_hash_rejects ... ok
test model::tests::event_roundtrip ... ok
test notify::tests::redact_reports_unset ... ok
test auth::tests::password_roundtrip ... ok
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.24s
running 2 tests
test client_without_certificate_is_rejected ... ok
test push_roundtrip_over_mtls ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s
The two integration tests spin up the real gRPC service with throwaway certs
and prove (a) a signed client can push, (b) a client with no certificate
cannot. The notify:: tests cover the secret redaction / merge logic.
running 12 tests
test listeners::bacnet::tests::garbage_is_rejected ... ok
test listeners::snmp_ber::tests::rejects_garbage ... ok
test listeners::bacnet::tests::who_is_is_labelled ... ok
test listeners::snmp_ber::tests::oid_decoding ... ok
test listeners::snmp_ber::tests::parses_minimal_v2c_trap ... ok
test listeners::bacnet::tests::decodes_i_am ... ok
test listeners::snmp_ber::tests::oid_encode_decode_roundtrip ... ok
test listeners::snmp_ber::tests::integer_encoding_is_minimal_twos_complement ... ok
test listeners::snmp_ber::tests::parses_typed_response_values ... ok
test listeners::snmp_ber::tests::request_roundtrips_through_response_parser ... ok
test listeners::snmp_ber::tests::oid_encode_rejects_garbage ... ok
test listeners::snmp_ber::tests::long_form_length_roundtrips ... ok
test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
cargo test implies a build, so there's no separate build step. For a
release binary use cargo build --release --locked and swap target/debug
for target/release in every command below.
--locked pins every dependency to the committed Cargo.lock, so a build
here matches a build on the RHEL hosts. Drop it only when you are
deliberately taking upgrades — cargo update, then re-run the tests.
3. Generate the mTLS certificates¶
mTLS is mandatory — there is no plaintext mode. Generate the CA + server cert with loopback SANs, then one client cert for the edge:
cd ../ironshep-core
bash scripts/gen_certs.sh localhost 127.0.0.1
bash scripts/gen_certs.sh --edge lab1
== Creating POC CA ==
== Creating core server certificate (SANs: DNS:localhost,IP:127.0.0.1) ==
Signature ok
subject=/O=JADEC LABS/CN=ironshep-core
Getting CA Private Key
Done. Now create one cert per edge: bash scripts/gen_certs.sh --edge lab1
== Creating client certificate for edge 'lab1' ==
Signature ok
subject=/O=JADEC LABS/CN=ironshep-edge-lab1
Getting CA Private Key
Copy to the 'lab1' edge host (e.g. into ironshep-edge/certs/):
ca.crt edge-lab1.crt edge-lab1.key
Verify the chain and the SANs:
cd certs
openssl verify -CAfile ca.crt core.crt edge-lab1.crt
openssl x509 -in core.crt -noout -text | grep -A1 "Subject Alternative Name"
cd ..
Copy the edge's credentials into the edge repo — this stands in for the
scp you'd do between real hosts:
mkdir -p ../ironshep-edge/certs
cp certs/ca.crt certs/edge-lab1.crt certs/edge-lab1.key ../ironshep-edge/certs/
chmod 600 ../ironshep-edge/certs/edge-lab1.key
macOS gotcha, already handled in the script: LibreSSL defaults to explicit EC curve parameters and SHA-1 signatures, both of which rustls rejects.
gen_certs.shpasses-pkeyopt ec_param_enc:named_curveand-sha256for exactly this reason. Regenerating certs by hand without those flags produces a stack that fails its TLS handshake.
4. Initialize the schema and create a console account¶
INFO ironshep_core::db: connected to database
INFO sqlx::postgres::notice: extension "timescaledb" already exists, skipping
INFO ironshep_core::db: schema applied
Database schema created/updated. You can now run ironshep-core normally.
Create the web console login. Interactively it prompts twice with no echo; piping two lines works for scripted setup. Accounts have a role — admin (full) or user (everything except notification setup):
printf 'flock-watch-2026\nflock-watch-2026\n' \
| ./target/debug/ironshep-core --config config/core.local.toml --add-user admin
# optional second account with the read-mostly 'user' role:
printf 'watch-only-2026\nwatch-only-2026\n' \
| ./target/debug/ironshep-core --config config/core.local.toml --add-user operator --role user
Confirm what was created:
docker exec ironshep-db psql -U ironshep -d ironshep -c '\dt'
docker exec ironshep-db psql -U ironshep -d ironshep -c "SELECT extname FROM pg_extension ORDER BY 1;"
List of relations
Schema | Name | Type | Owner
--------+---------------+-------+----------
public | assets | table | ironshep
public | events | table | ironshep
public | log_templates | table | ironshep
public | telemetry | table | ironshep
public | ui_sessions | table | ironshep
public | users | table | ironshep
(6 rows)
extname
---------------------
plpgsql
timescaledb
timescaledb_toolkit
vector
(4 rows)
vector is now present, as promised in step 1.
5. Start core and edge¶
Start the Modbus simulator first — see the note below on why.
cd ../ironshep-edge
python3 tools/simulators/modbus_sim.py &
cd ../ironshep-core
RUST_LOG=info ./target/debug/ironshep-core --config config/core.local.toml &
cd ../ironshep-edge
RUST_LOG=info ./target/debug/ironshep-edge --config config/edge.local.toml &
Core:
INFO ironshep_core::db: connected to database
INFO ironshep_core: ironshep-core listening on 127.0.0.1:50051 (gRPC, mTLS required)
INFO ironshep_core::web: web ui listening on http://127.0.0.1:8080 (front with TLS before real deployments)
Edge:
INFO ironshep_edge: ironshep-edge 'lab1' running, forwarding to https://localhost:50051 - press Ctrl-C to stop
INFO ironshep_edge::listeners::modbus: modbus polling 127.0.0.1:1502 unit 1 every 5s
INFO ironshep_edge::listeners::bacnet: bacnet listener on udp/127.0.0.1:17808
INFO ironshep_edge::listeners::syslog: syslog listening on udp/127.0.0.1:15514 and tcp/127.0.0.1:15514
INFO ironshep_edge::listeners::snmp: snmp trap receiver listening on udp/127.0.0.1:15162
WARN ironshep_edge::listeners::modbus: modbus 127.0.0.1:1502: connect failed (Connection refused (os error 61)); retrying in 15s
INFO ironshep_edge::forward: connected to core at https://localhost:50051
That
WARNis what happens when you start the simulator last. On this run the edge came up before the simulator was listening; it retried on its 15-second backoff and connected fine. Harmless, but starting the simulator first avoids a minute of empty telemetry.
Wait for the mTLS session rather than assuming it's up:
6. Send traffic and verify it lands¶
EDGE_HOST=127.0.0.1 SYSLOG_PORT=15514 SNMP_PORT=15162 BACNET_PORT=17808 \
bash scripts/synthetic_traffic.sh --verbose
== syslog udp/15514 ==
== syslog rfc5424 tcp/15514 ==
== snmp v2c trap udp/15162 ==
synthetic_traffic: pass complete
The env vars are required here: the script defaults to the RHEL ports (5514/5162/47808), while this local stack uses the 1xxxx range. Without them, every send silently goes nowhere.
Events:
docker exec ironshep-db psql -U ironshep -d ironshep -c \
"SELECT edge_id, protocol, severity, left(message,58) AS msg FROM events ORDER BY ts DESC LIMIT 12;"
edge_id | protocol | severity | msg
---------+----------+----------+------------------------------------------------------------
lab1 | snmp | 4 | SNMPv2c trap 1.3.6.1.6.3.1.1.5.3
lab1 | syslog | 5 | - compressor stage 1 cycling, discharge 81C
lab1 | syslog | 6 | Aug 23 18:52:17 chiller-7 motorctl: cycle complete, part c
lab1 | syslog | 5 | Aug 23 18:52:17 hmi-station-1 hydctl: cycle complete, part
lab1 | syslog | 6 | Aug 23 18:52:17 boiler-ctl-1 motorctl: motor 2 current dra
lab1 | syslog | 5 | Aug 23 18:52:17 plc-gateway-02 tempmon: motor 1 current dr
lab1 | syslog | 6 | Aug 23 18:52:17 compressor-3 iolink: motor 4 current draw
lab1 | snmp | 4 | SNMPv2c trap 1.3.6.1.6.3.1.1.5.3
lab1 | syslog | 5 | - compressor stage 2 cycling, discharge 79C
lab1 | syslog | 5 | Aug 23 18:52:16 conveyor-a hydctl: cycle complete, part co
lab1 | syslog | 6 | Aug 23 18:52:16 vfd-pump-12 plcbridge: heartbeat ok, uptim
lab1 | syslog | 5 | Aug 23 18:52:16 plc-gateway-02 vibmon: setpoint reached: 7
(12 rows)
Telemetry (the Modbus pump — temperature drifts upward over time by design, giving a future anomaly detector something real to find):
docker exec ironshep-db psql -U ironshep -d ironshep -c \
"SELECT edge_id, metric, round(value::numeric,2) AS value FROM telemetry ORDER BY ts DESC LIMIT 6;"
edge_id | metric | value
---------+---------------------+---------
lab1 | pump_rpm | 1450.00
lab1 | pump_vibration_mm_s | 1.48
lab1 | pump_temperature_c | 54.80
lab1 | pump_rpm | 1454.00
lab1 | pump_vibration_mm_s | 1.50
lab1 | pump_temperature_c | 54.90
(6 rows)
Asset inventory, auto-populated with site = the reporting edge:
docker exec ironshep-db psql -U ironshep -d ironshep -c \
"SELECT source_addr, protocol, site FROM assets ORDER BY protocol, source_addr;"
source_addr | protocol | site
-----------------+----------+------
127.0.0.1:1502 | modbus | lab1
127.0.0.1:60958 | snmp | lab1
127.0.0.1:64195 | snmp | lab1
127.0.0.1:49647 | syslog | lab1
127.0.0.1:49649 | syslog | lab1
...
(15 rows)
Expected POC artifact: each syslog/SNMP datagram leaves from a fresh ephemeral source port, and
assetsis keyed onsource_addr(ip:port), so loopback testing inflates the asset count. Real devices send from stable ports. If this becomes noisy in a pilot, key assets on IP alone.
7. The web console¶
Open http://127.0.0.1:8080 and sign in as admin / flock-watch-2026.
The auth gate, verified from the command line:
# unauthenticated API call
curl -s -o /dev/null -w "GET /api/overview -> HTTP %{http_code}\n" \
http://127.0.0.1:8080/api/overview
# unauthenticated page
curl -s -o /dev/null -w "GET / -> HTTP %{http_code} -> %{redirect_url}\n" \
http://127.0.0.1:8080/
# wrong password
curl -s -X POST http://127.0.0.1:8080/api/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"wrong"}' -w " <- HTTP %{http_code}\n"
# correct password, keep the cookie
curl -s -c /tmp/cookies.txt -X POST http://127.0.0.1:8080/api/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"flock-watch-2026"}' -w " <- HTTP %{http_code}\n"
# authenticated call
curl -s -b /tmp/cookies.txt http://127.0.0.1:8080/api/overview | python3 -m json.tool
GET /api/overview -> HTTP 401
GET / -> HTTP 303 -> http://127.0.0.1:8080/login
{"error":"invalid username or password","ok":false} <- HTTP 401
{"ok":true} <- HTTP 200
{
"edges": [
{
"asset_count": 15,
"edge_id": "lab1",
"errors_24h": 0,
"events_24h": 14,
"last_seen": "2026-08-23T22:52:30.841351Z",
"metrics_24h": 21,
"online": true
}
],
...
}
In the browser, click the lab1 card on the Fleet view — a detail drawer slides in from the right showing live pump metrics with sparklines:
8. Resilience check — core outage and recovery¶
Worth running once; it's the behaviour that makes the edge safe to deploy on a flaky plant link.
# how many events exist right now
docker exec ironshep-db psql -U ironshep -d ironshep -tAc "SELECT count(*) FROM events"
# -> 14
# stop core, keep generating traffic
pkill -f "ironshep-core --config"
for i in 1 2 3; do
EDGE_HOST=127.0.0.1 SYSLOG_PORT=15514 SNMP_PORT=15162 BACNET_PORT=17808 \
bash scripts/synthetic_traffic.sh
sleep 2
done
docker exec ironshep-db psql -U ironshep -d ironshep -tAc "SELECT count(*) FROM events"
# -> 14 (unchanged: nothing lost, nothing written — it's buffered on the edge)
The edge log during the outage, showing the buffer filling and the backoff doubling:
WARN ironshep_edge::forward: cannot reach core (transport error); retrying in 2s (9 captures buffered)
WARN ironshep_edge::forward: cannot reach core (transport error); retrying in 4s (18 captures buffered)
WARN ironshep_edge::forward: cannot reach core (transport error); retrying in 16s (33 captures buffered)
Restart core and the backlog drains on its own:
docker exec ironshep-db psql -U ironshep -d ironshep -tAc "SELECT count(*) FROM events"
# -> 38 (14 + the buffered backlog)
The important part — capture timestamps are preserved, so the recovered data sits at the time it was observed, not the time it was delivered:
docker exec ironshep-db psql -U ironshep -d ironshep -c \
"SELECT date_trunc('second', ts) AS captured_at, count(*) FROM events
WHERE ts > now() - interval '3 minutes' GROUP BY 1 ORDER BY 1 DESC LIMIT 8;"
captured_at | count
------------------------+-------
2026-08-23 22:53:33+00 | 5
2026-08-23 22:53:32+00 | 4
2026-08-23 22:53:30+00 | 6
2026-08-23 22:53:28+00 | 9
2026-08-23 22:52:17+00 | 7
2026-08-23 22:52:16+00 | 7
Reconnection happened at 22:54:00, but the recovered rows are spread across 22:53:28–33 — their real capture times.
8a. The shortcut: three scripts¶
Once you've been through the steps above once (certificates in particular are
a one-time job), day-to-day use is three commands from Code/:
bash ironshep-start.sh --sim # database → core → edge, in order
bash ironshep-status.sh # what's up, and is data arriving?
bash ironshep-stop.sh # graceful shutdown, buffers flushed
ironshep-start.sh starts things in dependency order and waits for each
to be genuinely ready before starting the next — polling for the port to
answer rather than sleeping and hoping. It creates the database container if
it's missing, restarts it if it's stopped, applies the schema (idempotent),
and confirms the edge actually reached core over mTLS before declaring
success. Anything already running is left alone, so it is safe to re-run.
| Flag | Effect |
|---|---|
--sim |
also start the Modbus pump simulator (before the edge, so its first poll succeeds) |
--build |
cargo build --locked both repos first |
ironshep-stop.sh sends SIGTERM and waits for each process to flush —
the edge ships whatever it still has buffered and core drains its write queue
— escalating to SIGKILL only if something outstays 15 seconds. It tells you
which happened, because a hard kill can lose buffered captures.
| Flag | Effect |
|---|---|
| (none) | stop the processes, leave the database running |
--all |
also stop the database container (data preserved) |
--purge |
also delete the container and all its data |
--force |
skip the graceful wait — last resort |
Logs go to Code/.run/. Both scripts are idempotent and exit non-zero on
trouble, so they work inside other scripts too.
8b. Seeing what's running¶
Code/ironshep-status.sh answers "what IronShep is up on this Mac?" in one
command — processes, ports, the database container, and whether data is
actually arriving:
IronShep — local status
───────────────────────────────────────────────────────────
ironshep-core ● pid 35372 up 19:27 cpu 0.0 rss 19MB
ironshep-edge ● pid 30686 up 56:57 cpu 0.0 rss 13MB
modbus simulator ● pid 30687 optional test data
ironshep-db ● Up 57 minutes port 5433
Listening ports
50051 ● gRPC ingest (mTLS) ironshep- 35372
8080 ● web console ironshep- 35372
15514 ● syslog udp+tcp ironshep- 30686
15162 ● snmp traps ironshep- 30686
17808 ● bacnet/ip ironshep- 30686
1502 ● modbus simulator Python 30687
5433 ● postgres (docker) com.docke 26749
Health
console ● http://127.0.0.1:8080
database ● events 44 · telemetry 2046 · open signals 1
data flowing ● 6 events in the last 5 min
All expected components are up.
Exits 0 when everything expected is up, 1 otherwise — so it also works as a
pre-flight check in a shell one-liner. bash Code/ironshep-status.sh --stop
shuts down everything it found (the database container keeps its data).
Doing it by hand instead:
pgrep -fl "[/]ironshep-(core|edge)( |$)" # processes — anchored, no false hits
lsof -nP -iTCP:8080 -sTCP:LISTEN # who owns a port
docker ps --filter name=ironshep # the database container
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/login # console alive?
A plain pgrep -f ironshep is too loose — it also matches any process whose
command line merely mentions the word, including editors and this project's
own tooling. Anchor on the binary name as above.
9. What does not work on macOS¶
scripts/edge_healthcheck.sh is a RHEL/systemd script. On macOS:
scripts/edge_healthcheck.sh: line 35: systemctl: command not found
ironshep-edge UNHEALTHY on JsMacMobile.local:
- ironshep-edge is NOT active (systemctl status ironshep-edge)
It exits 1, which is correct behaviour — it genuinely cannot verify the service. Use direct equivalents locally:
pgrep -f "ironshep-core --config" >/dev/null && echo "core: running"
pgrep -f "ironshep-edge --config" >/dev/null && echo "edge: running"
for p in 15514 15162 17808 50051 8080; do
lsof -nP -iTCP:$p -sTCP:LISTEN >/dev/null 2>&1 || lsof -nP -iUDP:$p >/dev/null 2>&1 \
&& echo "port $p: listening" || echo "port $p: NOT listening"
done
core: running
edge: running
port 15514: listening
port 15162: listening
port 17808: listening
port 50051: listening
port 8080: listening
The cron jobs from 03-edge-host.md are equally systemd/cron-oriented.
synthetic_traffic.sh itself runs fine on macOS (it was verified under bash
3.2 with a minimal PATH and no TTY) — it's only the crontab wiring and the
health check that assume RHEL.
10. Teardown¶
# stop the processes
pkill -f "ironshep-core --config"
pkill -f "ironshep-edge --config"
pkill -f modbus_sim.py
# stop the database, keeping its data for next time
docker stop ironshep-db
# ...or remove it entirely for a clean slate
docker rm -f ironshep-db
To start over completely, also clear the certs (step 3 refuses to overwrite an existing CA, by design):
Quick reference¶
| What | Where |
|---|---|
| Web console | http://127.0.0.1:8080 (admin / flock-watch-2026) |
| gRPC ingest | 127.0.0.1:50051 (mTLS) |
| Database | postgres://ironshep:ironshep_pw_change_me@127.0.0.1:5433/ironshep |
| Edge listeners | syslog 15514 udp+tcp · snmp 15162 udp · bacnet 17808 udp |
| Modbus simulator | 127.0.0.1:1502 (unit 1) |
| Core config | ironshep-core/config/core.local.toml |
| Edge config | ironshep-edge/config/edge.local.toml |
Credentials here are dev-only. The RHEL runbooks (01–03) use real
passwords, host certificates, and firewall scoping.