|
#!/bin/bash |
|
|
|
set -Eeuo pipefail |
|
IFS=$'\n\t' |
|
|
|
TAILNET_NAME="klarc.com" |
|
SERVER_HOSTNAME="klarc" |
|
SERVER_DNS_NAME="klarc.tail769c37.ts.net" |
|
SERVER_FALLBACK_IP="100.82.110.25" |
|
CA_URL="https://gist.githubusercontent.com/joachimBrindeau/f63913d35d3659fda46ff74cbe1ac686/raw/klarc-rootCA.pem" |
|
CA_SHA256="A0AC77DD9D84BD60EED767DCBF4B69CFB1552F2D2E6446029CFEA9A9EF671A4D" |
|
SITES=( |
|
"klarc.test" |
|
"cosmetic-labs.test" |
|
"best-matcha.test" |
|
) |
|
LEGACY_BEGIN="# BEGIN KLARC TAILSCALE" |
|
LEGACY_END="# END KLARC TAILSCALE" |
|
MANAGED_BEGIN="# BEGIN KLARC TEST SITES" |
|
MANAGED_END="# END KLARC TEST SITES" |
|
|
|
BREW_BIN="" |
|
TAILSCALE_CLI="" |
|
WORK_DIR="" |
|
CA_FILE="" |
|
|
|
log() { |
|
printf '\033[1;34m==>\033[0m %s\n' "$*" |
|
} |
|
|
|
success() { |
|
printf '\033[1;32m✓\033[0m %s\n' "$*" |
|
} |
|
|
|
warn() { |
|
printf '\033[1;33mWarning:\033[0m %s\n' "$*" >&2 |
|
} |
|
|
|
die() { |
|
printf '\033[1;31mError:\033[0m %s\n' "$*" >&2 |
|
exit 1 |
|
} |
|
|
|
cleanup() { |
|
if [ -n "$CA_FILE" ] && [ -e "$CA_FILE" ]; then |
|
unlink "$CA_FILE" 2>/dev/null || true |
|
fi |
|
if [ -n "$WORK_DIR" ] && [ -d "$WORK_DIR" ]; then |
|
rmdir "$WORK_DIR" 2>/dev/null || true |
|
fi |
|
} |
|
|
|
trap cleanup EXIT |
|
|
|
ts() { |
|
TAILSCALE_BE_CLI=1 "$TAILSCALE_CLI" "$@" |
|
} |
|
|
|
status_json() { |
|
ts status --json 2>/dev/null || true |
|
} |
|
|
|
backend_state() { |
|
status_json | jq -r '.BackendState // empty' 2>/dev/null || true |
|
} |
|
|
|
ensure_macos() { |
|
[ "$(uname -s)" = "Darwin" ] || die "This installer supports macOS only." |
|
command -v sudo >/dev/null 2>&1 || die "sudo is required." |
|
sudo -v |
|
} |
|
|
|
ensure_homebrew() { |
|
if command -v brew >/dev/null 2>&1; then |
|
BREW_BIN="$(command -v brew)" |
|
elif [ -x /opt/homebrew/bin/brew ]; then |
|
BREW_BIN="/opt/homebrew/bin/brew" |
|
elif [ -x /usr/local/bin/brew ]; then |
|
BREW_BIN="/usr/local/bin/brew" |
|
else |
|
log "Installing Homebrew" |
|
NONINTERACTIVE=1 /bin/bash -c "$(/usr/bin/curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" |
|
if [ -x /opt/homebrew/bin/brew ]; then |
|
BREW_BIN="/opt/homebrew/bin/brew" |
|
elif [ -x /usr/local/bin/brew ]; then |
|
BREW_BIN="/usr/local/bin/brew" |
|
else |
|
die "Homebrew installation completed but brew was not found." |
|
fi |
|
fi |
|
|
|
eval "$("$BREW_BIN" shellenv)" |
|
success "Homebrew is available at $BREW_BIN" |
|
|
|
if ! command -v jq >/dev/null 2>&1; then |
|
log "Installing jq with Homebrew" |
|
"$BREW_BIN" install jq |
|
fi |
|
} |
|
|
|
candidate_is_running() { |
|
local candidate="$1" |
|
local state |
|
[ -x "$candidate" ] || return 1 |
|
state="$(TAILSCALE_BE_CLI=1 "$candidate" status --json 2>/dev/null | jq -r '.BackendState // empty' 2>/dev/null || true)" |
|
[ "$state" = "Running" ] |
|
} |
|
|
|
select_running_tailscale() { |
|
local candidate |
|
local command_candidate="" |
|
local formula_candidate="" |
|
local app_candidate="/Applications/Tailscale.app/Contents/MacOS/Tailscale" |
|
|
|
if command -v tailscale >/dev/null 2>&1; then |
|
command_candidate="$(command -v tailscale)" |
|
fi |
|
if "$BREW_BIN" list --formula tailscale >/dev/null 2>&1; then |
|
formula_candidate="$("$BREW_BIN" --prefix tailscale)/bin/tailscale" |
|
fi |
|
|
|
for candidate in "$command_candidate" "$app_candidate" "$formula_candidate"; do |
|
if [ -n "$candidate" ] && candidate_is_running "$candidate"; then |
|
TAILSCALE_CLI="$candidate" |
|
return 0 |
|
fi |
|
done |
|
|
|
return 1 |
|
} |
|
|
|
select_available_tailscale() { |
|
local formula_candidate="" |
|
local app_candidate="/Applications/Tailscale.app/Contents/MacOS/Tailscale" |
|
|
|
if [ -x "$app_candidate" ]; then |
|
TAILSCALE_CLI="$app_candidate" |
|
open -a Tailscale >/dev/null 2>&1 || true |
|
return 0 |
|
fi |
|
|
|
if "$BREW_BIN" list --formula tailscale >/dev/null 2>&1; then |
|
formula_candidate="$("$BREW_BIN" --prefix tailscale)/bin/tailscale" |
|
log "Starting the existing Homebrew tailscaled service" |
|
sudo "$BREW_BIN" services start tailscale >/dev/null 2>&1 || sudo "$BREW_BIN" services restart tailscale |
|
TAILSCALE_CLI="$formula_candidate" |
|
return 0 |
|
fi |
|
|
|
if command -v tailscale >/dev/null 2>&1; then |
|
TAILSCALE_CLI="$(command -v tailscale)" |
|
return 0 |
|
fi |
|
|
|
return 1 |
|
} |
|
|
|
install_tailscale() { |
|
log "Installing the Tailscale standalone app with Homebrew" |
|
"$BREW_BIN" install --cask tailscale-app |
|
open -a Tailscale >/dev/null 2>&1 || true |
|
|
|
if [ -x /Applications/Tailscale.app/Contents/MacOS/Tailscale ]; then |
|
TAILSCALE_CLI="/Applications/Tailscale.app/Contents/MacOS/Tailscale" |
|
elif command -v tailscale >/dev/null 2>&1; then |
|
TAILSCALE_CLI="$(command -v tailscale)" |
|
else |
|
die "Tailscale was installed, but its CLI could not be found. Restart the Mac and rerun this installer." |
|
fi |
|
} |
|
|
|
ensure_tailscale_running() { |
|
local state |
|
local waited=0 |
|
|
|
if select_running_tailscale; then |
|
success "Using connected Tailscale client: $TAILSCALE_CLI" |
|
return |
|
fi |
|
|
|
select_available_tailscale || install_tailscale |
|
|
|
log "Connecting Tailscale" |
|
open -a Tailscale >/dev/null 2>&1 || true |
|
ts up || true |
|
|
|
while [ "$waited" -lt 600 ]; do |
|
state="$(backend_state)" |
|
if [ "$state" = "Running" ]; then |
|
success "Tailscale is connected" |
|
return |
|
fi |
|
|
|
if [ "$waited" -eq 0 ]; then |
|
printf '\nComplete any Tailscale login or macOS system-extension approval that appears.\n' |
|
printf 'This installer will wait for the connection.\n\n' |
|
fi |
|
|
|
sleep 2 |
|
waited=$((waited + 2)) |
|
done |
|
|
|
die "Tailscale did not connect within 10 minutes. Approve its system extension, sign in, and rerun the installer." |
|
} |
|
|
|
ensure_correct_tailnet() { |
|
local current_tailnet |
|
local login_name |
|
local json |
|
|
|
json="$(status_json)" |
|
current_tailnet="$(printf '%s' "$json" | jq -r '.CurrentTailnet.Name // empty')" |
|
login_name="$(printf '%s' "$json" | jq -r '.Self.UserID as $id | .User[($id | tostring)].LoginName // empty')" |
|
|
|
if [ "$current_tailnet" != "$TAILNET_NAME" ]; then |
|
warn "This Mac is connected to '$current_tailnet', not '$TAILNET_NAME'." |
|
open -a Tailscale >/dev/null 2>&1 || true |
|
if [ -t 0 ]; then |
|
printf 'Switch Tailscale to the %s tailnet, then press Return here: ' "$TAILNET_NAME" |
|
read -r _ |
|
else |
|
die "Switch Tailscale to the $TAILNET_NAME tailnet and rerun the installer." |
|
fi |
|
|
|
json="$(status_json)" |
|
current_tailnet="$(printf '%s' "$json" | jq -r '.CurrentTailnet.Name // empty')" |
|
[ "$current_tailnet" = "$TAILNET_NAME" ] || die "Tailscale is still connected to '$current_tailnet'." |
|
login_name="$(printf '%s' "$json" | jq -r '.Self.UserID as $id | .User[($id | tostring)].LoginName // empty')" |
|
fi |
|
|
|
success "Connected to $TAILNET_NAME as ${login_name:-a tailnet member}" |
|
} |
|
|
|
discover_server_ip() { |
|
local discovered |
|
local json |
|
|
|
json="$(status_json)" |
|
discovered="$(printf '%s' "$json" | jq -r --arg host "$SERVER_HOSTNAME" --arg dns "$SERVER_DNS_NAME" ' |
|
(.Peer // {}) |
|
| to_entries[] |
|
| .value |
|
| select((.HostName // "") == $host or (.DNSName // "") == $dns) |
|
| (.TailscaleIPs // [])[] |
|
| select(startswith("100.")) |
|
' | head -n 1)" |
|
|
|
if [ -n "$discovered" ]; then |
|
printf '%s' "$discovered" |
|
else |
|
warn "Could not discover $SERVER_HOSTNAME from Tailscale status; using the known fallback address." |
|
printf '%s' "$SERVER_FALLBACK_IP" |
|
fi |
|
} |
|
|
|
download_and_verify_ca() { |
|
local fingerprint |
|
|
|
WORK_DIR="$(mktemp -d)" |
|
CA_FILE="$WORK_DIR/klarc-rootCA.pem" |
|
|
|
log "Downloading the Klarc public certificate authority" |
|
/usr/bin/curl --proto '=https' --tlsv1.2 -fsSL "$CA_URL" -o "$CA_FILE" |
|
chmod 600 "$CA_FILE" |
|
|
|
fingerprint="$(/usr/bin/openssl x509 -in "$CA_FILE" -noout -fingerprint -sha256 | awk -F= '{print $2}' | tr -d ':[:space:]')" |
|
[ "$fingerprint" = "$CA_SHA256" ] || die "The downloaded CA fingerprint does not match the expected Klarc CA." |
|
success "Verified Klarc CA fingerprint" |
|
} |
|
|
|
verify_server_before_install() { |
|
local server_ip="$1" |
|
local site |
|
local code |
|
|
|
log "Checking the private sites over Tailscale" |
|
for site in "${SITES[@]}"; do |
|
code="$(/usr/bin/curl -sS --cacert "$CA_FILE" --connect-timeout 5 --max-time 20 --resolve "$site:443:$server_ip" -o /dev/null -w '%{http_code}' "https://$site/" || true)" |
|
case "$code" in |
|
2??|3??) success "$site is reachable (HTTP $code)" ;; |
|
*) die "$site is not reachable through $server_ip (HTTP ${code:-connection failure})." ;; |
|
esac |
|
done |
|
} |
|
|
|
install_ca() { |
|
local ca_name="mkcert agents@klarc" |
|
|
|
log "Installing the Klarc CA into the macOS System keychain" |
|
if sudo /usr/bin/security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$CA_FILE"; then |
|
success "Klarc CA installed and trusted" |
|
return |
|
fi |
|
|
|
if /usr/bin/security find-certificate -a -c "$ca_name" /Library/Keychains/System.keychain >/dev/null 2>&1; then |
|
warn "The Klarc CA already exists in the System keychain; continuing with strict verification." |
|
return |
|
fi |
|
|
|
die "Could not install the Klarc CA into the System keychain." |
|
} |
|
|
|
remove_managed_hosts_blocks() { |
|
local output_file="$1" |
|
local input_file="${2:-/etc/hosts}" |
|
|
|
/usr/bin/awk -v legacy_begin="$LEGACY_BEGIN" -v legacy_end="$LEGACY_END" -v managed_begin="$MANAGED_BEGIN" -v managed_end="$MANAGED_END" ' |
|
function is_klarc_host(host) { |
|
return host == "klarc.test" || host ~ /[.]klarc[.]test$/ || |
|
host == "cosmetic-labs.test" || host ~ /[.]cosmetic-labs[.]test$/ || |
|
host == "best-matcha.test" || host ~ /[.]best-matcha[.]test$/ || |
|
host == "pretext.test" || host ~ /[.]pretext[.]test$/ || |
|
host == "ergotrans.test" || host ~ /[.]ergotrans[.]test$/ || |
|
host == "guy-hoquet-paris-10.test" || host ~ /[.]guy-hoquet-paris-10[.]test$/ || |
|
host == "astra.test" || host ~ /[.]astra[.]test$/ || |
|
host == "centre-anti-douleur.test" || host ~ /[.]centre-anti-douleur[.]test$/ || |
|
host == "my-proof.test" || host ~ /[.]my-proof[.]test$/ |
|
} |
|
|
|
$0 == legacy_begin || $0 == managed_begin { skipping = 1; next } |
|
$0 == legacy_end || $0 == managed_end { skipping = 0; next } |
|
skipping { next } |
|
/^[[:space:]]*#/ || /^[[:space:]]*$/ { print; next } |
|
|
|
{ |
|
line = $0 |
|
comment = "" |
|
comment_position = index(line, "#") |
|
if (comment_position > 0) { |
|
comment = substr(line, comment_position) |
|
line = substr(line, 1, comment_position - 1) |
|
} |
|
|
|
field_count = split(line, fields, /[[:space:]]+/) |
|
address = "" |
|
host_count = 0 |
|
output = "" |
|
|
|
for (field_index = 1; field_index <= field_count; field_index++) { |
|
field = fields[field_index] |
|
if (field == "") { |
|
continue |
|
} |
|
if (address == "") { |
|
address = field |
|
output = field |
|
continue |
|
} |
|
if (!is_klarc_host(field)) { |
|
output = output " " field |
|
host_count++ |
|
} |
|
} |
|
|
|
if (address != "" && host_count > 0) { |
|
if (comment != "") { |
|
output = output " " comment |
|
} |
|
print output |
|
} |
|
} |
|
' "$input_file" > "$output_file" |
|
} |
|
|
|
install_hosts_fallback() { |
|
local server_ip="$1" |
|
local hosts_file="$WORK_DIR/hosts" |
|
local site |
|
|
|
warn "macOS did not use the .test resolver; installing a managed /etc/hosts fallback." |
|
remove_managed_hosts_blocks "$hosts_file" |
|
{ |
|
printf '\n%s\n' "$MANAGED_BEGIN" |
|
for site in "${SITES[@]}"; do |
|
printf '%s %s www.%s\n' "$server_ip" "$site" "$site" |
|
done |
|
printf '%s\n' "$MANAGED_END" |
|
} >> "$hosts_file" |
|
sudo /usr/bin/install -o root -g wheel -m 644 "$hosts_file" /etc/hosts |
|
} |
|
|
|
clean_stale_hosts_entries() { |
|
local hosts_file="$WORK_DIR/hosts-clean" |
|
|
|
remove_managed_hosts_blocks "$hosts_file" |
|
if ! /usr/bin/cmp -s /etc/hosts "$hosts_file"; then |
|
sudo /usr/bin/install -o root -g wheel -m 644 "$hosts_file" /etc/hosts |
|
success "Removed stale Klarc mappings from /etc/hosts" |
|
fi |
|
} |
|
|
|
flush_dns_cache() { |
|
sudo /usr/bin/dscacheutil -flushcache >/dev/null 2>&1 || true |
|
sudo /usr/bin/killall -HUP mDNSResponder >/dev/null 2>&1 || true |
|
} |
|
|
|
system_resolves_to_server() { |
|
local expected_ip="$1" |
|
local resolved_ip |
|
local attempt=0 |
|
|
|
while [ "$attempt" -lt 10 ]; do |
|
resolved_ip="$(/usr/bin/dscacheutil -q host -a name "${SITES[0]}" 2>/dev/null | awk '/ip_address:/ { print $2; exit }')" |
|
if [ "$resolved_ip" = "$expected_ip" ]; then |
|
return 0 |
|
fi |
|
sleep 1 |
|
attempt=$((attempt + 1)) |
|
done |
|
|
|
return 1 |
|
} |
|
|
|
configure_test_dns() { |
|
local server_ip="$1" |
|
local resolver_file="$WORK_DIR/test-resolver" |
|
local direct_dns |
|
|
|
direct_dns="$(/usr/bin/dig +short +time=2 +tries=2 @"$server_ip" "${SITES[0]}" A 2>/dev/null | head -n 1 || true)" |
|
|
|
if [ "$direct_dns" = "$server_ip" ]; then |
|
log "Configuring wildcard .test split DNS" |
|
sudo /bin/mkdir -p /etc/resolver |
|
{ |
|
printf 'domain test\n' |
|
printf 'nameserver %s\n' "$server_ip" |
|
printf 'port 53\n' |
|
printf 'timeout 2\n' |
|
} > "$resolver_file" |
|
sudo /usr/bin/install -o root -g wheel -m 644 "$resolver_file" /etc/resolver/test |
|
clean_stale_hosts_entries |
|
flush_dns_cache |
|
|
|
if system_resolves_to_server "$server_ip"; then |
|
success "Wildcard .test DNS resolves through $server_ip" |
|
return |
|
fi |
|
else |
|
warn "The tailnet DNS service at $server_ip did not answer as expected." |
|
fi |
|
|
|
install_hosts_fallback "$server_ip" |
|
flush_dns_cache |
|
system_resolves_to_server "$server_ip" || die "macOS still cannot resolve ${SITES[0]} to $server_ip." |
|
success "Current Klarc sites resolve through the managed fallback" |
|
} |
|
|
|
verify_final_access() { |
|
local site |
|
local code |
|
|
|
log "Running strict end-to-end HTTPS verification" |
|
for site in "${SITES[@]}"; do |
|
code="$(/usr/bin/curl -sS --connect-timeout 5 --max-time 20 -o /dev/null -w '%{http_code}' "https://$site/" || true)" |
|
case "$code" in |
|
2??|3??) success "https://$site/ works (HTTP $code)" ;; |
|
*) die "Strict HTTPS verification failed for $site (HTTP ${code:-connection failure}). Restart browsers and rerun the installer." ;; |
|
esac |
|
done |
|
} |
|
|
|
main() { |
|
local server_ip |
|
|
|
printf '\nKlarc private .test access installer\n' |
|
printf 'Tailscale + split DNS + trusted HTTPS\n\n' |
|
|
|
ensure_macos |
|
ensure_homebrew |
|
ensure_tailscale_running |
|
ensure_correct_tailnet |
|
|
|
server_ip="$(discover_server_ip)" |
|
success "Using Klarc server $server_ip" |
|
|
|
download_and_verify_ca |
|
verify_server_before_install "$server_ip" |
|
install_ca |
|
configure_test_dns "$server_ip" |
|
verify_final_access |
|
|
|
printf '\n\033[1;32mSetup complete.\033[0m\n' |
|
printf 'Available sites:\n' |
|
printf ' https://%s/\n' "${SITES[@]}" |
|
printf '\nNo auth key, admin API token, SSH access, or private certificate key was installed.\n' |
|
} |
|
|
|
main "$@" |