Skip to content

Instantly share code, notes, and snippets.

@ochafik

ochafik/hl Secret

Last active August 16, 2021 20:29
Show Gist options
  • Select an option

  • Save ochafik/dee717dc508e9e92d3a8743fb9b911df to your computer and use it in GitHub Desktop.

Select an option

Save ochafik/dee717dc508e9e92d3a8743fb9b911df to your computer and use it in GitHub Desktop.
Hardlink: access devices from anywhere, securely.
#!/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