#!/usr/bin/env bash
#
# sclink.sh -- bring a caller into Skull Crash over SCLink, from any board that
# runs a STDIO door, with bash, curl and openssl.
#
# Design: the game's Vault, "Remote BBS Door Design 2026-10". The board proves
# itself with an HMAC over a one-time challenge, gets a single-use ticket, and
# this script connects the caller -- over TLS at level "tls" (the default,
# via `openssl s_client`) -- and pipes the door's stdin/stdout to the game.
#
#   sclink.sh <config> <user number> [rows] [cols]
#   sclink.sh --selftest
#
# <config> is a shell file with the values the game's owner issued you:
#
#   SCLINK_BOARD=DMB
#   SCLINK_SECRET=<the 64 hex characters issued for this board>
#   SCLINK_HOST=game.example.org
#   SCLINK_AUTH_PORT=4443
#   SCLINK_PLAY_PORT=4514
#   SCLINK_LEVEL=tls            # tls | tls-auth | plain
#   SCLINK_CA=                  # optional: a CA file to trust
#   SCLINK_VERIFY=true          # false is the last-resort loosening
#
# $SCLINK_CALLER, if the board sets it, is the caller's IP (sent only over TLS).
# The secret is a password: keep the config private. Needs bash, curl and
# OpenSSL 1.0.2+ (for -verify_hostname / -verify_ip).

set -u

VECTOR_SECRET=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
VECTOR_MAC=f33b142e92a2f05c5dea0a5c8e47c1783d9c9a8a86af5172946298949d668a9d

# The lines, in order, joined with \n, ending in END: never a trailing newline,
# so $(...) cannot eat one.
signed_string() { # alg board user challenge rows cols term caller
    printf 'SCLINK1\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\nEND' "$@"
}

# Hex digits -> printf escapes (\xNN), so bash's BUILTIN printf can emit bytes.
hex_to_escapes() {
    local hex=$1 out="" i
    for ((i = 0; i < ${#hex}; i += 2)); do out+="\\x${hex:i:2}"; done
    printf '%s' "$out"
}

# HMAC-SHA256. The key is the ASCII TEXT of the 64-hex secret -- 64 bytes, one
# SHA-256 block exactly, so it needs neither hashing nor padding.
#
# Built by hand rather than `openssl dgst -hmac "$secret"`: that would put the
# board's secret on openssl's command line, where any local user's `ps` can read
# it. Here the key only ever passes through bash's builtin printf.
mac() { # secret signed-string
    local key=$1 ipad="" opad="" i c byte inner
    # printf -v, never $(...): no fork per byte (a fork is slow under MSYS).
    for ((i = 0; i < 64; i++)); do
        printf -v c '%d' "'${key:i:1}"
        printf -v byte '\\x%02x' $((c ^ 0x36))
        ipad+=$byte
        printf -v byte '\\x%02x' $((c ^ 0x5c))
        opad+=$byte
    done
    inner=$({ printf "$ipad"; printf '%s' "$2"; } | openssl dgst -sha256 | sed 's/^.*= //')
    { printf "$opad"; printf "$(hex_to_escapes "$inner")"; } | openssl dgst -sha256 | sed 's/^.*= //'
}

self_test() {
    [ "$(mac "$VECTOR_SECRET" "$(signed_string sha256 DMB 1234 00112233445566778899aabbccddeeff '' '' '' '')")" = "$VECTOR_MAC" ]
}

# What the caller may be shown: printable ASCII, at most 60 characters.
sanitise() { printf '%s' "$1" | tr -cd '\040-\176' | cut -c1-60; }

say() { printf '\r\n%s\r\n' "$(sanitise "$1")"; }

if [ "${1:-}" = "--selftest" ]; then
    if self_test; then echo "sclink.sh self-test: PASS"; exit 0; fi
    echo "sclink.sh self-test: FAIL"
    exit 1
fi

if [ $# -lt 2 ]; then
    echo "usage: sclink.sh <config> <user number> [rows] [cols]" >&2
    exit 2
fi

# shellcheck disable=SC1090
. "$1"
USERNUM=$2
ROWS=${3:-}
COLS=${4:-}
LEVEL=${SCLINK_LEVEL:-tls}
VERIFY=${SCLINK_VERIFY:-true}

problem=""
[[ "${SCLINK_BOARD:-}" =~ ^[A-Z0-9]{2,6}$ ]] || problem="SCLINK_BOARD must be 2-6 of A-Z / 0-9"
[[ "${SCLINK_SECRET:-}" =~ ^[0-9a-f]{64}$ ]] || problem="SCLINK_SECRET must be the 64 hex characters issued"
[ -n "${SCLINK_HOST:-}" ] || problem="SCLINK_HOST is required"
[[ "$USERNUM" =~ ^[0-9]{1,12}$ ]] || problem="the user NUMBER must be digits"
case "$LEVEL" in tls | tls-auth | plain) ;; *) problem="SCLINK_LEVEL must be tls, tls-auth or plain" ;; esac
# Windows builds of `openssl s_client` read stdin from the console, never from a
# pipe, so a door's input would never reach the game (measured 2026-10-23 with
# OpenSSL 1.0.2 under MSYS: not one byte sent). Use sclink.py there instead.
if [ "$LEVEL" = tls ]; then
    case "$(uname -s 2>/dev/null)" in
        MINGW* | MSYS* | CYGWIN*)
            problem="level tls needs openssl s_client to read a pipe, which Windows builds cannot: use sclink.py, or level tls-auth"
            ;;
    esac
fi
if [ -n "$problem" ] || ! self_test; then
    echo "sclink.sh: ${problem:-the built-in MAC self-test failed}" >&2
    say "Skull Crash is not set up on this board yet. Tell your sysop."
    exit 1
fi

# An optional field that does not fit its syntax is sent empty.
[[ "$ROWS" =~ ^[0-9]{2,3}$ ]] && [ "$ROWS" -le 255 ] || ROWS=""
[[ "$COLS" =~ ^[0-9]{2,3}$ ]] && [ "$COLS" -le 255 ] || COLS=""
CALLER=""
if [ "$LEVEL" != plain ] && [[ "${SCLINK_CALLER:-}" =~ ^[0-9a-fA-F:.]{2,45}$ ]]; then
    CALLER=$SCLINK_CALLER
fi

if [ "$LEVEL" = plain ]; then
    SCHEME=http
    CURL_TLS=()
else
    SCHEME=https
    CURL_TLS=()
    [ -n "${SCLINK_CA:-}" ] && CURL_TLS+=(--cacert "$SCLINK_CA")
    [ "$VERIFY" = false ] && CURL_TLS+=(-k)
fi
BASE="$SCHEME://$SCLINK_HOST:$SCLINK_AUTH_PORT/sclink/v1"

# "OK <value>" -> the value, status 0. Anything else -> the line for the caller,
# status 1. Both on stdout, because this runs inside $(...): printing to the
# caller from in here would be captured, never shown.
reply_value() {
    local line=${1%%$'\r'*}
    line=${line%%$'\n'*}
    case "$line" in
        "OK "*) printf '%s' "${line#OK }" ;;
        "ERR "*) printf '%s' "${line#ERR * }"; return 1 ;;
        *) printf '%s' "Skull Crash is unreachable right now."; return 1 ;;
    esac
}

reply=$(curl -s --max-time 10 ${CURL_TLS[@]+"${CURL_TLS[@]}"} "$BASE/challenge?board=$SCLINK_BOARD") || {
    say "Skull Crash is unreachable right now."
    exit 1
}
CHALLENGE=$(reply_value "$reply") || { say "$CHALLENGE"; exit 1; }
[[ "$CHALLENGE" =~ ^[0-9a-f]{32}$ ]] || { say "Connection error. Tell your sysop."; exit 1; }

MAC=$(mac "$SCLINK_SECRET" "$(signed_string sha256 "$SCLINK_BOARD" "$USERNUM" "$CHALLENGE" "$ROWS" "$COLS" "" "$CALLER")")
HEADERS=(
    -H "X-SC-Board: $SCLINK_BOARD"
    -H "X-SC-User: $USERNUM"
    -H "X-SC-Challenge: $CHALLENGE"
    -H "X-SC-Alg: sha256"
    -H "X-SC-Mac: $MAC"
    -H "X-SC-Client: generic-sh/1"
)
[ -n "$ROWS" ] && HEADERS+=(-H "X-SC-Rows: $ROWS")
[ -n "$COLS" ] && HEADERS+=(-H "X-SC-Cols: $COLS")
[ -n "$CALLER" ] && HEADERS+=(-H "X-SC-Caller: $CALLER")

reply=$(curl -s --max-time 10 ${CURL_TLS[@]+"${CURL_TLS[@]}"} "${HEADERS[@]}" "$BASE/ticket") || {
    say "Skull Crash is unreachable right now."
    exit 1
}
TICKET=$(reply_value "$reply") || { say "$TICKET"; exit 1; }
[[ "$TICKET" =~ ^[0-9a-f]{32}$ ]] || { say "Connection error. Tell your sysop."; exit 1; }

# The play connection: RLOGIN framing, then the door's stdin/stdout.
if [ "$LEVEL" = tls ]; then
    S_CLIENT=(openssl s_client -quiet -connect "$SCLINK_HOST:$SCLINK_PLAY_PORT")
    if [ "$VERIFY" != false ]; then
        S_CLIENT+=(-verify_return_error)
        if [[ "$SCLINK_HOST" =~ ^[0-9.]+$|: ]]; then
            S_CLIENT+=(-verify_ip "$SCLINK_HOST")
        else
            S_CLIENT+=(-servername "$SCLINK_HOST" -verify_hostname "$SCLINK_HOST")
        fi
        [ -n "${SCLINK_CA:-}" ] && S_CLIENT+=(-CAfile "$SCLINK_CA")
    fi
    # -no_ign_eof: the caller hanging up (stdin ends) closes the game session.
    # The input runs in a process substitution so this script ends as soon as
    # s_client does -- the game ending the session -- without waiting on it.
    S_CLIENT+=(-no_ign_eof)
    "${S_CLIENT[@]}" 2>/dev/null < <(
        printf '\0%s\0%s\0ansi\0' "$USERNUM" "$TICKET"
        cat
    )
else
    exec 3<>"/dev/tcp/$SCLINK_HOST/$SCLINK_PLAY_PORT" || {
        say "Skull Crash is unreachable right now."
        exit 1
    }
    printf '\0%s\0%s\0ansi\0' "$USERNUM" "$TICKET" >&3
    # Both directions in the background; whichever ends first -- the caller
    # hanging up, or the game ending the session -- ends the other.
    cat <&3 &
    reader=$!
    cat 0<&0 >&3 &
    writer=$!
    wait -n
    kill "$reader" "$writer" 2>/dev/null
    exec 3>&-
fi
