Kinjo
Browse local DNS-SD services, filter them, and run custom actions from a terminal UI.
What's it for?
Avahi, the common Linux implementation of Bonjour / mDNS / DNS-SD, allows services to be published and discovered on a local network. This TUI lets users browse discovered services, filter and group them, and launch configured actions for a selected service.
Launch the TUI without arguments to browse the default local domain:
Browse another DNS-SD domain with the --domain (-d) flag (supported by the
default mdns-sd backend; see backends below):
For development without a running Avahi setup:
Discovery backends
The app discovers services over mDNS/DNS-SD so no external CLI tools are
required. Backends are selected exclusively with --backend:
mdns-sd(default): themdns-sd-discoverycrate. A single browser enumerates every service type on the link via the native DNS-SD meta-query. It accepts a custom--domain.zeroconf: thezeroconf-tokiocrate, which talks to the system Avahi daemon on Linux. It browses one service type at a time, so a curated set of common types is swept in parallel when no--service-typeis given. This backend is behind the off-by-defaultzeroconfcargo feature (it needs the Avahi client headers to build, e.g.libavahi-client-devon Debian/Ubuntu).fake: a finite built-in sample stream for development and deterministic UI smoke tests. It is behind the off-by-defaultfakeCargo feature so release binaries do not ship actionable sample endpoints unless explicitly requested.
Install or build with sample discovery explicitly:
Selecting an optional backend in a binary built without it fails with an error that names the Cargo feature to enable.
--fake-discovery has been removed; replace it with --backend fake. See the
release notes for the migration decision.
The zeroconf backend browses only the default local domain. Its browser
exposes no way to select a domain, so rather than accept --domain and quietly
browse local anyway, it refuses the combination up front:
$ kinjo --backend zeroconf --domain corp
error: invalid value for `--domain`: the `zeroconf` backend cannot browse the
`corp` domain: it can only browse the default `local` domain. Browse `local`, or
select the `mdns-sd` backend, which supports custom domains
Use the default mdns-sd backend to browse a custom domain. Empty, local, and
local. all name the default domain.
Limiting discovery to one service type
When a --service-type is given, only that type is browsed:
The value must be a DNS-SD service type — _<name>._tcp or _<name>._udp,
where <name> is 1–15 ASCII letters, digits, and internal hyphens, begins and
ends alphanumeric, and contains at least one letter. Service types are
case-insensitive, so _SSH._TCP and _ssh._tcp are the same browse.
A value that is not a service type is rejected before discovery starts, rather than being ignored in favour of browsing everything — a filter is there to narrow what the program observes, so a typo must never widen it:
$ kinjo --service-type bogus
error: invalid value for `--service-type`: `bogus` is not a DNS-SD service type:
a service type begins with `_`. Use a type such as `_ssh._tcp` or `_dns-sd._udp`,
or omit it to browse every service type
Omit --service-type to browse every supported type.
Discovery never falls back to sample records. If mDNS discovery is unavailable,
the list stays empty and the status line explains why. If a running browse stops
unexpectedly, kinjo says so and clears the list rather than leaving stale
entries on screen: mDNS is edge-triggered, so once the browse is gone nothing can
report that a listed service has since disappeared, and a command launched at one
could be pointed at a host that is no longer there. Either way the failure and its
cause stay on screen rather than scrolling past — discovery is not retried
automatically. Refresh (r) restarts it, and is the recovery action from a
failure.
Select --backend fake in a build with the fake feature for sample records on
demand. Those samples are a short, finite stream; when it ends the status line
reports normal completion and the samples remain listed.
The sample set is chosen to exercise the behavior the real app has: a service
reachable at several addresses, a service with no resolved host yet, and SSH on
two different hosts — so the _ssh._tcp service-type row aggregates children
whose commands differ and asks which host to act on.
Privacy
kinjo browses your local network, so it's worth being explicit about what it
does and does not do with that access:
- No telemetry.
kinjodoes not phone home, collect analytics, or send usage data anywhere. There is no update checker and no crash reporter. - No proactive scanning.
kinjonever port-scans or probes hosts on its own initiative. All discovery is delegated to a pluggable backend behind a discovery session (see Architecture), and every backend speaks only the standard mDNS/DNS-SD protocol — it surfaces services that are already being announced on the link, nothing more. - Discovered data stays local. Services found on the network are shown in the terminal and used only to fill in the commands you configure. Nothing is uploaded or shared with anyone but you.
The two network discovery backends differ in how they reach the network:
mdns-sd(default) implements mDNS/DNS-SD itself: it sends standard multicast queries on the local link and listens for responses. It makes no other network calls and talks to nothing off-link.zeroconf(opt-in, behind thezeroconfcargo feature) delegates all network I/O to the systemavahi-daemonover D-Bus.kinjoitself opens no sockets in this mode — it only reads the records the daemon already maintains.
Either way, the traffic involved is the same kind of local multicast query your OS already performs for Bonjour/AirPlay/network-printer discovery — not a general network scan.
Installation
Debian / Ubuntu
Download the latest .deb package from the project's
GitHub Releases page, then
install it with apt:
The package installs kinjo and the bundled system command files under
/etc/kinjo/commands.
For real local-network discovery, make sure Avahi is installed and running:
Cargo
Install from crates.io with Cargo:
On Debian or Ubuntu, install native build dependencies first:
Nix / NixOS
This repository is a Nix flake (outputs for x86_64-linux and aarch64-linux).
Run kinjo without installing it:
To install it declaratively, add the flake as an input and apply
overlays.default to get pkgs.kinjo:
# flake inputs:
kinjo.url = "github:abbyssoul/kinjo";
# in your NixOS module:
nixpkgs.overlays = [ inputs.kinjo.overlays.default ];
environment.systemPackages = [ pkgs.kinjo ];
The default mdns-sd backend speaks mDNS/DNS-SD itself and needs no system
daemon, so discovery works as soon as it's installed — there's nothing to
enable on NixOS. (The zeroconf backend does go through avahi-daemon, but
it's behind an off-by-default cargo feature that this flake doesn't build.)
The packaged Nix build intentionally omits the off-by-default fake feature.
To try the UI against built-in sample data from a source checkout, use
cargo run --features fake -- --backend fake.
Build From Source
Clone the repository and build locally:
Run from the source tree:
Install the built binary into Cargo's bin directory:
Smoke Test
You can verify the UI without a running Avahi daemon:
To browse real services on the default local domain:
How It Works
kinjo has five moving parts:
- Discovery finds DNS-SD service records on the network. Each record can carry fields such as service name, service type, domain, hostname, address, port, and TXT values.
- Filtering and view tabs organize those records in the UI. You can fuzzy-search the visible services, limit by service type, and switch the top-panel tab to view discovery by service, host, service type, or matching command.
- Actions decide what can be done with a selected service. Action command files
define match predicates such as "service type equals
_ssh._tcp" or "TXT field contains a URL". - Command templates turn service fields into executable commands. For example,
ssh {hostname}uses the selected service hostname, whilexdg-open http://{hostname}:{port}builds a URL from the selected instance. - Keybindings control the TUI. The defaults use Vim-style navigation, and every
built-in UI command can be rebound in
keybindings.toml.
The result is a small local service browser that behaves like a configurable launcher: discover services, narrow the list, choose a matching action, and run the command built from that service's fields.
Architecture
Internally those moving parts live in three deliberately decoupled modules, so the project is easy to extend and hack on. Each is designed to be swapped or reused independently:
- Discovery (
src/discovery/) — the producer of entries. An entry is a discovered record described entirely by its attributes (name, type, host, address, port, TXT, …);Entryis the only contract the rest of the program depends on. Starting discovery hands back aDiscoverySession: one value owning the running adapter, its events, its state, and its shutdown, so the caller cannot hold a receiver whose producer has silently died, and dropping it stops the browse. Adapters vary behind that session — the mDNS/Avahi backend is the default, with a feature-gated built-in sample backend selected by--backend fake— and you could drop in a different DNS-SD source, a static file, or an SSDP/UPnP browser without touching anything else. Discovery options are checked once at that seam: aDiscoveryConfigis a request, and validating it yields theDiscoveryOptionsthat starting an adapter requires. A malformed service type, or a domain the selected backend cannot honour, is therefore refused before anything spawns — no adapter can be reached with a value it would have to quietly reinterpret. - Plumber (
src/plumber/) — the rules engine. A serializable collection of command rules (the TOML command files) is matched against entries by their attributes; multiple rules can match one entry, and a matching rule can be executed. It depends only onEntry, never on the UI, and sits behind aRuleEnginetrait so an alternative matching strategy can be substituted. - UI (
src/ui/) — ties discovery and the rules engine together for a person at the terminal: CLI parsing, config and keymap loading, the application state machine, and rendering. It depends on the other two; they do not depend on it.
The dependency flow is one-directional — discovery ← plumber ← ui — wired
together in main.rs. The two trait seams (Discovery and RuleEngine) are the
intended extension points: implement a trait, swap it in at the composition root,
and experiment. See CONTRIBUTING.md for development setup.
UI
Default keys follow Vim-style conventions:
j/down: move downk/up: move upenter: show or run matching actions/: fuzzy text filtert: service type checklist filtertab/shift+tab(or←/→): switch view tabs: narrow the list to the selected row's host (press again to clear)d/u(orpage down/page up): scroll the details paner/F5: refresh — restart service discovery from scratch?: helpq: quit
The top panel exposes four tabs — services, hosts, types, and commands. Each tab swaps the list and details panes to view discovery from that angle: individual services, hosts and the services they offer, discovered service types, or configured commands and the services they match. The list also supports fuzzy text search and service type filtering.
Each tab shows how many rows it lists: logical services, hosts (plus one row for the registrations that have not resolved a host yet), distinct service types, or configured commands. The aggregate tabs report only what is true of a whole row. A host's details name the host and list every service on it — each with its own type, port, and TXT data — rather than presenting one service's fields as the host's. A service type's details likewise list every host offering it. Actions always run against a concrete discovered service, whichever tab they start from.
The indicator at the top left says what discovery is doing, and it only animates while something is actually happening:
| Indicator | Meaning |
|---|---|
⠋ (spinning) |
Browsing. The list may still change. |
✓ |
A sample stream finished normally. Its records stay valid; nothing more will arrive. |
✗ |
Discovery failed or stopped. Its records are no longer being confirmed. |
A still indicator therefore means the list is final until you refresh (r),
which starts a new browse and returns the indicator to spinning. The empty-list
message says the same thing in words, so the two can never disagree. Neither
ending retries by itself.
The type filter (t) lists the service types currently being advertised, and
its types n/m chip counts only those: m is how many types are on the link
right now, and n is how many of them you are showing. Switching a type off is
remembered rather than observed, so a device that drops off the link and comes
back stays switched off — but a type nobody is advertising is counted on neither
side of the chip.
The details pane keeps your scroll position while you stay on the same row, including as discovery re-reports it. Moving to a different row starts its details from the top, and a row that shortens or a terminal that grows pulls the view back to the end of the content rather than past it.
The s (same-host) filter needs a row with a single host, so it is offered in
the services and hosts tabs. The types and commands tabs report it unavailable
instead of guessing a host; an active filter can be cleared from any tab.
Keybindings are fully customizable: all built-in UI commands can be rebound with a keybindings config file. See docs/keybindings.md for the full keybinding reference and examples.
Configuration
Command files follow the XDG Base Directory Specification. User command files are loaded from:
If XDG_CONFIG_HOME is not set, the fallback path is:
Additional command directories can be provided with:
Validate and list the registered commands with:
To validate and list only the commands from a specific directory:
--config-dir may be written on either side of list-commands, and repeated on
both; the two lines below are equivalent. Directories always overlay in the
order they appear on the command line, whichever side they were written on.
A running instance reloads its command files on SIGHUP (the conventional
reload signal), so edits apply without restarting the TUI:
The reload is transactional: it applies only if every configured command file is valid. If one is not, the commands already loaded stay in force and the reason is reported, rather than a half-saved edit taking away a command you were using. See docs/actions.md.
Keybindings can be overridden at:
See docs/keybindings.md for examples and the complete list of bindable commands.
Command Files
Each command file defines one action and structured match predicates. Example SSH opener:
[]
= "ssh"
= "SSH into a service"
= ["ssh"]
[]
= "_ssh._tcp"
[]
= "SSH into the selected service"
= "ssh {hostname}"
= "execute"
Supported action modes:
fork: spawn the command and return to the TUI.execute: restore the terminal and replace the TUI process with the command.
Supported match predicates:
equalscontainsregex
Supported service fields:
nameservice_typeortypedomainhostnameaddressporttxt.<key>
The same fields can be used in action command interpolation, for example
{hostname}, {address}, and {port}.
Multiple configured actions can match the same service. In that case, the TUI
shows an action picker. If an action needs instance-specific fields such as
address or port and the selected row contains multiple instances, the TUI
asks which exact instance to use.
For the full command file format, examples, and overlay rules, see docs/actions.md.
Contributing
Development setup, required system packages, and local verification commands are documented in CONTRIBUTING.md.