Skip to content

About

C# .NET XMPP Console

Resources

Security policy

Stars

1 star

Watchers

1 watching

Forks

Repository files navigation

XMPPConsole

CI Nightly

Two badges, and the difference between them is which library they build against. CI gates every push on the revisions libs/ pins — what a fresh git clone --recurse-submodules produces — on Windows and Debian 13: 29 of 29, nothing skipped, on both. Nightly runs the same suite on the same two platforms with the three submodules moved to master, because a push to Ratatoskr does not touch this repository and the pins keep the gate green through any amount of movement underneath. Red there and green here means the library changed in a way this console can see.

An interactive XMPP client for the command line: it logs in to an XMPP server over WebSocket (RFC 7395), authenticates with SCRAM, and then gives you a prompt from which you chat, manage your roster, query services and drive PubSub — everything as a /command, everything else you type goes to the current conversation partner.

The protocol itself does not live here. It lives in Ratatoskr, a library of its own under libs/; XMPPConsole is its hand-operated front end. What is in this repository is user interface: command line parsing, command dispatch and presentation.

Maturity: experimental. The client connects, authenticates and chats against Prosody 13 over wss://. Connection management and error handling are incomplete. What is checked in this repository is the console output and nothing else — the protocol is checked with the library. Not for production use.


Contents


What it does

  • Chat. Set a conversation partner with /to, then type — every line without a leading / goes out as a message. Single messages without switching partner go with /msg. A message already sent is corrected with /fix (XEP-0308), and a message that arrived is answered with /re (XEP-0461) — which is not the same as simply writing back, because the answer names the message it is about.
  • Roster. List, filter, add and remove contacts, see who is online, read the details of a single contact, and accept or deny incoming contact requests.
  • Presence. Set your own status (available, away, chat, dnd, xa) with a status text.
  • Typing notifications and read markers. /typing, /paused, /gone (XEP-0085) and /mark received|displayed|ack (XEP-0333).
  • Service discovery. Ask any JID what it can do (/disco info), what it offers (/disco items), and compare the server's feature list against your own (/features).
  • PubSub. Roughly two dozen subcommands under /pubsub: subscribe, publish, fetch items, create, configure and delete nodes, manage roles, subscribers and access models, and answer subscription requests (XEP-0060).
  • Files. /send <path> puts a file on the server's upload service (XEP-0363) and sends the address; /send -e <path> encrypts it first (XEP-0454), and then the storage host holds bytes it cannot read. The limit the service announces is asked for before the file is read from disk, so a refusal does not arrive after the reading. An incoming file is shown as one rather than as the address it is, and fetched into the archive when --storeChatMedia says so.
  • Avatars. /avatar <path> publishes a picture over PEP (XEP-0084), /avatar off takes it down, /avatar <jid> fetches somebody else's and says where it was written. A terminal cannot show a face; what it can do is tell you there is one and put it where something else can open it. A change announced by a contact appears as a note and is not fetched: doing that unasked would let every contact decide that this machine downloads something.
  • OMEMO. End-to-end encrypted messages, fingerprint comparison and per-device trust decisions (XEP-0384) — in a room as well, where it needs one thing first: one encrypts to the devices of a real address, and a room hands out nicknames. /roomencrypt makes the room non-anonymous, which is what puts real addresses into everybody's view, and it asks before doing it because it changes the room for everybody in it. A line in a room then says which of the two it was — a lock, or the reason it went in the clear — exactly as a one-to-one line does. One occupant whose address the room will not give stops the whole message: a person standing in the room is not a device that can be quietly skipped.
  • Operational insight. /ping with round-trip measurement, /who, /carbons, /sm, /csi, /keepalive, and /raw to watch the actual XML going back and forth.

The console keeps its input line intact while all of this happens: incoming messages, presence changes and the log itself go through one output lock, so nothing is ever written into the middle of what you are typing.

What it speaks today

Inherited from Ratatoskr and reachable from this console:

Area State
Transport WebSocket over TLS (RFC 7395), wss://. A ws:// endpoint is refused unless --insecure is given. No TCP, no BOSH
Authentication SCRAM-SHA-256, and demanded by default — SCRAM-SHA-1 and SASL PLAIN only for whoever lowers the bar with --sasl. No channel binding (-PLUS)
Endpoint discovery XEP-0156 host-meta over HTTPS; only wss:// endpoints are taken
JID handling RFC 7622 (PRECIS, IDNA2008)
XEP-0030 / XEP-0115 / XEP-0128 Service discovery, entity capabilities, disco extensions
XEP-0060 Publish-Subscribe, including node ownership and access models
XEP-0085 / XEP-0184 / XEP-0333 Chat states, delivery receipts, chat markers
XEP-0198 Stream Management with resumption, on by default
XEP-0199 Ping and RTT measurement
XEP-0203 Delayed delivery — late messages carry their original date
XEP-0280 Message Carbons, with spoofing protection
XEP-0308 Last message correction (/fix)
XEP-0424 Message retraction (/unsay) — a request and not a deletion, and the archive keeps its own copy
XEP-0045 Multi-user chat (/join, /part, /rooms, /nick, /topic), and /roomencrypt for the one setting that lets a room be encrypted in. The moderator's half (/kick, /ban, /voice, /askvoice, /grantvoice, /denyvoice) and the owner's (/affiliations, /destroyroom), a nickname held against everybody else (/holdnick, section 7.10), and the private word of section 7.5 (/pm)
XEP-0313 Message archives (/history), and a room's history on entering it
XEP-0359 Stable stanza IDs — read, to know which name a reply may point at
XEP-0426 / XEP-0428 / XEP-0461 Message replies (/re), with the quoted lines marked as the duplicate they are
XEP-0352 Client State Indication (/csi)
XEP-0363 / XEP-0454 HTTP file upload (/send), encrypted first with -e
XEP-0084 Avatars (/avatar), over XEP-0163
XEP-0384 / XEP-0420 OMEMO 2 (urn:xmpp:omemo:2) with stanza content encryption, one to one and in a room

Carbons, receipts, PubSub events, roster pushes and caps answers are all checked against a forged sender before they are processed.

Not implemented: MIX (XEP-0369), Jingle, blocking (XEP-0191), and in-band registration of an account — XEP-0077 is here, but only pointed at a room, which is what /holdnick is. SASL2 and Bind 2 need no command: the console speaks them because Ratatoskr negotiates them. The full catalogue of what a modern XMPP client could speak — and where each specification stands — is in docs/STANDARDS.md.

Requirements

  • .NET SDK 10.0 or newer
  • An XMPP account on a server that offers XMPP over WebSocket (RFC 7395) — Prosody, ejabberd and most modern servers do, but it often has to be enabled

Getting started

Ratatoskr, Hermod and Styx are submodules under libs/, so one clone is the whole build:

git clone --recurse-submodules https://github.com/Vanaheimr/XMPPConsole.git

If you already cloned without them:

git submodule update --init

The submodule URLs are the public https://github.com/Vanaheimr/… mirrors, so this needs no account and no deploy key. Inside libs/ the three end up beside each other, which is the layout they expect of one another — Ratatoskr reaches for ..\..\Hermod, Hermod for ..\..\Styx:

libs/Ratatoskr/   libs/Hermod/   libs/Styx/

The revisions are pinned, so a breaking change upstream does not turn this red on its own — and equally, does not get noticed here. That question is asked by the nightly in XMPPConformanceTests, which builds the same chain at master.

Two references, one of which is always off

XMPPConsole.csproj names Ratatoskr twice, under opposite conditions:

<ProjectReference Include="..\libs\Ratatoskr\Ratatoskr\Ratatoskr.csproj"
                  Condition="Exists('..\libs\Ratatoskr\Ratatoskr\Ratatoskr.csproj')" />
<ProjectReference Include="..\..\Ratatoskr\Ratatoskr\Ratatoskr.csproj"
                  Condition="!Exists('..\libs\Ratatoskr\Ratatoskr\Ratatoskr.csproj')" />

Condition is allowed on any MSBuild item, and Exists() is evaluated while the project is being read — before any target runs, so a ProjectReference that loses its condition is not a reference that fails to resolve, it is one that was never in the project. Relative paths inside Exists() resolve against the directory of the file the condition is written in, not the working directory, so this means the same thing wherever dotnet build is invoked from.

That is not indecision, it is the two trees this project is built in:

Checkout libs/Ratatoskr Resolves to
This repository, cloned on its own present, pinned libs/Ratatoskr — one clone is the whole build
As libs/XMPPConsole inside XMPPConformanceTests empty, that checkout is deliberately not recursive ..\..\Ratatoskr — which inside its libs/ is the Ratatoskr that repository pins

So the pin here never competes with the pin there. That matters because XMPPConformanceTests holds this console in its solution: if both pins were live in one tree, two Ratatoskr.csproj would build the same assembly twice. Delete the fallback and the conformance suite stops building; delete the submodules and a plain clone stops building.

To see which one won, ask MSBuild rather than guessing — this prints the evaluated items without building anything:

dotnet msbuild XMPPConsole/XMPPConsole.csproj -getItem:ProjectReference

Build:

dotnet build XMPPConsole.Tests/XMPPConsole.Tests.csproj

or open XMPPConsole.slnx, which holds the console, its tests and the three libraries as source rather than as references pointing off-screen.

Run — with no arguments it asks for JID, password and WebSocket URI interactively:

dotnet run --project XMPPConsole

Or pass them:

dotnet run --project XMPPConsole -- -j user@example.com -p secret

The built executable is XMPPConsole and takes the same options:

./XMPPConsole/bin/Debug/net10.0/XMPPConsole -j user@example.com -p secret

Command line options

Option Meaning
-j, --jid <jid> JID in the form user@domain. A resource may be written along — user@domain/phone then asks the server for exactly that resource instead of console-<pid>
-p, --password <pw> Password
-w, --ws, --websocket <uri> WebSocket endpoint, e.g. wss://xmpp.example.com:5281/xmpp-websocket. wss:// only, see below
--insecure Allow a ws:// endpoint anyway
--sasl <mechanism> The weakest SASL mechanism still accepted. Default SCRAM-SHA-256
-v, --verbose Verbose logging (trace level — shows every stanza)
--chatlogs <dir> Keep the conversations as text, see below
--storeChatMedia Fetch the files that were shared. Needs --chatlogs
-h, --help Show the help and exit

Long options take their value either way, --chatlogs logs and --chatlogs=logs.

Anything not given on the command line is asked for at startup. The password prompt does not echo. Leaving the WebSocket URI empty starts the endpoint discovery described below.

# interactive
dotnet run --project XMPPConsole

# with an explicit endpoint (needed for servers that publish no host-meta)
dotnet run --project XMPPConsole -- -j user@example.com -p pw \
  -w wss://xmpp.example.com:5281/xmpp-websocket

# with the full protocol log
dotnet run --project XMPPConsole -- -j user@example.com -p secret -v

Chat logs

--chatlogs <dir> writes what was said as text — not the XML that carried it. The raw stream is already available live through /raw, and it answers a different question: whoever opens a chat log a year later wants to read the conversation, not the protocol.

<dir>/
└── ahzf@example.org/
    ├── ahzf@example.org_202608.log
    ├── ahzf@example.org_202609.log
    └── media/                         (only with --storeChatMedia)
        └── 20260815_134207_photo.jpg

One directory per conversation, one file per month, one line per event:

# XMPPConsole chat log - ahzf@example.org - 2026-08
# <date time>  <marker>  <who>  <what>
# markers: '<' received, '>' sent, '*' presence, '#' session, '@' file
# a line break inside a message is written as \n, a backslash as \\
2026-08-15 13:42:07  <  ahzf@example.org/iPhone  Hi 4!
2026-08-15 13:42:11  >  me  Hallo\nzurück
2026-08-15 13:42:34  *  ahzf@example.org/iPhone  available  (back at four)
2026-08-15 13:43:02  <  ahzf@example.org/iPhone  Hi 5!  [corrects msg-17]

Both directions land in the file named after the far end, carbons from your other devices included — a conversation belongs in one place. Presence changes are kept with their status text, and so is the session itself (connecting, losing the connection, contact requests), because a gap in a log is otherwise an unanswerable question later: was nothing said, or was nobody connected?

A message may contain line breaks and a log line may not, so they are written as \n and a backslash as \\. That keeps every event greppable as one line and stays reversible.

A JID is not a file name — the resource part forbids almost nothing, and eve@example.org/..\..\..\Windows is a JID anybody may carry. Everything outside A-Z a-z 0-9 @ . - _ therefore becomes _, which costs two JIDs differing only in a forbidden character their own directory, and saves a class of bug in which the answer to "where did the file go" is "anywhere".

Shared files

--storeChatMedia additionally fetches what was shared, into <dir>/<jid>/media/, named after the moment it arrived — that is the one time this side actually knows, since the timestamp inside a message is what the sender claims.

This turns an incoming message into a network request from your machine, so what counts as a shared file is deliberately narrow:

  • aesgcm:// always. That scheme exists for nothing else (XEP-0454); the URL carries the key. The file is downloaded, decrypted with the key from the URL fragment and stored in the clear — the fragment itself is never written to the log and never sent to the server.
  • https:// only when the message is nothing but that one URL. That is how a client hands over an upload (XEP-0363): the body repeats the URL because the receiver may not read the jabber:x:oob element beside it. A link inside a sentence is a different act — somebody is pointing at something, not handing it over.
  • http:// never, and no other scheme.

What is refused is written into the log too, with the reason, so a file that never arrived does not simply go missing.

The remaining rules: at most 100 MB per file, five minutes, redirects followed by hand and checked at every hop, and no address that belongs to this machine or to a private network. One gap is named rather than papered over — the host is resolved, checked, and then resolved a second time by the HTTP client, so a name that answers differently the second time gets through. Closing that needs a socket handler of its own.

Ratatoskr does not read jabber:x:oob today, so the detection works on the message text. In practice that is the same thing, because XEP-0363 senders put the URL in the body as well — but a client that does not would go unnoticed.

Reading the aesgcm:// URL and decrypting what comes back is not done here: that is XEP-0454, it is protocol rather than application, and it lives in Ratatoskr as AesGcmUrl where any client on that library can use it. The fetching stays here on purpose — whether an incoming message may cause a request at all, and under which limits, is a decision an application has to make. A protocol library that downloads on its own hands that decision to whoever sent the message.

Commands

At the prompt, a line without a leading / is sent as a message to the current conversation partner. Everything else is a command.

Messages

/to <jid>                 set the conversation partner (alias: /chat)
/to                       reset the conversation partner
/msg <jid> <text>         send a single message (alias: /m)
/fix <text>               correct the last message to this partner (alias: /corr)
/unsay                    take back the last thing said (alias: /retract)
/re <text>                answer the last message from this partner (alias: /reply)
/join <room> [nick]       enter a room and make it the conversation (alias: /j)
/part [reason]            leave the current room (alias: /leave)
/rooms                    the rooms, their subjects and who is in them
/roomencrypt              make this room non-anonymous, which is what lets it
                          be written in encrypted - it changes the room for
                          everybody in it, so it asks first
/pm <nick> <text>         a word to one occupant and to nobody else
/nick <name>              a different name in the current room
/holdnick [name]          claim a nickname here; bare, says what is held
/topic [text]             the subject of the current room (alias: /subject)
/history [count]          what was said before, out of the archive (alias: /hist)
/invite <jid> [reason]    ask somebody into the current room
/decline <room> [reason]  say no to an invitation that arrived
/kick <nick> [reason]     throw somebody out of the room for this visit
/ban <nick> [reason]      keep somebody out of the room for good
/affiliations [which]     who is on one of this room's lists
/destroyroom [jid] [reason]
                          take this room down for everybody, for good
/voice <nick> on|off      the voice in a moderated room
/askvoice                 ask a moderated room to be allowed to speak
/grantvoice <nick>        answer a request for a voice that arrived here
/denyvoice <nick>         refuse one
/status [show] [text]     set the status: available|away|chat|dnd|xa (alias: /s)

/fix takes the complete new text, not the change to it. It corrects the last message to the current partner, and a correction can be corrected again — every one of them naming the original, as XEP-0308 asks since 1.1.0. Because it carries the whole new text, it goes out the way the corrected message did: encrypted after an encrypted line, with the <replace/> inside the envelope. Where that is no longer possible it sends nothing and says why, rather than write the new text in the clear.

/unsay takes back the last thing said in this conversation (XEP-0424). It is a request and not a deletion: what the far side does with it is theirs, the archive keeps its own copy, and whoever was reading has already read. In a room what it takes back is the line under the name the room gave it — and that name arrives only when the room hands our own line back, so the console notes it from the reflection and not from the send. An encrypted line comes back unreadable, and only its name is taken from it.

A line that went out encrypted is taken back encrypted, the <retract/> inside the envelope: in the clear the request would name the line and tell everybody watching that you wanted it gone. When a room can no longer carry that, /unsay says why and sends nothing.

/re answers the last message that arrived from the current partner, and sends the old text along as > lines so that a client which does not know XEP-0461 still shows what the answer is about. Those lines are marked as fallback (XEP-0428), so a client which does know it hides them and shows its own quotation instead — which is what this console does with incoming answers:

[21:14:32] alice ↩:
           > Coming along?
           Eight o'clock then

The quotation is dimmed and the answer is not, because only one of the two was written just now. What is quoted when sending is the other side's text without its quotation — otherwise every turn would add a layer of > and after four exchanges the message would be mostly other people's words.

Rooms

/join chat@conference.example achim enters a room and makes it the current conversation, so an ordinary typed line goes into it. A room that did not exist is created by entering it — and is then locked until its owner configures it, so /join does that straight away; without it you would have a room only you can see, with nothing anywhere saying so.

/rooms lists who is in them with affiliation and role. The real address is usually missing, and that is not a gap: a room is semi-anonymous by default and tells nobody who anybody is. When it is not — /join says so.

What happens in a room is shown dimmed, because none of it is somebody saying something. Being thrown out is not:

[21:14:32] chat@conference: bob is here
[21:14:40] chat@conference: subject: deployment on Friday
[21:15:02] kicked out of chat@conference - enough of that (by alice@example)

Leaving, being kicked, being banned and the service shutting down all arrive as the same stanza, and the status codes are the only thing that tells them apart. Reported as "you have left the room" they read identically — and the one thing worth knowing, whether coming back is worth trying, would be the part dropped.

What was said before

/join shows the last ten lines of a room as it lets you in, because walking into a room and seeing nothing is walking in blind. /history [count] asks for more — the room's archive for a room and your own for a person, which are two different archives at two different addresses: your server never saw a word of what was said in a room before you arrived.

It is dimmed and carries the date, because none of it is happening now and a bare time of day would say that it is. The whole difficulty of an archive on a screen is that it looks exactly like a conversation.

A room that keeps no archive says so:

> /history
chat@conference keeps no archive, so nothing said there can be looked up - or
answered.

The second half of that sentence is not a throwaway. A room assigns a message its own name only if it archives, and without that name there is nothing for a reply to point at — so in such a room /re cannot work either (XEP-0461 §4).

Being asked in, and throwing somebody out

An invitation is the only thing a room says about a room you are not in — and therefore the only way to hear that one exists:

[21:14:32] alice asks you into chat@conference: come along
           /join chat@conference   or   /decline chat@conference

The invitation is kept, because a refusal has to be addressed to whoever asked and by then the stanza is gone. Who that is comes back in one of two shapes, and both real services were measured doing a different one — ejabberd names the inviter's real address, Prosody their address in the room. The second says who asked without saying who that is, which for a semi-anonymous room is the more careful answer; the console prints alice (in the room) for it rather than a room address where a person belongs.

/kick <nick> and /ban <nick> both take a nickname, and only one of them can keep it:

> /ban bob
This room does not say who 'bob' really is, and a ban has to name somebody who
exists outside it. /kick works.

A kick takes away a role, which lasts only for the visit, so a nickname names somebody. A ban sets an affiliation, which outlives the visit, so it has to name somebody who exists outside it — and a semi-anonymous room gives real addresses to moderators only. The console looks the nickname up and says which of the two it is: a missing name, or a missing permission. They are different problems and only one of them is yours.

Destroying a room is here: /destroyroom takes it down for everybody and for good. The address it takes is where the occupants are sent instead, and it is the only part of a destruction that is of any use to them.

Not here: the configuration form as a whole — /roomencrypt sets the one field that OMEMO in a room needs and nothing offers the rest — and a password for a protected room, which /join reports rather than sends. /join says which of those a refusal was about.

Two limits worth knowing. There is no /re for a particular older message: only the last one that arrived can be answered, because that is all a console that keeps no history can point at. And an answer to an encrypted message is encrypted, with the <reply/> and the quotation's marking inside the envelope: beside it they would say who answered whom and when, which is the shape of a conversation in clear to anyone watching the connection. In a room an answer follows the rule an ordinary line follows — encrypted when the room can carry it — except that a line which arrived encrypted is never answered in the clear, because the answer quotes it; /re says so and sends nothing.

A voice, a name, and a word to one person

/askvoice asks a moderated room to be allowed to speak (section 8.6). Nobody answers it as such: the request goes to the moderators as a form, and what comes back is a role or nothing at all. On the other side /grantvoice <nick> and /denyvoice <nick> answer one that arrived here — the console keeps the request, because the form has to go back with the fields it came with, and by the time somebody has read the line the stanza is gone.

/holdnick [name] claims a nickname in this room so that nobody else may enter under it (section 7.10, which is XEP-0077 pointed at a room). Bare, it says what is held. A name held is not a name taken: it keeps somebody else from using it, it does not put you in the room.

/affiliations [members|admins|owners|banned] shows who is on one of the room's lists. An affiliation outlives a visit and a role does not, which is the same distinction /kick and /ban run into from the other side — the list is the room's memory of people who are not in it.

/pm <nick> <text> says something to one occupant and to nobody else (section 7.5). It goes through the room, so a nickname is all that is needed and, in a semi-anonymous room, all there usually is. It is shown differently from a room line on purpose: of the two possible mistakes, a private word read as a public one is the expensive one.

Contacts (roster)

/roster [filter]          show the contacts (aliases: /list, /contacts)
/online                   only the contacts that are online
/add <jid> [name] [g1,g2] add a contact and ask for a subscription
/remove <jid>             remove a contact (alias: /del)
/info <jid>               contact details
/groups                   groups with the number of contacts
/pending                  pending contact requests
/accept [jid]             accept a contact request (no argument: the first)
/deny [jid]               deny a contact request (no argument: the first)

Chat states (XEP-0085)

/typing                   send 'is typing'
/paused                   send 'has stopped typing'
/gone                     leave the chat

Chat markers (XEP-0333)

/mark received [msg-id]   mark as received (alias: r)
/mark displayed [msg-id]  mark as read (aliases: d, read)
/mark ack [msg-id]        acknowledge (aliases: acknowledged, a)

Without a msg-id the message received last is used.

Service discovery (XEP-0030)

/disco                    show the subcommands
/disco server             features of your own server
/disco info <jid>         features of a JID
/disco items <jid>        services/items of a JID
/features                 the server's features and your own

PubSub (XEP-0060)

/pubsub                             show the subcommands
/pubsub sub <node> [jid]            subscribe to a node (alias: subscribe)
/pubsub unsub <node> [jid] [subid]  end a subscription (alias: unsubscribe)
/pubsub subs                        own subscriptions with subid (alias: subscriptions)
/pubsub sync [jid]                  fetch the subscriptions from the service
/pubsub opts <node> [subid]         options of the subscription (alias: options)
/pubsub deliver <node> <on|off> [subid]   delivery on/off
/pubsub pub <node> <id> <data>      publish an item (alias: publish)
/pubsub get <node> [max]            fetch items (alias: items)
/pubsub create <node> [access]      create a node (models as with 'access')
/pubsub cfg <node>                  node settings (alias: nodecfg)
/pubsub access <node> <open|presence|whitelist|roster|authorize>   change the access
/pubsub groups <node> [group...]    roster groups for 'roster' (alias: rostergroups)
/pubsub roles <node>                who is what at this node (alias: affiliations)
/pubsub role <node> <jid> <owner|publisher|member|outcast|none>    set a role
/pubsub subscribers <node>          who subscribes to this node (alias: who)
/pubsub kick <node> <jid> [subid]   remove a subscriber (alias: remove)
/pubsub request <node> <jid> <yes|no>   answer a subscription request (alias: authorize)
/pubsub retract <node> <id>         take back a single item (alias: undo)
/pubsub purge <node>                empty a node, keep the subscribers (alias: empty)
/pubsub delete <node>               delete a node

Without a <jid> the request goes to pubsub.<domain>; a PEP node belongs to an account, and then its bare JID stands there. Every one of these commands reports what the service answered — "Subscribed" means it confirmed, not that it was asked.

The [subid] on unsubscribing is needed as soon as there are several subscriptions to the same node: without it there is no saying which one is meant, and the client picks none. /pubsub subs shows them. With kick the [subid] is optional by contrast — without it all subscriptions of that JID go.

subs and subscribers ask in opposite directions: the first where this client subscribes, the second who subscribes to a node of your own.

Encryption (XEP-0384)

/omemo on                       switch OMEMO on for this account
/omemo on <jid> <text>          send an encrypted message
/omemo fingerprints             own and known fingerprints (alias: fp)
/omemo trust <jid> <device>     confirm a device
/omemo distrust <jid> <device>  refuse a device

Connection

/ping [jid]               send a ping and measure the RTT (XEP-0199)
/keepalive [on|off|sec]   show/change the keepalive
/sm [on|off]              show/change stream management (XEP-0198)
/csi [active|inactive]    client state indication (XEP-0352)
/who                      your own connection status
/carbons                  the carbon status
/reconnect                connect again
/disconnect               disconnect
/raw                      toggle the raw XML display
/help                     help (aliases: /h, /?)
/quit                     quit (aliases: /q, /exit)

How the connection is made

If -w gives an endpoint, that endpoint is used and never overruled. Otherwise the client looks for one (XEP-0156):

  1. https://<domain>/.well-known/host-meta.json
  2. https://<domain>/.well-known/host-meta
  3. If neither yields a wss:// endpoint: wss://<domain>:5443/ws

Only HTTPS is used for the lookup, and only wss:// endpoints are taken over — a BOSH endpoint is read and passed over, this client does not speak it. The fallback port is ejabberd's default; Prosody usually wants wss://<host>:5281/xmpp-websocket, which you then have to give with -w.

ConnectAsync throws when the setup fails, and every reading step of the negotiation has a 10 second deadline — the one case an error does not cover is a far side that accepts the connection and then keeps quiet. After the connection is up, a break is answered with automatic reconnection: exponential backoff from 1 second up to 30, at most 5 attempts. The resource is console-<pid> unless the server is left to choose.

Why ws:// is refused

An endpoint given with -w has to be wss://. Not warned about — refused, and the program stops before it asks for a password.

The damage is not the one the word "unencrypted" suggests. It is not that somebody reads along; it is that the password itself goes out. The list of SASL mechanisms comes from the server and nothing signs it, so whoever sits on the way rewrites it: the SCRAM entries disappear, PLAIN stays, and PLAIN is not a proof that you know the password, it is the password. The pinning in Ratatoskr would notice the downgrade — from the second login onwards. The first one is the one being taken, and a first one can be arranged by cutting the connection, since this client rebuilds by itself.

Hence two settings that work from the very first frame:

Setting Default What it is for
--insecure off Permits ws://. For a server on the same machine, or a test setup with no certificate. Permits nothing else — an http:// address stays refused, because that objection is a different one
--sasl <mechanism> SCRAM-SHA-256 The weakest mechanism still accepted. Everything this console is pointed at offers it: Prosody, ejabberd, and the XMPPServer in Ratatoskr

A server that really cannot do SCRAM-SHA-256 is told apart from an attack by nothing this program can see. So it is refused, and the refusal names the option: --sasl SCRAM-SHA-1, or --sasl PLAIN, which demands nothing at all.

The endpoint discovery above needs neither setting. XEP-0156 takes only wss:// out of a host-meta, and the fallback names it itself — a plain endpoint can only ever come from the command line or the prompt.

Keepalive

Default interval: 25 seconds. If stream management is active an <r/> is sent, otherwise an XEP-0199 ping.

/keepalive
Keepalive status:
  Enabled: True
  Interval: 25s
  Method: Stream Management <r/>

/keepalive 60      # set the interval to 60s
/keepalive off     # disable

Changes take effect only after a reconnect, because the loop is started when the connection is set up. The same holds for /sm on|off.

End-to-end encryption (OMEMO)

/omemo on creates or loads a device identity. The store lies next to the executable and carries the JID in its name (omemo-user_example.com.json), so two accounts on the same machine are two devices and do not share a fingerprint.

The store file is not encrypted. Whoever can read it can read along.

/omemo fingerprints shows your own fingerprint and every device known so far, grouped in blocks of eight so a human can compare them. Compare a fingerprint over another channel, never over the one you are securing. /omemo trust and /omemo distrust record the decision; devices you have not decided on stay marked ?.

When an encrypted message cannot reach a device, the console names it and says why — a sender who does not learn of it takes their conversation for held. And it keeps two cases apart that look alike: one device of four missing is a message that arrived, none of three is a message that went out and nobody can open. The second is what writing to somebody who does not do OMEMO at all looks like, and it gets a line of its own rather than the arrow that means sent.

A device that has written before and turns up with a different identity key gets its message refused, and the console says so — naming the fingerprint on file beside the one just offered. That is the whole of what blind trust buys: the first message is given away against the promise that a change afterwards is noticed, and this is the promise. Until recently it was not kept here. The library detected the case correctly and raised an event, nothing in the console listened, and the contact simply stopped arriving with no line anywhere saying why — which is indistinguishable from the far side having gone quiet.

There is no way to accept a new key here and no command for it. A new installation and somebody pushing in between look exactly alike from this end, so whoever wants the device back deletes the store and loses every other comparison with it. That price is the honest one; a one-key override would look cheaper and be worth less.

What the console does not show is which device an incoming encrypted message came from, or whether that device is confirmed: it listens on the plain message event, which carries the text and not the rating. The message arrives, and nothing marks it as having been encrypted at all.

Security notes

  • -p on the command line is visible. It lands in the shell history and in the process list of the machine. The interactive password prompt does not.
  • The OMEMO store is plaintext. See above.
  • /raw prints everything, message bodies included. Do not paste that output into a bug report unedited.
  • XMPPConsole/Properties/launchSettings.json is a local debugging profile. If you put real credentials in it, keep it out of the repository.
  • The password stays in memory for as long as the connection does, as an ordinary string, so a crash dump of this process contains it. That is a named trade-off and not an oversight: SCRAM needs it at every authentication and not only the first, so removing it means an API that can demand a password back at any moment. Worth knowing what it costs — whoever can read this process already has the session and the OMEMO identity, so what the password adds is worth something elsewhere, wherever it was reused.

Where the reasoning lives. A code review of this console and of Ratatoskr was made in August 2026 and is kept in docs/grok-review.md, annotated since with what has been closed and what deliberately has not. Ten findings, seven closed outright; the rest carry their reasons, and the trade-offs listed above are written out there in one piece rather than one line each. Two of the review's own sentences turned out to be wrong, and the answers stand beside them — which is the whole argument for keeping a review after its list has gone quiet.

Architecture

Three layers, cleanly separated:

Layer Type Task
UI Program Command line, command dispatch, presentation. Holds no protocol logic
Application XMPPClient Session state (conversation partner, pending contact requests, last message id) and composite operations
Protocol XMPPConnection WebSocket I/O, SASL, resource binding, stanza routing

Only the first of these lives in this repository; the other two come from Ratatoskr. XMPPClient and XMPPConnection write nothing to the console — everything runs over events and the injected ILoggerFactory.

That injected logger is why ConsoleUI/ exists. A console that keeps an input line has a problem a pure output does not: the user is typing right now. An AddSimpleConsole writes whenever it suits it, straight into a half-finished line. ConsoleOutput is the one door everything goes through — incoming events, system notices and the log — under one lock: the input line gives way, the output appears, the prompt is back afterwards.

Log levels: Information for connection steps, Debug for protocol details, Trace for single stanzas (that is what -v turns on), Warning for spoofing attempts fended off and protocol oddities.

Repository layout

XMPPConsole/                             the console application
├── XMPPConsole.csproj
├── Program.cs                           command line, dispatch, presentation
└── ConsoleUI/
    ├── ConsoleOutput.cs                 one lock, one line, one output
    └── ConsoleOutputLoggerProvider.cs   the log through the same door

XMPPConsole.Tests/                       eight tests on one question:
└── ConsoleOutputTests.cs                does the input line survive output?

docs/STANDARDS.md                        catalogue of relevant RFCs and XEPs

Pinned submodules, inside the repository:

libs/Ratatoskr/                          XMPP protocol: client, server, XEPs, OMEMO
libs/Hermod/                             network stack: TCP/TLS, HTTP, DNS/SRV, WebSockets
libs/Styx/                               data flow / pipeline abstractions
Submodule Repository Purpose
libs/Ratatoskr Vanaheimr/Ratatoskr The XMPP protocol — everything this console drives
libs/Hermod Vanaheimr/Hermod Network stack Ratatoskr builds on
libs/Styx Vanaheimr/Styx Data-flow abstractions Hermod builds on

The dependency chain runs XMPPConsole → Ratatoskr → Hermod → Styx; only the first reference is declared here, the rest follow.

The namespace of this application is org.GraphDefined.Vanaheimr.XMPPConsole (and …XMPPConsole.ConsoleUI); the protocol types come from org.GraphDefined.Vanaheimr.Ratatoskr.

Tests

dotnet test XMPPConsole.Tests/XMPPConsole.Tests.csproj

Twenty-nine tests on three questions, and all three are things that can be got wrong invisibly.

Eight ask whether the input line survives an output that comes unasked — a log line written past the prompt looks fine in isolation and only shows up while somebody is typing, to the one person it happened to.

Thirteen are about the chat log and what it fetches, and there the invisible part is that the input comes from strangers. A JID becomes a directory name, a message becomes a download: the tests are accordingly about a resource called ..\..\Windows and about a link inside a sentence.

The last eight ask which endpoints this client will open at all — ws:// is refused unless --insecure says otherwise, which is one comparison and exactly the kind of thing that stays silently correct until the day it is silently not. NUnit, in the same versions as the other Vanaheimr suites.

All twenty-nine run on Windows and on Debian 13, and that is not symmetry for its own sake: what a stranger's name becomes as a file name is a platform's answer. ..\..\Windows is one segment on Linux and three on Windows, and NUL is a device on one and an ordinary name on the other. Checked on one platform, a sanitiser is checked in one of the two situations it exists for.

Reading and decrypting an aesgcm:// URL is checked with Ratatoskr rather than here — it is XEP-0454 and therefore protocol, not console. What this application answers for itself, and what is checked here, is whether to fetch at all.

The protocol is not checked here. Its suite lives with Ratatoskr, in the submodule:

dotnet test libs/Ratatoskr/RatatoskrTests/RatatoskrTests.csproj

And everything that needs a foreign implementation — Prosody, ejabberd, python-omemo — lives in XMPPConformanceTests, next to the setups that produce those far sides.

License

Apache License 2.0 — see LICENSE, like the other Vanaheimr projects.

About

C# .NET XMPP Console

Resources

Security policy

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages