computer made a thing

This commit is contained in:
Ricardo (XenGi) Band 2026-08-07 15:42:27 +02:00
commit c7d2470f22
No known key found for this signature in database
7 changed files with 651 additions and 0 deletions

3
.dockerignore Normal file
View file

@ -0,0 +1,3 @@
.git
.gitignore
*.md

View file

@ -0,0 +1,45 @@
name: Build container
on:
schedule:
- cron: '0 12 * * *'
timezone: Europe/Berlin
workflow_dispatch:
enable-email-notifications: true
env:
BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
REGISTRY_URL: git.sb20.xengi.de
REGISTRY_TAG: latest
jobs:
build:
runs-on: buildkit
name: Build container
steps:
- name: Check out the repository
run: git clone ${{ env.FORGEJO_SERVER_URL }}/${{ env.FORGEJO_REPOSITORY }} .
- name: Setup buildkit
run: |
mkdir -p ~/.docker
echo "{\"auths\":{\"${{ env.REGISTRY_URL }}\":{\"username\":\"${{ env.FORGEJO_REPOSITORY_OWNER }}\",\"password\":\"${{ secrets.REGISTRY_TOKEN }}\"}}}" > ~/.docker/config.json
- name: Build container
run: |
export CREATED=$(date -Iseconds)
sed -i "s/%%CREATED%%/$CREATED/" Dockerfile
sed -i "s/%%COMMIT%%/${{ env.FORGEJO_SHA }}/" Dockerfile
buildctl-daemonless.sh build \
--frontend dockerfile.v0 \
--local context=./ \
--local dockerfile=./ \
--output type=image,name=${{ env.REGISTRY_URL }}/${{ env.FORGEJO_REPOSITORY }}:${{ env.REGISTRY_TAG }},push=true
- name: Send notification
if: ${{ failure() }}
run: |
curl -s \
--form-string "token=${{ secrets.PUSHOVER_API_TOKEN }}" \
--form-string "user=${{ secrets.PUSHOVER_USER_KEY }}" \
--form-string "message=Building mdns-reflector container failed." \
https://api.pushover.net/1/messages.json

28
Dockerfile Normal file
View file

@ -0,0 +1,28 @@
FROM docker.io/alpine:latest AS builder
RUN apk add --no-cache build-base linux-headers
COPY mdns-repeater.c /src/
RUN gcc -static -O2 -o /mdns-repeater /src/mdns-repeater.c -lpthread
FROM docker.io/alpine:latest as runtime
COPY --from=builder /mdns-repeater /usr/local/bin/
COPY --chmod=755 entrypoint.sh /
COPY --chmod=755 healthcheck.sh /usr/local/bin/
RUN apk add --no-cache libcap iproute2 \
&& adduser -D -s /sbin/nologin mdns \
&& setcap 'cap_net_raw,cap_net_bind_service=+ep' /usr/local/bin/mdns-repeater \
&& mkdir -p /tmp \
&& chown mdns:mdns /tmp
USER mdns
HEALTHCHECK --interval=15s --timeout=5s --start-period=5s --retries=3 \
CMD /usr/local/bin/healthcheck.sh
EXPOSE 5353/udp
ENTRYPOINT ["/entrypoint.sh"]

103
README.md Normal file
View file

@ -0,0 +1,103 @@
# mdns-reflector — IPv4 + IPv6 mDNS reflector for MikroTik RouterOS
A lightweight mDNS reflector (multicast DNS repeater) designed to run as a container on MikroTik RouterOS 7.x. It relays mDNS packets (port 5353, groups `224.0.0.251` and `ff02::fb`) between all specified interfaces — both IPv4 **and** IPv6 — filling the gap where RouterOS' built-in `/ip dns set mdns-repeat-ifaces=` only supports IPv4.
## How it works
- Opens one IPv4 and one IPv6 UDP socket **per interface**, bound to that interface via `SO_BINDTODEVICE`
- Joins the mDNS multicast groups on each interface
- Spawns one reader thread per interface; packets received on any interface are forwarded to every other interface
- Writes a health heartbeat to `/tmp/mdns-health` every 10 seconds
- Runs as non-root user `mdns` with `cap_net_raw,cap_net_bind_service` capabilities
## Quick start
```bash
# Build
podman build -t mdns-reflector .
# Run (auto-detect non-loopback interfaces)
podman run --rm --network host mdns-reflector
# Run with explicit interfaces
podman run --rm --network host \
-e INTERFACES="ether1 bridge" \
mdns-reflector
```
## MikroTik RouterOS container setup
RB5009 running RouterOS 7.x:
```routeros
/container config set registry-url=https://git.sb20.xengi.de
/container add \
remote-image=git.sb20.xengi.de/xengi/mdns-reflector:latest \
interface=bridge \
root-dir=mdns-reflector \
envlist=mdns-env \
start-on-boot=yes
/container env add name=mdns-env key=INTERFACES value="bridge iot private"
/container start [find where image~"mdns-reflector"]
```
> **Note:** MikroTik containers require `--network host`-like behavior, which you get by attaching the container to a single interface (usually your LAN bridge). The `INTERFACES` env var tells the reflector which interfaces *inside* the host to bridge mDNS across.
### Unprivileged container considerations
> This container already runs as non-root user `mdns`.
> On RouterOS, ensure the container has sufficient privileges:
>
> ```routeros
> /container set [find where image~"mdns-reflector"] \
> nesting=yes \
> hostname=mdns-reflector
> ```
## Environment variables
| Variable | Default | Description |
|---------------|-----------------------|--------------------------------------------------|
| `INTERFACES` | all non-lo interfaces | Space-separated list of interface names to bridge |
## Health check
The container exposes a Docker `HEALTHCHECK` driven by `healthcheck.sh`. It verifies:
1. Health heartbeat file exists and is < 30 seconds old
2. `mdns-repeater` process is running
3. At least one bound UDP socket exists on port 5353
Then on RouterOS, pull the updated image:
```routeros
/container pull [find where image~"mdns-reflector"]
```
## Building from source
```bash
# Multi-stage build (produces ~3 MB image)
podman build -t mdns-reflector .
# Or compile the C binary directly
gcc -static -O2 -o mdns-repeater mdns-repeater.c -lpthread
```
## Files
| File | Purpose |
|---------------------|-----------------------------------------------|
| `mdns-repeater.c` | IPv4/IPv6 mDNS reflector |
| `Dockerfile` | Multi-stage Alpine build |
| `entrypoint.sh` | Interface detection & launcher |
| `healthcheck.sh` | Docker HEALTHCHECK probe |
| `.dockerignore` | Reduces build context size |
## License
MIT

24
entrypoint.sh Executable file
View file

@ -0,0 +1,24 @@
#!/bin/sh
# entrypoint.sh — mdns-reflector container entrypoint
#
# Expects interface names via env var INTERFACES (space-separated),
# or falls back to all non-loopback interfaces.
set -e
if [ -n "$INTERFACES" ]; then
# shellcheck disable=SC2086
set -- $INTERFACES
else
# Auto-detect: all 'up' interfaces except loopback
set -- $(ip -o link show up | awk -F': ' '!/lo/{print $2}' | cut -d@ -f1)
fi
if [ $# -eq 0 ]; then
echo "ERROR: No interfaces found. Set INTERFACES env var or attach interfaces."
exit 1
fi
echo "[entrypoint] Starting mdns-repeater on: $*"
exec /usr/local/bin/mdns-repeater "$@"

56
healthcheck.sh Executable file
View file

@ -0,0 +1,56 @@
#!/bin/sh
# healthcheck.sh — RouterOS container health check for mdns-reflector
#
# Returns 0 (healthy) if:
# 1. Health file exists and is younger than 30 seconds
# 2. mdns-repeater process is running
# 3. At least one interface socket is alive
#
# Returns 1 (unhealthy) otherwise.
HEALTH_FILE="/tmp/mdns-health"
# 1. Check health file exists
if [ ! -f "$HEALTH_FILE" ]; then
echo "unhealthy: no health file"
exit 1
fi
# 2. Check health file freshness (must be < 30s old)
now=$(date +%s)
file_ts=$(awk -F= '/^ts=/{print $2}' "$HEALTH_FILE" 2>/dev/null)
if [ -z "$file_ts" ]; then
echo "unhealthy: no timestamp in health file"
exit 1
fi
age=$((now - file_ts))
if [ "$age" -gt 30 ]; then
echo "unhealthy: health file stale (${age}s old)"
exit 1
fi
# 3. Check process is running
pid=$(pidof mdns-repeater 2>/dev/null)
if [ -z "$pid" ]; then
echo "unhealthy: mdns-repeater not running"
exit 1
fi
# 4. Check at least one interface has a socket (fd4 or fd6 open)
n_ifaces=$(awk -F= '/^n_ifaces=/ {print $2}' "$HEALTH_FILE" 2>/dev/null)
if [ -z "$n_ifaces" ] || [ "$n_ifaces" -eq 0 ]; then
echo "unhealthy: no interfaces configured"
exit 1
fi
# 5. Verify sockets are actually bound to port 5353
ss -lupn 2>/dev/null | grep -q ":5353"
if [ $? -ne 0 ]; then
echo "unhealthy: no UDP socket on port 5353"
exit 1
fi
echo "healthy: ${n_ifaces} interfaces, age=${age}s"
exit 0

392
mdns-repeater.c Normal file
View file

@ -0,0 +1,392 @@
/*
* mdns-repeater.c — Lightweight IPv4/IPv6 mDNS reflector with health check
*
* Listens on all specified interfaces for mDNS packets (port 5353,
* multicast groups 224.0.0.251 and ff02::fb) and repeats them
* to every other interface.
*
* Writes a heartbeat to /tmp/mdns-health for container health checks.
*
* Build:
* gcc -static -O2 -o mdns-repeater mdns-repeater.c -lpthread
*
* Usage:
* mdns-repeater eth0 eth1 eth2 ...
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <signal.h>
#include <pthread.h>
#include <time.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/select.h>
#include <sys/stat.h>
#include <net/if.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define MDNS_PORT 5353
#define MDNS_GROUP_IP "224.0.0.251"
#define MDNS_GROUP_IP6 "ff02::fb"
#define PACKET_SZ 4096
#define MAX_IFACES 32
#define HEALTH_FILE "/tmp/mdns-health"
/* ── Per-interface state ────────────────────────────────────── */
typedef struct {
char name[IFNAMSIZ];
int index;
int fd4; /* IPv4 socket */
int fd6; /* IPv6 socket */
uint64_t pkts_in; /* health: packets received */
uint64_t pkts_out; /* health: packets forwarded */
} iface_t;
static iface_t ifaces[MAX_IFACES];
static int n_ifaces = 0;
static volatile int running = 1;
/* ── Signal handler ─────────────────────────────────────────── */
static void handle_signal(int sig) {
(void)sig;
running = 0;
}
/* ── Write health heartbeat ─────────────────────────────────── */
static void write_health(void) {
FILE *f = fopen(HEALTH_FILE, "w");
if (!f) return;
time_t now = time(NULL);
uint64_t total_in = 0, total_out = 0;
fprintf(f, "ts=%ld\n", (long)now);
for (int i = 0; i < n_ifaces; i++) {
fprintf(f, "iface.%s.pkts_in=%lu\n",
ifaces[i].name, (unsigned long)ifaces[i].pkts_in);
fprintf(f, "iface.%s.pkts_out=%lu\n",
ifaces[i].name, (unsigned long)ifaces[i].pkts_out);
total_in += ifaces[i].pkts_in;
total_out += ifaces[i].pkts_out;
}
fprintf(f, "total.pkts_in=%lu\n", (unsigned long)total_in);
fprintf(f, "total.pkts_out=%lu\n", (unsigned long)total_out);
fprintf(f, "n_ifaces=%d\n", n_ifaces);
fprintf(f, "alive=1\n");
fclose(f);
}
/* ── Heartbeat thread: writes health file every 10 seconds ──── */
static void *heartbeat_thread(void *arg) {
(void)arg;
write_health(); /* initial write */
while (running) {
sleep(10);
write_health();
}
return NULL;
}
/* ── Join IPv4 mDNS group on an fd ──────────────────────────── */
static int join_mcast4(int fd, int ifindex) {
struct ip_mreqn mreq = {0};
inet_pton(AF_INET, MDNS_GROUP_IP, &mreq.imr_multiaddr);
mreq.imr_ifindex = ifindex;
if (setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)) < 0) {
fprintf(stderr, "IP_ADD_MEMBERSHIP(%s): %s\n", MDNS_GROUP_IP, strerror(errno));
return -1;
}
return 0;
}
/* ── Join IPv6 mDNS group on an fd ──────────────────────────── */
static int join_mcast6(int fd, int ifindex) {
struct ipv6_mreq mreq = {0};
inet_pton(AF_INET6, MDNS_GROUP_IP6, &mreq.ipv6mr_multiaddr);
mreq.ipv6mr_interface = ifindex;
if (setsockopt(fd, IPPROTO_IPV6, IPV6_JOIN_GROUP, &mreq, sizeof(mreq)) < 0) {
fprintf(stderr, "IPV6_JOIN_GROUP(%s): %s\n", MDNS_GROUP_IP6, strerror(errno));
return -1;
}
return 0;
}
/* ── Set common socket options for mDNS ─────────────────────── */
static int setup_sockopts(int fd, int arr_idx, int sys_ifindex, int af) {
int on = 1;
int off = 0;
/* Reuse address (allow multiple listeners) */
if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) < 0)
perror("SO_REUSEADDR");
#if defined(SO_REUSEPORT)
if (setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &on, sizeof(on)) < 0)
perror("SO_REUSEPORT");
#endif
/* Bind to interface so packets from wrong interface are dropped */
if (setsockopt(fd, SOL_SOCKET, SO_BINDTODEVICE, ifaces[arr_idx].name,
strlen(ifaces[arr_idx].name)) < 0)
perror("SO_BINDTODEVICE");
if (af == AF_INET) {
if (setsockopt(fd, IPPROTO_IP, IP_MULTICAST_LOOP, &off, sizeof(off)) < 0)
perror("IP_MULTICAST_LOOP");
if (setsockopt(fd, IPPROTO_IP, IP_MULTICAST_IF, &sys_ifindex, sizeof(sys_ifindex)) < 0)
perror("IP_MULTICAST_IF");
} else {
if (setsockopt(fd, IPPROTO_IPV6, IPV6_MULTICAST_LOOP, &off, sizeof(off)) < 0)
perror("IPV6_MULTICAST_LOOP");
if (setsockopt(fd, IPPROTO_IPV6, IPV6_MULTICAST_IF, &sys_ifindex, sizeof(sys_ifindex)) < 0)
perror("IPV6_MULTICAST_IF");
}
return 0;
}
/* ── Create and bind an mDNS socket ─────────────────────────── */
static int create_mdns_socket(int af, int arr_idx) {
struct sockaddr_storage addr = {0};
socklen_t addrlen;
int sys_ifindex = ifaces[arr_idx].index;
int fd;
fd = socket(af, SOCK_DGRAM, 0);
if (fd < 0) {
perror("socket");
return -1;
}
setup_sockopts(fd, arr_idx, sys_ifindex, af);
if (af == AF_INET) {
struct sockaddr_in *sa = (struct sockaddr_in *)&addr;
sa->sin_family = AF_INET;
sa->sin_port = htons(MDNS_PORT);
sa->sin_addr.s_addr = INADDR_ANY;
addrlen = sizeof(struct sockaddr_in);
} else {
struct sockaddr_in6 *sa6 = (struct sockaddr_in6 *)&addr;
sa6->sin6_family = AF_INET6;
sa6->sin6_port = htons(MDNS_PORT);
sa6->sin6_addr = in6addr_any;
sa6->sin6_scope_id = sys_ifindex;
addrlen = sizeof(struct sockaddr_in6);
}
if (bind(fd, (struct sockaddr *)&addr, addrlen) < 0) {
fprintf(stderr, "bind(%s): %s\n", ifaces[arr_idx].name, strerror(errno));
close(fd);
return -1;
}
if (af == AF_INET) {
if (join_mcast4(fd, sys_ifindex) < 0) { close(fd); return -1; }
} else {
if (join_mcast6(fd, sys_ifindex) < 0) { close(fd); return -1; }
}
return fd;
}
/* ── Send a packet to all interfaces except the source ──────── */
static void repeat(const char *buf, int len, int src_idx, int af) {
struct msghdr msg = {0};
struct iovec iov[1];
char cmsg_buf[CMSG_SPACE(sizeof(struct in_pktinfo))];
iov[0].iov_base = (void *)buf;
iov[0].iov_len = len;
msg.msg_iov = iov;
msg.msg_iovlen = 1;
for (int i = 0; i < n_ifaces; i++) {
if (i == src_idx) continue;
int fd = (af == AF_INET) ? ifaces[i].fd4 : ifaces[i].fd6;
if (fd < 0) continue;
if (af == AF_INET) {
struct in_pktinfo pktinfo = {0};
pktinfo.ipi_ifindex = ifaces[i].index;
inet_pton(AF_INET, MDNS_GROUP_IP, &pktinfo.ipi_spec_dst);
msg.msg_control = cmsg_buf;
msg.msg_controllen = sizeof(cmsg_buf);
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = IPPROTO_IP;
cmsg->cmsg_type = IP_PKTINFO;
cmsg->cmsg_len = CMSG_LEN(sizeof(struct in_pktinfo));
memcpy(CMSG_DATA(cmsg), &pktinfo, sizeof(pktinfo));
msg.msg_controllen = cmsg->cmsg_len;
msg.msg_name = NULL;
msg.msg_namelen = 0;
} else {
struct sockaddr_in6 dst = {0};
dst.sin6_family = AF_INET6;
dst.sin6_port = htons(MDNS_PORT);
inet_pton(AF_INET6, MDNS_GROUP_IP6, &dst.sin6_addr);
dst.sin6_scope_id = ifaces[i].index;
msg.msg_name = &dst;
msg.msg_namelen = sizeof(dst);
msg.msg_control = NULL;
msg.msg_controllen = 0;
}
ssize_t sent = sendmsg(fd, &msg, 0);
if (sent < 0 && errno != EAGAIN && errno != EWOULDBLOCK) {
fprintf(stderr, "sendmsg(%s): %s\n", ifaces[i].name, strerror(errno));
} else {
ifaces[i].pkts_out++;
}
}
}
/* ── Reader thread: read packets from one interface ─────────── */
static void *reader_thread(void *arg) {
int idx = (int)(intptr_t)arg;
iface_t *iface = &ifaces[idx];
char buf[PACKET_SZ];
int maxfd = (iface->fd4 > iface->fd6) ? iface->fd4 : iface->fd6;
while (running) {
fd_set rfds;
FD_ZERO(&rfds);
if (iface->fd4 >= 0) FD_SET(iface->fd4, &rfds);
if (iface->fd6 >= 0) FD_SET(iface->fd6, &rfds);
struct timeval tv = {1, 0};
int ret = select(maxfd + 1, &rfds, NULL, NULL, &tv);
if (ret < 0) {
if (errno == EINTR) continue;
perror("select");
break;
}
if (ret == 0) continue;
if (iface->fd4 >= 0 && FD_ISSET(iface->fd4, &rfds)) {
int len = recv(iface->fd4, buf, sizeof(buf), 0);
if (len > 0) {
iface->pkts_in++;
repeat(buf, len, idx, AF_INET);
}
}
if (iface->fd6 >= 0 && FD_ISSET(iface->fd6, &rfds)) {
int len = recv(iface->fd6, buf, sizeof(buf), 0);
if (len > 0) {
iface->pkts_in++;
repeat(buf, len, idx, AF_INET6);
}
}
}
return NULL;
}
/* ── Print usage ────────────────────────────────────────────── */
static void usage(const char *prog) {
fprintf(stderr,
"Usage: %s <interface> [interface ...]\n"
"\n"
"Reflect mDNS packets (IPv4 + IPv6) between all listed interfaces.\n"
"Writes health status to " HEALTH_FILE " every 10s.\n"
"\n"
"Example:\n"
" %s eth0 eth1 br-lan\n",
prog, prog);
}
/* ── Main ────────────────────────────────────────────────────── */
int main(int argc, char **argv) {
if (argc < 2) {
usage(argv[0]);
return 1;
}
if (argc - 1 > MAX_IFACES) {
fprintf(stderr, "Too many interfaces (max %d)\n", MAX_IFACES);
return 1;
}
printf("[*] mdns-repeater starting...\n");
/* Parse interfaces */
for (int i = 1; i < argc; i++) {
strncpy(ifaces[n_ifaces].name, argv[i], IFNAMSIZ - 1);
ifaces[n_ifaces].name[IFNAMSIZ - 1] = '\0';
ifaces[n_ifaces].index = if_nametoindex(argv[i]);
if (ifaces[n_ifaces].index == 0) {
fprintf(stderr, "Unknown interface: %s\n", argv[i]);
return 1;
}
ifaces[n_ifaces].fd4 = create_mdns_socket(AF_INET, n_ifaces);
if (ifaces[n_ifaces].fd4 < 0)
fprintf(stderr, "WARNING: Could not create IPv4 mDNS socket on %s\n", argv[i]);
ifaces[n_ifaces].fd6 = create_mdns_socket(AF_INET6, n_ifaces);
if (ifaces[n_ifaces].fd6 < 0)
fprintf(stderr, "WARNING: Could not create IPv6 mDNS socket on %s\n", argv[i]);
if (ifaces[n_ifaces].fd4 < 0 && ifaces[n_ifaces].fd6 < 0) {
fprintf(stderr, "FATAL: No sockets created for %s\n", argv[i]);
return 1;
}
ifaces[n_ifaces].pkts_in = 0;
ifaces[n_ifaces].pkts_out = 0;
printf("[+] %-16s (idx=%3d) fd4=%d fd6=%d\n",
ifaces[n_ifaces].name, ifaces[n_ifaces].index,
ifaces[n_ifaces].fd4, ifaces[n_ifaces].fd6);
n_ifaces++;
}
/* Install signal handlers */
signal(SIGINT, handle_signal);
signal(SIGTERM, handle_signal);
signal(SIGQUIT, handle_signal);
/* Start heartbeat thread */
pthread_t hb_thread;
pthread_create(&hb_thread, NULL, heartbeat_thread, NULL);
/* Start one reader thread per interface */
pthread_t threads[MAX_IFACES];
for (int i = 0; i < n_ifaces; i++) {
if (pthread_create(&threads[i], NULL, reader_thread, (void*)(intptr_t)i) != 0) {
perror("pthread_create");
return 1;
}
}
printf("[*] Reflecting mDNS between %d interfaces (IPv4 + IPv6)\n", n_ifaces);
/* Wait for all reader threads */
for (int i = 0; i < n_ifaces; i++) {
pthread_join(threads[i], NULL);
}
/* Stop heartbeat */
running = 0;
pthread_join(hb_thread, NULL);
/* Cleanup */
for (int i = 0; i < n_ifaces; i++) {
if (ifaces[i].fd4 >= 0) close(ifaces[i].fd4);
if (ifaces[i].fd6 >= 0) close(ifaces[i].fd6);
}
unlink(HEALTH_FILE);
printf("[*] Shutdown complete.\n");
return 0;
}