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
Executable
+9
@@ -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"
|
||||
Executable
+47
@@ -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"
|
||||
@@ -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])")
|
||||
@@ -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
|
||||
Executable
+122
@@ -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")
|
||||
@@ -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)")
|
||||
Executable
+17
@@ -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/"
|
||||
Executable
+11
@@ -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"
|
||||
Executable
+96
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
Executable
+57
@@ -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"
|
||||
Executable
+247
@@ -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
|
||||
Executable
+189
@@ -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
|
||||
Reference in new issue
Block a user