├── Dockerfile ├── README.md ├── client.conf ├── default.pa ├── run.sh ├── supervisord.conf └── webaudio.js /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:buster 2 | 3 | ENV LANG=en_US.UTF-8 LANGUAGE=en_US.UTF-8 LC_ALL=C.UTF-8 DISPLAY=:0.0 4 | 5 | # Install dependencies. 6 | RUN apt-get update \ 7 | && DEBIAN_FRONTEND=noninteractive apt-get install --yes --no-install-recommends \ 8 | bzip2 \ 9 | gstreamer1.0-plugins-good \ 10 | gstreamer1.0-pulseaudio \ 11 | gstreamer1.0-tools \ 12 | libglu1-mesa \ 13 | libgtk2.0-0 \ 14 | libncursesw5 \ 15 | libopenal1 \ 16 | libsdl-image1.2 \ 17 | libsdl-ttf2.0-0 \ 18 | libsdl1.2debian \ 19 | libsndfile1 \ 20 | novnc \ 21 | pulseaudio \ 22 | supervisor \ 23 | ucspi-tcp \ 24 | wget \ 25 | x11vnc \ 26 | xvfb \ 27 | && rm -rf /var/lib/apt/lists/* 28 | 29 | # Configure pulseaudio. 30 | COPY default.pa client.conf /etc/pulse/ 31 | 32 | # Force vnc_lite.html to be used for novnc, to avoid having the directory listing page. 33 | # Additionally, turn off the control bar. Finally, add a hook to start audio. 34 | COPY webaudio.js /usr/share/novnc/core/ 35 | RUN ln -s /usr/share/novnc/vnc_lite.html /usr/share/novnc/index.html \ 36 | && sed -i 's/display:flex/display:none/' /usr/share/novnc/app/styles/lite.css \ 37 | && sed -i "/import RFB/a \ 38 | import WebAudio from './core/webaudio.js'" \ 39 | /usr/share/novnc/vnc_lite.html \ 40 | && sed -i "/function connected(e)/a \ 41 | var wa = new WebAudio('ws://localhost:8081/websockify'); \ 42 | document.getElementsByTagName('canvas')[0].addEventListener('keydown', e => { wa.start(); });" \ 43 | /usr/share/novnc/vnc_lite.html 44 | 45 | # Configure supervisord. 46 | COPY supervisord.conf /etc/supervisor/supervisord.conf 47 | ENTRYPOINT [ "supervisord", "-c", "/etc/supervisor/supervisord.conf" ] 48 | 49 | # Run everything as standard user/group df. 50 | RUN groupadd df \ 51 | && useradd --create-home --gid df df 52 | WORKDIR /home/df 53 | USER df 54 | 55 | # Install Dwarf Fortress. 56 | ARG DF_VERSION=47_04 57 | RUN wget http://www.bay12games.com/dwarves/df_${DF_VERSION}_linux.tar.bz2 \ 58 | && tar xf df_${DF_VERSION}_linux.tar.bz2 \ 59 | && rm df_${DF_VERSION}_linux.tar.bz2 \ 60 | && mv df_linux/libs/libstdc++.so.6 df_linux/libs/libstdc++.so.6.disabled \ 61 | && sed -i 's/[WINDOWED:YES]/[WINDOWED:NO]/' df_linux/data/init/init.txt 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dwarf Fortress Docker 2 | 3 | Run Dwarf Fortress in an unprivileged Docker container using 4 | [novnc](https://novnc.com/info.html). 5 | 6 | Presently, this repository primarily serves as a demonstration of getting a 7 | side-channel audio stream working in novnc using as little custom code as 8 | possible. This is not a complete, robust implementation, but I think it does 9 | prove the concept. 10 | 11 | ## Usage 12 | 13 | Run `./run.sh` in the repository root. It will build the Docker container, and 14 | then run it, exposing ports `8080` and `8081` on localhost. Once the container 15 | is started the script will wait a few seconds (for the VNC server to start up) 16 | and then open the default browser to `http://localhost:8080` which should load 17 | the VNC screen. The screen will be blank for a few seconds, and then Dwarf 18 | Fortress should automatically load. Note, audio will not play until you press a 19 | key after the VNC session has connected. Once you press a key, the audio stream 20 | should start automatically. 21 | 22 | ## Details 23 | 24 | At a high level, this repository is simply a Docker image, built using the 25 | `Dockerfile` in this repository. This image is Debian-based, but it should be 26 | straightforward to port to other base images. No special privileges are required 27 | to run the container, and all processes in the container run as the non-root 28 | user named `df`. Nothing is required to be mounted into the container. The 29 | `run.sh` script automates the building and running of the Docker image. 30 | 31 | The container needs to run multiple processes, so we use supervisord as the 32 | init process and it launches all of the background processes. See 33 | `supervisord.conf` for the raw config file. The processes that run are: 34 | 35 | * `xvfb` -- The X Virtual FrameBuffer. An in-memory X display server. 36 | * `x11vnc` -- A VNC server that serves the `xvfb` screen on TCP port `5900`. 37 | * `websockify_vnc` -- Serves `x11vnc`, but through a websocket. Also serves the 38 | files in `/usr/share/novnc` as a webserver. Basically, this process allows the 39 | `x11vnc` server to be accessible through a browser, using `novnc` as the 40 | client. This service is exposed on port `8080`. 41 | * `pulseaudio` -- The audio server. 42 | * `audiostream` -- A TCP server listening on port `5901`. This uses 43 | [ucspi-tcp](https://cr.yp.to/ucspi-tcp.html), a generic TCP client/server 44 | adapter that can use stdin/stdout for streaming TCP data. When a client 45 | connects to TCP port `5901`, the `tcpserver` spawns a new process, and the 46 | stdin/stdout are used for communication between the client and the server. In 47 | this case, the process that is spawned is a gstreamer pipeline that takes 48 | audio from the `pulseaudio` server and encodes it as a webm stream. 49 | * `websockify_audio` -- Serves `audiostream`, but through a websocket. 50 | * `dwarffortress` -- The game Dwarf Fortress. It draws to the `xvfb` display and 51 | transmits audio through the `pulseaudio` server. 52 | 53 | Pulseaudio requires two small configuration files to function properly as a 54 | non-root user. Both `default.pa` and `client.conf` are copied to 55 | `/etc/pulseaudio` within the contianer. The `default.pa` file specifies that by 56 | default the `pulseaudio` server should use the unix socket at 57 | `/tmp/pulseaudio.socket` for communication, and should always have an audio sink 58 | available, even if no audio hardware is detected. Since `/tmp` is writable by 59 | the `df` user, this works. The `client.conf` file specifies that by default any 60 | client should use that unix socket as its default server. 61 | 62 | Finally, there are the changes required to get `novnc` to connect to and use 63 | this new audio websocket. First, there is a new `webaudio.js` file, written by 64 | GitHub user [no-body-in-particular](https://github.com/no-body-in-particular), 65 | and described in this 66 | [blog post](https://coredump.ws/index.php?dir=code&post=NoVNC_with_audio). This 67 | file is the client-side code that connects to the new websocket and streams the 68 | audio from it. In the `Dockerfile` it can be seen that this file is copied to 69 | `/usr/share/novnc/core/webaudio.js` so it is available among the other `novnc` 70 | core javascript files. Finally, we edit the `vnc_lite.html` file using two sed 71 | commands in the `Dockerfile` (a patch file would probably be more appropriate, 72 | but this works). 73 | 74 | 75 | ``` 76 | && sed -i "/import RFB/a \ 77 | import WebAudio from './core/webaudio.js'" \ 78 | /usr/share/novnc/vnc_lite.html \ 79 | && sed -i "/function connected(e)/a \ 80 | var wa = new WebAudio('ws://localhost:8081/websockify'); \ 81 | document.getElementsByTagName('canvas')[0].addEventListener('keydown', e => { wa.start(); });" \ 82 | /usr/share/novnc/vnc_lite.html 83 | ``` 84 | 85 | The first command edits the file to import the new `webaudio.js` file. The 86 | second command adds new code to the `connected` function that is called when the 87 | VNC session connects. We create a new instance of the `WebAudio` class, and tell 88 | it to start playing audio when a `keydown` event is received by the `canvas` 89 | tag. Presently this is hardcoded to `https://localhost:8081/websockify`. A more 90 | robust implementation would allow the audio URL to be set to different values 91 | depending on the environment. 92 | -------------------------------------------------------------------------------- /client.conf: -------------------------------------------------------------------------------- 1 | default-server=unix:/tmp/pulseaudio.socket 2 | -------------------------------------------------------------------------------- /default.pa: -------------------------------------------------------------------------------- 1 | #!/usr/bin/pulseaudio -nF 2 | load-module module-native-protocol-unix socket=/tmp/pulseaudio.socket auth-anonymous=1 3 | load-module module-always-sink 4 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eux 2 | docker build . --tag dwarffortress 3 | docker rm -f dwarffortress || true 4 | docker run \ 5 | --detach \ 6 | --env DISPLAY_SETTINGS="1920x1080x24" \ 7 | --publish 8080:8080 \ 8 | --publish 8081:8081 \ 9 | --rm \ 10 | --name dwarffortress \ 11 | dwarffortress 12 | sleep 3 13 | xdg-open http://localhost:8080 14 | -------------------------------------------------------------------------------- /supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | pidfile=/home/df/supervisord.pid 4 | logfile=/home/df/supervisord.log 5 | 6 | [program:x11vnc] 7 | command=x11vnc -forever -shared 8 | stdout_logfile=/home/df/x11vnc.log 9 | redirect_stderr=true 10 | 11 | [program:xvfb] 12 | command=Xvfb :0 -screen 0 "%(ENV_DISPLAY_SETTINGS)s" -listen tcp -ac 13 | stdout_logfile=/home/df/xvfb.log 14 | redirect_stderr=true 15 | 16 | [program:websockify_vnc] 17 | command=websockify --web /usr/share/novnc 8080 localhost:5900 18 | stdout_logfile=/home/df/websockify-vnc.log 19 | redirect_stderr=true 20 | 21 | [program:pulseaudio] 22 | command=/usr/bin/pulseaudio --disallow-module-loading -vvvv --disallow-exit --exit-idle-time=-1 23 | stdout_logfile=/home/df/pulseaudio.log 24 | redirect_stderr=true 25 | 26 | [program:audiostream] 27 | command=tcpserver localhost 5901 gst-launch-1.0 -q pulsesrc server=/tmp/pulseaudio.socket ! audio/x-raw, channels=2, rate=24000 ! cutter ! opusenc ! webmmux ! fdsink fd=1 28 | stdout_logfile=/home/df/audiostream.log 29 | redirect_stderr=true 30 | 31 | [program:websockify_audio] 32 | command=websockify 8081 localhost:5901 33 | stdout_logfile=/home/df/websockify-audio.log 34 | redirect_stderr=true 35 | 36 | [program:dwarffortress] 37 | command=/bin/bash -c 'sleep 10 && /home/df/df_linux/df' 38 | stdout_logfile=/home/df/df.log 39 | redirect_stderr=true 40 | -------------------------------------------------------------------------------- /webaudio.js: -------------------------------------------------------------------------------- 1 | export default class WebAudio { 2 | constructor(url) { 3 | this.url = url 4 | 5 | this.connected = false; 6 | 7 | //constants for audio behavoir 8 | this.maximumAudioLag = 1.5; //amount of seconds we can potentially be behind the server audio stream 9 | this.syncLagInterval = 5000; //check every x milliseconds if we are behind the server audio stream 10 | this.updateBufferEvery = 20; //add recieved data to the player buffer every x milliseconds 11 | this.reduceBufferInterval = 500; //trim the output audio stream buffer every x milliseconds so we don't overflow 12 | this.maximumSecondsOfBuffering = 1; //maximum amount of data to store in the play buffer 13 | this.connectionCheckInterval = 500; //check the connection every x milliseconds 14 | 15 | //register all our background timers. these need to be created only once - and will run independent of the object's streams/properties 16 | setInterval(() => this.updateQueue(), this.updateBufferEvery); 17 | setInterval(() => this.syncInterval(), this.syncLagInterval); 18 | setInterval(() => this.reduceBuffer(), this.reduceBufferInterval); 19 | setInterval(() => this.tryLastPacket(), this.connectionCheckInterval); 20 | 21 | } 22 | 23 | //registers all the event handlers for when this stream is closed - or when data arrives. 24 | registerHandlers() { 25 | this.mediaSource.addEventListener('sourceended', e => this.socketDisconnected(e)) 26 | this.mediaSource.addEventListener('sourceclose', e => this.socketDisconnected(e)) 27 | this.mediaSource.addEventListener('error', e => this.socketDisconnected(e)) 28 | this.buffer.addEventListener('error', e => this.socketDisconnected(e)) 29 | this.buffer.addEventListener('abort', e => this.socketDisconnected(e)) 30 | } 31 | 32 | //starts the web audio stream. only call this method on button click. 33 | start() { 34 | if (!!this.connected) return; 35 | if (!!this.audio) this.audio.remove(); 36 | this.queue = null; 37 | 38 | this.mediaSource = new MediaSource() 39 | this.mediaSource.addEventListener('sourceopen', e => this.onSourceOpen()) 40 | //first we need a media source - and an audio object that contains it. 41 | this.audio = document.createElement('audio'); 42 | this.audio.src = window.URL.createObjectURL(this.mediaSource); 43 | 44 | //start our stream - we can only do this on user input 45 | this.audio.play(); 46 | } 47 | 48 | wsConnect() { 49 | if (!!this.socket) this.socket.close(); 50 | 51 | this.socket = new WebSocket(this.url, ['binary', 'base64']) 52 | this.socket.binaryType = 'arraybuffer' 53 | this.socket.addEventListener('message', e => this.websocketDataArrived(e), false); 54 | } 55 | 56 | //this is called when the media source contains data 57 | onSourceOpen(e) { 58 | this.buffer = this.mediaSource.addSourceBuffer('audio/webm; codecs="opus"') 59 | this.registerHandlers(); 60 | this.wsConnect(); 61 | } 62 | 63 | //whenever data arrives in our websocket this is called. 64 | websocketDataArrived(e) { 65 | this.lastPacket = Date.now(); 66 | this.connected = true; 67 | this.queue = this.queue == null ? e.data : this.concat(this.queue, e.data); 68 | } 69 | 70 | //whenever a disconnect happens this is called. 71 | socketDisconnected(e) { 72 | console.log(e); 73 | this.connected = false; 74 | } 75 | 76 | tryLastPacket() { 77 | if (this.lastPacket == null) return; 78 | if ((Date.now() - this.lastPacket) > 1000) { 79 | this.socketDisconnected('timeout'); 80 | } 81 | } 82 | 83 | //this updates the buffer with the data from our queue 84 | updateQueue() { 85 | if (!(!!this.queue && !!this.buffer && !this.buffer.updating)) { 86 | return; 87 | } 88 | 89 | this.buffer.appendBuffer(this.queue); 90 | this.queue = null; 91 | } 92 | 93 | //reduces the stream buffer to the minimal size that we need for streaming 94 | reduceBuffer() { 95 | if (!(this.buffer && !this.buffer.updating && !!this.audio && !!this.audio.currentTime && this.audio.currentTime > 1)) { 96 | return; 97 | } 98 | 99 | this.buffer.remove(0, this.audio.currentTime - 1); 100 | } 101 | 102 | //synchronizes the current time of the stream with the server 103 | syncInterval() { 104 | if (!(this.audio && this.audio.currentTime && this.audio.currentTime > 1 && this.buffer && this.buffer.buffered && this.buffer.buffered.length > 1)) { 105 | return; 106 | } 107 | 108 | var currentTime = this.audio.currentTime; 109 | var targetTime = this.buffer.buffered.end(this.buffer.buffered.length - 1); 110 | 111 | if (targetTime > (currentTime + this.maximumAudioLag)) this.audio.fastSeek(targetTime); 112 | } 113 | 114 | //joins two data arrays - helper function 115 | concat(buffer1, buffer2) { 116 | var tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength); 117 | tmp.set(new Uint8Array(buffer1), 0); 118 | tmp.set(new Uint8Array(buffer2), buffer1.byteLength); 119 | return tmp.buffer; 120 | }; 121 | } 122 | --------------------------------------------------------------------------------