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

+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# --dns: the vmnet DNS forwarder isn't reachable on every network.
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
container build --cpus 4 --memory 4g --dns 1.1.1.1 --pull \
--build-arg WERROR=-Werror \
--build-arg APT_REFRESH="$(date -u +%Y-%m-%d)" \
--build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-f "$root/vm/Containerfile" -t local/knotchy-vm:latest "$root/vm"
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Builds Knotchy.app, installs it to ~/Applications and refreshes the Services menu.
# Signs with the keychain's Developer ID certificate (or KNOTCHY_SIGN_IDENTITY), ad hoc if there is none.
# --release skips the install and adds the secure timestamp notarization needs; see scripts/release.sh.
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$root"
release=0
[[ ${1:-} == --release ]] && release=1
identity="${KNOTCHY_SIGN_IDENTITY:-$(security find-identity -v -p codesigning | awk '/"Developer ID Application: / && !id { id = $2 } END { print id }')}"
if (( release )) && [[ -z $identity ]]; then
echo "A release needs a Developer ID Application certificate in the keychain." >&2
exit 1
fi
swift build -c release --product Knotchy
bin="$(swift build -c release --product Knotchy --show-bin-path)/Knotchy"
app="$root/build/Knotchy.app"
rm -rf "$app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
cp "$bin" "$app/Contents/MacOS/Knotchy"
cp Sources/Knotchy/Info.plist "$app/Contents/Info.plist"
cp assets/AppIcon.icns "$app/Contents/Resources/AppIcon.icns"
# The image is built from these on the user's Mac.
cp -R vm "$app/Contents/Resources/vm"
find "$app" -name .DS_Store -delete
if [[ -z $identity ]]; then
codesign --force --sign - "$app"
elif (( release )); then
codesign --force --options runtime --timestamp --sign "$identity" "$app"
else
codesign --force --options runtime --timestamp=none --sign "$identity" "$app"
fi
if (( release )); then
exit 0
fi
dest="$HOME/Applications/Knotchy.app"
mkdir -p "$HOME/Applications"
pkill -x Knotchy 2>/dev/null || true
rm -rf "$dest"
cp -R "$app" "$dest"
/System/Library/CoreServices/pbs -update
echo "Installed $dest"
+28
View File
@@ -0,0 +1,28 @@
// swift scripts/icons/frame.swift <screenshot> <out.png>: fits a screenshot into the 1280×800 frame the
// Chrome Web Store requires, on the icon's navy, as a 24-bit PNG. Window captures keep their own shadow.
import AppKit
import ImageIO
import UniformTypeIdentifiers
let args = CommandLine.arguments
guard args.count == 3, let shot = NSImage(contentsOfFile: args[1]),
let image = shot.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
print("usage: swift scripts/icons/frame.swift <screenshot> <out.png>")
exit(1)
}
let width = 1280, height = 800, margin: CGFloat = 40
let ctx = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)!
let colors = [CGColor(srgbRed: 0x1C / 255, green: 0x35 / 255, blue: 0x63 / 255, alpha: 1),
CGColor(srgbRed: 0x0D / 255, green: 0x1A / 255, blue: 0x33 / 255, alpha: 1)] as CFArray
let gradient = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), colors: colors, locations: [0, 1])!
ctx.drawLinearGradient(gradient, start: CGPoint(x: 0, y: CGFloat(height)), end: .zero, options: [])
let scale = min((CGFloat(width) - 2 * margin) / CGFloat(image.width), (CGFloat(height) - 2 * margin) / CGFloat(image.height))
let size = CGSize(width: CGFloat(image.width) * scale, height: CGFloat(image.height) * scale)
ctx.interpolationQuality = .high
ctx.draw(image, in: CGRect(x: (CGFloat(width) - size.width) / 2, y: (CGFloat(height) - size.height) / 2,
width: size.width, height: size.height))
let dest = CGImageDestinationCreateWithURL(URL(fileURLWithPath: args[2]) as CFURL, UTType.png.identifier as CFString, 1, nil)!
CGImageDestinationAddImage(dest, ctx.makeImage()!, nil)
CGImageDestinationFinalize(dest)
print("wrote \(args[2])")
+1
View File
@@ -0,0 +1 @@
{"icon": [[[43.147358872228004, 35.597261122187355], [42.61532507762965, 36.304681642555074], [42.09814885584686, 37.02497342074467], [41.59628295059067, 37.75754407139881], [41.11016314262925, 38.501788171305485], [40.64020783440553, 39.25708782787771], [40.186817652259094, 40.02281326017288], [39.7503750666409, 40.798323391885674], [39.331244030691536, 41.5829664557369], [38.92976963753638, 42.37608060866765], [38.54627779663111, 43.17699455723982], [38.18107492947324, 43.985028192631795], [37.83444768497557, 44.79949323460966], [37.506662674778056, 45.61969388384524], [37.19796622875481, 46.444927481943765], [36.90858417095287, 47.274485178536395], [36.638721616179424, 48.107652604785535], [36.38856278743295, 48.94371055264535], [36.158270854354186, 49.78193565921238], [35.94798779285065, 50.6216010954986], [35.757834266028794, 51.46197725895178], [35.58790952654568, 52.30233246904732], [35.43829134047141, 53.14193366527007], [35.3090359327321, 53.980047106803596], [35.20017795418167, 54.81593907324261], [35.11173047032945, 55.64887656564209], [35.04368497172906, 56.47812800721761], [34.99601140601252, 57.30296394301044], [34.96865823153224, 58.12265773783302], [34.961552492551895, 58.93648627181062], [34.974599915906126, 59.743730632839], [35.00768502902749, 60.54367680527923], [35.06067129921806, 61.335616354215425], [35.13340129402195, 62.11884710460527], [35.225696862534214, 62.89267381465754], [35.337359337460796, 63.65640884277764], [35.46816975772352, 64.40937280742695], [35.617889111384144, 65.15089523925003], [35.786258598640785, 65.88031522483004], [35.97299991463079, 66.59698204144215], [36.17781555175401, 67.30025578218226], [36.40038912121131, 67.98950797085851], [36.64038569343427, 68.66412216604321], [36.89745215706282, 69.32349455369211], [37.17121759611, 69.96703452775148], [37.46129368493396, 70.5941652581821], [37.767275100620964, 71.20432424584489], [38.08873995236454, 71.79696386370284], [38.425250227410274, 72.37155188380925], [38.776352253118695, 72.92757198956546], [39.14157717468284, 73.46452427274542], [39.52044144802173, 73.98192571480013], [39.91244734735576, 74.47931065196943], [40.31708348695592, 74.95623122374559], [40.73382535654387, 75.41225780424828], [41.1621358698074, 75.84697941608812], [41.60146592548216, 76.26000412631285], [42.051254980438095, 76.65095942404778], [42.510931634197796, 77.01949257945994], [42.97991422430148, 77.36527098369383], [43.4576114319241, 77.68798246944569], [43.94342289713845, 77.98733561186113], [44.43673984320961, 78.26306000946163], [44.9369457092966, 78.5149065448237], [45.443416790929255, 78.74264762475569], [45.95552288761989, 78.94607739973573], [46.47262795696309, 79.1250119623963], [46.99409077457018, 79.27928952486037], [47.51926559917878, 79.408770574755], [48.04750284227361, 79.5133380097496], [48.578149741549126, 79.59289725048669], [49.1105510375421, 79.64737633179448], [49.644049652757644, 79.67672597209153], [50.177987372610914, 79.68091962091582], [50.711705527504435, 79.65995348453107], [51.24454567535968, 79.61384652958573], [51.77585028392153, 79.54264046482056], [52.304963412154024, 79.44639970084314], [52.83123139004737, 79.32521128800849], [53.354003496156544, 79.1791848324669], [53.87263263219571, 79.00845239046137], [54.38647599401408, 78.81316834097822], [54.89489573828321, 78.59350923687589], [55.3972596442302, 78.34967363463801], [55.89294176975501, 78.0818819029179], [56.38132310127728, 77.79037601006189], [56.86179219666286, 77.47541929082043], [57.33374582058782, 77.13729619247562], [57.79658957170505, 76.7763120006342], [58.24973850098674, 76.39279254495501], [58.69261772062432, 75.98708388509974], [59.12466300287774, 75.55955197721482], [59.545321368274685, 75.11058232127185], [59.954051662571985, 74.64057958961236], [60.3503251219019, 74.14996723706122], [60.733625925537865, 73.63918709299146], [61.10345173572674, 73.10869893573992], [61.459314224046835, 72.55898004979225], [61.800739583765235, 71.99052476617032], [62.127269027680796, 71.40384398647382], [62.438459270954354, 70.79946469104226], [62.7338829984421, 70.17792943171975], [63.013129316063434, 69.53979580972054], [63.27580418575093, 68.88563593910635], [63.521530843545385, 68.21603589640245], [63.74995020041658, 67.53159515689163], [63.96072122540671, 66.83292601813906], [64.15352131071153, 66.1206530113129], [64.32804661833163, 65.39541230087809], [64.48401240794503, 64.65785107325169], [64.57754692112395, 64.1596231815178]], [[62.165552315024435, 44.799493234609656], [61.818925070526774, 43.98502819263181], [61.4537222033689, 43.17699455723984], [61.07023036246362, 42.376080608667664], [60.66875596930847, 41.582966455736916], [60.24962493335912, 40.79832339188571], [59.81318234774089, 40.022813260172846], [59.35979216559447, 39.257087827877704], [58.88983685737078, 38.50178817130552], [58.40371704940935, 37.75754407139884], [57.901851144153156, 37.024973420744686], [57.38467492237035, 36.30468164255507], [56.85264112777201, 35.59726112218736], [Line truncated
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""Generates the knot icons' geometry; run via scripts/make-icons.sh."""
import json
import math
import os
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
NAVY = "#132441"
CORAL = "#FF8A5B"
def trefoil(cx, cy, size, gap):
n = 1200
pts = []
for i in range(n):
t = 2 * math.pi * i / n
pts.append((math.sin(t) + 2 * math.sin(2 * t), -(math.cos(t) - 2 * math.cos(2 * t))))
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
mx, my = (max(xs) + min(xs)) / 2, (max(ys) + min(ys)) / 2
span = max(max(xs) - min(xs), max(ys) - min(ys))
p = [(cx + (x - mx) / span * size, cy + (y - my) / span * size) for x, y in pts]
# Self-crossings: points far apart along the curve that land on each other.
cands = []
for i in range(n):
for j in range(i + n // 6, n):
if j - i > n - n // 6:
continue
d = (p[i][0] - p[j][0]) ** 2 + (p[i][1] - p[j][1]) ** 2
if d < (size * 0.004) ** 2:
cands.append((d, i, j))
cands.sort()
crossings = []
for _, i, j in cands:
far = lambda a, b: min(abs(a - b) % n, n - abs(a - b) % n) > 40
if all(far(i, a) and far(j, b) for a, b in crossings):
crossings.append((i, j))
if len(crossings) == 3:
break
assert len(crossings) == 3, crossings
# Alternate crossings go under; hide points within `gap` (straight-line) of each.
visits = sorted([i for i, _ in crossings] + [j for _, j in crossings])
under = visits[1::2]
hidden = set()
for u in under:
for d in range(-n // 10, n // 10 + 1):
k = (u + d) % n
if math.dist(p[k], p[u]) < gap:
hidden.add(k)
strands, cur = [], []
start = next(i for i in range(n) if i in hidden)
for k in range(1, n + 1):
i = (start + k) % n
if i in hidden:
if len(cur) > 1:
strands.append(cur)
cur = []
else:
cur.append(p[i])
if len(cur) > 1:
strands.append(cur)
assert len(strands) == 3
return [s[::3] + [s[-1]] for s in strands]
def path(strand):
return "M" + " L".join(f"{x:.2f},{y:.2f}" for x, y in strand)
# Each gap is half the crossing strand's width, plus the round cap, plus clear space.
ICON_STROKE, GLYPH_STROKE = 8.5, 1.9
icon = trefoil(50, 51, 62, ICON_STROKE + 1.3)
glyph = trefoil(9, 9.3, 14.2, GLYPH_STROKE + 0.6)
os.makedirs(os.path.join(ROOT, "assets"), exist_ok=True)
json.dump({"icon": icon, "iconStroke": ICON_STROKE, "glyph": glyph, "glyphStroke": GLYPH_STROKE,
"navy": NAVY, "coral": CORAL},
open(os.path.join(ROOT, "scripts/icons/knot.json"), "w"))
strokes = "".join(f'<path d="{path(s)}"/>' for s in icon)
open(os.path.join(ROOT, "assets/knotchy-icon.svg"), "w").write(
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
f'<rect width="100" height="100" rx="22.5" fill="{NAVY}"/>'
f'<g fill="none" stroke="{CORAL}" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round">{strokes}</g></svg>\n')
open(os.path.join(ROOT, "assets/knotchy-mark.svg"), "w").write(
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="15 18 70 66">'
f'<g fill="none" stroke="{CORAL}" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round">{strokes}</g></svg>\n')
points = ",\n".join(
" [" + ", ".join(f"CGPoint(x: {x:.2f}, y: {y:.2f})" for x, y in s) + "]" for s in glyph)
open(os.path.join(ROOT, "Sources/Knotchy/KnotGlyph.swift"), "w").write(f'''// Generated by scripts/icons/knot.py; run scripts/make-icons.sh to change it.
import AppKit
enum KnotGlyph {{
static let strands: [[CGPoint]] = [
{points},
]
static let menuBarImage: NSImage = {{
let image = NSImage(size: NSSize(width: 18, height: 18), flipped: true) {{ _ in
NSColor.black.setStroke()
for strand in strands {{
let path = NSBezierPath()
path.move(to: strand[0])
for point in strand.dropFirst() {{ path.line(to: point) }}
path.lineWidth = 1.9
path.lineCapStyle = .round
path.lineJoinStyle = .round
path.stroke()
}}
return true
}}
image.isTemplate = true
image.accessibilityDescription = "Knotchy"
return image
}}()
}}
''')
print("knot: 3 strands; wrote knot.json, assets/*.svg, Sources/Knotchy/KnotGlyph.swift")
+183
View File
@@ -0,0 +1,183 @@
// swift scripts/icons/render.swift <out-dir>; run via scripts/make-icons.sh.
import AppKit
import CoreGraphics
import ImageIO
import UniformTypeIdentifiers
struct Knot: Decodable {
let icon: [[[Double]]]
let iconStroke: Double
}
let args = CommandLine.arguments
let here = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
let knot = try JSONDecoder().decode(Knot.self, from: Data(contentsOf: here.appendingPathComponent("knot.json")))
let out = URL(fileURLWithPath: args.count > 1 ? args[1] : ".")
func color(_ hex: UInt32, _ alpha: CGFloat = 1) -> CGColor {
CGColor(srgbRed: CGFloat(hex >> 16 & 0xFF) / 255, green: CGFloat(hex >> 8 & 0xFF) / 255,
blue: CGFloat(hex & 0xFF) / 255, alpha: alpha)
}
/// Apple's icon shape is close to a superellipse with exponent 5.
func squircle(_ r: CGRect) -> CGPath {
let path = CGMutablePath()
let steps = 720
for i in 0...steps {
let t = Double(i) / Double(steps) * 2 * .pi
let c = cos(t), s = sin(t)
let x = r.midX + r.width / 2 * CGFloat(copysign(pow(abs(c), 2.0 / 5), c))
let y = r.midY + r.height / 2 * CGFloat(copysign(pow(abs(s), 2.0 / 5), s))
if i == 0 { path.move(to: CGPoint(x: x, y: y)) } else { path.addLine(to: CGPoint(x: x, y: y)) }
}
path.closeSubpath()
return path
}
func verticalGradient(_ ctx: CGContext, in rect: CGRect, top: CGColor, bottom: CGColor) {
let gradient = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), colors: [top, bottom] as CFArray, locations: [0, 1])!
ctx.drawLinearGradient(gradient, start: CGPoint(x: rect.midX, y: rect.minY), end: CGPoint(x: rect.midX, y: rect.maxY), options: [])
}
/// The knot as drawn on the icon: `box` is the icon's body, and the knot keeps the icon's margins inside it.
func drawKnot(_ ctx: CGContext, in box: CGRect) {
let scale = box.width / 100
let width = knot.iconStroke * scale
for strand in knot.icon {
let line = CGMutablePath()
for (i, p) in strand.enumerated() {
let point = CGPoint(x: box.minX + p[0] * scale, y: box.minY + p[1] * scale)
if i == 0 { line.move(to: point) } else { line.addLine(to: point) }
}
let stroked = line.copy(strokingWithWidth: width, lineCap: .round, lineJoin: .round, miterLimit: 4)
ctx.saveGState()
ctx.addPath(stroked)
ctx.clip()
verticalGradient(ctx, in: box, top: color(0xFFA37A), bottom: color(0xFF7345))
ctx.restoreGState()
}
}
func render(size: Int, body: CGRect, shadow: Bool, to url: URL) {
let s = CGFloat(size)
let ctx = CGContext(data: nil, width: size, height: size, bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
ctx.translateBy(x: 0, y: s)
ctx.scaleBy(x: 1, y: -1)
ctx.setShouldAntialias(true)
ctx.interpolationQuality = .high
let shape = squircle(body)
if shadow {
ctx.saveGState()
ctx.setShadow(offset: CGSize(width: 0, height: s * 0.012), blur: s * 0.028, color: color(0x000000, 0.32))
ctx.addPath(shape)
ctx.setFillColor(color(0x132441))
ctx.fillPath()
ctx.restoreGState()
}
ctx.saveGState()
ctx.addPath(shape)
ctx.clip()
verticalGradient(ctx, in: body, top: color(0x1C3563), bottom: color(0x0D1A33))
drawKnot(ctx, in: body)
ctx.restoreGState()
let image = ctx.makeImage()!
let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)!
CGImageDestinationAddImage(dest, image, nil)
CGImageDestinationFinalize(dest)
}
// Apple's grid: an 824-point body on a 1024-point canvas.
let iconset = out.appendingPathComponent("AppIcon.iconset")
try FileManager.default.createDirectory(at: iconset, withIntermediateDirectories: true)
for (points, scale) in [(16, 1), (16, 2), (32, 1), (32, 2), (128, 1), (128, 2), (256, 1), (256, 2), (512, 1), (512, 2)] {
let size = points * scale
let inset = CGFloat(size) * 100 / 1024
let body = CGRect(x: inset, y: inset, width: CGFloat(size) - 2 * inset, height: CGFloat(size) - 2 * inset)
let name = scale == 1 ? "icon_\(points)x\(points).png" : "icon_\(points)x\(points)@2x.png"
render(size: size, body: body, shadow: size >= 64, to: iconset.appendingPathComponent(name))
}
// The Chrome Web Store wants 96 points of art with 16 of padding at 128.
for (size, pad) in [(128, 16.0), (48, 2.0), (32, 1.0), (16, 0.0)] {
let body = CGRect(x: pad, y: pad, width: Double(size) - 2 * pad, height: Double(size) - 2 * pad)
render(size: size, body: body, shadow: false, to: out.appendingPathComponent("chrome-\(size).png"))
}
// The disk image window: Finder puts Knotchy at (180, 175) and Applications at (460, 175).
// It's drawn taller than the 400-point window so a slightly taller window never shows an edge, and it's light
// because Finder always draws the file names in black over a background picture, even in dark mode.
func renderBackground(scale: Int, to url: URL) {
let width = 640, height = 440
let ctx = CGContext(data: nil, width: width * scale, height: height * scale, bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
ctx.scaleBy(x: CGFloat(scale), y: CGFloat(scale))
ctx.translateBy(x: 0, y: CGFloat(height))
ctx.scaleBy(x: 1, y: -1)
ctx.setFillColor(color(0xF5F5F7))
ctx.fill(CGRect(x: 0, y: 0, width: width, height: height))
let arrow = CGMutablePath()
arrow.move(to: CGPoint(x: 272, y: 175))
arrow.addLine(to: CGPoint(x: 366, y: 175))
arrow.move(to: CGPoint(x: 356, y: 165))
arrow.addLine(to: CGPoint(x: 368, y: 175))
arrow.addLine(to: CGPoint(x: 356, y: 185))
ctx.addPath(arrow)
ctx.setStrokeColor(color(0xFF8456))
ctx.setLineWidth(3)
ctx.setLineCap(.round)
ctx.setLineJoin(.round)
ctx.strokePath()
NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: true)
let caption = NSAttributedString(string: "Drag Knotchy to Applications", attributes: [
.font: NSFont.systemFont(ofSize: 12, weight: .medium),
.foregroundColor: NSColor(cgColor: color(0x86868B))!,
])
let size = caption.size()
caption.draw(at: CGPoint(x: (CGFloat(width) - size.width) / 2, y: 300))
NSGraphicsContext.current = nil
let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)!
CGImageDestinationAddImage(dest, ctx.makeImage()!, [kCGImagePropertyDPIWidth: 72 * scale, kCGImagePropertyDPIHeight: 72 * scale] as CFDictionary)
CGImageDestinationFinalize(dest)
}
renderBackground(scale: 1, to: out.appendingPathComponent("dmg-background.png"))
renderBackground(scale: 2, to: out.appendingPathComponent("dmg-background@2x.png"))
// Chrome Web Store promo images. The store takes 24-bit PNGs only, hence no alpha.
func renderPromo(width: Int, height: Int, to url: URL) {
let w = CGFloat(width), h = CGFloat(height), unit = h / 280
let ctx = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)!
ctx.translateBy(x: 0, y: h)
ctx.scaleBy(x: 1, y: -1)
verticalGradient(ctx, in: CGRect(x: 0, y: 0, width: w, height: h), top: color(0x1C3563), bottom: color(0x0D1A33))
let title = NSAttributedString(string: "Open in Knotchy", attributes: [
.font: NSFont.systemFont(ofSize: 28 * unit, weight: .bold),
.foregroundColor: NSColor.white,
])
let subtitle = NSAttributedString(string: "Open suspicious links in a\ndisposable, isolated browser.", attributes: [
.font: NSFont.systemFont(ofSize: 14 * unit, weight: .medium),
.foregroundColor: NSColor(white: 1, alpha: 0.72),
])
let knotSide = 140 * unit, gap = 14 * unit, lineGap = 8 * unit
let left = (w - knotSide - gap - max(title.size().width, subtitle.size().width)) / 2
drawKnot(ctx, in: CGRect(x: left, y: (h - knotSide) / 2, width: knotSide, height: knotSide))
let top = (h - title.size().height - lineGap - subtitle.size().height) / 2
NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: true)
title.draw(at: CGPoint(x: left + knotSide + gap, y: top))
subtitle.draw(at: CGPoint(x: left + knotSide + gap, y: top + title.size().height + lineGap))
NSGraphicsContext.current = nil
let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)!
CGImageDestinationAddImage(dest, ctx.makeImage()!, nil)
CGImageDestinationFinalize(dest)
}
renderPromo(width: 440, height: 280, to: out.appendingPathComponent("promo-440x280.png"))
renderPromo(width: 1400, height: 560, to: out.appendingPathComponent("marquee-1400x560.png"))
print("rendered AppIcon.iconset, chrome-{16,32,48,128}.png, dmg-background{,@2x}.png and the store promos into \(out.path)")
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Regenerates every icon (app, SVGs, Chrome extension, menu bar glyph) from scripts/icons/knot.py,
# the disk image background and the Chrome Web Store promo images.
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
python3 "$root/scripts/icons/knot.py"
swift "$root/scripts/icons/render.swift" "$work"
iconutil -c icns "$work/AppIcon.iconset" -o "$root/assets/AppIcon.icns"
for size in 16 32 48 128; do
cp "$work/chrome-$size.png" "$root/extensions/chrome/icons/icon-$size.png"
done
mkdir -p "$root/extensions/chrome/store"
cp "$work/promo-440x280.png" "$work/marquee-1400x560.png" "$root/extensions/chrome/store/"
tiffutil -cathidpicheck "$work/dmg-background.png" "$work/dmg-background@2x.png" -out "$root/assets/dmg-background.tiff"
echo "Wrote assets/AppIcon.icns, assets/dmg-background.tiff, extensions/chrome/icons/ and extensions/chrome/store/"
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Zips extensions/chrome for the Chrome Web Store: build/open-in-knotchy-<version>.zip.
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$root/extensions/chrome"
version="$(plutil -extract version raw manifest.json)"
out="$root/build/open-in-knotchy-$version.zip"
mkdir -p "$root/build"
rm -f "$out"
zip -q -r -X "$out" manifest.json background.js icons -x '*.DS_Store'
echo "$out"
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Builds a signed, notarized build/Knotchy-<version>.dmg for a GitHub release.
# Needs a Developer ID Application certificate and notarization credentials saved once with
# xcrun notarytool store-credentials knotchy --apple-id <email> --team-id <team id>
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$root"
profile="${KNOTCHY_NOTARY_PROFILE:-knotchy}"
identity="${KNOTCHY_SIGN_IDENTITY:-$(security find-identity -v -p codesigning | awk '/"Developer ID Application: / && !id { id = $2 } END { print id }')}"
if [[ -z $identity ]]; then
echo "A release needs a Developer ID Application certificate in the keychain." >&2
exit 1
fi
if [[ -e /Volumes/Knotchy ]]; then
echo "Eject the mounted Knotchy disk first; Finder finds the new one by name." >&2
exit 1
fi
notarize() {
local result status
result="$(xcrun notarytool submit "$1" --keychain-profile "$profile" --wait --output-format json)"
status="$(plutil -extract status raw - <<<"$result")"
if [[ $status != Accepted ]]; then
xcrun notarytool log "$(plutil -extract id raw - <<<"$result")" --keychain-profile "$profile" >&2 || true
echo "Notarization of $1 failed: $status" >&2
exit 1
fi
}
KNOTCHY_SIGN_IDENTITY="$identity" scripts/bundle-app.sh --release
app="build/Knotchy.app"
version="$(/usr/libexec/PlistBuddy -c 'Print CFBundleShortVersionString' "$app/Contents/Info.plist")"
# The app gets its own ticket so it still opens offline once it's copied out of the disk image.
ditto -c -k --keepParent "$app" build/Knotchy.zip
notarize build/Knotchy.zip
rm build/Knotchy.zip
xcrun stapler staple -q "$app"
dmg="build/Knotchy-$version.dmg"
rw="build/Knotchy-rw.dmg"
stage="build/dmg"
rm -rf "$stage" "$dmg" "$rw"
mkdir -p "$stage/.background"
ditto "$app" "$stage/Knotchy.app"
ln -s /Applications "$stage/Applications"
cp assets/dmg-background.tiff "$stage/.background/background.tiff"
hdiutil create -quiet -volname Knotchy -fs HFS+ -format UDRW -size "$(( $(du -sm "$stage" | cut -f1) + 20 ))m" \
-srcfolder "$stage" "$rw"
rm -rf "$stage"
mnt="$(hdiutil attach -readwrite -noverify -noautoopen "$rw" | awk -F'\t' '/\/Volumes\// { print $NF }')"
trap 'hdiutil detach -quiet -force "$mnt" 2>/dev/null || true' EXIT
# Finder saves the window layout into the disk's .DS_Store; macOS asks once to let this script control Finder.
osascript - <<'EOF'
tell application "Finder"
tell disk "Knotchy"
open
set current view of container window to icon view
set toolbar visible of container window to false
set statusbar visible of container window to false
set bounds of container window to {200, 120, 840, 548}
set viewOptions to icon view options of container window
set arrangement of viewOptions to not arranged
set icon size of viewOptions to 128
set text size of viewOptions to 13
set background picture of viewOptions to file ".background:background.tiff"
set position of item "Knotchy.app" of container window to {180, 175}
set position of item "Applications" of container window to {460, 175}
close
open
update without registering applications
delay 1
close
end tell
end tell
EOF
for _ in {1..20}; do
[[ -f $mnt/.DS_Store ]] && break
sleep 0.5
done
# Added after Finder is done, since laying out the window deletes the icon and clears the custom-icon flag.
cp assets/AppIcon.icns "$mnt/.VolumeIcon.icns"
xattr -wx com.apple.FinderInfo 0000000000000000040000000000000000000000000000000000000000000000 "$mnt"
rm -rf "$mnt/.fseventsd"
sync
hdiutil detach -quiet "$mnt" || { sleep 2; hdiutil detach -quiet -force "$mnt"; }
trap - EXIT
hdiutil convert -quiet "$rw" -format UDZO -imagekey zlib-level=9 -o "$dmg"
rm "$rw"
codesign --sign "$identity" --timestamp "$dmg"
notarize "$dmg"
xcrun stapler staple -q "$dmg"
spctl --assess --type open --context context:primary-signature --verbose "$dmg"
shasum -a 256 "$dmg"
+8
View File
@@ -0,0 +1,8 @@
# Test fixture for scripts/test-routes.sh: HTTP and SOCKS5 proxies and a WireGuard server.
FROM debian:trixie-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
tinyproxy microsocks wireguard-go wireguard-tools iproute2 nftables && \
rm -rf /var/lib/apt/lists/*
COPY --chmod=755 serve.sh /usr/local/bin/serve.sh
ENTRYPOINT ["/usr/local/bin/serve.sh"]
+49
View File
@@ -0,0 +1,49 @@
#!/bin/sh
set -eu
me=$(ip -4 -o address show dev eth0 | awk '{ split($4, a, "/"); print a[1]; exit }')
cat > /tmp/tinyproxy.conf <<CONF
Port 8888
Listen 0.0.0.0
Timeout 60
LogLevel Connect
LogFile "/tmp/tinyproxy.log"
BasicAuth knotchy s3cr3t
MaxClients 20
CONF
tinyproxy -c /tmp/tinyproxy.conf
microsocks -i 0.0.0.0 -p 1080 -u knotchy -P 's3cr3t' > /tmp/microsocks.log 2>&1 &
umask 077
wg genkey > /tmp/server.key
wg pubkey < /tmp/server.key > /tmp/server.pub
wg genkey > /tmp/client.key
wg pubkey < /tmp/client.key > /tmp/client.pub
WG_I_PREFER_BUGGY_USERSPACE_TO_POLISHED_KMOD=1 wireguard-go wg0
wg set wg0 listen-port 51820 private-key /tmp/server.key \
peer "$(cat /tmp/client.pub)" allowed-ips 10.99.0.2/32
ip address add 10.99.0.1/24 dev wg0
ip link set dev wg0 up
echo 1 > /proc/sys/net/ipv4/ip_forward
nft -f - <<NFT
table ip nat {
chain postrouting {
type nat hook postrouting priority srcnat;
oifname "eth0" masquerade
}
}
NFT
cat > /tmp/client.conf <<CONF
[Interface]
PrivateKey = $(cat /tmp/client.key)
Address = 10.99.0.2/32
DNS = 1.1.1.1
[Peer]
PublicKey = $(cat /tmp/server.pub)
AllowedIPs = 0.0.0.0/0
Endpoint = $me:51820
CONF
echo "route-server ready on $me"
exec sleep infinity
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# scripts/run-vm.sh NAME URL: starts a VM with the app's flags, prints the host socket.
# Env: KNOTCHY_DEBUG, KNOTCHY_FIRST_VIEWER_TIMEOUT, KNOTCHY_ENV_FILE, KNOTCHY_SECRETS_DIR.
# Name VMs knotchyctl-<8 hex> so the app's orphan sweep ignores them.
set -euo pipefail
name="${1:?usage: run-vm.sh NAME URL}"
url="${2:?usage: run-vm.sh NAME URL}"
if [[ ! "$name" =~ ^[a-z0-9][a-z0-9-]{0,40}$ ]]; then
echo "run-vm.sh: invalid VM name '$name' (lowercase letters, digits, dashes)" >&2; exit 2
fi
if [[ "$name" =~ ^knotchy-[0-9a-f]{8}$ ]]; then
echo "run-vm.sh: '$name' uses the app's naming; use knotchyctl-<8 hex>" >&2; exit 2
fi
tmp="${TMPDIR:-/tmp}"
run_dir="${tmp%/}/knotchy"
mkdir -p -m 700 "$run_dir"
if [[ ! -d "$run_dir" || -L "$run_dir" || ! -O "$run_dir" ]]; then
echo "run-vm.sh: refusing unsafe socket directory $run_dir" >&2; exit 1
fi
chmod 700 "$run_dir"
sock="$run_dir/$name.sock"
rm -f "$sock"
# The Mac's networks may be public addresses, so the VM blocks them explicitly.
block=$(python3 - <<'PY'
import ipaddress, re, subprocess
out = subprocess.run(["ifconfig"], capture_output=True, text=True).stdout
nets = set()
for ip, mask in re.findall(r"inet (\d+\.\d+\.\d+\.\d+)(?: --> \S+)? netmask 0x([0-9a-f]{8})", out):
if ip.startswith("127."):
continue
net = ipaddress.ip_network(f"{ip}/{bin(int(mask, 16)).count('1')}", strict=False)
if net.prefixlen >= 8:
nets.add(str(net))
print(" ".join(sorted(nets)))
PY
)
if [[ -z "$block" ]]; then
echo "run-vm.sh: warning: found no Mac IPv4 networks to block" >&2
fi
container run -d --rm --name "$name" \
--cpus 4 --memory 2g --shm-size 512m \
--dns 1.1.1.1 \
--cap-add NET_ADMIN \
--masked-path NONE --read-only-path NONE \
--publish-socket "$sock:/tmp/knotchy/knotchy.sock" \
-e "KNOTCHY_URL=$url" \
-e "KNOTCHY_SIZE=1024x640" \
-e "KNOTCHY_BLOCK_CIDRS=$block" \
-e "KNOTCHY_DEBUG=${KNOTCHY_DEBUG:-0}" \
-e "KNOTCHY_FIRST_VIEWER_TIMEOUT=${KNOTCHY_FIRST_VIEWER_TIMEOUT:-60}" \
${KNOTCHY_ENV_FILE:+--env-file "$KNOTCHY_ENV_FILE"} \
${KNOTCHY_SECRETS_DIR:+--cap-add SYS_ADMIN --mount "type=bind,source=$KNOTCHY_SECRETS_DIR,target=/run/knotchy-secrets"} \
local/knotchy-vm:latest >/dev/null
echo "$sock"
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env bash
# Isolation regression test: from inside a VM, as the browser's user, the Mac, the
# LAN, link-local and vmnet DNS are rejected and the internet isn't; IPv6 stays shut;
# PID 1 is unprivileged. Skips fail unless KNOTCHY_EGRESS_ALLOW_SKIP=1. Bash 3.2-safe.
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
name="knotchyctl-$(openssl rand -hex 4)"
work="$(mktemp -d)"
n_ok=0 n_fail=0 n_skip=0 skipped=""
http_pid="" udp_pid=""
# macOS has no timeout(1); rc 142 on timeout. The command's stderr goes via fd 3,
# so only bash's "Alarm clock" is discarded.
deadline() {
{ ( perl -e 'alarm shift; exec @ARGV or die "exec $ARGV[0]: $!\n"' "$@" 2>&3 3>&-; exit $? ) 2>/dev/null; } 3>&2
}
cx() { deadline 20 container exec "$@"; }
cleanup() {
trap '' INT TERM
for pid in $http_pid $udp_pid; do
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
done
deadline 30 container stop "$name" >/dev/null 2>&1 || true
deadline 30 container delete --force "$name" >/dev/null 2>&1 || true
rm -rf "$work"
}
trap cleanup EXIT
# `container exec` swallows ^C and exits 0; don't carry on to "all checks passed".
trap 'exit 130' INT
trap 'exit 143' TERM
ok() { printf " ok %s\n" "$1"; n_ok=$((n_ok + 1)); }
bad() { printf " FAIL %s\n" "$1"; n_fail=$((n_fail + 1)); }
skip() { printf " skip %s\n" "$1"; n_skip=$((n_skip + 1)); skipped="$skipped - $1"$'\n'; }
check() {
if [[ "$2" == "$3" ]]; then ok "$1: $2"; else bad "$1: got '$2', want '$3'"; fi
}
summary() {
printf "\ntest-egress: %d ok, %d fail, %d skip\n" "$n_ok" "$n_fail" "$n_skip"
if (( n_skip )); then printf "test-egress: skipped:\n%s" "$skipped"; fi
if (( n_skip )) && [[ "${KNOTCHY_EGRESS_ALLOW_SKIP:-}" != 1 ]]; then
echo "test-egress: skipped checks count as failures (KNOTCHY_EGRESS_ALLOW_SKIP=1 allows them)"
exit 1
fi
if (( n_fail )); then exit 1; fi
echo "test-egress: all checks passed"
}
poll() {
local i
for ((i = 0; i < $1 * 2; i++)); do "${@:2}" && return 0; sleep 0.5; done
return 1
}
# Blocked means rc 1 plus the nft reject's error: TCP/IPv4 "No route to host", TCP/IPv6
# "Permission denied", UDP "Operation not permitted". With IPv6 disabled nft never sees
# the packet: blocked-no-ipv6. Anything else (timeout, refused, exec error) fails, so a
# Mac firewall that drops SYNs can't pass for the VM's.
signature() {
[[ "$1" == 1 && "$2" == *"$3"* ]] && why=$3
}
classify_tcp() {
cls=inconclusive why=""
if [[ "$1" == 0 ]]; then cls=reachable
elif [[ "$3" != *:* ]]; then
if signature "$1" "$2" "connect: No route to host"; then cls=blocked; fi
elif signature "$1" "$2" "connect: Permission denied"; then cls=blocked
elif signature "$1" "$2" "connect: Cannot assign requested address" ||
signature "$1" "$2" "connect: Network is unreachable"; then cls=blocked-no-ipv6
fi
}
classify_udp() {
cls=inconclusive why=""
if [[ "$1" == 0 ]]; then cls=reachable
elif signature "$1" "$2" "write error: Operation not permitted"; then cls=blocked
fi
}
verdict() { # verdict EXPECT LABEL RC OUTPUT (EXPECT may be "a|b")
case "|$1|" in
*"|$cls|"*) ok "$cls${why:+ ($why)}: $2" ;;
*) local err; err=$(printf '%s' "$4" | tr '\n' ' ')
bad "$cls (expected ${1//|/ or }): $2 [rc $3${err:+: ${err:0:240}}]" ;;
esac
}
tcp() {
local rc=0 out
if [[ -z "$3" ]]; then skip "$2 (address unknown)"; return; fi
out=$(cx -u knotchy "$name" timeout 3 bash -c \
"exec 3<>/dev/tcp/$3/$4 && printf 'GET / HTTP/1.0\r\n\r\n' >&3" 2>&1) || rc=$?
classify_tcp "$rc" "$out" "$3"
verdict "$1" "$2" "$rc" "$out"
}
udp() {
local rc=0 out
if [[ -z "$3" ]]; then skip "$2 (address unknown)"; return; fi
out=$(cx -u knotchy "$name" timeout 3 bash -c "printf x > /dev/udp/$3/$4" 2>&1) || rc=$?
classify_udp "$rc" "$out"
verdict "$1" "$2" "$rc" "$out"
}
# Append mode, so truncating a log doesn't leave the writer at a stale offset.
hport=$(python3 -c 'import socket; s = socket.socket(socket.AF_INET6); s.bind(("::", 0)); print(s.getsockname()[1])')
echo "MAC-REACHED" > "$work/index.html"
(cd "$work" && exec python3 -u -m http.server "$hport" --bind :: >> "$work/http.log" 2>&1) &
http_pid=$!
python3 -u -c '
import socket
s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
s.bind(("::", 0))
print("READY", s.getsockname()[1])
while True:
data, addr = s.recvfrom(2048)
print("DGRAM", addr[0], data[:64])
' >> "$work/udp.log" 2>&1 &
udp_pid=$!
http_up() { curl -fsS --noproxy '*' -o /dev/null --max-time 2 "http://127.0.0.1:$hport/" 2>/dev/null; }
udp_up() { uport=$(awk '$1 == "READY" {print $2}' "$work/udp.log"); [[ -n "$uport" ]]; }
if ! poll 10 http_up || ! poll 10 udp_up; then bad "Mac-side HTTP server / UDP listener did not start"; summary; fi
# Before the address lookups: the vmnet bridge only exists while a VM runs.
if ! out=$(KNOTCHY_FIRST_VIEWER_TIMEOUT=300 "$root/scripts/run-vm.sh" "$name" about:blank 2>&1 >/dev/null); then
printf '%s\n' "$out"; bad "could not start $name"; summary
fi
streaming() { [[ "$(deadline 10 container logs "$name" 2>/dev/null)" == *"streaming on"* ]]; }
if ! poll 30 streaming; then bad "$name did not log 'streaming on' within 30 s"; summary; fi
gw4=$(container network inspect default | python3 -c 'import json, sys; print(json.load(sys.stdin)[0]["status"]["ipv4Gateway"])')
bridge=""
for i in $(ifconfig -l); do
if ifconfig "$i" | grep -F "inet $gw4 " >/dev/null; then bridge=$i; break; fi
done
# The bridge's fd… address appears ~15 s after the VM starts, tentative at first.
bridge_v6() {
gw6=$(ifconfig "$bridge" 2>/dev/null |
awk '$1 == "inet6" && $2 ~ /^fd/ && $0 !~ / tentative/ && !n++ {print $2}')
[[ -n "$gw6" ]]
}
gw6=""
if [[ -n "$bridge" ]]; then poll 30 bridge_v6 || true; fi
mac4=$( (echo "$gw4"; ifconfig | awk '$1 == "inet" && $2 !~ /^127\./ {print $2}') | sort -u)
router=$(route -n get default 2>/dev/null | awk '/gateway:/ {print $2}' || true)
echo "Targets:"
echo " Mac IPv4:$(ifconfig | awk '/^[^ \t]/ {i = substr($1, 1, length($1) - 1)}
$1 == "inet" && $2 !~ /^127\./ {printf " %s (%s)", $2, i}')"
echo " VM gateway $gw4 on ${bridge:-?}, bridge IPv6 ${gw6:-none}, router ${router:-none}"
echo " HTTP port $hport, UDP port $uport"
# Otherwise "blocked" below could just mean nothing was listening.
echo "Control (from the Mac, each target must answer):"
udp_seen() { grep -F "'ctl-$1'" "$work/udp.log" >/dev/null; }
for ip in $mac4; do
if curl -fsS --noproxy '*' -o /dev/null --max-time 3 "http://$ip:$hport/" 2>/dev/null; then
ok "HTTP answers on $ip:$hport"
else bad "HTTP does not answer on $ip:$hport"; fi
{ printf "ctl-%s" "$ip" > "/dev/udp/$ip/$uport"; } 2>/dev/null || true
if poll 2 udp_seen "$ip"; then ok "UDP listener receives on $ip:$uport"
else bad "UDP listener got nothing sent to $ip:$uport"; fi
done
if [[ -z "$gw6" ]]; then
skip "HTTP on the bridge IPv6 (no settled fd… address on ${bridge:-the vmnet bridge} within 30 s)"
elif curl -g -fsS --noproxy '*' -o /dev/null --max-time 3 "http://[$gw6]:$hport/" 2>/dev/null; then
ok "HTTP answers on [$gw6]:$hport"
else bad "HTTP does not answer on [$gw6]:$hport"; fi
if (( n_fail )); then echo "test-egress: control checks failed; the probes would be meaningless"; summary; fi
: > "$work/http.log"
: > "$work/udp.log"
echo "Phase A, IPv6 disabled; from inside $name as the browser user:"
for ip in $mac4; do tcp blocked "TCP Mac $ip:$hport" "$ip" "$hport"; done
tcp blocked "TCP LAN router ${router:-?}:80" "$router" 80
tcp blocked "TCP cloud metadata 169.254.169.254:80" 169.254.169.254 80
tcp reachable "TCP internet 1.1.1.1:443" 1.1.1.1 443
tcp reachable "TCP internet example.com:443" example.com 443
for ip in $mac4; do udp blocked "UDP Mac $ip:$uport" "$ip" "$uport"; done
udp blocked "UDP vmnet DNS $gw4:53" "$gw4" 53
udp blocked "UDP LAN router ${router:-?}:53" "$router" 53
udp blocked "UDP cloud metadata 169.254.169.254:53" 169.254.169.254 53
udp reachable "UDP internet DNS 1.1.1.1:53" 1.1.1.1 53
tcp "blocked|blocked-no-ipv6" "TCP Mac IPv6 [${gw6:-?}]:$hport" "$gw6" "$hport"
tcp "blocked|blocked-no-ipv6" "TCP internet IPv6 [2606:4700:4700::1111]:443" 2606:4700:4700::1111 443
echo "DNS and IPv6 state:"
check "resolv.conf nameservers" \
"$(cx "$name" awk '$1 == "nameserver" {print $2}' /etc/resolv.conf 2>&1 | paste -sd ' ' -)" "1.1.1.1"
if cx -u knotchy "$name" getent ahostsv4 example.com >/dev/null 2>&1; then
ok "browser user resolves example.com"
else bad "browser user cannot resolve example.com"; fi
check "all/disable_ipv6" "$(cx "$name" cat /proc/sys/net/ipv6/conf/all/disable_ipv6 2>&1)" 1
echo "Privileges of the browser process (PID 1):"
status=$(cx "$name" cat /proc/1/status 2>&1 || true)
field() { awk -v f="$1:" '$1 == f {$1 = ""; print substr($0, 2)}' <<<"$status"; }
check "Name" "$(field Name)" knotchy-agent
check "Uid (real effective saved fs)" "$(field Uid)" "1000 1000 1000 1000"
check "Gid (real effective saved fs)" "$(field Gid)" "1000 1000 1000 1000"
for c in CapInh CapPrm CapEff CapBnd CapAmb; do check "$c" "$(field $c)" 0000000000000000; done
check "NoNewPrivs" "$(field NoNewPrivs)" 1
rc=0
out=$(cx -u knotchy "$name" nft flush ruleset 2>&1) || rc=$?
if (( rc != 0 )) && [[ "$out" == *"Operation not permitted"* ]]; then
ok "browser user cannot flush the firewall (Operation not permitted)"
else bad "nft flush ruleset as the browser user: rc $rc: $(printf '%s' "$out" | tr '\n' ' ')"; fi
# IPv6 back on so probes reach nft, and NDP let through so SLAAC can assign an address.
echo "Phase B, IPv6 re-enabled by root (proves the nft IPv6 rule):"
gw6_a=$gw6
if [[ -n "$bridge" ]]; then poll 30 bridge_v6 || true; fi
if [[ "$gw6" != "$gw6_a" ]]; then echo " bridge IPv6 is now ${gw6:-none}"; fi
vm_v6() { # not link-local (scope 20), not tentative (0x40)
local addr idx plen scope flags dev
while read -r addr idx plen scope flags dev; do
if [[ "$dev" == eth0 && "$scope" != 20 ]] && (( (0x$flags & 0x40) == 0 )); then
vm6=$addr; return 0
fi
done <<<"$(cx "$name" cat /proc/net/if_inet6 2>/dev/null || true)"
return 1
}
vm6="" rc=0
out=$(cx "$name" sh -c '
nft insert rule inet knotchy output icmpv6 type { nd-router-solicit, nd-neighbor-solicit, nd-neighbor-advert } accept &&
nft insert rule inet knotchy input icmpv6 type { nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept &&
echo 0 > /proc/sys/net/ipv6/conf/all/disable_ipv6 && echo 0 > /proc/sys/net/ipv6/conf/eth0/disable_ipv6' 2>&1) || rc=$?
if (( rc != 0 )); then
bad "root could not open NDP and re-enable IPv6: rc $rc: $(printf '%s' "$out" | tr '\n' ' ')"
elif ! poll 15 vm_v6; then
skip "no routable IPv6 address on eth0 within 15 s; the nft IPv6 rule is unproven"
else
ok "eth0 has IPv6 $(sed 's/..../&:/g; s/:$//' <<<"$vm6")"
tcp blocked "TCP Mac IPv6 [${gw6:-?}]:$hport" "$gw6" "$hport"
tcp blocked "TCP internet IPv6 [2606:4700:4700::1111]:443" 2606:4700:4700::1111 443
udp blocked "UDP Mac IPv6 [${gw6:-?}]:$uport" "$gw6" "$uport"
udp blocked "UDP internet IPv6 [2606:4700:4700::1111]:53" 2606:4700:4700::1111 53
fi
echo "Mac-side evidence:"
evidence() {
local hits
hits=$(grep -cF "$3" "$2" || true)
if [[ "$hits" == 0 ]]; then ok "$1 received nothing from the VM"; return; fi
bad "$1 received $hits from the VM:"
grep -F "$3" "$2" | sed 's/^/ /'
}
evidence "HTTP server" "$work/http.log" '"GET '
evidence "UDP listener" "$work/udp.log" 'DGRAM '
summary
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
# Proxy and VPN route test (vm/knotchy-init): each route lets out only what it should,
# secrets stay out of the environment and `container inspect`, and pages load through
# knotchyctl. Bash 3.2-safe. Needs local/knotchy-vm:latest and the fixture:
# container build --dns 1.1.1.1 -t local/knotchy-route-server scripts/route-server
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
server="knotchyctl-$(openssl rand -hex 4)"
vm=""
work="$(mktemp -d)"
n_ok=0 n_fail=0
deadline() {
{ ( perl -e 'alarm shift; exec @ARGV or die "exec $ARGV[0]: $!\n"' "$@" 2>&3 3>&-; exit $? ) 2>/dev/null; } 3>&2
}
cleanup() {
trap '' INT TERM
for name in $vm $server; do
deadline 30 container stop "$name" >/dev/null 2>&1 || true
deadline 30 container delete --force "$name" >/dev/null 2>&1 || true
done
rm -rf "$work"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
ok() { printf " ok %s\n" "$1"; n_ok=$((n_ok + 1)); }
bad() { printf " FAIL %s\n" "$1"; n_fail=$((n_fail + 1)); }
summary() {
printf "\ntest-routes: %d ok, %d fail\n" "$n_ok" "$n_fail"
if (( n_fail )); then exit 1; fi
echo "test-routes: all checks passed"
}
poll() {
local i
for ((i = 0; i < $1 * 2; i++)); do "${@:2}" && return 0; sleep 0.5; done
return 1
}
# probe USER VM tcp|udp HOST PORT -> "reachable", "blocked" (the nft reject),
# "unreachable" (no route) or "other: …"
probe() {
local rc=0 out cmd
if [[ "$3" == tcp ]]; then cmd="exec 3<>/dev/tcp/$4/$5 && printf 'GET / HTTP/1.0\r\n\r\n' >&3"
else cmd="printf x > /dev/udp/$4/$5"; fi
out=$(deadline 20 container exec -u "$1" "$2" timeout 3 bash -c "$cmd" 2>&1) || rc=$?
if [[ $rc == 0 ]]; then echo reachable
elif [[ $rc == 1 && ( "$out" == *"No route to host"* || "$out" == *"Operation not permitted"* ) ]]; then echo blocked
elif [[ $rc == 1 && "$out" == *"Network is unreachable"* ]]; then echo unreachable
else echo "other: rc $rc $(printf '%s' "$out" | tr '\n' ' ' | cut -c1-160)"; fi
}
expect() { # expect "WANT|WANT2" LABEL USER tcp|udp HOST PORT
local got
got=$(probe "$3" "$vm" "$4" "$5" "$6")
case "|$1|" in *"|$got|"*) ok "$got: $2" ;; *) bad "$got (expected ${1//|/ or }): $2" ;; esac
}
start_vm() {
vm="knotchyctl-$(openssl rand -hex 4)"
if ! KNOTCHY_ENV_FILE="$1" KNOTCHY_SECRETS_DIR="${2:-}" KNOTCHY_FIRST_VIEWER_TIMEOUT=300 \
"$root/scripts/run-vm.sh" "$vm" about:blank >/dev/null; then
bad "could not start $vm"; summary
fi
streaming() { [[ "$(deadline 10 container logs "$vm" 2>/dev/null)" == *"streaming on"* ]]; }
if ! poll 30 streaming; then
bad "$vm did not start streaming"; deadline 10 container logs "$vm" 2>&1 | tail -5; summary
fi
}
stop_vm() {
deadline 30 container stop "$vm" >/dev/null 2>&1 || true
deadline 30 container delete --force "$vm" >/dev/null 2>&1 || true
vm=""
}
if ! deadline 60 container run -d --rm --name "$server" --dns 1.1.1.1 --cap-add NET_ADMIN \
--masked-path NONE --read-only-path NONE local/knotchy-route-server:latest >/dev/null; then
bad "could not start the route server (build it: see the top of this script)"; summary
fi
ready() { [[ "$(deadline 10 container logs "$server" 2>/dev/null)" == *"route-server ready"* ]]; }
if ! poll 30 ready; then bad "route server did not come up"; summary; fi
srv=$(deadline 10 container logs "$server" | awk '/route-server ready/ {print $NF}')
gw4=$(container network inspect default | python3 -c 'import json, sys; print(json.load(sys.stdin)[0]["status"]["ipv4Gateway"])')
echo "Route server $srv, Mac (vmnet gateway) $gw4"
echo "Proxy route (HTTP proxy $srv:8888):"
printf 'KNOTCHY_ROUTE=proxy\nKNOTCHY_PROXY_ADDR=%s:8888\n' "$srv" > "$work/proxy.env"
mkdir -m 700 "$work/secrets"
printf 'KNOTCHY_PROXY_URL=http://knotchy:s3cr3t@%s:8888\n' "$srv" > "$work/secrets/route"
start_vm "$work/proxy.env" "$work/secrets"
if [[ ! -e "$work/secrets/route" ]]; then ok "the VM deleted the secrets file"; else bad "the secrets file is still on the Mac"; fi
if ! deadline 10 container exec "$vm" grep -q knotchy-secrets /proc/mounts; then ok "the secrets share is unmounted"
else bad "the secrets share is still mounted"; fi
if ! deadline 10 container inspect "$vm" | grep -q s3cr3t; then ok "container inspect doesn't show the password"
else bad "container inspect shows the password"; fi
capbnd=$(deadline 10 container exec "$vm" awk '/^CapBnd/ {print $2}' /proc/1/status)
if [[ "$capbnd" == 0000000000000000 ]]; then ok "the browser has no capabilities (SYS_ADMIN dropped)"; else bad "CapBnd $capbnd"; fi
expect reachable "the proxy port" knotchy tcp "$srv" 8888
expect blocked "the fixture's other port" knotchy tcp "$srv" 1080
expect blocked "the internet directly (1.1.1.1:443)" knotchy tcp 1.1.1.1 443
expect blocked "DNS directly (1.1.1.1:53)" knotchy udp 1.1.1.1 53
expect blocked "the Mac" knotchy tcp "$gw4" 22
environ=$(deadline 10 container exec -u knotchy "$vm" cat /proc/1/environ | tr '\0' '\n')
if [[ "$environ" == *"https_proxy=http://knotchy:s3cr3t@$srv:8888"* && "$environ" != *KNOTCHY_PROXY* ]]; then
ok "the browser gets https_proxy and none of the KNOTCHY_PROXY_* variables"
else bad "the browser's environment is wrong"; fi
stop_vm
echo "WireGuard route (server $srv:51820):"
client=$(deadline 10 container exec "$server" cat /tmp/client.conf)
key=$(awk '$1 == "PrivateKey" {print $3}' <<< "$client")
pub=$(awk '$1 == "PublicKey" {print $3}' <<< "$client")
setconf=$(printf '[Interface]\nPrivateKey = %s\n\n[Peer]\nPublicKey = %s\nEndpoint = %s:51820\nAllowedIPs = 0.0.0.0/0\nPersistentKeepalive = 25\n' "$key" "$pub" "$srv" | base64 | tr -d '\n')
# A private resolver on the VPN side, as Mullvad and Proton VPN use.
printf 'KNOTCHY_ROUTE=wireguard\nKNOTCHY_WG_SETCONF=%s\nKNOTCHY_WG_ENDPOINT=%s:51820\nKNOTCHY_WG_ADDRESS=10.99.0.2/32\nKNOTCHY_WG_DNS=10.99.0.1\nKNOTCHY_WG_MTU=1420\n' \
"$setconf" "$srv" > "$work/wg.env"
start_vm "$work/wg.env"
route=$(deadline 10 container exec "$vm" ip -4 route get 1.1.1.1)
if [[ "$route" == *"dev wg0"* ]]; then ok "the internet routes through wg0"; else bad "the internet routes: $route"; fi
before=$(deadline 10 container exec "$server" wg show wg0 transfer | awk '{print $2}')
expect reachable "the internet through the tunnel (1.1.1.1:443)" knotchy tcp 1.1.1.1 443
after=$(deadline 10 container exec "$server" wg show wg0 transfer | awk '{print $2}')
if [[ -n "$after" && "${after:-0}" -gt "${before:-0}" ]]; then ok "the server saw the traffic ($before -> $after bytes)"
else bad "the server's received bytes didn't grow ($before -> $after)"; fi
expect blocked "the server's other port, outside the tunnel" knotchy tcp "$srv" 8888
expect blocked "the endpoint, for the browser's user" knotchy udp "$srv" 51820
expect blocked "the Mac" knotchy tcp "$gw4" 22
expect blocked "a private address on the VPN side (10.99.0.2 -> 10.99.0.1:22)" knotchy tcp 10.99.0.1 22
expect reachable "DNS to the VPN's resolver (10.99.0.1:53)" knotchy udp 10.99.0.1 53
expect blocked "another port on the VPN's resolver (10.99.0.1:80)" knotchy tcp 10.99.0.1 80
resolv=$(deadline 10 container exec "$vm" cat /etc/resolv.conf)
if [[ "$resolv" == "nameserver 10.99.0.1" ]]; then ok "resolv.conf names the VPN's DNS only"; else bad "resolv.conf: $resolv"; fi
environ=$(deadline 10 container exec -u knotchy "$vm" cat /proc/1/environ | tr '\0' '\n')
if [[ "$environ" != *KNOTCHY_WG* ]]; then ok "the browser doesn't get the WireGuard keys"; else bad "KNOTCHY_WG_* reached the browser"; fi
deadline 10 container exec "$vm" pkill wireguard-go || true
sleep 1
expect "blocked|unreachable" "the internet with the tunnel gone" knotchy tcp 1.1.1.1 443
via=$(deadline 10 container exec "$vm" ip -4 route get "$srv" | awk '{for (i = 1; i < NF; i++) if ($i == "via") print $(i + 1)}')
deadline 10 container exec "$vm" ip -4 route replace default via "${via:-$gw4}" dev eth0 || true
expect blocked "the internet over a restored direct route" knotchy tcp 1.1.1.1 443
stop_vm
echo "Missing route settings:"
for route in proxy wireguard; do
printf 'KNOTCHY_ROUTE=%s\n' "$route" > "$work/broken.env"
rc=0
out=$(deadline 60 container run --rm --dns 1.1.1.1 --cap-add NET_ADMIN --masked-path NONE --read-only-path NONE \
--env-file "$work/broken.env" -e KNOTCHY_URL=about:blank local/knotchy-vm:latest 2>&1) || rc=$?
if [[ $rc != 0 && "$out" == *"knotchy-init: "* && "$out" != *"streaming on"* ]]; then
ok "a $route route without its settings stops the VM ($(grep -o 'knotchy-init: .*' <<< "$out" | head -1))"
else bad "a $route route without its settings didn't stop the VM (rc $rc)"; fi
done
# The app refuses a proxy on the local network without --allow-local-proxy.
echo "End to end (knotchyctl):"
swift build --package-path "$root" --product knotchyctl >/dev/null
ctl="$(swift build --package-path "$root" --product knotchyctl --show-bin-path)/knotchyctl"
snap() {
if deadline 120 "$ctl" snapshot https://example.com "$2" --after 2 "${@:3}" >/dev/null 2>"$work/ctl.err"; then return 0; fi
bad "$1: knotchyctl failed: $(tail -1 "$work/ctl.err")"; return 1
}
if snap "HTTP proxy" "$work/http.png" --allow-local-proxy --proxy "http://knotchy:s3cr3t@$srv:8888"; then
if deadline 10 container exec "$server" grep -q 'Established connection to host "example.com"' /tmp/tinyproxy.log; then
ok "a page loads through the HTTP proxy"
else bad "the HTTP proxy didn't carry the page"; fi
fi
if snap "SOCKS5 proxy" "$work/socks.png" --allow-local-proxy --proxy "socks5://knotchy:s3cr3t@$srv:1080"; then
if deadline 10 container exec "$server" grep -q 'connected to example.com:443' /tmp/microsocks.log; then
ok "a page loads through the SOCKS5 proxy"
else bad "the SOCKS5 proxy didn't carry the page"; fi
fi
deadline 10 container exec "$server" sh -c ': > /tmp/tinyproxy.log'
if snap "wrong password" "$work/wrong.png" --allow-local-proxy --proxy "http://knotchy:wrong@$srv:8888"; then
if deadline 10 container exec "$server" grep -q 'Established connection' /tmp/tinyproxy.log; then
bad "a wrong proxy password still got through"
else ok "a wrong proxy password gets nowhere"; fi
fi
if ! deadline 120 "$ctl" snapshot https://example.com "$work/local.png" --proxy "http://knotchy:s3cr3t@$srv:8888" \
>/dev/null 2>"$work/ctl.err" && grep -q "use a proxy on this Mac or your local network" "$work/ctl.err"; then
ok "a proxy on the local network is refused"
else bad "a proxy on the local network wasn't refused: $(tail -1 "$work/ctl.err")"; fi
deadline 10 container exec "$server" cat /tmp/client.conf > "$work/client.conf"
before=$(deadline 10 container exec "$server" wg show wg0 transfer | awk '{print $2}')
if snap "WireGuard" "$work/wg.png" --wireguard "$work/client.conf"; then
after=$(deadline 10 container exec "$server" wg show wg0 transfer | awk '{print $2}')
if [[ "${after:-0}" -gt $(( ${before:-0} + 2000 )) ]]; then ok "a page loads through the WireGuard tunnel"
else bad "the tunnel didn't carry the page ($before -> $after bytes)"; fi
fi
summary