mirror of
https://github.com/aculix/knotchy.git
synced 2026-10-27 07:15:53 +00:00
Initial release
This commit is contained in:
commit
4b67728cce
108 files changed
+13310
No files matched your search
@@ -0,0 +1,52 @@
|
||||
# The only definition of the protocol version, so preflight and HELLO agree.
|
||||
# 2: honours proxy and VPN routes (older images would browse directly); 3: PAGE_URL.
|
||||
ARG KNOTCHY_PROTOCOL=3
|
||||
|
||||
FROM debian:trixie-slim AS build
|
||||
ARG KNOTCHY_PROTOCOL
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
gcc libc6-dev pkg-config libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev
|
||||
# Only developer builds use -Werror, so a new compiler warning can't break setup.
|
||||
ARG WERROR=
|
||||
COPY knotchy-agent.c progress-gate.c progress-gate.h /src/
|
||||
COPY tests/ /src/tests/
|
||||
RUN gcc -O2 -Wall $WERROR -I/src -o /tmp/test-progress-gate \
|
||||
/src/tests/test-progress-gate.c /src/progress-gate.c -lm && \
|
||||
/tmp/test-progress-gate
|
||||
RUN gcc -O2 -Wall $WERROR -DPROTOCOL_VERSION=$KNOTCHY_PROTOCOL -I/src -o /knotchy-agent /src/knotchy-agent.c /src/progress-gate.c \
|
||||
$(pkg-config --cflags --libs gstreamer-1.0 gstreamer-app-1.0 gstreamer-video-1.0 glib-2.0) -lm && \
|
||||
strip /knotchy-agent
|
||||
|
||||
FROM debian:trixie-slim
|
||||
ARG KNOTCHY_PROTOCOL
|
||||
LABEL aculix.knotchy.protocol="$KNOTCHY_PROTOCOL"
|
||||
# A new APT_REFRESH (the build date) re-runs apt, so rebuilds get security updates.
|
||||
ARG APT_REFRESH=unset
|
||||
# Without autoaudiosink (plugins-good) any page with media crashes the web process.
|
||||
# Plugins that can't work in the VM are deleted to shrink the attack surface;
|
||||
# plain rm fails the build unless each name matches exactly one file.
|
||||
RUN echo "apt refresh: $APT_REFRESH" && apt-get update && apt-get -y upgrade && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
gstreamer1.0-wpe gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-ugly \
|
||||
gstreamer1.0-libav gstreamer1.0-plugins-bad \
|
||||
libgles2 glib-networking ca-certificates nftables \
|
||||
wireguard-go wireguard-tools iproute2 \
|
||||
fonts-dejavu-core fonts-liberation2 fonts-inter-variable fonts-noto-core fonts-noto-color-emoji && \
|
||||
rm -rf /var/lib/apt/lists/* /usr/share/doc /usr/share/man /usr/share/locale && \
|
||||
for p in video4linux2 rtsp rtp rtpmanager udp jack oss4 ossaudio ximagesrc shout2 1394 pulseaudio; do \
|
||||
f="$(dpkg -L gstreamer1.0-plugins-good | grep "/libgst$p\.so\$")" && rm "$f" || \
|
||||
{ echo "prune: libgst$p.so not found in gstreamer1.0-plugins-good" >&2; exit 1; }; \
|
||||
done && \
|
||||
useradd --create-home --uid 1000 knotchy
|
||||
COPY --from=build /knotchy-agent /usr/local/bin/knotchy-agent
|
||||
COPY --chmod=755 knotchy-init /usr/local/bin/knotchy-init
|
||||
COPY --chmod=644 fonts.conf /etc/fonts/local.conf
|
||||
|
||||
# WebKit's bubblewrap sandbox needs the non-root user knotchy-init switches to.
|
||||
WORKDIR /home/knotchy
|
||||
ENTRYPOINT ["/usr/local/bin/knotchy-init"]
|
||||
|
||||
# Last, so the per-build timestamp doesn't invalidate cached layers.
|
||||
ARG BUILD_DATE=unknown
|
||||
LABEL aculix.knotchy.built="$BUILD_DATE"
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
|
||||
<!-- Pages ask for Mac and Windows fonts; without these they all fall back to DejaVu Sans. -->
|
||||
<fontconfig>
|
||||
<alias binding="same"><family>system-ui</family><prefer><family>Inter Variable</family><family>Inter</family></prefer></alias>
|
||||
<alias binding="same"><family>-apple-system</family><prefer><family>Inter Variable</family><family>Inter</family></prefer></alias>
|
||||
<alias binding="same"><family>BlinkMacSystemFont</family><prefer><family>Inter Variable</family><family>Inter</family></prefer></alias>
|
||||
<alias binding="same"><family>ui-sans-serif</family><prefer><family>Inter Variable</family><family>Inter</family></prefer></alias>
|
||||
<alias binding="same"><family>SF Pro</family><prefer><family>Inter Variable</family><family>Inter</family></prefer></alias>
|
||||
<alias binding="same"><family>SF Pro Text</family><prefer><family>Inter Variable</family><family>Inter</family></prefer></alias>
|
||||
<alias binding="same"><family>SF Pro Display</family><prefer><family>Inter Variable</family><family>Inter</family></prefer></alias>
|
||||
<alias binding="same"><family>San Francisco</family><prefer><family>Inter Variable</family><family>Inter</family></prefer></alias>
|
||||
<alias binding="same"><family>Helvetica Neue</family><prefer><family>Inter Variable</family><family>Inter</family></prefer></alias>
|
||||
<alias binding="same"><family>Segoe UI</family><prefer><family>Inter Variable</family><family>Inter</family></prefer></alias>
|
||||
<alias binding="same"><family>Roboto</family><prefer><family>Inter Variable</family><family>Inter</family></prefer></alias>
|
||||
|
||||
<alias><family>sans-serif</family><prefer><family>Inter Variable</family><family>Inter</family><family>Noto Sans</family><family>DejaVu Sans</family></prefer></alias>
|
||||
<alias><family>serif</family><prefer><family>Noto Serif</family><family>DejaVu Serif</family></prefer></alias>
|
||||
|
||||
<!-- Menlo is derived from DejaVu Sans Mono. -->
|
||||
<alias binding="same"><family>ui-monospace</family><prefer><family>DejaVu Sans Mono</family></prefer></alias>
|
||||
<alias binding="same"><family>SF Mono</family><prefer><family>DejaVu Sans Mono</family></prefer></alias>
|
||||
<alias binding="same"><family>SFMono-Regular</family><prefer><family>DejaVu Sans Mono</family></prefer></alias>
|
||||
<alias binding="same"><family>Menlo</family><prefer><family>DejaVu Sans Mono</family></prefer></alias>
|
||||
<alias binding="same"><family>Monaco</family><prefer><family>DejaVu Sans Mono</family></prefer></alias>
|
||||
<alias binding="same"><family>Consolas</family><prefer><family>DejaVu Sans Mono</family></prefer></alias>
|
||||
|
||||
<alias binding="same"><family>emoji</family><prefer><family>Noto Color Emoji</family></prefer></alias>
|
||||
<alias binding="same"><family>Apple Color Emoji</family><prefer><family>Noto Color Emoji</family></prefer></alias>
|
||||
<alias binding="same"><family>Segoe UI Emoji</family><prefer><family>Noto Color Emoji</family></prefer></alias>
|
||||
<alias binding="same"><family>Segoe UI Symbol</family><prefer><family>Noto Color Emoji</family></prefer></alias>
|
||||
</fontconfig>
|
||||
@@ -0,0 +1,697 @@
|
||||
/*
|
||||
* knotchy-agent: streams one WPE WebKit view as H.264 to a single viewer over a
|
||||
* Unix socket and injects the viewer's input.
|
||||
*
|
||||
* Messages: [u8 type][u32 BE payload length][payload]
|
||||
* agent -> viewer
|
||||
* 0x01 HELLO u8 protocol version, u16 width, u16 height
|
||||
* 0x02 VIDEO u16 width, u16 height, H.264 Annex-B access unit
|
||||
* 0x03 PROGRESS u8 percent
|
||||
* 0x04 NAVIGATED empty; every navigation (the error page resets progress without one)
|
||||
* 0x05 PAGE_URL UTF-8, at most 16 KiB, never about:blank
|
||||
* A new viewer gets HELLO, then NAVIGATED, PROGRESS and PAGE_URL as last sent,
|
||||
* then an IDR frame.
|
||||
* viewer -> agent
|
||||
* 0x10 POINTER_MOVE u16 x, u16 y, u8 mods
|
||||
* 0x11 POINTER_BUTTON u8 button (1 left, 2 right, 3 middle), u8 pressed, u16 x, u16 y, u8 mods
|
||||
* 0x12 SCROLL i16 ticks_x, i16 ticks_y (negative = right/down), u16 x, u16 y
|
||||
* 0x13 KEY u8 pressed, u8 mods, UTF-8 character or XKB keysym name
|
||||
* 0x14 NAVIGATE UTF-8 URL
|
||||
* 0x15 SCRIPT UTF-8 JavaScript
|
||||
* 0x16 KEYFRAME empty
|
||||
* mods: bit 0 shift, 1 control, 2 alt, 3 meta, 4/5/6 left/right/middle button held
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <signal.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/time.h>
|
||||
#include <sys/uio.h>
|
||||
#include <sys/un.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <glib-unix.h>
|
||||
#include <gst/app/gstappsink.h>
|
||||
#include <gst/gst.h>
|
||||
#include <gst/video/navigation.h>
|
||||
#include <gst/video/video.h>
|
||||
|
||||
#include "progress-gate.h"
|
||||
|
||||
#ifndef PROTOCOL_VERSION
|
||||
#error "build with -DPROTOCOL_VERSION=<n>; vm/Containerfile passes it from KNOTCHY_PROTOCOL"
|
||||
#endif
|
||||
|
||||
_Static_assert (PROTOCOL_VERSION >= 1 && PROTOCOL_VERSION <= 255,
|
||||
"PROTOCOL_VERSION must fit HELLO's u8 version field");
|
||||
|
||||
enum {
|
||||
MSG_HELLO = 0x01,
|
||||
MSG_VIDEO = 0x02,
|
||||
MSG_PROGRESS = 0x03,
|
||||
MSG_NAVIGATED = 0x04,
|
||||
MSG_PAGE_URL = 0x05,
|
||||
MSG_POINTER_MOVE = 0x10,
|
||||
MSG_POINTER_BUTTON = 0x11,
|
||||
MSG_SCROLL = 0x12,
|
||||
MSG_KEY = 0x13,
|
||||
MSG_NAVIGATE = 0x14,
|
||||
MSG_SCRIPT = 0x15,
|
||||
MSG_KEYFRAME = 0x16,
|
||||
};
|
||||
|
||||
#define MAX_INPUT_MESSAGE (64 * 1024)
|
||||
#define MAX_PAGE_URL (16 * 1024)
|
||||
|
||||
typedef struct {
|
||||
GMainLoop *loop;
|
||||
GstElement *pipeline;
|
||||
GstElement *src;
|
||||
GstElement *sink;
|
||||
GstPad *src_pad;
|
||||
int width, height;
|
||||
int listen_fd;
|
||||
int client_fd; /* guarded by write_lock */
|
||||
gboolean viewer_dead; /* guarded by write_lock */
|
||||
gboolean viewer_present; /* main loop only */
|
||||
guint client_watch;
|
||||
GMutex write_lock;
|
||||
GByteArray *inbuf;
|
||||
gboolean need_keyframe;
|
||||
gint64 started_us;
|
||||
gboolean first_frame_logged;
|
||||
guint watchdog;
|
||||
int orphan_timeout_s;
|
||||
gchar *initial_url;
|
||||
gchar *page_url; /* guarded by write_lock */
|
||||
gboolean debug;
|
||||
ProgressGate progress; /* main loop only */
|
||||
} Agent;
|
||||
|
||||
static int
|
||||
env_int (const char *name, int fallback)
|
||||
{
|
||||
const char *v = g_getenv (name);
|
||||
return v && *v ? atoi (v) : fallback;
|
||||
}
|
||||
|
||||
static guint16
|
||||
rd16 (const guint8 * p)
|
||||
{
|
||||
return (guint16) (p[0] << 8 | p[1]);
|
||||
}
|
||||
|
||||
static void
|
||||
wr16 (guint8 * p, guint16 v)
|
||||
{
|
||||
p[0] = v >> 8;
|
||||
p[1] = v & 0xff;
|
||||
}
|
||||
|
||||
static void
|
||||
wr32 (guint8 * p, guint32 v)
|
||||
{
|
||||
p[0] = v >> 24;
|
||||
p[1] = (v >> 16) & 0xff;
|
||||
p[2] = (v >> 8) & 0xff;
|
||||
p[3] = v & 0xff;
|
||||
}
|
||||
|
||||
static gboolean
|
||||
writev_all (int fd, struct iovec *iov, int iovcnt)
|
||||
{
|
||||
while (iovcnt > 0) {
|
||||
ssize_t n = writev (fd, iov, iovcnt);
|
||||
if (n < 0) {
|
||||
if (errno == EINTR)
|
||||
continue;
|
||||
return FALSE;
|
||||
}
|
||||
while (iovcnt > 0 && (size_t) n >= iov->iov_len) {
|
||||
n -= iov->iov_len;
|
||||
iov++;
|
||||
iovcnt--;
|
||||
}
|
||||
if (iovcnt > 0) {
|
||||
iov->iov_base = (guint8 *) iov->iov_base + n;
|
||||
iov->iov_len -= n;
|
||||
}
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static void
|
||||
drop_client_locked (Agent * a)
|
||||
{
|
||||
if (a->client_fd >= 0) {
|
||||
shutdown (a->client_fd, SHUT_RDWR);
|
||||
close (a->client_fd);
|
||||
a->client_fd = -1;
|
||||
a->viewer_dead = FALSE;
|
||||
g_print ("knotchy-agent: viewer disconnected\n");
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
send_locked (Agent * a, guint8 type, const guint8 * p1, gsize n1,
|
||||
const guint8 * p2, gsize n2)
|
||||
{
|
||||
guint8 header[5];
|
||||
struct iovec iov[3];
|
||||
|
||||
if (a->client_fd < 0 || a->viewer_dead)
|
||||
return;
|
||||
header[0] = type;
|
||||
wr32 (header + 1, (guint32) (n1 + n2));
|
||||
iov[0] = (struct iovec) {header, sizeof header};
|
||||
iov[1] = (struct iovec) {(void *) p1, n1};
|
||||
iov[2] = (struct iovec) {(void *) p2, n2};
|
||||
if (!writev_all (a->client_fd, iov, n2 ? 3 : 2)) {
|
||||
/* Not close(): on the streaming thread, a reused fd number would end up
|
||||
* under the main loop's watch. The main loop sees EOF and cleans up. */
|
||||
a->viewer_dead = TRUE;
|
||||
shutdown (a->client_fd, SHUT_RDWR);
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
send_message (Agent * a, guint8 type, const guint8 * payload, gsize len)
|
||||
{
|
||||
g_mutex_lock (&a->write_lock);
|
||||
send_locked (a, type, payload, len, NULL, 0);
|
||||
g_mutex_unlock (&a->write_lock);
|
||||
}
|
||||
|
||||
static void
|
||||
request_keyframe (Agent * a)
|
||||
{
|
||||
g_mutex_lock (&a->write_lock);
|
||||
a->need_keyframe = TRUE;
|
||||
g_mutex_unlock (&a->write_lock);
|
||||
gst_element_send_event (a->sink,
|
||||
gst_video_event_new_upstream_force_key_unit (GST_CLOCK_TIME_NONE, TRUE,
|
||||
0));
|
||||
}
|
||||
|
||||
static void
|
||||
navigate_to (Agent * a, const char *url)
|
||||
{
|
||||
if (a->debug)
|
||||
g_print ("knotchy-agent: navigating to %s\n", url);
|
||||
progress_gate_navigated (&a->progress);
|
||||
send_message (a, MSG_NAVIGATED, NULL, 0);
|
||||
g_object_set (a->src, "location", url, NULL);
|
||||
}
|
||||
|
||||
static void
|
||||
on_page_uri (GObject * view, GParamSpec * pspec, gpointer user_data)
|
||||
{
|
||||
Agent *a = user_data;
|
||||
gchar *uri = NULL;
|
||||
g_object_get (view, "uri", &uri, NULL);
|
||||
if (uri && *uri && strcmp (uri, "about:blank") != 0 && strlen (uri) <= MAX_PAGE_URL
|
||||
&& g_utf8_validate (uri, -1, NULL)) {
|
||||
g_mutex_lock (&a->write_lock);
|
||||
if (g_strcmp0 (uri, a->page_url) != 0) {
|
||||
g_free (a->page_url);
|
||||
a->page_url = g_strdup (uri);
|
||||
send_locked (a, MSG_PAGE_URL, (const guint8 *) uri, strlen (uri), NULL, 0);
|
||||
}
|
||||
g_mutex_unlock (&a->write_lock);
|
||||
}
|
||||
g_free (uri);
|
||||
}
|
||||
|
||||
/* Plain GObject calls, so the agent needn't link against WebKit. */
|
||||
static void
|
||||
on_configure_web_view (GstElement * src, GObject * view, gpointer user_data)
|
||||
{
|
||||
g_signal_connect (view, "notify::uri", G_CALLBACK (on_page_uri), user_data);
|
||||
}
|
||||
|
||||
static gboolean
|
||||
navigate_to_initial_url (gpointer user_data)
|
||||
{
|
||||
Agent *a = user_data;
|
||||
navigate_to (a, a->initial_url);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static GstFlowReturn
|
||||
on_new_sample (GstAppSink * sink, gpointer user_data)
|
||||
{
|
||||
Agent *a = user_data;
|
||||
GstSample *sample = gst_app_sink_pull_sample (sink);
|
||||
GstBuffer *buf;
|
||||
GstMapInfo map;
|
||||
guint8 dims[4];
|
||||
|
||||
if (!sample)
|
||||
return GST_FLOW_EOS;
|
||||
buf = gst_sample_get_buffer (sample);
|
||||
|
||||
if (!a->first_frame_logged) {
|
||||
a->first_frame_logged = TRUE;
|
||||
g_print ("knotchy-agent: first encoded frame %" G_GINT64_FORMAT " ms after start\n",
|
||||
(g_get_monotonic_time () - a->started_us) / 1000);
|
||||
g_idle_add (navigate_to_initial_url, a);
|
||||
}
|
||||
|
||||
g_mutex_lock (&a->write_lock);
|
||||
if (a->client_fd >= 0) {
|
||||
gboolean is_key = !GST_BUFFER_FLAG_IS_SET (buf, GST_BUFFER_FLAG_DELTA_UNIT);
|
||||
if (is_key)
|
||||
a->need_keyframe = FALSE;
|
||||
if (!a->need_keyframe && gst_buffer_map (buf, &map, GST_MAP_READ)) {
|
||||
wr16 (dims, a->width);
|
||||
wr16 (dims + 2, a->height);
|
||||
send_locked (a, MSG_VIDEO, dims, sizeof dims, map.data, map.size);
|
||||
gst_buffer_unmap (buf, &map);
|
||||
}
|
||||
}
|
||||
g_mutex_unlock (&a->write_lock);
|
||||
|
||||
gst_sample_unref (sample);
|
||||
return GST_FLOW_OK;
|
||||
}
|
||||
|
||||
static GstNavigationModifierType
|
||||
to_gst_mods (guint8 m)
|
||||
{
|
||||
GstNavigationModifierType s = 0;
|
||||
if (m & 0x01) s |= GST_NAVIGATION_MODIFIER_SHIFT_MASK;
|
||||
if (m & 0x02) s |= GST_NAVIGATION_MODIFIER_CONTROL_MASK;
|
||||
if (m & 0x04) s |= GST_NAVIGATION_MODIFIER_MOD1_MASK;
|
||||
if (m & 0x08) s |= GST_NAVIGATION_MODIFIER_META_MASK;
|
||||
if (m & 0x10) s |= GST_NAVIGATION_MODIFIER_BUTTON1_MASK;
|
||||
if (m & 0x20) s |= GST_NAVIGATION_MODIFIER_BUTTON2_MASK;
|
||||
if (m & 0x40) s |= GST_NAVIGATION_MODIFIER_BUTTON3_MASK;
|
||||
return s;
|
||||
}
|
||||
|
||||
static void
|
||||
inject (Agent * a, GstEvent * ev)
|
||||
{
|
||||
/* WebKit uses event time for double-click detection. */
|
||||
GST_EVENT_TIMESTAMP (ev) = g_get_monotonic_time () * 1000;
|
||||
gst_pad_send_event (a->src_pad, ev);
|
||||
}
|
||||
|
||||
static void
|
||||
handle_message (Agent * a, guint8 type, const guint8 * p, guint32 len)
|
||||
{
|
||||
switch (type) {
|
||||
case MSG_POINTER_MOVE:
|
||||
if (len >= 5)
|
||||
inject (a, gst_navigation_event_new_mouse_move (rd16 (p), rd16 (p + 2),
|
||||
to_gst_mods (p[4])));
|
||||
break;
|
||||
case MSG_POINTER_BUTTON:
|
||||
if (len >= 7) {
|
||||
int button = p[0];
|
||||
double x = rd16 (p + 2), y = rd16 (p + 4);
|
||||
GstNavigationModifierType mods = to_gst_mods (p[6]);
|
||||
inject (a, p[1]
|
||||
? gst_navigation_event_new_mouse_button_press (button, x, y, mods)
|
||||
: gst_navigation_event_new_mouse_button_release (button, x, y, mods));
|
||||
}
|
||||
break;
|
||||
case MSG_SCROLL:
|
||||
if (len >= 8) {
|
||||
gint16 tx = (gint16) rd16 (p), ty = (gint16) rd16 (p + 2);
|
||||
double x = rd16 (p + 4), y = rd16 (p + 6);
|
||||
/* wpevideosrc sends one axis per event and WebKit uses only the sign. */
|
||||
for (int i = 0; i < ABS (tx) && i < 50; i++)
|
||||
inject (a, gst_navigation_event_new_mouse_scroll (x, y, tx > 0 ? 1 : -1, 0, 0));
|
||||
for (int i = 0; i < ABS (ty) && i < 50; i++)
|
||||
inject (a, gst_navigation_event_new_mouse_scroll (x, y, 0, ty > 0 ? 1 : -1, 0));
|
||||
}
|
||||
break;
|
||||
case MSG_KEY:
|
||||
if (len >= 3) {
|
||||
char *key = g_strndup ((const char *) p + 2, len - 2);
|
||||
GstNavigationModifierType mods = to_gst_mods (p[1]);
|
||||
if (g_utf8_validate (key, -1, NULL))
|
||||
inject (a, p[0]
|
||||
? gst_navigation_event_new_key_press (key, mods)
|
||||
: gst_navigation_event_new_key_release (key, mods));
|
||||
g_free (key);
|
||||
}
|
||||
break;
|
||||
case MSG_NAVIGATE:{
|
||||
char *url = g_strndup ((const char *) p, len);
|
||||
if (g_utf8_validate (url, -1, NULL)) {
|
||||
if (!a->progress.has_navigated) {
|
||||
if (a->debug)
|
||||
g_print ("knotchy-agent: early NAVIGATE replaces the initial URL\n");
|
||||
g_free (a->initial_url);
|
||||
a->initial_url = g_strdup (url);
|
||||
} else {
|
||||
navigate_to (a, url);
|
||||
}
|
||||
}
|
||||
g_free (url);
|
||||
break;
|
||||
}
|
||||
case MSG_SCRIPT:{
|
||||
char *js = g_strndup ((const char *) p, len);
|
||||
if (g_utf8_validate (js, -1, NULL))
|
||||
g_signal_emit_by_name (a->src, "run-javascript", js);
|
||||
g_free (js);
|
||||
break;
|
||||
}
|
||||
case MSG_KEYFRAME:
|
||||
request_keyframe (a);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* PID 1 exiting stops the VM and --rm deletes it, so a crashed app leaves no
|
||||
* session behind. */
|
||||
static gboolean
|
||||
on_watchdog (gpointer user_data)
|
||||
{
|
||||
Agent *a = user_data;
|
||||
a->watchdog = 0;
|
||||
g_printerr ("knotchy-agent: no viewer; shutting the session down\n");
|
||||
g_main_loop_quit (a->loop);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static void
|
||||
arm_watchdog (Agent * a, int seconds)
|
||||
{
|
||||
if (a->watchdog)
|
||||
g_source_remove (a->watchdog);
|
||||
a->watchdog = g_timeout_add_seconds (seconds, on_watchdog, a);
|
||||
}
|
||||
|
||||
static void
|
||||
disarm_watchdog (Agent * a)
|
||||
{
|
||||
if (a->watchdog) {
|
||||
g_source_remove (a->watchdog);
|
||||
a->watchdog = 0;
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
close_client (Agent * a)
|
||||
{
|
||||
gboolean had_client;
|
||||
|
||||
had_client = a->viewer_present;
|
||||
a->viewer_present = FALSE;
|
||||
if (a->client_watch) {
|
||||
g_source_remove (a->client_watch);
|
||||
a->client_watch = 0;
|
||||
}
|
||||
g_mutex_lock (&a->write_lock);
|
||||
drop_client_locked (a);
|
||||
g_mutex_unlock (&a->write_lock);
|
||||
g_byte_array_set_size (a->inbuf, 0);
|
||||
if (had_client)
|
||||
arm_watchdog (a, a->orphan_timeout_s);
|
||||
}
|
||||
|
||||
static gboolean
|
||||
on_client_readable (gint fd, GIOCondition cond, gpointer user_data)
|
||||
{
|
||||
Agent *a = user_data;
|
||||
guint8 chunk[16384];
|
||||
ssize_t n = read (fd, chunk, sizeof chunk);
|
||||
|
||||
if (n <= 0) {
|
||||
if (n < 0 && errno == EINTR)
|
||||
return G_SOURCE_CONTINUE;
|
||||
a->client_watch = 0;
|
||||
close_client (a);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
g_byte_array_append (a->inbuf, chunk, n);
|
||||
|
||||
while (a->inbuf->len >= 5) {
|
||||
guint8 *d = a->inbuf->data;
|
||||
guint32 len = (guint32) d[1] << 24 | d[2] << 16 | d[3] << 8 | d[4];
|
||||
if (len > MAX_INPUT_MESSAGE) {
|
||||
a->client_watch = 0;
|
||||
close_client (a);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
if (a->inbuf->len < 5 + len)
|
||||
break;
|
||||
handle_message (a, d[0], d + 5, len);
|
||||
g_byte_array_remove_range (a->inbuf, 0, 5 + len);
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
static gboolean
|
||||
on_accept (gint fd, GIOCondition cond, gpointer user_data)
|
||||
{
|
||||
Agent *a = user_data;
|
||||
guint8 hello[5];
|
||||
int cfd = accept4 (fd, NULL, NULL, SOCK_CLOEXEC);
|
||||
|
||||
if (cfd < 0)
|
||||
return G_SOURCE_CONTINUE;
|
||||
|
||||
/* CLOEXEC keeps the socket from WebKit's children. The timeout keeps a stalled
|
||||
* viewer from blocking the encoder thread and, via write_lock, the main loop. */
|
||||
struct timeval send_timeout = { .tv_sec = 2, .tv_usec = 0 };
|
||||
setsockopt (cfd, SOL_SOCKET, SO_SNDTIMEO, &send_timeout, sizeof send_timeout);
|
||||
|
||||
close_client (a);
|
||||
disarm_watchdog (a);
|
||||
|
||||
g_mutex_lock (&a->write_lock);
|
||||
a->client_fd = cfd;
|
||||
a->viewer_present = TRUE;
|
||||
a->need_keyframe = TRUE;
|
||||
hello[0] = PROTOCOL_VERSION;
|
||||
wr16 (hello + 1, a->width);
|
||||
wr16 (hello + 3, a->height);
|
||||
send_locked (a, MSG_HELLO, hello, sizeof hello, NULL, 0);
|
||||
/* Same critical section as HELLO, so no VIDEO frame can come between. */
|
||||
if (a->progress.has_navigated)
|
||||
send_locked (a, MSG_NAVIGATED, NULL, 0, NULL, 0);
|
||||
if (a->progress.last_sent >= 0) {
|
||||
guint8 pct = (guint8) a->progress.last_sent;
|
||||
send_locked (a, MSG_PROGRESS, &pct, 1, NULL, 0);
|
||||
}
|
||||
if (a->page_url)
|
||||
send_locked (a, MSG_PAGE_URL, (const guint8 *) a->page_url, strlen (a->page_url), NULL, 0);
|
||||
g_mutex_unlock (&a->write_lock);
|
||||
|
||||
a->client_watch = g_unix_fd_add (cfd, G_IO_IN | G_IO_HUP | G_IO_ERR,
|
||||
on_client_readable, a);
|
||||
request_keyframe (a);
|
||||
g_print ("knotchy-agent: viewer connected %" G_GINT64_FORMAT " ms after start\n",
|
||||
(g_get_monotonic_time () - a->started_us) / 1000);
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
static int
|
||||
listen_unix (const char *path)
|
||||
{
|
||||
struct sockaddr_un addr = { .sun_family = AF_UNIX };
|
||||
char *dir = g_path_get_dirname (path);
|
||||
int fd;
|
||||
|
||||
g_mkdir_with_parents (dir, 0700);
|
||||
g_free (dir);
|
||||
unlink (path);
|
||||
if (strlen (path) >= sizeof addr.sun_path)
|
||||
return -1;
|
||||
strcpy (addr.sun_path, path);
|
||||
|
||||
fd = socket (AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
||||
if (fd < 0)
|
||||
return -1;
|
||||
if (bind (fd, (struct sockaddr *) &addr, sizeof addr) < 0
|
||||
|| listen (fd, 4) < 0) {
|
||||
close (fd);
|
||||
return -1;
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
/* A failed navigation is a normal outcome, not a reason to end the session. */
|
||||
static gboolean
|
||||
is_load_failure (GstMessage * msg, const GError * err, const gchar * dbg)
|
||||
{
|
||||
return GST_IS_ELEMENT (GST_MESSAGE_SRC (msg))
|
||||
&& g_error_matches (err, GST_RESOURCE_ERROR, GST_RESOURCE_ERROR_FAILED)
|
||||
&& dbg && strstr (dbg, "Failed to load") != NULL;
|
||||
}
|
||||
|
||||
static void
|
||||
show_error_page (Agent * a, const gchar * detail)
|
||||
{
|
||||
gchar *escaped = g_markup_escape_text (detail, -1);
|
||||
gchar *html = g_strdup_printf ("<!doctype html><meta charset=utf-8>"
|
||||
"<body style=\"font:18px sans-serif;margin:48px;color:#333\">"
|
||||
"<h2>This page couldn't be loaded</h2><p style=\"color:#777\">%s</p>",
|
||||
escaped);
|
||||
GBytes *bytes = g_bytes_new_take (html, strlen (html));
|
||||
|
||||
/* WebKit reports the failed load's final 100 after load-failed; resetting
|
||||
* the gate drops it, so a failed page never reads as loaded. */
|
||||
progress_gate_navigated (&a->progress);
|
||||
g_signal_emit_by_name (a->src, "load-bytes", bytes);
|
||||
g_bytes_unref (bytes);
|
||||
g_free (escaped);
|
||||
}
|
||||
|
||||
static gboolean
|
||||
on_bus_message (GstBus * bus, GstMessage * msg, gpointer user_data)
|
||||
{
|
||||
Agent *a = user_data;
|
||||
GError *err = NULL;
|
||||
gchar *dbg = NULL;
|
||||
|
||||
switch (GST_MESSAGE_TYPE (msg)) {
|
||||
case GST_MESSAGE_ERROR:
|
||||
gst_message_parse_error (msg, &err, &dbg);
|
||||
if (is_load_failure (msg, err, dbg)) {
|
||||
g_printerr ("knotchy-agent: %s\n", dbg);
|
||||
show_error_page (a, strstr (dbg, "Failed to load"));
|
||||
} else {
|
||||
/* Includes WEBKIT_WEB_PROCESS_CRASHED. Fail closed: the VM goes away. */
|
||||
g_printerr ("knotchy-agent: pipeline error: %s (%s)\n", err->message,
|
||||
dbg ? dbg : "");
|
||||
g_main_loop_quit (a->loop);
|
||||
}
|
||||
g_clear_error (&err);
|
||||
g_free (dbg);
|
||||
break;
|
||||
case GST_MESSAGE_ELEMENT:{
|
||||
const GstStructure *s = gst_message_get_structure (msg);
|
||||
gdouble value;
|
||||
if (s && gst_structure_has_name (s, "wpe-stats")
|
||||
&& gst_structure_get_double (s, "estimated-load-progress", &value)) {
|
||||
int pct = progress_gate_feed (&a->progress, value);
|
||||
if (a->debug)
|
||||
g_print ("knotchy-agent: wpe-stats %.1f (%s)\n", value,
|
||||
pct < 0 ? "dropped" : "sent");
|
||||
if (pct >= 0) {
|
||||
guint8 b = (guint8) pct;
|
||||
send_message (a, MSG_PROGRESS, &b, 1);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GST_MESSAGE_EOS:
|
||||
g_main_loop_quit (a->loop);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
static gboolean
|
||||
on_terminate (gpointer user_data)
|
||||
{
|
||||
g_main_loop_quit (((Agent *) user_data)->loop);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
int
|
||||
main (int argc, char **argv)
|
||||
{
|
||||
Agent a = { .client_fd = -1 };
|
||||
const char *url = g_getenv ("KNOTCHY_URL");
|
||||
const char *size = g_getenv ("KNOTCHY_SIZE");
|
||||
const char *sock = g_getenv ("KNOTCHY_SOCKET");
|
||||
int fps = env_int ("KNOTCHY_FPS", 30);
|
||||
int qp = env_int ("KNOTCHY_QP", 21);
|
||||
GError *err = NULL;
|
||||
gchar *desc;
|
||||
GstBus *bus;
|
||||
|
||||
a.started_us = g_get_monotonic_time ();
|
||||
a.debug = env_int ("KNOTCHY_DEBUG", 0) != 0;
|
||||
progress_gate_init (&a.progress);
|
||||
signal (SIGPIPE, SIG_IGN);
|
||||
gst_init (&argc, &argv);
|
||||
|
||||
if (!url || !*url)
|
||||
url = "about:blank";
|
||||
if (!sock || !*sock)
|
||||
sock = "/tmp/knotchy/knotchy.sock";
|
||||
if (!size || sscanf (size, "%dx%d", &a.width, &a.height) != 2) {
|
||||
a.width = 1024;
|
||||
a.height = 640;
|
||||
}
|
||||
/* The cap keeps an IDR frame well under the viewer's 2 MiB VIDEO limit. */
|
||||
a.width = CLAMP (a.width, 320, 2048) & ~1;
|
||||
a.height = CLAMP (a.height, 240, 1280) & ~1;
|
||||
|
||||
/* Constant quality: bandwidth is free over a local socket, sharp text isn't. */
|
||||
desc = g_strdup_printf ("wpevideosrc name=src draw-background=true "
|
||||
"! video/x-raw,width=%d,height=%d,framerate=%d/1 "
|
||||
"! videoconvert ! video/x-raw,format=I420 "
|
||||
"! x264enc tune=zerolatency speed-preset=ultrafast pass=qual quantizer=%d "
|
||||
"key-int-max=%d byte-stream=true option-string=repeat-headers=1 "
|
||||
"! video/x-h264,stream-format=byte-stream,alignment=au "
|
||||
"! appsink name=sink sync=false max-buffers=8",
|
||||
a.width, a.height, fps, qp, fps * 4);
|
||||
a.pipeline = gst_parse_launch (desc, &err);
|
||||
g_free (desc);
|
||||
if (!a.pipeline) {
|
||||
g_printerr ("knotchy-agent: %s\n", err->message);
|
||||
return 1;
|
||||
}
|
||||
|
||||
a.src = gst_bin_get_by_name (GST_BIN (a.pipeline), "src");
|
||||
a.sink = gst_bin_get_by_name (GST_BIN (a.pipeline), "sink");
|
||||
a.src_pad = gst_element_get_static_pad (a.src, "src");
|
||||
/* wpevideosrc creates the view only once a page paints, which a failed first
|
||||
* load never does. The URL is a property, never parsed as pipeline syntax. */
|
||||
a.initial_url = g_strdup (url);
|
||||
g_object_set (a.src, "location", "about:blank", NULL);
|
||||
g_signal_connect (a.src, "configure-web-view", G_CALLBACK (on_configure_web_view), &a);
|
||||
|
||||
gst_app_sink_set_callbacks (GST_APP_SINK (a.sink),
|
||||
&(GstAppSinkCallbacks) {.new_sample = on_new_sample}, &a, NULL);
|
||||
|
||||
a.inbuf = g_byte_array_new ();
|
||||
g_mutex_init (&a.write_lock);
|
||||
a.loop = g_main_loop_new (NULL, FALSE);
|
||||
|
||||
a.listen_fd = listen_unix (sock);
|
||||
if (a.listen_fd < 0) {
|
||||
g_printerr ("knotchy-agent: cannot listen on %s: %s\n", sock, g_strerror (errno));
|
||||
return 1;
|
||||
}
|
||||
g_unix_fd_add (a.listen_fd, G_IO_IN, on_accept, &a);
|
||||
|
||||
bus = gst_element_get_bus (a.pipeline);
|
||||
gst_bus_add_watch (bus, on_bus_message, &a);
|
||||
gst_object_unref (bus);
|
||||
|
||||
/* As PID 1, SIGTERM has no default action. */
|
||||
g_unix_signal_add (SIGTERM, on_terminate, &a);
|
||||
g_unix_signal_add (SIGINT, on_terminate, &a);
|
||||
|
||||
a.orphan_timeout_s = MAX (1, env_int ("KNOTCHY_ORPHAN_TIMEOUT", 10));
|
||||
arm_watchdog (&a, MAX (1, env_int ("KNOTCHY_FIRST_VIEWER_TIMEOUT", 60)));
|
||||
|
||||
g_print ("knotchy-agent: %dx%d@%d streaming on %s\n", a.width, a.height, fps, sock);
|
||||
gst_element_set_state (a.pipeline, GST_STATE_PLAYING);
|
||||
g_main_loop_run (a.loop);
|
||||
disarm_watchdog (&a);
|
||||
|
||||
gst_element_set_state (a.pipeline, GST_STATE_NULL);
|
||||
close_client (&a);
|
||||
close (a.listen_fd);
|
||||
unlink (sock);
|
||||
return 0;
|
||||
}
|
||||
Executable
+197
@@ -0,0 +1,197 @@
|
||||
#!/bin/sh
|
||||
# PID 1 of a session VM: firewalls egress to the public internet (or only the
|
||||
# proxy, or only the WireGuard server and its tunnel), then runs knotchy-agent as
|
||||
# the knotchy user with no capabilities, so nothing in the browser can undo it.
|
||||
# Fails closed: a missing or malformed setting stops the VM. Needs NET_ADMIN.
|
||||
# Env: KNOTCHY_ROUTE (direct|proxy|wireguard), KNOTCHY_BLOCK_CIDRS, KNOTCHY_PROXY_ADDR,
|
||||
# KNOTCHY_PROXY_URL*, KNOTCHY_WG_SETCONF*, KNOTCHY_WG_ENDPOINT, KNOTCHY_WG_ADDRESS,
|
||||
# KNOTCHY_WG_DNS, KNOTCHY_WG_MTU. *Secrets, normally in /run/knotchy-secrets/route.
|
||||
set -eu
|
||||
|
||||
fail() {
|
||||
echo "knotchy-init: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
blocked="0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16,
|
||||
172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24, 192.168.0.0/16,
|
||||
198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4"
|
||||
|
||||
is_ipv4() {
|
||||
case "$1" in *[!0-9.]* | "" | *..* | .* | *.) return 1 ;; esac
|
||||
old_ifs=$IFS; IFS=.
|
||||
# shellcheck disable=SC2086 # splitting on dots is the point
|
||||
set -- $1
|
||||
IFS=$old_ifs
|
||||
[ $# -eq 4 ] || return 1
|
||||
for octet in "$@"; do
|
||||
[ "${#octet}" -le 3 ] && [ "$octet" -le 255 ] || return 1
|
||||
done
|
||||
}
|
||||
|
||||
is_port() {
|
||||
case "$1" in *[!0-9]* | "") return 1 ;; esac
|
||||
[ "${#1}" -le 5 ] && [ "$1" -ge 1 ] && [ "$1" -le 65535 ]
|
||||
}
|
||||
|
||||
is_cidr() {
|
||||
case "$1" in */*) ;; *) return 1 ;; esac
|
||||
prefix=${1##*/}
|
||||
case "$prefix" in *[!0-9]* | "") return 1 ;; esac
|
||||
is_ipv4 "${1%/*}" && [ "${#prefix}" -le 2 ] && [ "$prefix" -le 32 ]
|
||||
}
|
||||
|
||||
set -f
|
||||
for cidr in ${KNOTCHY_BLOCK_CIDRS:-}; do
|
||||
case "$cidr" in
|
||||
*[!0-9./]* | "") fail "malformed CIDR '$cidr' in KNOTCHY_BLOCK_CIDRS" ;;
|
||||
*) blocked="$blocked, $cidr" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
route=${KNOTCHY_ROUTE:-direct}
|
||||
case $route in
|
||||
direct | proxy | wireguard) ;;
|
||||
*) fail "unknown KNOTCHY_ROUTE '$route'" ;;
|
||||
esac
|
||||
|
||||
# Secrets come on a share because `container` keeps -e values on disk. Delete
|
||||
# the file (on the Mac too) and unmount before anything else runs.
|
||||
proxy_url=${KNOTCHY_PROXY_URL:-}
|
||||
wg_setconf=${KNOTCHY_WG_SETCONF:-}
|
||||
secrets=/run/knotchy-secrets
|
||||
if [ -e "$secrets/route" ]; then
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
KNOTCHY_PROXY_URL=*) proxy_url=${line#*=} ;;
|
||||
KNOTCHY_WG_SETCONF=*) wg_setconf=${line#*=} ;;
|
||||
esac
|
||||
done < "$secrets/route"
|
||||
rm -f "$secrets/route" || fail "could not delete the secrets file"
|
||||
fi
|
||||
if grep -q " $secrets " /proc/mounts; then
|
||||
umount "$secrets" || fail "could not unmount the secrets share"
|
||||
fi
|
||||
|
||||
case $route in
|
||||
direct)
|
||||
[ -z "${KNOTCHY_PROXY_ADDR:-}${KNOTCHY_WG_ENDPOINT:-}$proxy_url$wg_setconf" ] ||
|
||||
fail "proxy or VPN settings given for a direct route"
|
||||
egress="ip daddr @blocked4 reject with icmpx type admin-prohibited" ;;
|
||||
proxy)
|
||||
proxy_addr=${KNOTCHY_PROXY_ADDR:-}
|
||||
proxy_ip=${proxy_addr%:*}
|
||||
proxy_port=${proxy_addr##*:}
|
||||
is_ipv4 "$proxy_ip" && is_port "$proxy_port" || fail "malformed KNOTCHY_PROXY_ADDR"
|
||||
case "$proxy_url" in
|
||||
http://*@"$proxy_addr" | http://"$proxy_addr" | socks5://*@"$proxy_addr" | socks5://"$proxy_addr") ;;
|
||||
*) fail "malformed or missing KNOTCHY_PROXY_URL" ;;
|
||||
esac
|
||||
egress="ip daddr $proxy_ip tcp dport $proxy_port accept
|
||||
reject with icmpx type admin-prohibited" ;;
|
||||
wireguard)
|
||||
[ -n "$wg_setconf" ] || fail "KNOTCHY_WG_SETCONF is missing"
|
||||
wg_endpoint=${KNOTCHY_WG_ENDPOINT:-}
|
||||
wg_ip=${wg_endpoint%:*}
|
||||
wg_port=${wg_endpoint##*:}
|
||||
is_ipv4 "$wg_ip" && is_port "$wg_port" || fail "malformed KNOTCHY_WG_ENDPOINT"
|
||||
[ -n "${KNOTCHY_WG_ADDRESS:-}" ] || fail "KNOTCHY_WG_ADDRESS is empty"
|
||||
for cidr in $KNOTCHY_WG_ADDRESS; do is_cidr "$cidr" || fail "malformed KNOTCHY_WG_ADDRESS"; done
|
||||
wg_dns=${KNOTCHY_WG_DNS:-1.1.1.1}
|
||||
[ -n "$wg_dns" ] || wg_dns=1.1.1.1
|
||||
for ns in $wg_dns; do is_ipv4 "$ns" || fail "malformed KNOTCHY_WG_DNS"; done
|
||||
wg_mtu=${KNOTCHY_WG_MTU:-1420}
|
||||
case "$wg_mtu" in *[!0-9]* | "") fail "malformed KNOTCHY_WG_MTU" ;; esac
|
||||
[ "${#wg_mtu}" -le 4 ] && [ "$wg_mtu" -ge 576 ] || fail "malformed KNOTCHY_WG_MTU"
|
||||
wg_dns_set=$(echo $wg_dns | sed 's/ /, /g')
|
||||
# DNS only to the VPN's (often private) resolvers; outside the tunnel, only wireguard-go.
|
||||
egress="oifname \"wg0\" ip daddr { $wg_dns_set } meta l4proto { tcp, udp } th dport 53 accept
|
||||
oifname \"wg0\" ip daddr @blocked4 reject with icmpx type admin-prohibited
|
||||
oifname \"wg0\" accept
|
||||
ip daddr $wg_ip udp dport $wg_port meta skuid 0 accept
|
||||
reject with icmpx type admin-prohibited" ;;
|
||||
esac
|
||||
|
||||
# No DNS exception in the firewall: a --dns resolver in a blocked range can't answer.
|
||||
dns=$(awk '$1 == "nameserver" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ {
|
||||
printf "%s%s", sep, $2; sep = ", " }' /etc/resolv.conf)
|
||||
bad_ns=$(awk '$1 == "nameserver" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ {
|
||||
split($2, o, "."); a = o[1] + 0; b = o[2] + 0
|
||||
if (a == 10 || a == 127 || (a == 169 && b == 254) || (a == 172 && b >= 16 && b <= 31) ||
|
||||
(a == 192 && b == 168) || (a == 100 && b >= 64 && b <= 127)) { print $2; exit }
|
||||
}' /etc/resolv.conf)
|
||||
[ -z "$bad_ns" ] || fail "resolver $bad_ns is in a blocked range; it cannot be reached through the egress firewall"
|
||||
|
||||
nft -f - <<EOF || fail "could not apply the egress firewall"
|
||||
flush ruleset
|
||||
table inet knotchy {
|
||||
set blocked4 {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
elements = { $blocked }
|
||||
}
|
||||
|
||||
chain output {
|
||||
type filter hook output priority filter; policy accept;
|
||||
oifname "lo" accept
|
||||
meta nfproto ipv6 reject with icmpx type admin-prohibited
|
||||
$egress
|
||||
}
|
||||
|
||||
chain input {
|
||||
type filter hook input priority filter; policy drop;
|
||||
iifname "lo" accept
|
||||
ct state established,related accept
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy drop;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
echo 1 > /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null || true
|
||||
|
||||
nft list table inet knotchy >/dev/null 2>&1 || fail "egress firewall is not in place"
|
||||
|
||||
case $route in
|
||||
direct)
|
||||
echo "knotchy-init: egress locked to the public internet${dns:+ (DNS via $dns)}" ;;
|
||||
proxy)
|
||||
echo "knotchy-init: egress locked to the proxy at $KNOTCHY_PROXY_ADDR"
|
||||
export http_proxy="$proxy_url" https_proxy="$proxy_url" \
|
||||
HTTP_PROXY="$proxy_url" HTTPS_PROXY="$proxy_url" no_proxy="" ;;
|
||||
wireguard)
|
||||
# The firewall is already in place, so a half-built tunnel leaks nothing.
|
||||
old_umask=$(umask)
|
||||
umask 077
|
||||
printf '%s' "$wg_setconf" | base64 -d > /run/knotchy-wg.conf 2>/dev/null ||
|
||||
fail "malformed KNOTCHY_WG_SETCONF"
|
||||
# Without SYS_ADMIN, which init only needed to unmount the secrets share.
|
||||
WG_I_PREFER_BUGGY_USERSPACE_TO_POLISHED_KMOD=1 setpriv --bounding-set -sys_admin \
|
||||
wireguard-go wg0 >/dev/null 2>&1 ||
|
||||
fail "could not start wireguard-go"
|
||||
wg setconf wg0 /run/knotchy-wg.conf || fail "the WireGuard settings were rejected"
|
||||
rm -f /run/knotchy-wg.conf
|
||||
umask "$old_umask"
|
||||
for cidr in $KNOTCHY_WG_ADDRESS; do
|
||||
ip -4 address add "$cidr" dev wg0 || fail "could not give the tunnel its address"
|
||||
done
|
||||
ip link set dev wg0 mtu "$wg_mtu" up || fail "could not bring the tunnel up"
|
||||
via=$(ip -4 route get "$wg_ip" | awk '{ for (i = 1; i < NF; i++) if ($i == "via") { print $(i + 1); exit } }')
|
||||
dev=$(ip -4 route get "$wg_ip" | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i + 1); exit } }')
|
||||
[ -n "$dev" ] || fail "no route to the VPN server"
|
||||
ip -4 route add "$wg_ip/32" ${via:+via "$via"} dev "$dev" || fail "could not route to the VPN server"
|
||||
ip -4 route replace default dev wg0 || fail "could not route through the tunnel"
|
||||
printf 'nameserver %s\n' $wg_dns > /etc/resolv.conf || fail "could not set the VPN's DNS"
|
||||
echo "knotchy-init: egress locked to the WireGuard tunnel via $KNOTCHY_WG_ENDPOINT" ;;
|
||||
esac
|
||||
|
||||
# The browser has no business knowing the Mac's networks or the VPN's keys.
|
||||
unset KNOTCHY_ROUTE KNOTCHY_BLOCK_CIDRS KNOTCHY_PROXY_ADDR KNOTCHY_PROXY_URL KNOTCHY_WG_SETCONF \
|
||||
KNOTCHY_WG_ENDPOINT KNOTCHY_WG_ADDRESS KNOTCHY_WG_DNS KNOTCHY_WG_MTU
|
||||
export HOME=/home/knotchy USER=knotchy LOGNAME=knotchy
|
||||
exec setpriv --reuid=knotchy --regid=knotchy --init-groups \
|
||||
--inh-caps=-all --bounding-set=-all --no-new-privs \
|
||||
/usr/local/bin/knotchy-agent
|
||||
@@ -0,0 +1,53 @@
|
||||
#include <math.h>
|
||||
|
||||
#include "progress-gate.h"
|
||||
|
||||
void
|
||||
progress_gate_init (ProgressGate * g)
|
||||
{
|
||||
g->has_navigated = false;
|
||||
g->saw_below_100 = false;
|
||||
g->last_sent = -1;
|
||||
}
|
||||
|
||||
void
|
||||
progress_gate_navigated (ProgressGate * g)
|
||||
{
|
||||
g->has_navigated = true;
|
||||
g->saw_below_100 = false;
|
||||
g->last_sent = -1;
|
||||
}
|
||||
|
||||
int
|
||||
progress_gate_feed (ProgressGate * g, double value)
|
||||
{
|
||||
if (!isfinite (value))
|
||||
return -1;
|
||||
|
||||
/* Floor, not round: 99.6 % is not loaded. */
|
||||
int v = value <= 0 ? 0 : value >= 100 ? 100 : (int) floor (value);
|
||||
|
||||
if (!g->has_navigated)
|
||||
return -1;
|
||||
|
||||
/* A drop means a new load started. */
|
||||
if (g->last_sent >= 0 && v < g->last_sent) {
|
||||
g->saw_below_100 = true;
|
||||
g->last_sent = v;
|
||||
return v;
|
||||
}
|
||||
|
||||
/* A 100 before any lower value is the previous page finishing. */
|
||||
if (v < 100)
|
||||
g->saw_below_100 = true;
|
||||
|
||||
if (v == 100) {
|
||||
if (!g->saw_below_100 || g->last_sent == 100)
|
||||
return -1;
|
||||
} else if (g->last_sent >= 0 && v < g->last_sent + 5) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
g->last_sent = v;
|
||||
return v;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/* Decides which WebKit load-progress values the agent forwards as PROGRESS. */
|
||||
#ifndef KNOTCHY_PROGRESS_GATE_H
|
||||
#define KNOTCHY_PROGRESS_GATE_H
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
typedef struct {
|
||||
bool has_navigated; /* false while the about:blank bootstrap loads */
|
||||
bool saw_below_100;
|
||||
int last_sent;
|
||||
} ProgressGate;
|
||||
|
||||
void progress_gate_init (ProgressGate * g);
|
||||
|
||||
/* On every new top-level load, the error page included. */
|
||||
void progress_gate_navigated (ProgressGate * g);
|
||||
|
||||
/* Returns the percent to send, or -1 to drop the value. */
|
||||
int progress_gate_feed (ProgressGate * g, double value);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,155 @@
|
||||
/* cc -Wall -Werror -I vm -o /tmp/test-progress-gate \
|
||||
* vm/tests/test-progress-gate.c vm/progress-gate.c -lm && /tmp/test-progress-gate */
|
||||
#include <stdio.h>
|
||||
#include <math.h>
|
||||
|
||||
#include "progress-gate.h"
|
||||
|
||||
static int failures;
|
||||
|
||||
#define EXPECT(got, want) \
|
||||
do { \
|
||||
int g_ = (got), w_ = (want); \
|
||||
if (g_ != w_) { \
|
||||
printf ("FAIL %s:%d: %s = %d, want %d\n", __FILE__, __LINE__, #got, g_, \
|
||||
w_); \
|
||||
failures++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static ProgressGate
|
||||
navigated_gate (void)
|
||||
{
|
||||
ProgressGate g;
|
||||
progress_gate_init (&g);
|
||||
progress_gate_navigated (&g);
|
||||
return g;
|
||||
}
|
||||
|
||||
static void
|
||||
drops_everything_before_navigation (void)
|
||||
{
|
||||
ProgressGate g;
|
||||
progress_gate_init (&g);
|
||||
EXPECT (progress_gate_feed (&g, 10), -1);
|
||||
EXPECT (progress_gate_feed (&g, 100), -1);
|
||||
EXPECT (g.last_sent, -1);
|
||||
}
|
||||
|
||||
static void
|
||||
late_bootstrap_100_is_not_page_loaded (void)
|
||||
{
|
||||
ProgressGate g = navigated_gate ();
|
||||
EXPECT (progress_gate_feed (&g, 100), -1);
|
||||
EXPECT (progress_gate_feed (&g, 10), 10);
|
||||
EXPECT (progress_gate_feed (&g, 100), 100);
|
||||
}
|
||||
|
||||
static void
|
||||
rate_limits_increases_but_always_sends_100_once (void)
|
||||
{
|
||||
ProgressGate g = navigated_gate ();
|
||||
EXPECT (progress_gate_feed (&g, 10), 10);
|
||||
EXPECT (progress_gate_feed (&g, 12), -1);
|
||||
EXPECT (progress_gate_feed (&g, 15), 15);
|
||||
EXPECT (progress_gate_feed (&g, 98), 98);
|
||||
EXPECT (progress_gate_feed (&g, 100), 100);
|
||||
EXPECT (progress_gate_feed (&g, 100), -1);
|
||||
}
|
||||
|
||||
static void
|
||||
decrease_is_a_new_navigation_and_sent_immediately (void)
|
||||
{
|
||||
ProgressGate g = navigated_gate ();
|
||||
EXPECT (progress_gate_feed (&g, 10), 10);
|
||||
EXPECT (progress_gate_feed (&g, 100), 100);
|
||||
EXPECT (progress_gate_feed (&g, 30), 30);
|
||||
EXPECT (progress_gate_feed (&g, 32), -1);
|
||||
EXPECT (progress_gate_feed (&g, 100), 100);
|
||||
}
|
||||
|
||||
static void
|
||||
first_value_after_navigation_is_always_sent (void)
|
||||
{
|
||||
ProgressGate g = navigated_gate ();
|
||||
EXPECT (progress_gate_feed (&g, 0), 0);
|
||||
EXPECT (progress_gate_feed (&g, 3), -1);
|
||||
}
|
||||
|
||||
static void
|
||||
floors_and_clamps_values (void)
|
||||
{
|
||||
ProgressGate g = navigated_gate ();
|
||||
EXPECT (progress_gate_feed (&g, -5), 0);
|
||||
EXPECT (progress_gate_feed (&g, 99.9), 99);
|
||||
EXPECT (progress_gate_feed (&g, 150), 100);
|
||||
}
|
||||
|
||||
static void
|
||||
navigating_again_rearms_the_bootstrap_guard (void)
|
||||
{
|
||||
ProgressGate g = navigated_gate ();
|
||||
EXPECT (progress_gate_feed (&g, 50), 50);
|
||||
progress_gate_navigated (&g);
|
||||
EXPECT (progress_gate_feed (&g, 100), -1);
|
||||
}
|
||||
|
||||
static void
|
||||
observed_wikipedia_sequence (void)
|
||||
{
|
||||
ProgressGate g;
|
||||
progress_gate_init (&g);
|
||||
EXPECT (progress_gate_feed (&g, 10.0), -1);
|
||||
EXPECT (progress_gate_feed (&g, 100.0), -1);
|
||||
progress_gate_navigated (&g);
|
||||
EXPECT (progress_gate_feed (&g, 10.0), 10);
|
||||
EXPECT (progress_gate_feed (&g, 10.3), -1);
|
||||
EXPECT (progress_gate_feed (&g, 48.7), 48);
|
||||
EXPECT (progress_gate_feed (&g, 63.7), 63);
|
||||
EXPECT (progress_gate_feed (&g, 84.4), 84);
|
||||
EXPECT (progress_gate_feed (&g, 86.0), -1);
|
||||
EXPECT (progress_gate_feed (&g, 86.4), -1);
|
||||
EXPECT (progress_gate_feed (&g, 100.0), 100);
|
||||
}
|
||||
|
||||
static void
|
||||
non_finite_values_are_dropped (void)
|
||||
{
|
||||
ProgressGate g = navigated_gate ();
|
||||
EXPECT (progress_gate_feed (&g, 10), 10);
|
||||
EXPECT (progress_gate_feed (&g, NAN), -1);
|
||||
EXPECT (progress_gate_feed (&g, INFINITY), -1);
|
||||
EXPECT (progress_gate_feed (&g, -INFINITY), -1);
|
||||
EXPECT (g.last_sent, 10);
|
||||
}
|
||||
|
||||
static void
|
||||
navigation_starts_a_fresh_load (void)
|
||||
{
|
||||
ProgressGate g = navigated_gate ();
|
||||
EXPECT (progress_gate_feed (&g, 50), 50);
|
||||
progress_gate_navigated (&g);
|
||||
EXPECT (g.last_sent, -1);
|
||||
EXPECT (progress_gate_feed (&g, 52), 52);
|
||||
}
|
||||
|
||||
int
|
||||
main (void)
|
||||
{
|
||||
drops_everything_before_navigation ();
|
||||
late_bootstrap_100_is_not_page_loaded ();
|
||||
rate_limits_increases_but_always_sends_100_once ();
|
||||
decrease_is_a_new_navigation_and_sent_immediately ();
|
||||
first_value_after_navigation_is_always_sent ();
|
||||
floors_and_clamps_values ();
|
||||
navigating_again_rearms_the_bootstrap_guard ();
|
||||
observed_wikipedia_sequence ();
|
||||
non_finite_values_are_dropped ();
|
||||
navigation_starts_a_fresh_load ();
|
||||
if (failures) {
|
||||
printf ("progress-gate: %d failure(s)\n", failures);
|
||||
return 1;
|
||||
}
|
||||
printf ("progress-gate: all tests passed\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in new issue
Block a user