Initial release

This commit is contained in:
aculix committed 2026-09-28 18:28:32 +05:30
commit 4b67728cce
108 files changed
+13310

No files matched your search

+52
View File
@@ -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"
+32
View File
@@ -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>
+697
View File
@@ -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;
}
+197
View File
@@ -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
+53
View File
@@ -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;
}
+21
View File
@@ -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
+155
View File
@@ -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;
}