diff --git a/tailscale/DOCS.md b/tailscale/DOCS.md index 96175f004..e551131b5 100644 --- a/tailscale/DOCS.md +++ b/tailscale/DOCS.md @@ -78,6 +78,7 @@ log_level: info login_server: "https://controlplane.tailscale.com" share_homeassistant: disabled share_on_port: 443 +services: [] snat_subnet_routes: true stateful_filtering: false tags: @@ -317,6 +318,47 @@ Only ports 443, 8443, and 10000 are allowed by Tailscale. Port 443 is used by default. +### Option: `services` + +This option allows you to advertise other local services running on this device +as [Tailscale Services][tailscale_info_services]. Each service needs a name, a +local target address, a protocol, and a port to expose it on. The `svc:` prefix +is added automatically by the app; do not include it in the configuration. + +You can use this to expose apps running on your Home Assistant instance, such as +an audiobookshelf add-on, to your tailnet using a stable MagicDNS name. + +```yaml +services: + - name: audiobookshelf + target: http://127.0.0.1:13378 + protocol: http + port: 80 + path: / +``` + +Supported protocols: + +- `http`: Expose the service as an HTTP server on the configured port. +- `https`: Expose the service as an HTTPS server on the configured port. + You must enable MagicDNS and HTTPS certificates for your tailnet in the + Tailscale admin console first. Once enabled, Tailscale automatically + provisions a TLS certificate for the service. +- `tcp`: Forward raw TCP packets to the configured target. +- `tls-terminated-tcp`: Forward TLS-terminated TCP packets to the configured + target. + +The `target` must be a local address reachable from this app. Use `http://` or +`https://` targets for HTTP/HTTPS protocols, and `tcp://` targets for TCP and +tls-terminated-tcp protocols, for example `http://127.0.0.1:13378`. The `path` +option is optional and defaults to `/`. + +Before a service can accept traffic, you must define the Service in the Tailscale +admin console and approve this device as a Service host. More information: +[Services][tailscale_info_services]. + +This option is disabled by default. + ### Option: `snat_subnet_routes` This option allows subnet devices to see the traffic originating from the subnet @@ -545,6 +587,7 @@ SOFTWARE. [tailscale_info_pi_hole]: https://tailscale.com/docs/solutions/block-ads-all-devices-anywhere-using-raspberry-pi [tailscale_info_quad100]: https://tailscale.com/docs/reference/quad100 [tailscale_info_serve]: https://tailscale.com/docs/features/tailscale-serve +[tailscale_info_services]: https://tailscale.com/docs/features/tailscale-services [tailscale_info_site_to_site]: https://tailscale.com/docs/features/site-to-site [tailscale_info_subnets]: https://tailscale.com/docs/features/subnet-routers [tailscale_info_tags]: https://tailscale.com/docs/features/tags diff --git a/tailscale/config.yaml b/tailscale/config.yaml index 885de6ef1..f5c78b398 100644 --- a/tailscale/config.yaml +++ b/tailscale/config.yaml @@ -52,6 +52,7 @@ options: login_server: "https://controlplane.tailscale.com" share_homeassistant: disabled share_on_port: 443 + services: [] snat_subnet_routes: true stateful_filtering: false tags: [] @@ -78,6 +79,12 @@ schema: login_server: url share_homeassistant: list(disabled|serve|funnel) share_on_port: match(^(?:443|8443|10000)$) + services: + - name: "match(^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$)" + target: "match(^(?:https?|tcp)://127\\.0\\.0\\.1:([1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])$)" + protocol: "list(http|https|tcp|tls-terminated-tcp)" + port: "match(^([1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])$)" + path: "match(^/.*$)?" snat_subnet_routes: bool stateful_filtering: bool tags: diff --git a/tailscale/rootfs/etc/s6-overlay/s6-rc.d/share-services/dependencies.d/post-tailscaled b/tailscale/rootfs/etc/s6-overlay/s6-rc.d/share-services/dependencies.d/post-tailscaled new file mode 100644 index 000000000..e69de29bb diff --git a/tailscale/rootfs/etc/s6-overlay/s6-rc.d/share-services/run b/tailscale/rootfs/etc/s6-overlay/s6-rc.d/share-services/run new file mode 100755 index 000000000..5c5ab06e6 --- /dev/null +++ b/tailscale/rootfs/etc/s6-overlay/s6-rc.d/share-services/run @@ -0,0 +1,117 @@ +#!/command/with-contenv bashio +# shellcheck shell=bash +export LOG_FD +# ============================================================================== +# Home Assistant Community App: Tailscale +# Advertises configured Tailscale Services from this host +# ============================================================================== + +declare current_config + +declare service +declare name +declare service_name + +declare protocol + +declare port + +declare target + +declare path + +declare -a options=() +declare -a configured_services=() +declare -a configured_names=() +declare -a current_names=() + +# Nothing to do when no services are configured +if ! bashio::config 'services' | jq -e '. | length > 0' > /dev/null 2>&1; then + exit 0 +fi + +# Read configured services +# Note: bashio::config emits one JSON object per line for array options +readarray -t configured_services < <(bashio::config 'services') + +# Build a list of configured service names (without the svc: prefix) +for service in "${configured_services[@]}"; do + name=$(jq -re '.name' <<< "${service}") + name="${name#svc:}" + configured_names+=("${name}") +done + +bashio::log.info "Configured service hosts: ${configured_names[*]}" + +# Wait for tailscaled to be available +while ! bashio::fs.socket_exists "/var/run/tailscale/tailscaled.sock"; do + sleep 1 +done + +while ! /opt/tailscale status --json --peers=false --self=false \ + | jq --exit-status '.BackendState == "Running"' > /dev/null; do + sleep 2 +done + +# Read currently advertised services from the tailscale serve config +if ! current_config=$(/opt/tailscale serve get-config --all 2>/dev/null); then + current_config='{}' +fi + +# Build a list of currently configured service names +readarray -t current_names < <(jq -re '.services | keys[]' <<< "${current_config}" 2>/dev/null || true) + +# Remove services that are no longer configured in this app +for service_name in "${current_names[@]}"; do + if [[ "${service_name}" != svc:* ]]; then + continue + fi + # Compare without the svc: prefix + if ! printf '%s\n' "${configured_names[@]}" | grep -qx "${service_name#svc:}"; then + bashio::log.info "Removing stale service host configuration for ${service_name}" + /opt/tailscale serve drain "${service_name}" >/dev/null 2>&1 || true + /opt/tailscale serve clear "${service_name}" >/dev/null 2>&1 || true + fi +done + +# Configure and advertise each service +for service in "${configured_services[@]}"; do + name=$(jq -re '.name' <<< "${service}") + name="${name#svc:}" + service_name="svc:${name}" + protocol=$(jq -re '.protocol' <<< "${service}") + port=$(jq -re '.port' <<< "${service}") + target=$(jq -re '.target' <<< "${service}") + path=$(jq -re '.path // empty' <<< "${service}") + + options=() + case "${protocol}" in + http) + options+=("--http=${port}") + ;; + https) + options+=("--https=${port}") + ;; + tcp) + options+=("--tcp=${port}") + ;; + tls-terminated-tcp) + options+=("--tls-terminated-tcp=${port}") + ;; + esac + + if [[ -n "${path}" ]]; then + options+=("--set-path=${path}") + fi + + # Clean any existing configuration for this service before reconfiguring + /opt/tailscale serve drain "${service_name}" >/dev/null 2>&1 || true + /opt/tailscale serve clear "${service_name}" >/dev/null 2>&1 || true + + bashio::log.info "Advertising service host for ${service_name}" + if ! /opt/tailscale serve --service="${service_name}" --yes "${options[@]}" "${target}"; then + bashio::exit.nok "Failed to advertise service host for ${service_name}" + fi +done + +exit 0 diff --git a/tailscale/rootfs/etc/s6-overlay/s6-rc.d/share-services/type b/tailscale/rootfs/etc/s6-overlay/s6-rc.d/share-services/type new file mode 100644 index 000000000..bdd22a185 --- /dev/null +++ b/tailscale/rootfs/etc/s6-overlay/s6-rc.d/share-services/type @@ -0,0 +1 @@ +oneshot diff --git a/tailscale/rootfs/etc/s6-overlay/s6-rc.d/share-services/up b/tailscale/rootfs/etc/s6-overlay/s6-rc.d/share-services/up new file mode 100644 index 000000000..715921c32 --- /dev/null +++ b/tailscale/rootfs/etc/s6-overlay/s6-rc.d/share-services/up @@ -0,0 +1 @@ +/etc/s6-overlay/s6-rc.d/share-services/run diff --git a/tailscale/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/share-services b/tailscale/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/share-services new file mode 100644 index 000000000..e69de29bb diff --git a/tailscale/rootfs/etc/s6-overlay/scripts/stage2_hook.sh b/tailscale/rootfs/etc/s6-overlay/scripts/stage2_hook.sh index 1f86618a9..d53e2de11 100755 --- a/tailscale/rootfs/etc/s6-overlay/scripts/stage2_hook.sh +++ b/tailscale/rootfs/etc/s6-overlay/scripts/stage2_hook.sh @@ -133,3 +133,8 @@ fi if bashio::config.equals 'share_homeassistant' 'disabled'; then rm /etc/s6-overlay/s6-rc.d/user/contents.d/share-homeassistant fi + +# Disable share-services service when no services are configured +if ! bashio::config 'services' | jq -e '. | length > 0' > /dev/null; then + rm -f /etc/s6-overlay/s6-rc.d/user/contents.d/share-services +fi diff --git a/tailscale/translations/en.yaml b/tailscale/translations/en.yaml index ee6e14bd8..9e85b343c 100644 --- a/tailscale/translations/en.yaml +++ b/tailscale/translations/en.yaml @@ -74,6 +74,14 @@ configuration: internet. Only ports 443, 8443, and 10000 are allowed by Tailscale. Port 443 is used by default. + services: + name: Service hosts + description: >- + This option allows you to advertise other local services running on this + device as Tailscale Services. Each service needs a name, a local target + address, a protocol, and a port to expose it on. The `svc:` prefix is added + automatically; do not include it in the configuration. + These options are disabled by default. snat_subnet_routes: name: Source NAT subnet routes description: >-