Running OwnTone behind a Proxy

OwnTone is a tricky software to run behind a proxy. In this post, I explain how to expose it with an ingres-nginx proxy in a Kubernetes cluster.

Screenshot of a Browser window that shows OwnTone behind a reverse proxy.

What is OwnTone?

OwnTone is a media player that can stream content to multiple AirPlay 2 targets. It can get its media from a variety of sources. I personally use OwnTone to stream my Spotify music to my HomePods. For that, I use an add-on created by me. More details can be found in the post on the Home Assistant Community.

Why do I need a Proxy?

I am a big Kubernetes enthusiast and love to host everything the “cloud native” way. This is also the case for my Home Lab. I am running a local k3s cluster that was installed by default on my TrueNAS SCALE installation.

Doing that, I really love to use domains wherever possible. Instead of accessing my Home Assistant instance through the local IP http://192.168.178.2:8123, I use http://ha.babel.sh. This makes things more easy, and the big benefit is that I can take advantage of things like SSL and remote access.

What makes OwnTone special?

Getting back to the topic of this post. OwnTone is not trivial to be hosted behind a reverse proxy. Normally, it would be a simple Ingress configuration inside Kubernetes and the reference to the local IP of OwnTone through a Service and Endpoint resource. But this software is different because it uses two separate ports. The first is 3689, which is the port for the web interface. And there is a second port 3688, which is used to establish a web socket connection. In praxis, this web socket is only used to push notifications to the frontend. That means, in theory, I could run OwnTone without the web socket and ignore the benefit of having some push notifications. But in practice, OwnTone just stops working completely, when the web socket connection cannot be established. Even though all features regarding the controlling of the UI are implemented through the web interface and not the socket.

The big issue with this two-port design is, that the web socket port is incompatible with normal reverse proxies. When OwnTone is behind a reverse proxy, the frontend tries to connect to e.g. https://owntone.example.com:3688. This is unexpected as normal reverse proxies only listen on :443 and :80.

Possible solutions

To tackle that, there are a few options to consider:

Running the web frontend only through the IP address

This is the simplest solution. Just live with the fact that the IP address needs to be used to access OwnTone. But I would not write the blog if the story ends here.

Proxying 3688 with Traefik

The second idea that came to my mind was proxying the 3688 port. In my setup I used Traefik for that. I just had Traefik running with a custom configuration. But this was also not ideal because I use ingress-nginx in production and disliked the extra overhead for this issue.

Changing the port through Proxy Magic

And this is the final piece, why I am writing this blog post. I found a way by using ingress-nginx and a small “configuration hack”.

My Solution

I found out that OwnTone's frontend receives the information regarding the web socket port through an API call. When the UI loads, a request is made to /api/config and it looks as follows:

{
  "library_name": "Home Assistant Music",
  "hide_singles": false,
  "websocket_port": 3688,
  "version": "28.6",
  "buildoptions": [
    "ffmpeg",
    "Spotify",
    "librespot-c",
    "LastFM",
    "Chromecast",
    "MPD",
    "Websockets",
    "ALSA",
    "Webinterface",
    "Regex"
  ],
  "directories": ["/music"],
  "radio_playlists": false,
  "allow_modifying_stored_playlists": false
}
API Response from /api/config

As you can see, the JSON data includes the websocket_port. Afterwards, I checked the source code to find out, how this variable is used.

    open_ws: function () {
      if (this.$store.state.config.websocket_port <= 0) {
        this.$store.dispatch('add_notification', {
          text: this.$t('server.missing-port'),
          type: 'danger'
        })
        return
      }
      const vm = this
      let protocol = 'ws://'
      if (window.location.protocol === 'https:') {
        protocol = 'wss://'
      }
      let wsUrl =
        protocol +
        window.location.hostname +
        ':' +
        vm.$store.state.config.websocket_port
Code from GitHub

As you can see, the websocket_port variable is directly used to build the wsUrl. Hence, I came up with an idea: What if I injected a custom path through the websocket_port? Through some more research in the code, I found out that there is no validation regarding the type. Therefore, I could try to replace the port with the default SSL port and a path:

{
...
-  "websocket_port": 3688,
+  "websocket_port": "443/ws",
...
}
🚀
And it actually worked!

My configuration

For my production deployment, I use the following Ingress configuration:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: proxy-owntone-ws
spec:
  ingressClassName: nginx
  tls:
    - hosts:
      - "ot.babel.sh"
  rules:
    - host: "ot.babel.sh"
      http:
        paths:
          - path: "/ws"
            pathType: Prefix
            backend:
              service:
                name: proxy-owntone-ws
                port:
                  number: 3688
WS Ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: proxy-owntone
  annotations:
    nginx.ingress.kubernetes.io/auth-signin: ...
    nginx.ingress.kubernetes.io/auth-url: ...
    nginx.ingress.kubernetes.io/configuration-snippet: |
      proxy_intercept_errors off;
      location /api/config {
        default_type application/json;
        return 200 '{ "library_name": "Home Assistant Music", "hide_singles": false, "websocket_port": "443/ws", "version": "28.6", "buildoptions": [ "ffmpeg", "Spotify", "librespot-c", "LastFM", "Chromecast", "MPD", "Websockets", "ALSA", "Webinterface", "Regex" ], "directories": [ "\/music" ], "radio_playlists": false, "allow_modifying_stored_playlists": false }';
      }
spec:
  ingressClassName: nginx
  tls:
    - hosts:
      - "ot.babel.sh"
  rules:
    - host: "ot.babel.sh"
      http:
        paths:
          - path: "/"
            pathType: Prefix
            backend:
              service:
                name: proxy-owntone
                port:
                  number: 3689
OwnTone Ingress

The second Ingress has a custom annotation that allows to give nginx a code snippet. In this snippet, the response of /api/config is hardcoded. I only adjusted the websocket_port variable. Everything else is the same as the API sends to the frontend.

Additionally, I recommend using the auth-url feature of ingress-nginx. With that, I secured OwnTone with my Single-Sign-On service and only authorized users can access the service, even though OwnTone isn't supporting user-based authentication.

Conclusion

I hope I can help someone with the same problem. Just use the Ingress configuration and your OwnTone is accessible through ingress-nginx 🎉