What's new

Full Control Over VPN Configuration Via Merlin Web Interface (HOWTO)

  • SNBForums Code of Conduct

    SNBForums is a community for everyone, no matter what their level of experience.

    Please be tolerant and patient of others, especially newcomers. We are all here to share and learn!

    The rules are simple: Be patient, be nice, be helpful or be gone!

Matteo Guglielmi

Regular Contributor
I wanted to assign static VPN IP address directly from the Merlin web interface and I ended up with a tool which you may also find useful for managing your ASUS routers.

Here is the router configuration:

Code:
Administration -> System -> Enable JFFS custom scripts and configs [*] Yes [ ] No


Here is the installation script (my router IP is associated to rtn66u in /etc/hosts):

Code:
#==============================================================================#

                                   install.sh

#==============================================================================#

#!/bin/bash

scp helper.sh helper.txt openvpnserver1.postconf admin@rtn66u:/jffs/scripts/

ssh -x admin@rtn66u chmod 755 /jffs/scripts/openvpnserver1.postconf
 
Last edited:
Here are the files to be uploaded into the jffs folder.

As you can see there is also a little functions' usage sample page for you to see how the new functions work... but I suggest you also play with them for a bit in case you are not familiar with sed's regular expression).

BTW, I've slightly changed the name of the functions so you can source both the original /usr/sbin/helper.sh and /jffs/scripts/helper.sh files.

Part #1/2

Code:
#==============================================================================#

          helper.sh (developed from the original /usr/sbin/helper.sh)

#==============================================================================#

#!/bin/sh

################################################################################
#                                                                              #
# Asuswrt-Merlin helper functions. For use with Postconf scripts (and others)  #
#                                                                              #
################################################################################

_quote() { echo $1 | sed 's/[]\/()$*.^|[]/\\&/g'; }

_assert_var_is_set() { eval [ \${$1+X} ]; }

_assert_var_is_not_set() { eval [ -z \${$1+X} ]; }

_assert_var_is_empty() { eval [ -z \${$1:+X} ]; }

_assert_var_is_not_empty() { eval [ \${$1:+X} ]; }

_assert_var_is_equal_to() { eval [ \"\$$1\" == \"\$2\" ]; }

_assert_var_is_not_equal_to() { eval [ \"\$$1\" != \"\$2\" ]; }

_random_string() { echo $(</dev/urandom tr -cd 'a-zA-Z' | head -c $1); }

################################################################################
#                                                                              #
# insert_line PATTERN NEWLINE FILE                                             #
#                                                                              #
################################################################################
#                                                                              #
# This function looks for a string (first argument), and inserts a specified   #
# string (second argument) into a new line (after each matching line), inside  #
# a given file (third argument).                                               #
#                                                                              #
# This function will modify the given file only if the INLINE global/external  #
# variable is undefined or set to 1. In all other cases i.e. when it is        #
# defined but empty or set to any value different from 1, the output of the    #
# function is sent to the standard output (useful to create new files).        #
#                                                                              #
# This function will treat the first argument as a regular expression/pattern  #
# only if the REGEX global/external variable is set to 1. In all other cases   #
# i.e. when it is undefined, defined but empty or set to any value different   #
# from 1, sed will not treat the first argument as a regular expression.       #
#                                                                              #
# PATTERN: the line to locate                                                  #
# NEWLINE: the line to insert                                                  #
# FILE:    file where to insert                                                #
#                                                                              #
# USAGE:   /jffs/scripts/helper.txt                                            #
#                                                                              #
################################################################################

insert_line() {
  local PATTERN=$(_quote "$1")
  local CONTENT=$(_quote "$2")
  local FILE=$(readlink -f $3)
  local OPTS=

  _assert_var_is_equal_to REGEX 1 && PATTERN=$1

  _assert_var_is_not_set INLINE || _assert_var_is_equal_to INLINE 1 && OPTS='-i'

  sed $OPTS "/$PATTERN/a$CONTENT" $FILE
}

################################################################################
#                                                                              #
# remove_line PATTERN FILE                                                     #
#                                                                              #
################################################################################
#                                                                              #
# This function looks for a string (first argument) and removes the            #
# corresponding line inside a given file (second argument).                    #
#                                                                              #
# This function will modify the given file only if the INLINE global/external  #
# variable is undefined or set to 1. In all other cases i.e. when it is        #
# defined but empty or set to any value different from 1, the output of the    #
# function is sent to the standard output (useful to create new files).        #
#                                                                              #
# This function will treat the first argument as a regular expression/pattern  #
# only if the REGEX global/external variable is set to 1. In all other cases   #
# i.e. when it is undefined, defined but empty or set to any value different   #
# from 1, sed will not treat the first argument as a regular expression.       #
#                                                                              #
# PATTERN: the line to locate                                                  #
# FILE:    file where to insert                                                #
#                                                                              #
# USAGE:   /jffs/scripts/helper.txt                                            #
#                                                                              #
################################################################################

remove_line() {
  local PATTERN=$(_quote "$1")
  local FILE=$(readlink -f $2)
  local OPTS=

  _assert_var_is_equal_to REGEX 1 && PATTERN=$1

  _assert_var_is_not_set INLINE || _assert_var_is_equal_to INLINE 1 && OPTS='-i'

  sed $OPTS "/$PATTERN/d" $FILE
}
 
Last edited:
Part #2/2 (paste me after Part #1/2)


Code:
################################################################################
#                                                                              #
# replace_string PATTERN NEWSTRING FILE                                        #
#                                                                              #
################################################################################
#                                                                              #
# This function looks for a string (first argument), and replaces it with a    #
# new string (second argument) inside a given file (third argument).           #
#                                                                              #
# This function will modify the given file only if the INLINE global/external  #
# variable is undefined or set to 1. In all other cases i.e. when it is        #
# defined but empty or set to any value different from 1, the output of the    #
# function is sent to the standard output (useful to create new files).        #
#                                                                              #
# This function will treat the first argument as a regular expression/pattern  #
# only if the REGEX global/external variable is set to 1. In all other cases   #
# i.e. when it is undefined, defined but empty or set to any value different   #
# from 1, sed will not treat the first argument as a regular expression.       #
#                                                                              #
# PATTERN:   the string to locate                                              #
# NEWSTRING: the string to replace with                                        #
# FILE:      file where to insert                                              #
#                                                                              #
# USAGE:     /jffs/scripts/helper.txt                                          #
#                                                                              #
################################################################################

replace_string() {
  local PATTERN=$(_quote "$1")
  local CONTENT=$(_quote "$2")
  local FILE=$(readlink -f $3)
  local OPTS=

  _assert_var_is_equal_to REGEX 1 && PATTERN=$1 && CONTENT=$2

  _assert_var_is_not_set INLINE || _assert_var_is_equal_to INLINE 1 && OPTS='-i'

  sed $OPTS "s/$PATTERN/$CONTENT/" $FILE
}

################################################################################
#                                                                              #
# append_line NEWLINE FILE                                                     #
#                                                                              #
################################################################################
#                                                                              #
# This function will append a given line (first argument) at the end of a      #
# given file (second argument).                                                #
#                                                                              #
# This function will modify the given file only if the INLINE global/external  #
# variable is undefined or set to 1. In all other cases i.e. when it is        #
# defined but empty or set to any value different from 1, the output of the    #
# function is sent to the standard output (useful to create new files).        #
#                                                                              #
# NEWLINE: the line to append at the end                                       #
# FILE:    file where to append                                                #
#                                                                              #
# USAGE:   /jffs/scripts/helper.txt                                            #
#                                                                              #
################################################################################

append_line() {
  local CONTENT=$1
  local FILE=$(readlink -f $2)

  _assert_var_is_not_set INLINE || _assert_var_is_equal_to INLINE 1 && {
    echo "$CONTENT" >> $FILE
    return
  }

  cat $FILE
  echo "$CONTENT"
}

################################################################################
#                                                                              #
# prepend_line NEWLINE FILE                                                    #
#                                                                              #
################################################################################
#                                                                              #
# This function will prepend a given line (first argument) at the top of a     #
# given file (second argument).                                                #
#                                                                              #
# This function will modify the given file only if the INLINE global/external  #
# variable is undefined or set to 1. In all other cases i.e. when it is        #
# defined but empty or set to any value different from 1, the output of the    #
# function is sent to the standard output (useful to create new files).        #
#                                                                              #
# NEWLINE: the line to prepend at the top                                      #
# FILE:    file where to append                                                #
#                                                                              #
# USAGE:   /jffs/scripts/helper.txt                                            #
#                                                                              #
################################################################################

prepend_line() {
  local CONTENT=$1
  local FILE=$(readlink -f $2)
  local TMPFILE=/dev/shm/$(_random_string 10)
  local OPTS=

  _assert_var_is_not_set INLINE || _assert_var_is_equal_to INLINE 1 && {
    echo "$CONTENT" > $TMPFILE
    cat $FILE >> $TMPFILE
    /bin/mv $TMPFILE $FILE
    return
  }

  echo "$CONTENT"
  cat $FILE
}
 
Last edited:
Code:
#==============================================================================#

                                   helper.txt

#==============================================================================#

###############
#             #
# insert_line #
#             #
###############

# cat config.ovpn

daemon
topology subnet
server 10.27.100.0 255.255.255.0
proto udp
push "route 10.33.222.0 255.255.255.0"
cert server.crt
key server.key
status status

# INLINE=0 REGEX=0 insert_line server NEWLINE config.ovpn

daemon
topology subnet
server 10.27.100.0 255.255.255.0
NEWLINE
proto udp
push "route 10.33.222.0 255.255.255.0"
cert server.crt
NEWLINE
key server.key
NEWLINE
status status

# INLINE=0 REGEX=0 insert_line '^server' NEWLINE config.ovpn

daemon
topology subnet
server 10.27.100.0 255.255.255.0
proto udp
push "route 10.33.222.0 255.255.255.0"
cert server.crt
key server.key
status status

# INLINE=0 REGEX=1 insert_line '^server' NEWLINE config.ovpn

daemon
topology subnet
server 10.27.100.0 255.255.255.0
NEWLINE
proto udp
push "route 10.33.222.0 255.255.255.0"
cert server.crt
key server.key
status status

# INLINE=0 REGEX=1 insert_line '[0-9][ \t]*[0-9]' NEWLINE config.ovpn

daemon
topology subnet
server 10.27.100.0 255.255.255.0
NEWLINE
proto udp
push "route 10.33.222.0 255.255.255.0"
NEWLINE
cert server.crt
key server.key
status status

###############
#             #
# remove_line #
#             #
###############

# cat config.ovpn

daemon
topology subnet
server 10.27.100.0 255.255.255.0
proto udp
push "route 10.33.222.0 255.255.255.0"
cert server.crt
key server.key
status status

# INLINE=0 REGEX=0 remove_line server config.ovpn

daemon
topology subnet
proto udp
push "route 10.33.222.0 255.255.255.0"
status status

# INLINE=0 REGEX=1 remove_line '^server' config.ovpn

daemon
topology subnet
proto udp
push "route 10.33.222.0 255.255.255.0"
cert server.crt
key server.key
status status

##################
#                #
# replace_string #
#                #
##################

# cat config.ovpn

daemon
topology subnet
server 10.27.100.0 255.255.255.0
proto udp
push "route 10.33.222.0 255.255.255.0"
cert server.crt
key server.key
status status

# INLINE=0 REGEX=0 replace_string server NEWSTRING config.ovpn

daemon
topology subnet
NEWSTRING 10.27.100.0 255.255.255.0
proto udp
push "route 10.33.222.0 255.255.255.0"
cert NEWSTRING.crt
key NEWSTRING.key
status status

# INLINE=0 REGEX=0 replace_string server '\1 10.55.140.0 255.255.0.0' config.ovpn

daemon
topology subnet
\1 10.55.140.0 255.255.0.0 10.27.100.0 255.255.255.0
proto udp
push "route 10.33.222.0 255.255.255.0"
cert \1 10.55.140.0 255.255.0.0.crt
key \1 10.55.140.0 255.255.0.0.key
status status

# INLINE=0 REGEX=1 replace_string '\(^server\).*' '\1 10.55.140.0 255.255.0.0' config.ovpn

daemon
topology subnet
server 10.55.140.0 255.255.0.0
proto udp
push "route 10.33.222.0 255.255.255.0"
cert server.crt
key server.key
status status

# INLINE=0 REGEX=1 replace_string '\(route\)[ \t]*\([0-9.]*\)[ \t]*\([0-9.]*\)' '\3 \2 \1' config.ovpn

daemon
topology subnet
server 10.27.100.0 255.255.255.0
proto udp
push "255.255.255.0 10.33.222.0 route"
cert server.crt
key server.key
status status

###############
#             #
# append_line #
#             #
###############

# cat config.ovpn

daemon
topology subnet
server 10.27.100.0 255.255.255.0
proto udp
push "route 10.33.222.0 255.255.255.0"
cert server.crt
key server.key
status status

# INLINE=0 append_line NEWLINE config.ovpn

daemon
topology subnet
server 10.27.100.0 255.255.255.0
proto udp
push "route 10.33.222.0 255.255.255.0"
cert server.crt
key server.key
status status
NEWLINE

################
#              #
# prepend_line #
#              #
################

# cat config.ovpn

daemon
topology subnet
server 10.27.100.0 255.255.255.0
proto udp
push "route 10.33.222.0 255.255.255.0"
cert server.crt
key server.key
status status

# INLINE=0 prepend_line NEWLINE config.ovpn

NEWLINE
daemon
topology subnet
server 10.27.100.0 255.255.255.0
proto udp
push "route 10.33.222.0 255.255.255.0"
cert server.crt
key server.key
status status
 
Code:
#==============================================================================#

                            openvpnserver1.postconf

#==============================================================================#

#!/bin/sh

#####################
# CONTROL VARIABLES #
#####################

DRYRUN=1

#############
# FUNCTIONS #
#############

grepScript() { sed -n '/<script>/,/<\/script>/{/<script>/d;/<\/script>/d;p}' $1; }

commentScript() { sed -i '/<script>/,/<\/script>/s/^/#/' $1; }

removeScript() { sed -i '/<script>/,/<\/script>/d' $1; }

assertScript() { grep -q '<script>' $1; }

removeCtrlM() { sed -i 's/\r//g' $1; }

######################
# INTERNAL VARIABLES #
######################

CONFIG=$(readlink -f $1)

TMPDIR=/dev/shm/ovpnsrv1

SCRIPT=$TMPDIR/${CONFIG##*/}.x

########
# MAIN #
########

removeCtrlM $CONFIG # firmware-generated config file is polluted by ^M characters

# extract custom script

if assertScript $CONFIG; then
  mkdir -p $TMPDIR || exit

  grepScript $CONFIG > $SCRIPT

  case $DRYRUN in
    0) removeScript $CONFIG ; MODE=755 ;;
    *) commentScript $CONFIG ; MODE=644 ;;
  esac

  chmod $MODE $SCRIPT
fi

# run custom script

[ -x $SCRIPT ] && $SCRIPT $CONFIG


NOTE: the openvpnserver1.postconf script can also be modified in case you need, for example, to pass extra parameters to $SCRIPT or anything else.

Finally, here is the script which configures the vpn server for static (and dynamic) IP addresses... but it can be customized to do whatever you want, directly from the web interface.

IMPORTANT NOTES:

  • The script can be inserted anywhere into the Custom Configuration area under the Advanced VPN Settings (an example is reported here below).
  • The script must be enclosed between the two tags <script></script> in order to be extracted from the config.ovpn file by the openvpnserver1.postconf script.
  • The script is launched by openvpnserver1.postconf with the absolute path of the config.ovpn file as its first argument (see bottom line of openvpnserver1.postconf).
  • The script will only be executed when DRYRUN is set to 0 in the openvpnserver1.postconf file.

The DRYRUN variable set to 1 is a very useful option when you want to do your own testing and debugging directly on the router (especially during the early stages of the development of your custom scripts). Note that the openvpnserver1.postconf script will always extract the custom script from the config file, no matter the value of DRYRUN. You will always find the custom script written into the /dev/shm/ovpnsrv1 folder. In this way, you can always login into router, make a copy of config.ovpn in /home/root for instance, and run the script on the copy (/dev/shm/ovpnsrv1/config.ovpn.x /home/root/config.ovpn).

Code:
#Custom Configuration Area (just an example)

push "route 10.77.220.0 255.255.255.0"
push "route 10.78.21.0 255.255.255.0"

<script>                    <<<=== SCRIPT BEGINS HERE / any text is allowed on this line after the opening tag
#/bin/sh

CONFIG=$(readlink -f $1)

source /jffs/scripts/helper.sh

INLINE=1
REGEX=1

insert_line '^server[ \t]\+' 'push "topology subnet"' $CONFIG
insert_line '^server[ \t]\+' 'push "route-gateway 10.48.111.1"' $CONFIG
insert_line '^server[ \t]\+' 'ifconfig-pool 10.48.111.100 10.48.111.200 255.255.255.0' $CONFIG
insert_line '^server[ \t]\+' 'ifconfig 10.48.111.1 255.255.255.0' $CONFIG
insert_line '^server[ \t]\+' 'tls-server' $CONFIG
insert_line '^server[ \t]\+' 'mode server' $CONFIG

remove_line '^server[ \t]\+' $CONFIG

CCD=/dev/shm/ovpnsrv1/ccd

mkdir -p $CCD

append_line "client-config-dir $CCD" $CONFIG

echo ifconfig-push 10.48.111.10 255.255.255.0 > $CCD/newmachine

chmod 644 $CCD/*
</script>                      <<<=== SCRIPT ENDS HERE / any text is allowed on this line after the closing tag

remote-cert-tls client
push "route 10.28.220.0 255.255.255.0"
push "route 10.12.21.0 255.255.255.0"


Ok, this is what there is to it.
 
Last edited:
I just want to add the procedure I normally use to create additional certificates signed by the router's CA with unique common names (useful to assign static VPN IPs / per-host vpn configuration).

Here it is:

Code:
#####################################################
# Add Client Certificates Signed by the Router's CA #
#####################################################

setuprsa.sh

cd /tmp/easy-rsa

vi vars

>export KEY_COUNTRY="TW"
>export KEY_PROVINCE="TW"
>export KEY_CITY="Taipei"
>export KEY_ORG="ASUS"
>export KEY_EMAIL="me@myhost.mydomain"
>export KEY_OU="ASUSWRT-MERLIN"

source vars

./clean-all

cp /etc/openvpn/server1/ca.* ./keys

CN=newmachine # CommonName

./build-key $CN

# Accept all the default values and answer 'y' to the last 2 questions:
#
# Country Name (2 letter code) [TW]:
# State or Province Name (full name) [TW]:
# Locality Name (eg, city) [Taipei]:
# Organization Name (eg, company) [ASUS]:
# Organizational Unit Name (eg, section) [ASUSWRT-MERLIN]:
# Common Name (eg, your name or your server's hostname) [newmachine]:
# Name [EasyRSA]:
# Email Address [me@myhost.mydomain]:
#
# A challenge password []:
# An optional company name []:
#
# Sign the certificate? [y/n]:y
# 1 out of 1 certificate requests certified, commit? [y/n]y

{ echo "<ca>"
  cat keys/ca.crt | grep -A 100 "BEGIN" | grep -B 100 "END"
  echo "</ca>"
  echo "<cert>"
  cat keys/$CN.crt | grep -A 100 "BEGIN" | grep -B 100 "END"
  echo "</cert>"
  echo "<key>"
  cat keys/$CN.key | grep -A 100 "BEGIN" | grep -B 100 "END"
  echo "</key>"
  echo "<tls-auth>"
  cat /etc/openvpn/server1/static.key | grep -A 100 "BEGIN" | grep -B 100 "END"
  echo "</tls-auth>"; }

cp /etc/openvpn/server1/client.ovpn /etc/openvpn/server1/newmachine.ovpn

vi /etc/openvpn/server1/newmachine.ovpn

# Replace the <ca></ca>, <cert></cert>, <key></key> and <tls-auth></tls-auth> sections.
#
# Actually, only the <cert></cert> and <key></key> sections should be replaced by the
# corresponding new ones for the other two should not have changed.
 
Last edited:
I think I might have give this a go at some point if not just to delve into your great looking code. Nice.
 
I wanted to assign static VPN IP address directly from the Merlin web interface and I end up with a tool which you may also find useful for managing your ASUS routers.

It's really good of you to make your work public. Lacking your coding skills, which I admire, I'm unable to work out how this might be useful to me. Could you possibly upload a screenshot showing the resulting modified web GUI page?
 
It's really good of you to make your work public. Lacking your coding skills, which I admire, I'm unable to work out how this might be useful to me. Could you possibly upload a screenshot showing the resulting modified web GUI page?

There is no change of the web GUI page at all.

I've only extented the functionality of the Custom Configuration area allowing it to contain whatever script (this could be anything, really!) you would like the system to execute for you.

This was possible also before, but you had to login into the router each time you needed to apply any patch to it.

Now you don't have to log in any more.

By installing the two files openvpnserver1.postconf and helper.sh into /jffs/scripts/ you'll make the router able to execute any script you have just pasted into the Custom Configuration area of the Web GUI.

That's it.

In this way, if you need to apply any patch to it, you don't have to ssh into the router and look for files here and there because everything is right there on the Web GUI before your eyes.

Just patch your code, click Apply and reboot the router, eventually.

Don't forget to debug it first, therefore install openvpnserver1.postconf with DRYRUN=1 which in turn will only install your script into /dev/shm/ovpnsrv1/ without executing it.
 

Attachments

  • MerlinGUI.png
    MerlinGUI.png
    336.3 KB · Views: 533
Last edited:
.....This was possible also before, but you had to login into the router each time you needed to apply any patch to it.

Now you don't have to log in any more.

Forgive my ignorance in this area, but does this pose a security risk - not needing to login to apply a patch?
 
I like the idea. Is it possible that instead of having to pre-fix every line with ;sh; that instead we use a set of tags? Cleaner...

<script>
</script>

This is pretty useful. I can replace my up/down scripts with this...
 
Worked Perfectly. I'm using it for my 2 client and 2 server VPNs. This helps quite a bit because I now auto-generate the up/down scripts instead of managing them directly via ssh in JFFS.

Although there is some advanced routing available in merlin, I prefer to do mine via script to do some more advanced routing and now I can do it via the GUI.
 
It's really good of you to make your work public. Lacking your coding skills, which I admire, I'm unable to work out how this might be useful to me. Could you possibly upload a screenshot showing the resulting modified web GUI page?
All he has done is made Merlin's postconf script sort of editable from within the existing GUI vpn "custom configuration" slot. So instead of managing that file from ssh, we can do it from the GUI.

My use case is that i do custom policy routing for my vpns. Normally to make changes, I'd need ssh to do it. Now I can make those changes from the GUI. Just a bit easier to customize
 
This is an interesting exercise with merit on its own.

But people may not realise everything in the custom config window is stored in nvram*. The more stuff you put in the custom window, the least nvram you're left with...if not, accidentally blow up your nvram.

*if my memory serves me right. I haven't run a vpn client on my router for many months.
 
This is an interesting exercise with merit on its own.

But people may not realise everything in the custom config window is stored in nvram*. The more stuff you put in the custom window, the least nvram you're left with...if not, accidentally blow up your nvram.

*if my memory serves me right. I haven't run a vpn client on my router for many months.
Yes, you do need to watch your nvram usage. Ever since certificates were moved to jffs, I have plenty but your point is a very good one.
 
Last edited:

Latest threads

Sign Up For SNBForums Daily Digest

Get an update of what's new every day delivered to your mailbox. Sign up here!
Top