-
-
Save ochafik/dee717dc508e9e92d3a8743fb9b911df to your computer and use it in GitHub Desktop.
Hardlink: access devices from anywhere, securely.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # | |
| # Hardlink: access your devices from anywhere, securely. | |
| # Ideal for IoT devices, RPis, webcams, etc. | |
| # | |
| # Install: | |
| # curl https://gist.githubusercontent.com/ochafik/dee717dc508e9e92d3a8743fb9b911df/raw/73e5f990f576abf3dd5279fc39e7de6d93856840/hl -o hl && chmod +x ./hl | |
| # | |
| # TODO: bid on syml.ink, permal.ink, hard.link | |
| # buy ssh.link | |
| # | |
| # TODO: add timestamp and expiration to signature format | |
| # payload= {timestamp};{expirationTimestamp};{message...} | |
| # text= {version};{base64(payload)};{base64(signature(payload))} | |
| # | |
| # TODO: support other dyndns services | |
| # | |
| # ./hl advertise ssh:tcp:22 | |
| # ./hl letsencrypt # Then can serve files w/ express.js | |
| # ./hl domain pizo.local | |
| # ./hl ssh [email protected] | |
| # ./hl curl -XPUT http://hardl.ink/dns/A/$( ./hl fingerprint ) -d '[{"target": "80.80.80.1"}]' -H 'Content-Type: application/json' | |
| # ./hl set somevalue | |
| # ./hl get picam.local somevalue # uses key from known_hosts | |
| # ./hl encrypt-for picam.local somevalue # send encrypted value | |
| # ./hl decrypt-from picam.local somevalue # receive decrypted value | |
| set -eu | |
| readonly MAX_TXT_RECORD_LENGTH=255 | |
| readonly private_keyfile=${PRIVATE_KEYFILE:-~/.ssh/id_rsa} | |
| readonly public_keyfile="${private_keyfile}.pub" | |
| readonly dns_host="${DNS_HOST:-ochafik.xyz}" | |
| # readonly dns_host="${DNS_HOST:-hardl.ink}" | |
| readonly upnp_duration_seconds=$(( 3600 * 24 * 365 )) | |
| function curl_with_cert() { | |
| # Note: the certificate is sent during handshake, so don't send personally-identifiable information in it | |
| # (e.g. no real hostname in the subject). | |
| readonly CERT_SUBJECT=${CERT_SUBJECT:-/C=/ST=/L=/O=/OU=/CN=localhost/} | |
| if ! test -f "${private_keyfile}"; then | |
| echo "${private_keyfile} not found, please create a key. | |
| Here are suggestions offering a compromise of compatibility & security (ed25519 and ecdsa/521bits are poorly supported by openssl and/or cURL, while node-sshpk won't work with ecdsa): | |
| ssh-keygen -m PEM -t rsa -b 8192 -q -N '' -f ~/.ssh/id_rsa | |
| ssh-keygen -m PEM -t ecdsa -b 384 -q -N '' -f ~/.ssh/id_ecdsa | |
| " >&2 | |
| exit 1 | |
| fi | |
| if ! openssl pkey -in "${private_keyfile}" -outform pem >/dev/null 2>&1 ; then | |
| echo "OpenSSL failed to read the private key. Try and update it to PEM and retry: | |
| ssh-keygen -p -m PEM -f '${private_keyfile}' -P '\$PASSPHRASE' -N '\$PASSPHRASE'" >&2 | |
| exit 1 | |
| fi | |
| readonly KEY=$( openssl pkey -in "${private_keyfile}" -outform pem ) | |
| readonly CSR=$( openssl req -new -key <( echo "${KEY}" ) -subj "${CERT_SUBJECT}" 2>/dev/null ) | |
| readonly CERT=$( echo "${CSR}" | openssl x509 -req -signkey <( echo "${KEY}" ) -days 1 2>/dev/null ) | |
| curl \ | |
| --cacert <( echo "${CERT}" ) \ | |
| --cert <( echo "${CERT}" ) \ | |
| --key <( echo "${KEY}" ) \ | |
| "$@" | |
| } | |
| function fingerprint_keyfile() { | |
| local keyfile="$1" | |
| ssh-keygen -l -f "${keyfile}" | \ | |
| awk '{ printf "%s", $2 }' | \ | |
| openssl dgst -sha256 | \ | |
| sed 's/(stdin)= //' | |
| } | |
| function fingerprint_key() { | |
| local key="$1" | |
| fingerprint_keyfile <( echo "${key}" ) | |
| } | |
| function fingerprint_self() { | |
| fingerprint_keyfile "${public_keyfile}" | |
| } | |
| function resolve() { | |
| local arg="$1" | |
| if [[ -z "${arg}" ]]; then | |
| echo "Please provide an identity argument (e.g. localhost or any known hostname from ~/.ssh/known_hosts)" >&2 | |
| exit 1 | |
| fi | |
| if [[ "${arg}" == "localhost" ]]; then | |
| cat "${public_keyfile}" # Resolve empty string to self. | |
| return 0 | |
| fi | |
| local candidates="$( grep "${arg}" ~/.ssh/known_hosts )" | |
| local partial_matches=() | |
| while IFS=' ' read -r hostnames key; do | |
| found=0 | |
| for hostname in $( tr "," "\n" <<< "${hostnames}" | sort ); do | |
| if [[ "${hostname}" == "${arg}" ]]; then | |
| found=1 | |
| break | |
| fi | |
| if grep "${arg}" <<< "${hostname}" >/dev/null; then | |
| partial_matches+=( "${hostname}" ) | |
| fi | |
| done | |
| if (( $found )); then | |
| echo "${key}" | |
| return 0 | |
| fi | |
| done <<< "${candidates}" | |
| # echo "${candidates}" >&2 | |
| echo "Failed to resolve ${arg}." >&2 | |
| if [[ "${#partial_matches[@]}" -gt 0 ]]; then | |
| echo -e "Partial matches:" >&2 | |
| printf " %s\n" "${partial_matches[@]}" >&2 | |
| # ( IFS="\n" echo -e "Partial matches:\n${partial_matches[*]}" >&2 ) | |
| fi | |
| return 1 | |
| } | |
| # resolve "$1" || echo "NOOOOOES" | |
| # exit | |
| function split_label() { | |
| local fp="$( cat )" | |
| echo "${fp:0:32}.${fp:32}" | |
| } | |
| function split_txt_records() { | |
| local full_text="$( cat )" | |
| for (( i = 0; i < ${#full_text}; i += MAX_TXT_RECORD_LENGTH )); do | |
| echo "${full_text:i:MAX_TXT_RECORD_LENGTH}" | |
| done | |
| } | |
| function getRaw() { | |
| # TODO: make the signed the default (old/get.sh)! | |
| key="$1" | |
| curl "https://${dns_host}/public/$( fingerprint_self )/${key}" | |
| } | |
| function setRaw() { | |
| # TODO: make the signed the default (old/set.sh)! | |
| key="$1" | |
| value="$2" | |
| curl_with_cert "https://${dns_host}/public/$( fingerprint_self )/${key}" -X PUT -H "Content-Type: text/plain" -d "${value}" | |
| } | |
| function domain() { | |
| local identity="${1:-localhost}" | |
| key="$( resolve "${identity}" )" | |
| echo "$( fingerprint_key "${key}" | split_label ).${dns_host}" | |
| } | |
| function setDns() { | |
| local key="$1" | |
| local url="https://${dns_host}/dns/TXT/$( fingerprint_self )/${key}" | |
| if [[ "$#" -eq 1 ]]; then | |
| curl_with_cert -X DELETE "${url}" | |
| else | |
| local message="$2" | |
| local signature="$( \ | |
| openssl dgst -sha256 \ | |
| -sign "${private_keyfile}" \ | |
| <( echo -n "${message}" ) | \ | |
| base64 | |
| )" | |
| local full_text="1;$( echo "${message}" | base64 );${signature}" | |
| local split_text="$( echo "$full_text" | split_txt_records )" | |
| local records=() | |
| while IFS= read -r line; do | |
| records+=( "{ \"target\": \"${line}\" }" ) | |
| done <<< "${split_text}" | |
| curl_with_cert \ | |
| -X POST "${url}" \ | |
| -H "Content-Type: application/json" \ | |
| -d "[$( IFS=','; echo "${records[*]}" )]" | |
| fi | |
| } | |
| function getDns() { | |
| local identity="$1" | |
| local key="$2" | |
| local public_key="$( resolve "${identity}" )" | |
| local full_text="$( \ | |
| dig "${key}.$( domain "${identity}" )" TXT +short +tcp | \ | |
| tr -d '\n' | tr -d '"' \ | |
| )" | |
| if [[ -z "${full_text}" ]]; then | |
| echo "Value not found!" >&2 | |
| exit 1 | |
| fi | |
| IFS=';' read version message64 signature64 <<< "${full_text}" | |
| if [[ ! "$version" -eq 1 ]]; then | |
| echo "Invalid format version ($version)" >&2 | |
| exit 1 | |
| fi | |
| local message="$( echo "${message64}" | base64 -d )" | |
| # node -e "require('sshpk').parseKey(require('fs').readFileSync(0, 'utf-8'), 'ssh').toString('pem')" <<< "${public_keyfile}" | |
| # local public_key="$( ssh-keygen -f <( echo "${public_key}" ) -e -m PKCS8 )" | |
| echo -n "${message}" | \ | |
| openssl dgst -sha256 \ | |
| -verify <( ssh-keygen -f <( echo "${public_key}" ) -e -m PKCS8 ) \ | |
| -signature <( echo -n "${signature64}" | base64 -d ) >/dev/null || ( | |
| echo "Signature verification failed: the message was tampered with!" >&2 | |
| return 1 | |
| ) | |
| echo "$message" | |
| } | |
| function extract_proto_port_subdomain_from_upnpc_list() { | |
| egrep "TCP|UDP" | sed -E "s/.*(TCP|UDP)[ \t]+([0-9]+)->[^']*'(_[^']+\\._[^']+)\\.[^']+'.*$/\1:\2:\3/" | |
| } | |
| function upnp_discovery_url() { | |
| upnpc -l | grep desc: | sed 's/.*desc: //' | |
| } | |
| function advertise() { | |
| local local_ip="$( ( hostname -I 2>/dev/null || ipconfig getifaddr en0 ) | tr -d " " )" | |
| local host="$( curl_with_cert https://${dns_host}/advertise 2>/dev/null )" | |
| local fingerprint="$( fingerprint_self )" | |
| local truncated_fingerprint="${fingerprint:0:32}" | |
| local discovery_url="$( upnp_discovery_url )" | |
| echo "# Advertised as ${host}" | |
| debug_commands=( "dig ${host} +short" ) | |
| for service_desc in "$@"; do | |
| IFS=':' read service protocol port requested_external_port <<< "${service_desc}" | |
| subdomain="$( tr '[:upper:]' '[:lower:]' <<< "_${service}._${protocol}" )" | |
| service_host="${subdomain}.${host}" | |
| upper_protocol="$( tr '[:lower:]' '[:upper:]' <<< "${protocol}" )" | |
| comment="${subdomain}.${truncated_fingerprint}" | |
| function get_external_port_mapped() { | |
| match="$( upnpc -u "${discovery_url}" -l | grep "${comment}" | extract_proto_port_subdomain_from_upnpc_list )" | |
| if [[ -n "$match" ]]; then | |
| # echo "match: $match" >&2 | |
| IFS=: read match_proto match_port match_subdomain <<< "${match}" | |
| echo "$match_port" | |
| fi | |
| } | |
| existing_external_port="$( get_external_port_mapped )" | |
| for attempt in {1..5}; do | |
| if [[ -n "${requested_external_port:-}" ]]; then | |
| if [[ -n "${existing_external_port}" ]]; then | |
| upnpc -d "${existing_external_port}" "${upper_protocol}" 2>/dev/null || true | |
| fi | |
| external_port="${requested_external_port}" | |
| elif [[ -n "${existing_external_port}" ]]; then | |
| external_port="${existing_external_port}" | |
| else | |
| external_port=$(( $RANDOM + 10000 )) # RANDOM itself is 0-32767 | |
| fi | |
| echo "# Advertising ${service} (${protocol}) ${local_ip}:${port} as ${host}:${external_port}" >&2 | |
| # https://manpages.debian.org/unstable/miniupnpc/upnpc.1.en.html | |
| upnpc \ | |
| -u "${discovery_url}" \ | |
| -e "${comment}" \ | |
| -a "${local_ip}" "${port}" "${external_port}" "${upper_protocol}" "${upnp_duration_seconds}" >/dev/null | |
| # -r "${port}" "${external_port}" "${upper_protocol}" # "${upnp_duration_seconds}" | |
| if [[ -z "$( get_external_port_mapped )" ]]; then | |
| echo "# Failed to open external port ${external_port} (attempt ${attempt}). Trying again in a sec." >&2 | |
| sleep 1 | |
| existing_external_port="" | |
| continue | |
| fi | |
| curl_with_cert \ | |
| -X POST "https://${dns_host}/dns/SRV/${fingerprint}/${subdomain}" \ | |
| -H "Content-Type: application/json" \ | |
| -d "[{ | |
| \"target\": \"${host}\", | |
| \"port\": \"${external_port}\" | |
| }]" >/dev/null 2>&1 | |
| debug_commands+=( "dig ${service_host} SRV +short" ) | |
| break | |
| done | |
| done | |
| debug_commands+=( "upnpc -u ${discovery_url} -l" ) | |
| echo "# Can display mappings with: | |
| " >&2 | |
| for cmd in "${debug_commands[@]}"; do | |
| echo " $cmd" >&2 | |
| done | |
| echo "" >&2 | |
| } | |
| function hide() { | |
| local fingerprint="$( fingerprint_self )" | |
| local truncated_fingerprint="${fingerprint:0:32}" | |
| local discovery_url="$( upnp_discovery_url )" | |
| echo "# Unadvertizing on ${dns_host}" >&2 | |
| curl_with_cert -X DELETE https://${dns_host}/advertise >/dev/null 2>&1 || true | |
| matching_lines="$( \ | |
| upnpc -u "${discovery_url}" -l | \ | |
| grep "${truncated_fingerprint}" | \ | |
| extract_proto_port_subdomain_from_upnpc_list \ | |
| )" | |
| if [[ -n "${matching_lines}" ]]; then | |
| while IFS=: read upper_protocol port subdomain ; do | |
| echo "# Closing mapped port ${port} (${subdomain})" >&2 | |
| upnpc -u "${discovery_url}" -d "${port}" "${upper_protocol}" >/dev/null 2>&1 || true | |
| curl_with_cert \ | |
| "https://${dns_host}/dns/SRV/${fingerprint}/${subdomain}" \ | |
| -X DELETE >/dev/null 2>&1 || true | |
| done <<< "${matching_lines}" | |
| fi | |
| } | |
| function print_usage() { | |
| echo "Usage: $0 command args... | |
| Commands: | |
| domain {host} | |
| Displays the subdomain associated to that host under ${dns_host}. | |
| It may not be resolve to any IP if the host hasn't called \`$0 advertise\`. | |
| advertise {service1:proto1:port1}* | |
| Registers this host's external IP on ${dns_host}'s DNS and displays the domain generated for it. | |
| Each argument opens a random port using UPNP on the local router, maps it to the provided | |
| port, and registers a corresponding SRV record on the DNS. | |
| Example: \`$0 advertise ssh:tcp:22\` | |
| hide | |
| Unregisters this host and all its services from ${dns_host}'s DNS (undoes the advertise command). | |
| set {key} {value} | |
| Assigns a value to the provided key in this host's namespace. | |
| delete {key} | |
| Deletes any value associated to the provided key in this host's namespace. | |
| get {host} {key} | |
| Gets the value associated to the provided key in a host's namespace. | |
| pubkey {host} | |
| Returns the public key of the known host provided, looking up in ~/.ssh/known_hosts. | |
| Example: \`$0 pubkey raspberrypi.local\` | |
| " >&2 | |
| } | |
| if [[ "$#" -eq 0 ]]; then | |
| print_usage | |
| exit 1 | |
| fi | |
| command="$1" | |
| shift 1 | |
| case "${command}" in | |
| fingerprint) fingerprint_self ;; | |
| curl) curl_with_cert "$@" ;; | |
| domain) domain "$@" ;; | |
| resolve) resolve "$@" ;; | |
| set) setDns "$@" ;; | |
| get) getDns "$@" ;; | |
| advertise | advertize) advertise "$@" ;; | |
| hide | unadvertise | unadvertize) hide "$@" ;; | |
| letsencrypt | ssh | scp ) | |
| echo "TODO: get certificate for *.$( fingerprint_self | split_label ).${dns_host}" >&2 | |
| exit 1 | |
| ;; | |
| get-raw) getRaw "$@" ;; | |
| set-raw) setRaw "$@" ;; | |
| *) | |
| echo "Unsupported command: ${command}" >&2 | |
| print_usage | |
| exit 1 | |
| ;; | |
| esac |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment