Last active
February 19, 2022 07:43
-
-
Save legale/a2b871b2c60b1202864a219a710d90d9 to your computer and use it in GitHub Desktop.
wg-quick without bin/bash busybox /bin/sh is enough
This file contains 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/sh | |
#set -x | |
WG_CONFIG="" | |
INTERFACE="" | |
ADDRESSES="" | |
MTU="" | |
DNS="" | |
DNS_SEARCH="" | |
TABLE="" | |
PRE_UP="" | |
POST_UP="" | |
PRE_DOWN="" | |
POST_DOWN="" | |
SAVE_CONFIG=1 | |
CONFIG_FILE="" | |
PROGRAM="${0##*/}" | |
ARGS="$*" | |
RED='\033[0;31m' | |
GREEN='\033[1;32m' | |
YELLOW='\033[1;33m' | |
BLUE='\033[1;34m' | |
NC='\033[0m' # No Color | |
undo_changes(){ | |
# log "abort()" | |
log_red "Error! Undoing changes..." | |
cmd_down force | |
log "Changes undone." | |
} | |
parse_options() { | |
# log "parse_options()" | |
local VAL | |
local NAMES | |
local NAME | |
local BLOCK="global" | |
CONFIG_FILE=$1 | |
local CONFIG_FILE_=$1 | |
[ -e $CONFIG_FILE ] || CONFIG_FILE="/etc/wireguard/$CONFIG_FILE.conf" | |
[ -e $CONFIG_FILE ] || die "'$CONFIG_FILE_' does not exist\n'$CONFIG_FILE' does not exist" | |
INTERFACE=$(basename "$CONFIG_FILE_" | cut -d'.' -f1) | |
[ $(echo $CONFIG_FILE | grep -o "^.\+\.conf$") ] || die "The config file must be a valid interface name, followed by .conf" | |
#log_start "Reading configuration file..." | |
while read -r LINE_; do | |
CONTINUE=1 | |
if [ ! -z "$LINE_" ] && [ $(echo "$LINE_" | cut -c 1) != "#" ]; then #check if not empty and not comment line | |
#block name parse | |
BLOCK_=$(echo "$LINE_" | grep -o "\[.\+\]" | tr -d "[]" | tr "[a-z]" "[A-Z]") #find pattern [.+] get BLOCK name without quotes | |
[ ! -z "$BLOCK_" ] && BLOCK="$BLOCK_" # set BLOCK name | |
#echo "BLOCK:$BLOCK line:$LINE" | |
#param, value parse | |
if [ -z "$BLOCK_" ]; then #if this line is not a BLOCK | |
NAME=$(echo -n $LINE_ | cut -f1 -d " " | tr "[a-z]" "[A-Z]") #split first part before space and make uppercase | |
VAL=$(echo -n $LINE_ | grep -o "=.\+" | cut -c 3-) #cut from 3 char after first = | |
BNAME="${BLOCK}_${NAME}" | |
eval "$BNAME"="\"$VAL\"" | |
NAMES="$NAMES$BNAME#" | |
fi | |
#interface block special params parse | |
if [ "$BLOCK" == "INTERFACE" ]; then | |
case "$NAME" in | |
ADDRESS) | |
VAL=$(echo -e $VAL | tr -d " " | tr -s "," "#") | |
if [ -z "$ADDRESSES" ]; then ADDRESSES="$VAL"; else ADDRESSES="$ADDRESSES#$VAL"; fi; CONTINUE=0 ;; | |
MTU) | |
if [ -z "$MTU" ]; then MTU="$VAL"; else MTU="$MTU#$VAL"; fi; CONTINUE=0 ;; | |
DNS) | |
VAL=$(echo -e $VAL | tr -d " " | tr -s "," "#") | |
echo $VAL | grep "^[0-9.]\+$" >/dev/null 2>&1 | |
if [ $? ]; then | |
if [ -z "$DNS" ]; then DNS="$VAL"; else DNS="$DNS#$VAL"; fi | |
else | |
if [ -z "$DNS_SEARCH" ]; then DNS_SEARCH="$VAL"; else DNS_SEARCH="$DNS_SEARCH#$VAL"; fi | |
fi | |
CONTINUE=0 ;; | |
TABLE) if [ -z "$TABLE" ]; then TABLE="$VAL"; else TABLE="$TABLE#$VAL"; fi; CONTINUE=0 ;; | |
PREUP) if [ -z "$PRE_UP" ]; then PRE_UP="$VAL"; else PRE_UP="$PRE_UP#$VAL"; fi; CONTINUE=0 ;; | |
PREDOWN) if [ -z "$PRE_DOWN" ]; then PRE_DOWN="$VAL"; else PRE_DOWN="$PRE_DOWN#$VAL"; fi; CONTINUE=0 ;; | |
POSTUP) if [ -z "$POST_UP" ]; then POST_UP="$VAL"; else POST_UP="$POST_UP#$VAL"; fi; CONTINUE=0 ;; | |
POSTDOWN) if [ -z "$POST_DOWN" ]; then POST_DOWN="$VAL"; else POST_DOWN="$POST_DOWN#$VAL"; fi; CONTINUE=0 ;; | |
SAVECONFIG) read_bool SAVE_CONFIG "$VAL"; CONTINUE=0 ;; | |
esac | |
fi | |
[ "$CONTINUE" == 0 ] && continue #trick to skip loop from case construction | |
fi | |
#wg only configuration params (stripped config) | |
WG_CONFIG="$WG_CONFIG$LINE_\n" | |
done < $CONFIG_FILE | |
# log_end "ok" | |
# log "Your configuration is:" | |
local IFS="#" | |
for N in $NAMES; do | |
local VALUE=$(eval echo "\${$N}") #this will expand $N variable and get it value | |
# printf "${YELLOW}param:${NC} %-40.40s ${YELLOW}value:${NC} %s\n" $N $VALUE | |
done | |
} | |
read_bool() { | |
# log "read_bool()" | |
case "$2" in | |
true) eval "$1"=1 ;; | |
false) eval "$1"=0 ;; | |
*) die "'\$2' is neither true nor false" | |
esac | |
} | |
auto_su() { | |
# log "auto_su()" | |
#log_start "Checking root access..." | |
if [ ! -z $UID ] && [ $UID != "0" ]; then #if UID is not unset and not 0 | |
[ ! $(which sudo >/dev/null) ] && die "$PROGRAM must be run as root but sudo is not installed!" | |
[ exec sudo -p "$PROGRAM must be run as root. Please enter the password for %u to continue: " -- /bin/sh -- "$0" "$*" ] | |
fi | |
#log_end "ok" | |
} | |
add_if() { | |
#log "add_if()" | |
local RET | |
# log "Adding interface $INTERFACE..." | |
cmd "ip link add $INTERFACE type wireguard" | |
if [ $? != 0 ]; then | |
local RET=$? | |
[ -e /sys/module/wireguard ] || [ ! command -v "${WG_QUICK_USERSPACE_IMPLEMENTATION:-wireguard-go}" >/dev/null ] && exit $RET | |
echo "[!] Missing WireGuard kernel module. Falling back to slow userspace implementation." >&2 | |
cmd "${WG_QUICK_USERSPACE_IMPLEMENTATION:-wireguard-go}" "$INTERFACE" | |
fi | |
} | |
del_if() { | |
# log "del_if()" | |
# log "Removing interface $INTERFACE..." | |
cmd "ip link delete dev $INTERFACE" | |
} | |
add_addr() { | |
# log "add_addr()" | |
local PROTO=-4 | |
echo -n "$1" | grep ":" >/dev/null && PROTO=-6 | |
cmd "ip $PROTO address add $1 dev $INTERFACE" || die "Unable to set ip address!" | |
return $? | |
} | |
set_mtu_up() { | |
# log "mtu_set()" | |
local ENDPOINT ENDPOINTS OUTPUT | |
if [ $MTU ]; then | |
cmd "ip link set mtu $MTU up dev $INTERFACE" | |
else | |
cmd "ip link set mtu $(( 1500 - 80 )) up dev $INTERFACE" | |
fi | |
return $? | |
} | |
set_dns() { | |
# log "set_dns()" | |
local SERVER | |
local IFS="#" | |
for SERVER in $DNS; do | |
cmd "echo \"nameserver $SERVER\" >> /etc/resolv.conf" | |
done | |
for SERVER in $DNS_SEARCH; do | |
cmd "echo \"search $SERVER\" >> /etc/resolv.conf" | |
done | |
return $? | |
} | |
unset_dns() { | |
# log "unset_dns()" | |
local SERVER | |
local TMP="/tmp/resolv.tmp.conf" | |
local IFS='#' | |
for SERVER in $DNS; do | |
cmd "cat /etc/resolv.conf | grep -v \"nameserver $SERVER\" > $TMP" | |
cmd "cat $TMP > /etc/resolv.conf" | |
done | |
for SERVER in $DNS_SEARCH; do | |
cmd "cat /etc/resolv.conf | grep -v \"search $SERVER\" > /tmp/resolv.tmp.conf" | |
cmd "cat $TMP > /etc/resolv.conf" | |
done | |
} | |
add_route() { | |
# log "add_route()" | |
local PROTO=-4 | |
echo -n "$1" | grep ":" >/dev/null && PROTO=-6 | |
[ "$TABLE" == "off" ] && return 0 | |
if [ -n "$TABLE" ] && [ "$TABLE" != "auto" ]; then | |
cmd "ip $PROTO route add $1 dev $INTERFACE table $TABLE" | |
elif [ $(echo -n "$1" | grep "/0") ]; then | |
add_default "$1" | |
else | |
cmd "ip $PROTO route add $1 dev $INTERFACE" | |
fi | |
} | |
get_min_rule_pref(){ | |
local PROTO=$2 | |
WG_PREF=$(ip $PROTO rule show | cut -d ':' -f 1 | grep "[^0]" | head -n 1) | |
eval "$1"=$((WG_PREF-1)) | |
} | |
bypass_route(){ | |
log_green "Trying to create bypass route" | |
local PROTO=$1 | |
local TABLE=$2 | |
local BYPASS_TABLE=$((TABLE+1)) | |
get_min_rule_pref WG_PREF | |
local MAIN_PREF=$((WG_PREF-1)) | |
log_green "Copying 'ip route' main table to the new bypass table $BYPASS_TABLE..." | |
ip_ro_copy_tab_skip_def $PROTO main $BYPASS_TABLE | |
cmd "ip $PROTO rule add from all table $BYPASS_TABLE pref $MAIN_PREF" | |
cmd "ip $PROTO rule add not fwmark 51820 table 51820 pref $WG_PREF" | |
} | |
ip_ro_copy_tab_skip_def(){ | |
local IFS=$'\n' | |
local PROTO=$1; local SRC=$2; local DST=$3 | |
for LINE in $(ip $PROTO route show table $SRC); do | |
[ $(echo $LINE | grep "^default") ] && continue | |
ip_ro_line $LINE | |
ip_ro_add "$PROTO" "$NET" "$VIA" "$DEV" "$METRIC" "$DST" | |
done | |
} | |
ip_ro_line(){ | |
NET=$(echo $LINE | cut -d ' ' -f 1) | |
VIA=""; DEV=""; METRIC=""; | |
local LINE=$1 | |
local ELSE=$(echo $LINE | cut -d ' ' -f 2-) | |
local IFS=' ' | |
local i=2 | |
for E in $ELSE; do | |
eval "E$i"="$E" | |
i=$(($i+1)) | |
done | |
local i=3 | |
for E in $ELSE; do | |
case $E in | |
"via") eval "VIA"="\$E$i" ;; | |
"dev") eval "DEV"="\$E$i" ;; | |
"metric") eval "METRIC"="\$E$i" ;; | |
esac | |
i=$(($i+1)) | |
done | |
} | |
ip_ro_add(){ | |
local PROTO; local NET; local VIA; local DEV; local METRIC; local TAB; | |
( [ -z "$1" ] || [ -z "$2" ] ) && log "ip_ro_add args error '$1' '$2'" && return 1 | |
PROTO=$1 | |
NET=$2 | |
[ -n "$3" ] && VIA="via $3" | |
[ -n "$4" ] && DEV="dev $4" | |
[ -n "$5" ] && METRIC="metric $5" | |
[ -n "$6" ] && TAB="table $6" | |
cmd "ip route add $NET $VIA $DEV $METRIC $TAB" | |
} | |
add_default() { | |
# log "add_default()" | |
get_ro_table_fwmark "TABLE" | |
[ $? ] && cmd "wg set $INTERFACE fwmark $TABLE" | |
local PROTO=-4 | |
echo -n "$1" | grep ":" >/dev/null && PROTO=-6 | |
local IPTABLES="iptables"; | |
[ "$PROTO" == "-6" ] && IPTABLES="ip6tables" | |
#ip routes and rules | |
cmd "ip $PROTO route add $1 dev $INTERFACE table $TABLE" || ( undo_changes && die "Unable to add route! Program aborted." ) | |
cmd "ip $PROTO rule add not fwmark $TABLE table $TABLE" || ( undo_changes && die "Unable to add rule! Program aborted." ) | |
cmd "ip $PROTO rule add table main suppress_prefixlength 0" || bypass_route "$PROTO" "$TABLE" | |
#iptables | |
get_dev_ip IP $PROTO $INTERFACE | |
if [ -n "$IP" ]; then | |
cmd "$IPTABLES -t raw -A PREROUTING ! -i $INTERFACE -d $IP -m addrtype ! --src-type LOCAL -j DROP" | |
cmd "$IPTABLES -t mangle -A POSTROUTING -m mark --mark $TABLE -p udp -j CONNMARK --save-mark" | |
cmd "$IPTABLES -t mangle -A PREROUTING -p udp -j CONNMARK --restore-mark" | |
fi | |
[ "$PROTO" == "-4" ] && cmd "sysctl -q net.ipv4.conf.all.src_valid_mark=1" | |
return 0 | |
} | |
get_dev_ip(){ | |
IP="" | |
local LINE A | |
local PROTO=$2 | |
local INTERFACE=$3 | |
log_yellow "[#] ip -o $PROTO address show dev $INTERFACE" | |
LINE=$(ip -o $PROTO address show dev $INTERFACE 2>/dev/null) | |
A=$(echo $LINE | grep -io "inet[6]\? [a-z0-9.:]\+" | cut -d " " -f 2) | |
[ -n "$A" ] || return 1 | |
eval "$1=$A" | |
return 0 | |
} | |
remove_firewall() { | |
# log "remove_firewall()" | |
get_ro_table_fwmark "TABLE" | |
local BYPASS_TABLE=$((TABLE+1)) | |
for PROTO in -4 -6; do | |
local MAIN_PREF=$((WG_PREF-1)) | |
while ip $PROTO rule show | grep "lookup $TABLE" >/dev/null 2>&1; do | |
cmd "ip $PROTO rule delete table $TABLE" | |
done | |
while ip $PROTO rule show | grep "from all lookup main suppress_prefixlength 0" >/dev/null 2>&1; do | |
cmd "ip $PROTO rule delete table main suppress_prefixlength 0" | |
done | |
#legacy ip routes and rules related to bypass table | |
while ip $PROTO rule show | grep "from all lookup $BYPASS_TABLE" >/dev/null 2>&1; do | |
cmd "ip $PROTO rule delete from all table $BYPASS_TABLE" | |
done | |
while [ -n "$(ip $PROTO route show table $BYPASS_TABLE 2>/dev/null)" ]; do | |
cmd "ip $PROTO route flush table $BYPASS_TABLE" | |
done | |
done | |
local IPTABLES="iptables" | |
if type -p iptables >/dev/null; then | |
for PROTO in -4 -6; do | |
get_dev_ip IP $PROTO $INTERFACE | |
if [ -n "$IP" ]; then | |
[ "$PROTO" == -6 ] && IPTABLES="ip6tables" | |
cmd "$IPTABLES -t raw -D PREROUTING ! -i $INTERFACE -d $IP -m addrtype ! --src-type LOCAL -j DROP" | |
cmd "$IPTABLES -t mangle -D POSTROUTING -m mark --mark $TABLE -p udp -j CONNMARK --save-mark" | |
cmd "$IPTABLES -t mangle -D PREROUTING -p udp -j CONNMARK --restore-mark" | |
fi | |
done | |
fi | |
} | |
get_ro_table_fwmark() { | |
# log "get_ro_table_fwmark()" | |
local FWMARK | |
FWMARK=$(wg show $INTERFACE fwmark) | |
if [ "$FWMARK" != "off" ]; then | |
FWMARK=$(printf "%d" $FWMARK) | |
eval "$1"=$(printf "%d" $FWMARK) | |
return 0 | |
fi | |
FWMARK=51820 | |
while [ -n "$(ip -4 route show table $FWMARK 2>/dev/null)" ] || \ | |
[ -n "$(ip -6 route show table $FWMARK 2>/dev/null)" ]; do | |
FWMARK=$((FWMARK+1)) | |
done | |
eval "$1=$FWMARK" | |
return 1 | |
} | |
set_config() { | |
# log "set_config()" | |
local TMP="/tmp/${INTERFACE}.tmp.conf" | |
echo -e "$WG_CONFIG" > "$TMP" | |
#log "Configuring interface $INTERFACE..." | |
cmd "wg setconf "$INTERFACE" $TMP" || die "Unable to set configuration $TMP. Program Terminated." | |
rm -f "$TMP" | |
} | |
save_config() { | |
# log "save_config()" | |
local i=0 | |
local VAL | |
local NEW_ADDR | |
local CONFIG_FILE="/etc/wireguard/$INTERFACE.conf" | |
local TMP="$CONFIG_FILE.tmp" | |
local OLD_UMASK=$(umask) | |
umask 077 | |
local WG_CONF_FILE="$CONFIG_FILE.wg.tmp" | |
wg showconf "$INTERFACE" >$WG_CONF_FILE | |
printf "[Interface]\n" >$TMP | |
for VAL in $(ip address show dev optzona | grep -io "inet[6]\? [a-z0-9.:]\+" | cut -d " " -f 2); do | |
printf "Address = %s\n" $VAL >>$TMP | |
done | |
[ -n "$DNS" ] && for VAL in $DNS; do echo "DNS = $VAL">>$TMP; done | |
[ -n "$DNS_SEARCH" ] && for VAL in $DNS_SEARCH; do echo "DNS = $VAL">>$TMP; done | |
[ -n "$MTU" ] && MTU=$(ip link show dev $INTERFACE | grep -o "mtu [0-9]*" | grep -o '[0-9]*') \ | |
&& echo "MTU = $MTU" >>$TMP | |
[ -n "$TABLE" ] && echo "Table = $TABLE" >>$TMP | |
[ "$SAVE_CONFIG" == 0 ] && echo "SaveConfig = true" >>$TMP | |
local IFS="#" | |
[ -n "$PRE_UP" ] && for VAL in $PRE_UP; do echo "PreUp = $VAL" >>$TMP; done | |
[ -n "$POST_UP" ] && for VAL in $POST_UP; do echo "PostUp = $VAL" >>$TMP; done | |
[ -n "$PRE_DOWN" ] && for VAL in $PRE_DOWN; do echo "PreDown = $VAL" >>$TMP; done | |
[ -n "$POST_DOWN" ] && for VAL in $POST_DOWN; do echo "PostDown = $VAL" >>$TMP; done | |
cat "$WG_CONF_FILE" | grep -iv "\[interface\]" >>$TMP | |
rm -f "$WG_CONF_FILE" | |
mv "$TMP" "$CONFIG_FILE" || die "Could not move configuration file $TMP to $CONFIG_FILE" | |
umask "$OLD_UMASK" | |
} | |
execute_hooks() { | |
# log "execute_hooks()" | |
local HOOK | |
local IFS="#" | |
[ -z "$*" ] && return 0 | |
for HOOK in $*; do | |
HOOK="${HOOK//%i/$INTERFACE}" | |
cmd "$HOOK" | |
done | |
} | |
cmd_usage() { | |
cat >&2 <<-_EOF | |
Usage: $PROGRAM [ up | down | save | strip ] [ CONFIG_FILE | INTERFACE ] | |
CONFIG_FILE is a configuration file, whose filename is the interface name | |
followed by \`.conf'. Otherwise, INTERFACE is an interface name, with | |
configuration found at /etc/wireguard/INTERFACE.conf. It is to be readable | |
by wg(8)'s \`setconf' sub-command, with the exception of the following additions | |
to the [Interface] section, which are handled by $PROGRAM: | |
- Address: may be specified one or more times and contains one or more | |
IP addresses (with an optional CIDR mask) to be set for the interface. | |
- DNS: an optional DNS server to use while the device is up. | |
- MTU: an optional MTU for the interface; if unspecified, auto-calculated. | |
- Table: an optional routing table to which routes will be added; if | |
unspecified or \`auto', the default table is used. If \`off', no routes | |
are added. | |
- PreUp, PostUp, PreDown, PostDown: script snippets which will be executed | |
by sh(1) at the corresponding phases of the link, most commonly used | |
to configure DNS. The string \`%i' is expanded to INTERFACE. | |
- SaveConfig: if set to \`true', the configuration is saved from the current | |
state of the interface upon shutdown. | |
See wg-quick(8) for more info and examples. | |
_EOF | |
return $? | |
} | |
cmd_usage_short() { | |
cat >&2 <<-_EOF | |
Usage: $PROGRAM [ up | down | save | strip ] [ CONFIG_FILE | INTERFACE ] | |
Try '$PROGRAM --help' for more information. | |
_EOF | |
return $? | |
} | |
is_interface() { | |
ip link show dev $1 >/dev/null 2>&1 | |
return $? | |
} | |
is_wg_interface () { | |
wg show interfaces | grep $1 >/dev/null 2>&1 | |
return $? | |
} | |
cmd_up() { | |
# log "cmd_up()" | |
if is_interface "$INTERFACE"; then | |
die "Interface $INTERFACE already exist" | |
fi | |
execute_hooks "$PRE_UP" | |
add_if | |
set_config | |
local i IP | |
local IFS="#" | |
for i in $ADDRESSES; do | |
add_addr "$i" | |
done | |
set_mtu_up | |
set_dns | |
IFS=$'\n\t ' | |
for i in $(wg show "$INTERFACE" allowed-ips); do | |
IP=$(echo "$i" | grep "^[0-9a-z.:/]\+$") | |
[ $IP ] && add_route "$IP" | |
done | |
execute_hooks "$POST_UP" | |
trap - INT TERM EXIT | |
return $? | |
} | |
cmd_down() { | |
# log "cmd_down()" | |
[ "$1" != "force" ] && ! is_wg_interface $INTERFACE && \ | |
die "interface '$INTERFACE' does not exist, or not a WireGuard interface" | |
execute_hooks "$PRE_DOWN" | |
[ $SAVE_CONFIG == 0 ] && save_config | |
remove_firewall | |
unset_dns | |
del_if | |
execute_hooks "$POST_DOWN" | |
return $? | |
} | |
cmd_save() { | |
# log "cmd_save()" | |
if ! is_wg_interface $INTERFACE; then | |
die "interface '$INTERFACE' does not exist, or not a WireGuard interface" | |
fi | |
save_config | |
return $? | |
} | |
cmd_strip() { | |
# log "cmd_strip()" | |
echo -e "$WG_CONFIG" | |
return $? | |
} | |
die() { | |
# log "die()" | |
echo -e "${RED}$PROGRAM: $*${NC}" >&2 | |
exit 1 | |
} | |
cmd() { | |
log_yellow "[#] $1" | |
eval "$1" >&2 | |
} | |
log() { | |
echo -e "$1$2" >&2 | |
} | |
log_yellow() { | |
echo -e "${YELLOW}${1}${NC}$2" >&2 | |
} | |
log_red() { | |
echo -e "${RED}${1}${NC}$2" >&2 | |
} | |
log_green() { | |
echo -e "${GREEN}${1}${NC}$2" >&2 | |
} | |
log_start() { | |
printf "${YELLOW}%-50.50s${NC}$2" "$1" >&2 | |
} | |
log_end() { | |
printf "%s$2\n" "$1" >&2 | |
} | |
# ~~ main routing point ~~ | |
[ "$#" == 1 ] && case $1 in | |
"--help") cmd_usage ;; | |
"help") cmd_usage ;; | |
*) cmd_usage_short; exit 1 ;; | |
esac && exit 0 | |
[ "$#" == 2 ] && case $1 in | |
"up") auto_su; parse_options "$2"; cmd_up ;; | |
"down") auto_su; parse_options "$2"; cmd_down ;; | |
"save") auto_su; parse_options "$2"; cmd_save ;; | |
"strip") auto_su; parse_options "$2"; cmd_strip ;; | |
*) cmd_usage_short; exit 1 ;; | |
esac && exit 0 | |
cmd_usage_short | |
exit 1 | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment