package/netbrowse: new package

- Advertises the build-time $hostname.local as a CNAME to the A and
   AAAA records already advertised by Avahi
 - Advertises a special network.local CNAME and provides a fastcgi
   service on unix:/tmp/netbrowse.sock for browsing mDNS services

This commit also activates netbrowse by default with nginx listeing
to port 80 on IPv4 and IPv6 /browse by default but also / if called
with server name network.local.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2024-04-15 15:36:41 +02:00
parent 1a99118a78
commit c1836af7cc
19 changed files with 505 additions and 3 deletions
+76
View File
@@ -0,0 +1,76 @@
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
#tcp_nopush on;
#keepalive_timeout 0;
keepalive_timeout 65;
#gzip on;
server {
listen 80;
listen [::]:80;
server_name _;
location / {
root html;
index index.html index.htm;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
location /browse {
include fastcgi_params;
fastcgi_pass unix:/tmp/netbrowse.sock;
}
}
server {
listen 80;
listen [::]:80;
server_name network.local;
location / {
include fastcgi_params;
fastcgi_pass unix:/tmp/netbrowse.sock;
}
}
#
# HTTPS server
#
# server {
# listen 443 ssl;
# server_name localhost;
# ssl_certificate cert.pem;
# ssl_certificate_key cert.key;
# ssl_session_cache shared:SSL:1m;
# ssl_session_timeout 5m;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;
# location / {
# root html;
# index index.html index.htm;
# }
# }
}
+32
View File
@@ -1,5 +1,6 @@
#!/bin/sh
# shellcheck disable=SC1090,SC1091
common=$(dirname "$(readlink -f "$0")")
. "$BR2_CONFIG" 2>/dev/null
. "$TARGET_DIR/usr/lib/os-release"
@@ -79,6 +80,37 @@ grep -qsE '^/bin/true$$' "$TARGET_DIR/etc/shells" \
|| echo "/bin/true" >> "$TARGET_DIR/etc/shells"
grep -qsE '^/bin/false$$' "$TARGET_DIR/etc/shells" \
|| echo "/bin/false" >> "$TARGET_DIR/etc/shells"
# Allow clish (symlink to /usr/bin/klish) to be a login shell
grep -qsE '^/bin/clish$$' "$TARGET_DIR/etc/shells" \
|| echo "/bin/clish" >> "$TARGET_DIR/etc/shells"
if [ -n "$BR2_PACKAGE_NGINX" ]; then
cp "$common/nginx.conf" "$TARGET_DIR/etc/nginx/"
ln -sf ../available/nginx.conf "$TARGET_DIR/etc/finit.d/enabled/nginx.conf"
cat <<EOF > "$TARGET_DIR/etc/avahi/services/http.service"
<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
<name replace-wildcards="yes">%h</name>
<service>
<type>_http._tcp</type>
<port>80</port>
<txt-record value-format="text">product=$INFIX_NAME</txt-record>
</service>
</service-group>
EOF
cat <<EOF > "$TARGET_DIR/etc/avahi/services/https.service"
<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
<name replace-wildcards="yes">%h</name>
<service>
<type>_https._tcp</type>
<port>443</port>
<txt-record value-format="text">product=$INFIX_NAME</txt-record>
</service>
</service-group>
EOF
fi
+5 -1
View File
@@ -80,6 +80,9 @@ BR2_PACKAGE_NETCALC=y
BR2_PACKAGE_NETCAT_OPENBSD=y
BR2_PACKAGE_NETSNMP=y
BR2_PACKAGE_NFTABLES=y
BR2_PACKAGE_NGINX=y
BR2_PACKAGE_NGINX_HTTP_SSL_MODULE=y
BR2_PACKAGE_NGINX_HTTP_V2_MODULE=y
BR2_PACKAGE_NMAP=y
BR2_PACKAGE_NMAP_NCAT=y
BR2_PACKAGE_NMAP_NMAP=y
@@ -137,12 +140,13 @@ BR2_PACKAGE_K8S_LOGGER=y
BR2_PACKAGE_KEYACK=y
BR2_PACKAGE_KLISH_PLUGIN_INFIX=y
BR2_PACKAGE_LOWDOWN=y
BR2_PACKAGE_MCD=y
BR2_PACKAGE_NETBROWSE=y
BR2_PACKAGE_PODMAN=y
BR2_PACKAGE_PODMAN_DRIVER_BTRFS=y
BR2_PACKAGE_PODMAN_DRIVER_DEVICEMAPPER=y
BR2_PACKAGE_PODMAN_DRIVER_VFS=y
BR2_PACKAGE_TETRIS=y
BR2_PACKAGE_MCD=y
BR2_PACKAGE_LIBINPUT=y
DISK_IMAGE_BOOT_BIN=y
GNS3_APPLIANCE_RAM=512
+6 -1
View File
@@ -63,6 +63,7 @@ BR2_PACKAGE_UBOOT_TOOLS_FIT_SIGNATURE_SUPPORT=y
BR2_PACKAGE_UBOOT_TOOLS_FIT_CHECK_SIGN=y
BR2_PACKAGE_UBOOT_TOOLS_MKENVIMAGE=y
BR2_PACKAGE_PYTHON3=y
BR2_PACKAGE_PYTHON_CRYPTOGRAPHY=y
BR2_PACKAGE_CA_CERTIFICATES=y
BR2_PACKAGE_LIBSSH_OPENSSL=y
BR2_PACKAGE_LIBSSH2=y
@@ -91,6 +92,9 @@ BR2_PACKAGE_NETCALC=y
BR2_PACKAGE_NETCAT_OPENBSD=y
BR2_PACKAGE_NETSNMP=y
BR2_PACKAGE_NFTABLES=y
BR2_PACKAGE_NGINX=y
BR2_PACKAGE_NGINX_HTTP_SSL_MODULE=y
BR2_PACKAGE_NGINX_HTTP_V2_MODULE=y
BR2_PACKAGE_NMAP=y
BR2_PACKAGE_NMAP_NCAT=y
BR2_PACKAGE_NMAP_NMAP=y
@@ -165,12 +169,13 @@ BR2_PACKAGE_K8S_LOGGER=y
BR2_PACKAGE_KEYACK=y
BR2_PACKAGE_KLISH_PLUGIN_INFIX=y
BR2_PACKAGE_LOWDOWN=y
BR2_PACKAGE_MCD=y
BR2_PACKAGE_NETBROWSE=y
BR2_PACKAGE_PODMAN=y
BR2_PACKAGE_PODMAN_DRIVER_BTRFS=y
BR2_PACKAGE_PODMAN_DRIVER_DEVICEMAPPER=y
BR2_PACKAGE_PODMAN_DRIVER_VFS=y
BR2_PACKAGE_TETRIS=y
BR2_PACKAGE_MCD=y
BR2_PACKAGE_LIBINPUT=y
# SIGN_ENABLED is not set
# GNS3_APPLIANCE is not set
+5 -1
View File
@@ -75,6 +75,9 @@ BR2_PACKAGE_NETCALC=y
BR2_PACKAGE_NETCAT_OPENBSD=y
BR2_PACKAGE_NETSNMP=y
BR2_PACKAGE_NFTABLES=y
BR2_PACKAGE_NGINX=y
BR2_PACKAGE_NGINX_HTTP_SSL_MODULE=y
BR2_PACKAGE_NGINX_HTTP_V2_MODULE=y
BR2_PACKAGE_NMAP=y
BR2_PACKAGE_NMAP_NCAT=y
BR2_PACKAGE_NMAP_NMAP=y
@@ -140,11 +143,12 @@ BR2_PACKAGE_K8S_LOGGER=y
BR2_PACKAGE_KEYACK=y
BR2_PACKAGE_KLISH_PLUGIN_INFIX=y
BR2_PACKAGE_LOWDOWN=y
BR2_PACKAGE_MCD=y
BR2_PACKAGE_NETBROWSE=y
BR2_PACKAGE_PODMAN=y
BR2_PACKAGE_PODMAN_DRIVER_BTRFS=y
BR2_PACKAGE_PODMAN_DRIVER_DEVICEMAPPER=y
BR2_PACKAGE_PODMAN_DRIVER_VFS=y
BR2_PACKAGE_TETRIS=y
BR2_PACKAGE_MCD=y
GNS3_APPLIANCE_RAM=512
GNS3_APPLIANCE_IFNUM=10
+1
View File
@@ -15,6 +15,7 @@ source "$BR2_EXTERNAL_INFIX_PATH/package/klish-plugin-sysrepo/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/libsrx/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/lowdown/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/mcd/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/netbrowse/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/podman/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/skeleton-init-finit/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/tetris/Config.in"
+10
View File
@@ -0,0 +1,10 @@
config BR2_PACKAGE_NETBROWSE
bool netbrowse
select BR2_PACKAGE_DBUS
select BR2_PACKAGE_DBUS_PYTHON
select BR2_PACKAGE_PYTHON_FLUP
help
Advertises the initial $(HOSTNAME).local and network.local to
give your device a http://network.local mDNS/SD browser.
UNIX socket available for fastcgi at /tmp/netbrowse.sock
+25
View File
@@ -0,0 +1,25 @@
################################################################################
#
# netbrowse
#
################################################################################
NETBROWSE_VERSION = 1.0
NETBROWSE_SITE_METHOD = local
NETBROWSE_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/netbrowse
NETBROWSE_SETUP_TYPE = setuptools
NETBROWSE_LICENSE = MIT
NETBROWSE_LICENSE_FILES = LICENSE
NETBROWSE_REDISTRIBUTE = NO
define NETBROWSE_INSTALL_EXTRA
$(INSTALL) -D -m 0644 $(NETBROWSE_PKGDIR)/netbrowse.svc \
$(FINIT_D)/available/netbrowse.conf
ln -sf ../available/netbrowse.conf $(FINIT_D)/enabled/netbrowse.conf
$(INSTALL) -d -m 755 $(TARGET_DIR)/etc/default
echo "NETBROWSE_ARGS=\"network.local $(BR2_TARGET_GENERIC_HOSTNAME).local\"" \
> $(TARGET_DIR)/etc/default/netbrowse
endef
NETBROWSE_POST_INSTALL_TARGET_HOOKS += NETBROWSE_INSTALL_EXTRA
$(eval $(python-package))
+3
View File
@@ -0,0 +1,3 @@
service env:-/etc/default/netbrowse log:prio:daemon.debug,tag:mdns \
<!service/mdns/running> @www-data:www-data \
[2345] netbrowse $NETBROWSE_ARGS -- Network browser
+1
View File
@@ -0,0 +1 @@
__pycache__/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Joachim Wiberg
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+38
View File
@@ -0,0 +1,38 @@
mDNS Network Browser
====================
This Python program both advertises CNAMEs (using D-Bus to Avahi) and
provides an mDNS browser over fastcgi for, e.g., Nginx.
To start the program:
netbrowse $hostname.local network.local
A UNIX socket, for fastcgi, is created in `/tmp/netbrowse.sock` with
permissions 0660 as the user and group the program is started as.
When using Finit this can be achieved with
service @www-data:www-data netbrowse network.local
In your Nginx server configuration, add:
location /browse {
include fastcgi_params;
fastcgi_pass unix:/tmp/netbrowse.sock;
}
For more a elaborate setup, you can have another server block:
```
server {
listen 80;
listen [::]:80;
server_name network.local;
location / {
include fastcgi_params;
fastcgi_pass unix:/tmp/netbrowse.sock;
}
}
```
+6
View File
@@ -0,0 +1,6 @@
"""
Avahi CNAME service over D-Bus advertiser
"""
from .avahi_alias import AvahiAlias
__all__ = ['AvahiAlias']
+56
View File
@@ -0,0 +1,56 @@
"""Avahi CNAME alias class"""
from encodings.idna import ToASCII
import dbus
class AvahiAlias:
"""Import to your project and use publish_cname() for each name."""
DBUS_NAME = 'org.freedesktop.Avahi'
CLASS_IN = 0x01
TYPE_CNAME = 0x05
TTL = 60
DBUS_PATH_SERVER = '/'
DBUS_INTERFACE_SERVER = DBUS_NAME + '.Server'
DBUS_INTERFACE_ENTRY_GROUP = DBUS_NAME + '.EntryGroup'
IF_UNSPEC = -1
PROTO_UNSPEC = -1
def publish_cname(self, cname):
"""Call this method for each alias to publish as a CNAME record."""
bus = dbus.SystemBus()
server = dbus.Interface(bus.get_object(self.DBUS_NAME, self.DBUS_PATH_SERVER),
self.DBUS_INTERFACE_SERVER)
group = dbus.Interface(bus.get_object(self.DBUS_NAME, server.EntryGroupNew()),
self.DBUS_INTERFACE_ENTRY_GROUP)
rdata = self.create_rr(server.GetHostNameFqdn())
cname = self.encode_dns(cname)
group.AddRecord(self.IF_UNSPEC, self.PROTO_UNSPEC, dbus.UInt32(0),
cname, self.CLASS_IN, self.TYPE_CNAME, self.TTL, rdata)
group.Commit()
print("Published " + cname.decode())
@staticmethod
def encode_dns(name):
"""Internal"""
out = []
name = name.decode()
for part in str(name).split('.'):
if len(part) == 0:
continue
out.append(ToASCII(part))
return b'.'.join(out)
@staticmethod
def create_rr(name):
"""Internal"""
out = []
for part in name.split('.'):
if len(part) == 0:
continue
out.append(chr(len(part)).encode())
out.append(ToASCII(part))
out.append(b'\0')
return b''.join(out)
+6
View File
@@ -0,0 +1,6 @@
"""
Very basic mDNS scanner with HTML table renderer
"""
from .mdns_hosts import MdnsHosts
__all__ = ['MdnsHosts']
+152
View File
@@ -0,0 +1,152 @@
"""
Very basic mDNS scanner with HTML table renderer
"""
import subprocess
class MdnsHosts:
"""mDNS scanner class using avahi-browse"""
def hask(self):
"""Check if avahi-browse has -k option"""
try:
result = subprocess.run(['avahi-browse', '--help'], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
return '-k' in result.stdout
except subprocess.CalledProcessError:
return False
def scan(self):
"""Perform mDNS scan and return list of hosts."""
services = {
'_daap._tcp': ('iTunes Server', None),
'_device-info._tcp': ('Device info', None),
'_http._tcp': ('HTTP', 'http://{address}'),
'_https._tcp': ('HTTPS', 'https://{address}'),
'_ipp._tcp': ('Printer', None),
'_ipps._tcp': ('Printer', None),
'_netconf-ssh._tcp': ('NETCONF', None),
'_raop._tcp': ('AirPlay ', None),
'_ssh._tcp': ('SSH', None),
'_sftp-ssh._tcp': ('SFTP', None),
'_workstation._tcp': ('Workstation', None),
}
result = subprocess.run(['avahi-browse', '-tarpk' if self.hask() else '-tarp'],
stdout=subprocess.PIPE, text=True)
lines = result.stdout.strip().split('\n')
hosts_services = {} # Key: address, Value: list of dicts with service details
for line in lines:
print(f"{line}")
parts = line.split(';')
if len(parts) <= 8 or parts[0] != '=':
continue
if (parts[2] != 'IPv4' and parts[2] != 'IPv6'):
continue
service_type = parts[4]
if service_type not in services:
continue
service_name = parts[3]
address = parts[7]
identifier, url_template = services[service_type]
url = url_template.format(address=address) if url_template else None
service_details = {
'name': self.decode(service_name),
'type': identifier,
'link': parts[6],
'url': url
}
if address not in hosts_services:
hosts_services[address] = {'services': [service_details], 'type': parts[2]}
else:
hosts_services[address]['services'].append(service_details)
return hosts_services
def decode(self, name):
"""Decode escape sequences like \032 and \040 in service names"""
name = name.replace('\\032', ' ')
name = name.replace('\\040', '(')
name = name.replace('\\041', ')')
return bytes(name, "utf-8").decode("unicode_escape")
def html(self):
"""Generate a HTML table of the mDNS scan results"""
hosts_services = self.scan()
html_content = """
<html>
<head>
<title>mDNS Hosts - Services</title>
<style>
.container {
max-width: 1024px;
margin: auto;
padding: 0 10px;
}
body { font-family: Arial, sans-serif; margin: 20px; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #ddd; }
th { background-color: #f2f2f2; }
tr:hover {background-color: #e8e8e8;}
</style>
</head>
<body>
<div class="container">
<input type="text" id="search" onkeyup="filter()" placeholder="Filter column ...">
<table id="main">
<tr><th>IP Address</th><th>Service Name</th><th>Link</th><th>Service Type</th></tr>
"""
for address, info in hosts_services.items():
for service in info['services']:
service_name = service['name']
service_type = service['type']
link = service['link']
url = service['url']
if url:
url = url.replace("127.0.0.1", link)
link_html = f'<a href="{url}">{link}</a>'
else:
link_html = f'{link}'
html_content += f"<tr><td>{address}</td><td>{service_name}</td>"
html_content += f"<td>{link_html}</td><td>{service_type}</td></tr>\n"
html_content += """
</table>
</div>
<script>
function filter() {
var input, filter, table, tr, tdaddr, tdserv, i, addr, serv;
input = document.getElementById("search");
filter = input.value.toUpperCase();
table = document.getElementById("main");
tr = table.getElementsByTagName("tr");
for (i = 1; i < tr.length; i++) {
tdaddr = tr[i].getElementsByTagName("td")[0];
tdserv = tr[i].getElementsByTagName("td")[1];
tdlink = tr[i].getElementsByTagName("td")[2];
tdtype = tr[i].getElementsByTagName("td")[3];
addr = tdaddr ? tdaddr.textContent || tdaddr.innerText : "";
serv = tdserv ? tdserv.textContent || tdserv.innerText : "";
link = tdlink ? tdlink.textContent || tdlink.innerText : "";
type = tdtype ? tdtype.textContent || tdtype.innerText : "";
if (addr.toUpperCase().indexOf(filter) > -1 ||
serv.toUpperCase().indexOf(filter) > -1 ||
link.toUpperCase().indexOf(filter) > -1 ||
type.toUpperCase().indexOf(filter) > -1) {
tr[i].style.display = "";
} else {
tr[i].style.display = "none";
}
}
}
</script>
</body>
</html>
"""
return html_content
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""
Main server loop.
"""
import os
import signal
import sys
from flup.server.fcgi import WSGIServer
from avahi_alias import AvahiAlias
from mdns_hosts import MdnsHosts
SOCK_FILE='/tmp/netbrowse.sock'
def app(_, start_response):
"""The /browse network.local application"""
mdns_hosts = MdnsHosts()
content = mdns_hosts.html().encode('utf-8')
start_response('200 OK', [('Content-Type', 'text/html'), ('Content-Length', str(len(content)))])
return [content]
def cleanup():
"""Clean up on server shutdown"""
try:
os.remove(SOCK_FILE)
except OSError:
pass
sys.exit(0)
def __sigcb(_, __):
cleanup()
if __name__ == '__main__':
avahi_aliases = AvahiAlias()
for each in sys.argv[1:]:
avahi_aliases.publish_cname(str(each).encode("utf-8", "strict"))
signal.signal(signal.SIGINT, __sigcb)
signal.signal(signal.SIGTERM, __sigcb)
try:
# For testing on PC as $USER, add: , umask=0
WSGIServer(app, bindAddress=SOCK_FILE).run()
except KeyboardInterrupt:
cleanup()
print("Exiting")
+5
View File
@@ -0,0 +1,5 @@
[metadata]
name = Network Browser
description = An mDNS CNAME advertiser over D-Bus and mDNS Browser (fcgi)
version = 1.0
license_files = LICENSE
+10
View File
@@ -0,0 +1,10 @@
from setuptools import setup, find_packages
setup(
packages=find_packages(),
scripts=['netbrowse'],
install_requires=[
'dbus-python',
'flup'
],
)