This software implements an OCPI roaming hub: the thing that sits between the charge point operators and the e-mobility service providers so that they do not each have to be peered with all the others. Every one of them is peered with the hub instead, once, and the hub is what turns that into a mesh.
CPO ──OCPI 2.2.1──▶ RoamingHub ◀──OCPI 2.2.1── EMSP
every call through it written down,
with the two parties it was between
What it is and what it can be told lives in libs/RoamingHub; this repository is the command line that starts it and the submodules it is built from.
The libraries it is built from are submodules, so they have to come along:
git clone --recurse-submodules <this repository>
If you already cloned it without them:
git submodule update --init --recursive
They are fetched from GitHub over https, so nothing but git is needed - no account, no key.
On Windows, turn long paths on first:
git config --global core.longpaths true
The deepest file in the submodules is 128 characters below the clone root, and
under the classic 260-character limit git creates no file whose whole path is
longer than 259, so the root itself may be at most 130 characters long.
D:\src\RoamingHub is fine; a checkout nested deep below
C:\Users\<you>\AppData\Local\Temp\... can run out of room, and then the clone
fails halfway through a submodule with Filename too long rather than at the
start. Per clone instead of globally: git clone -c core.longpaths=true ....
dotnet build RoamingHubCLI.slnx
dotnet run --project RoamingHubCLI
The build needs the .NET 10 SDK and Node.js: the web interface is built by npm
and embedded into the assembly, so the hub is one thing to deploy. dotnet build -p:SkipFrontendBuild=true leaves the npm step out and reuses whatever is
in libs/RoamingHub/RoamingHub/Frontend/dist.
At the first start there are no accounts, so the hub makes one up for the user
root, keeps its hash with the other accounts below accounts/ beside the
solution and prints the password once. Then open http://127.0.0.1:2356/ and
sign in. Signing in happens at Hermod's HTTPExt API, mounted under /ext - the
same door the other components use, which is what lets one sign-in cover
several of them when they share a server.
What it opens on is the traffic, and that is the whole difference from the other components: they open on their configuration, because that is what somebody sets up once and then leaves alone. A hub is set up once and watched.
A peer is given one URL, http://127.0.0.1:2356/ext/versions, and finds
everything else of OCPI from it. Who this hub is - DE-GDH unless
configuration.json beside the solution says otherwise - and which OCPI
versions it offers are read from that file once, at the start, and deliberately
not changeable while running: it is what every peer wrote into its credentials,
and changing it under a live registration would not rename the hub, it would
make it a second one nobody is peered with. The peers themselves are not in the
file: the OCPI library keeps them in append-only files of its own below ocpi/
beside it, one set per version, and reads them back at every start - a line it
cannot read is passed over, and the hub's log says so. A peer is
added, removed or registered only once its file has it: where that file cannot
be written, the Peers page is answered 500 with why, and nothing changes - but
for a registration the peer has accepted already, which is in effect and
written down with the next change the file takes, or when the hub stops. A
peer that registers here or unregisters meanwhile is answered OCPI 3000 with
HTTP 500, its token as it was. Any OCPI call the hub fails to handle is
answered OCPI 3000 with HTTP 500 and the request and correlation ids to quote,
and nothing of what failed: that is in the hub's log, under the same ids.
No OCPI 2.1.1, and there cannot be. The hub role arrived with OCPI 2.2 and the library has no hub side for the version before it. A CPO or an EMSP that speaks only 2.1.1 cannot be peered with this hub; it has to talk to its counterpart directly.
dotnet run --project RoamingHubCLI -- --help lists the rest: --port,
--any, --accounts <dir>, --frontend <dir>, --config <file>,
--verbose, --quiet, --no-trace, --log-file <dir>, --no-log-file,
and the certificate store's --certificates <dir>,
--import-certificate <kind>=<file>, --certificate-password <pw> and
--list-certificates. They are every node's switches, read by WWCP_Node, as
is what the console says once the hub is up.
Beside the solution, then, a first start leaves accounts/, ocpi/, the
certificate store certificates/ and the log files in logs/;
configuration.json follows when a page first saves something, and
known-servers.json when a server is first believed. All of them are this
installation's and none of them the source's, so git ignores them.
Everything that happens is written three times over, because the three answer
different questions. The console shows what is going on to whoever is
watching, at the level --verbose and --quiet choose. The Logs page
keeps the last two thousand entries for whoever asks, and loses them when the
process ends. And logs/ beside the solution keeps one file per UTC day, every
entry down to the debug ones, for the afternoon somebody asks what happened
last night - --log-file <dir> puts it elsewhere, --no-log-file leaves it
out, and nothing in it is ever deleted. Which directory it is, the start says
under log files, and the Configuration page on its Event log card.
Below it, metrological/ is the hub's log book: what bears on the time it
stamps things with and on what it trusts - its starts and its ends, every
synchronisation with what each time server answered, every change of its time
servers and of its certificates - one line after the other, each pointing back
at the one before and signed with a key kept beside them, signing-key.pem. A
file per day, never thinned out; without log files there is no log book
either. It is WWCP_Node's,
and its README says how a chain is checked.
What the peers did through the hub is in neither: the traffic stays in memory.
Once it is up, the console is a prompt rather than a place that only scrolls:
RoamingHub> syncNTS
succeeded after 712 ms: 4 of 4 server(s) answered (2 required), offset +1043.3 ms, spread 2.9 ms
ptbtime1.ptb.de +1043.3 ms, round trip 52.6 ms, key exchange new
ptbtime2.ptb.de +1043.3 ms, round trip 52.7 ms, key exchange new
ptbtime3.ptb.de +1043.4 ms, round trip 52.6 ms, key exchange new
ptbtime4.ptb.de +1046.2 ms, round trip 52.5 ms, key exchange new
help lists what can be typed, quit leaves, Tab completes and ↑
walks back through what was typed before. syncNTS is Sync now on the
NTS page, typed: the same group of time servers is asked, the same entries
go into the log, and the same result is left behind for the page and the
overview to show. The one entry that differs says who asked - the page names
the account that pressed the button, the prompt says it was somebody at the
command line. The console gets a line for each server as well, because the log
only records what the group concluded. Neither of them steps the clock.
With one of the hub's time servers after it, it is that server's Test button instead: one server, on the ports it is configured with, and every step of the key exchange and the time request with when it happened - the TLS certificate of the server and every certificate of the chain this machine built, with both ends of their validity, the SHA-256 fingerprints of the server's certificate and of the root, which are what a pin is compared with, and whether all of it held up. Only a server of this hub is tested; anything else is answered with the ones there are, and nothing is asked. Tab offers them as soon as the command is typed.
RoamingHub> syncNTS ptbtime2.ptb.de
ptbtime2.ptb.de answered, 295 ms altogether:
+0 ms Asking ptbtime2.ptb.de: key exchange on port 4460, time on port 123, 10 second(s) allowed.
+15 ms 'ptbtime2.ptb.de' resolves to 192.53.103.104, 2001:0638:0610:be01:0000:0000:0000:0104.
+15 ms Key exchange over TLS ...
+257 ms Connected to 2001:0638:0610:be01:0000:0000:0000:0104, of 2 address(es) that were offered.
+258 ms Where the time went: name 0 ms, TCP 22 ms, TLS 135 ms, key exchange 84 ms.
+260 ms TLS 1.3, TLS_AES_128_GCM_SHA256, ALPN ntske/1.
+263 ms Server certificate: CN=ptbtime2.ptb.de, for ptbtime2.ptb.de; RSA 3072-bit, sha256RSA; valid 2026-08-09 03:05:52 to 2026-11-07 03:05:51 UTC, 43 day(s) left.
+263 ms Its SHA-256 fingerprint: e2a0ad9e30fddb7d5b35b692159723a1aa96dd3062142456b3795b8638636472.
+263 ms Intermediate CA: CN=YR1, O=Let's Encrypt, C=US; RSA 2048-bit, sha256RSA; valid 2025-09-03 00:00:00 to 2028-09-02 23:59:59 UTC, 709 day(s) left.
+263 ms Intermediate CA: CN=Root YR, O=ISRG, C=US; RSA 4096-bit, sha256RSA; valid 2026-05-13 00:00:00 to 2032-09-02 23:59:59 UTC, 2170 day(s) left.
+263 ms Root CA: CN=ISRG Root X1, O=Internet Security Research Group, C=US; RSA 4096-bit, sha256RSA; valid 2015-06-04 11:04:38 to 2035-06-04 11:04:38 UTC, 3174 day(s) left.
+263 ms The root's SHA-256 fingerprint: 96bcec06264976f37460779acf28c5a7cfe8a3c0aae11a8ffcee05c0bddf08c6.
+263 ms Validated: the chain ends at a root this machine trusts, nothing in it is revoked (asked online), and 'ptbtime2.ptb.de' is one of the server certificate's names.
+264 ms The key exchange succeeded: AES_SIV_CMAC_256, 8 cookie(s).
+264 ms It named no NTP server of its own, so the time is asked of this host.
+264 ms Authenticated NTP request ...
+294 ms Answered by [2001:638:610:be01::104]:123; 8 cookie(s) left, and a fresh one came back.
+294 ms Round trip 29.4 ms.
+294 ms This RoamingHub's clock is +1045.5 ms off what ptbtime2.ptb.de says.
+294 ms The clock was not stepped: that is a different thing, with the sessions and charge detail records of every peer stamped against it, and not something a test does by surprise.
The log keeps writing while you type, from whichever thread did the thing it is reporting, and your half-typed line survives it: the line is taken off the screen, the entry is written whole, and the line comes back with the cursor where it was.
Where there is no terminal - from a script, under a service manager, in CI, or
with the output going into a file or through | tee - there is no prompt, and
the hub runs until it is stopped, exactly as it did before.
While working on the web interface, run npm run watch in
libs/RoamingHub/RoamingHub/Frontend and start the hub with --frontend libs/RoamingHub/RoamingHub/Frontend/dist: a reload in the browser then shows
the change, without rebuilding the C# side.
The same prompt is served over SSH, on port 22356 — twenty thousand above the
web interface's — and on the addresses the web interface listens on: the
loopback, or every address with --any. Nothing else is: no shell of the
machine, no files, no tunnels. --ssh-port moves it, --no-ssh switches it
off.
Whoever signs in is an account of the roaming hub, under its name, with a key
of its own. The first start makes root; give it your public key once:
dotnet run --project RoamingHubCLI -- --authorize-ssh-key root=C:\Users\you\.ssh\id_ed25519.pub
An OpenSSH .pub goes in as it is, and so does what PuTTYgen saves with Save
public key. The key is kept in accounts/ssh/root, a file in the format of
authorized_keys, and putting a line into it by hand does the same; taking one
out locks that key out at once. Then:
ssh -p 22356 root@127.0.0.1
or, in PuTTY, host 127.0.0.1, port 22356, Connection → Data → Auto-login
username root, and the private key under Connection → SSH → Auth →
Credentials. The first time, PuTTY asks whether to trust the roaming hub's
host key: the banner prints its fingerprint under SSH, to compare it with.
Everything works as at the console — Tab, the history, the log above the line
being typed — with three differences. quit, exit and Ctrl+D leave the
session, and the roaming hub keeps running. The account may do what its roles
let it do on the web interface, and the log names it: "'root' at the command
line over SSH asked this roaming hub to synchronise its time.", tagged cli
and ssh. And the session's log starts at the console's level and is its own:
log debug shows everything here, log off nothing, for this session alone.
who says who else is signed in.
Two peers that talk directly can each read their own log and compare them. The moment a hub is between them, neither can say what the other actually sent, and "it works for us" is an answer nobody can check. So every call is written down:
GET /api/v1/traffic?limit=200&after=1234&peer=DE*GEF
GET /api/v1/traffic/events ← the same, as it happens
GET /api/v1/traffic/peers ← everyone seen, for a filter
A line says which way the call went, which peer was at the other end, the two
parties out of the OCPI from and to headers, the version and module, the
HTTP status and the OCPI status inside the envelope - because OCPI answers a
refusal with 200 and a status code as readily as with a 4xx - how long it
took, and how big it was.
The bodies are not kept unless ocpi.logging.payloads in the configuration file
says so, and none of it is written to disk: how long a hub may keep its peers'
business is a question with a different answer in every jurisdiction. A
deployment that has to keep more should read the stream and put it where it has
decided to keep it. Reading it is its own permission, traffic:read, and not
part of configuration:read: the configuration is what this hub is, and the
traffic is what its peers did through it.
Three roles, and the accounts' groups are what gives them: viewer may look
at the hub and at who is on it and do nothing, and may not read the traffic;
hub runs it from day to day - the name servers and the time servers, and the
traffic; systemadmin may do everything, and is the only one who lets a peer
in or changes what the hub believes, and nothing but the hub says what it may
do. The roles section of configuration.json adds roles, or says
differently what viewer or hub may do:
"roles": { "support": [ "configuration:read", "dns:read", "nts:read", "traffic:read" ] }What each role may do, resource by resource, is in libs/RoamingHub.
What this hub believes in TLS, what it will present and every server it
recognises lives in one store, certificates/ beside the configuration file,
managed on the Certificates page or from the command line. A hub keeps the
four kinds of TLS and none of the seven of ISO 15118, which are a vehicle's:
a tlsRoot says which time server and which name server over TLS or HTTPS
may be believed, beside the roots of the machine, and a tlsServer is a
server's own certificate, kept so that the server can be held to it by its
fingerprint. Both are told what they are for - nts, dns or both. A
clientRoot and a tlsIdentity are kept, and used by nothing here yet.
dotnet run --project RoamingHubCLI -- --import-certificate tlsRoot=our-time-servers-root.pem --list-certificates
Importing a root makes it believed - for every use, until the Certificates
page says what it is for. PEM, DER and PKCS#12 all go in; a password for a
protected PKCS#12 is read from ROAMINGHUB_CERT_PASSWORD where
--certificate-password is not given, because a password given as a switch
stands in the process list for every other user of the machine. A relative
--certificates <dir> is measured from where the hub is started, as every
other path on the command line is.
The private keys in the store are not encrypted. What guards them is the file system, so the store belongs on a machine whose users are all trusted with this hub's identity; the hub says so at every start with a key in it.
A time server, and a name server over TLS or HTTPS, can be held to more than a
certificate authority vouching for it - the certificates it may show and the
roots its chain may end at, by their fingerprints, or whatever it was first
believed with. That goes into a server's dialog on the NTS and DNS
pages, and what every server was last believed with is kept in
known-servers.json, so that another certificate is noticed even where a
server is held to none.
RoamingHubCLI/ |
the command line: what -h and the console say of the hub at a start, among every node's switches and lines |
RoamingHubCLI/CLI/ |
the prompt, and one file per command that can be typed at it |
libs/RoamingHub/RoamingHub/ |
the hub itself - its section of the configuration, its JSON API, its OCPI bindings, its traffic log |
libs/RoamingHub/RoamingHub/Frontend/ |
the web interface: TypeScript and SCSS, bundled by webpack |
libs/RoamingHub/RoamingHubTests/ |
what a hub does when a peer, or a stranger, talks to it |
libs/WWCP_Node/ |
what the hub is before it is a hub: the log, the configuration file, name resolution and the time, the certificate store, the accounts and their roles, and the web server |
libs/WWCP_OCPI/ |
the protocol: OCPI 2.2.1 and 2.3.0 |
The command line is this program's vocabulary and nothing else - the switches
at a start and the commands at the prompt. What a roaming hub is, and what
it does, lives in libs/RoamingHub.
This software is Open Source under the Affero GPL 3.0 license. We appreciate your participation in this ongoing project, and your help to improve it and the e-mobility ICT in general. If you find bugs, want to request a feature or send us a pull request, feel free to use the normal GitHub features to do so. For this please read the Contributor License Agreement carefully and send us a signed copy or use a similar free and open license.