Xonsh Vpn

I know picking a shell in Unix is, by no means, a simple thing. People have very strong opinions about the shells that they want to use. Beyond using their favorite terminal program, the shell a developer, engineer, or administrator uses is something that is a highly personal choice. Most of us who have been working with a particular shell for a while have probably developed deeply ingrained habits and personalization choices.

Personally, I have never really become deeply enamored with highly customized prompts, status lines, or such. My prompt is a slightly modified version of the standard Ubuntu prompt with green and blue colors for unprivileged users and red for when I am running with root privileges. I keep four main pieces of information in my prompt: username, current working directory, and (if applicable) current git branch and Python virtualenv. For my professional work I do a significant amount of writing Python code, so adding in the Python virtualenv information is a nod to that bit which is often relevant to my work.

My Shell Choice: Xonsh

However, being that I am very comfortable with Python, I have made a choice to go away from the standard POSIX shells for my preferred choice. Of course the most popular Linux shell choices out there are Bash, zsh, and fish. However, a few years ago I struck on xonsh. The name is pronounced similarly to the conch shell of ocean creature fame, which inspires Xonsh’s logo and branding. Yes, it is a young and relatively new addititon to the Shell world, but I have quickly grown to appreciate it.

Bowing to the power of POSIX shells, Xonsh supports a wide variety of sh/Bash style syntax. It supports pipes, redirects, and sub-shell captures (that is, the command style of foo=$(cat bar.txt) to capture the output of a subcommand) using the same basic syntax as the standard shells you might be used to. You can also reference environment variables using mostly the same syntax such as $PATH to fetch its values.

Where Xonsh deviates from other shells is that it uses Python syntax for all of its control structures and for more advanced features. In fact, and this is something that can take a while to remember when interacting with it, Xonsh syntax is a strict superset of Python! So anything that is valid Python is valid Xonsh, with a few nice features added on that make the shell experience more friendly.

Examples: Shell Escaping

For some things, the Python syntax in the shell can be very handy if you don’t mind the feature and are comfortable with Python. One of my favorite shortcuts that the syntax of Xonsh allows me is simplifying the pesky “shell expansion” behavior in Bash. Take for example this basic Bash script where you need to use quotes to avoid shell problems:

name="Greg Hellings"
login_script --name "${name}"

With Xonsh, I do not need to quote the value when I refer to it. Xonsh will pass the value of the variable as a single value into the command. Note that I do have to use a different syntax to refer to the variable, because it is a local variable and not an environment variable.

name="Greg Hellings"
login_script --name @(name)

Examples: Arrays and Loops

Similarly to above, when using a list or array, I do not need to worry about spaces and values being interpreted incorrectly because Xonsh uses standard Python primitives for this. Similarly for appending to a list, popping from a list, etc. Xonsh handles all the spaces and so forth.

mylist=["Greg Hellings", "Bugs Bunny", "Captain Kirk"]
mylist.append("George Washington")
for name in mylist:
    login_script --name @(name)

There you can see that my looping must be handled with standard Python syntax, but I do not have issues with lists that contain spaces, and I don’t have issues with a regular variable that has spaces being interpreted as a list, etc.

Please know that I am not saying that these things cannot be addressed in Bash or similar shells. I am, however, saying that in Xonsh these things are very straightforward and are handled by the Python portion of the syntax.

Examples: Aliases and Functions

Because Xonsh uses a full Python syntax and all valid Python is also valid Xonsh, I can write or call any Python code I want directly from my Xonsh RC file or on the command line. Xonsh uses this to enhance the idea of alias commands. You can use regular single-line alias commands like you are probably used to but with a slightly different syntax. In my xonshrc file I have numerous such simple aliases that look something like aliases['work'] = 'cd ~/work'. And these work exactly like you might expect.

But then I also have some slightly more complicated ones. I use the following alias on my NixOS and MacOS systems so I can update my Nix environment without having to remember the exact command for each one:

def _rebuild(args):
    from os import uname
    system = uname()
    if system.sysname == 'Darwin':
        darwin-rebuild --flake ~/.config/darwin switch
    else:
        sudo nixos-rebuild switch
aliases['rebuild'] = _rebuild

Here you can see how a more copmlex alias can be created. These are similar to the bash function and are, really, just a Python function. I could call them directly with _rebuild("blah"), but it’s probably better for me to call them the way that Xonsh expects… as if they were shell commands. In this case I can just referenc rebuild at my prompt, and it will run the Xonsh function listed.

Hopefully you can also see that the Python code lives perfectly happily with the Xonsh shell commands. Only the lines with the darwin-rebuild and nixos-rebuild commands in the above function are Xonsh specific. Everything else is just plain Python.

Examples: Aliases with arguments

In the above example the function _rebuild accepts an argument args. This is required for any of these more complex aliases. args will be an array of any arguments passed to the alias on the command line. Here is an example of an alias where I accept an argument:

def _unknown_host(args):
    sed -i -e @(args[0])d ~/.ssh/known_hosts
aliases['unknown_host']

This alias allows me to selectively delete any specific line from my known hosts file when its SSH key has changed. As you see, I use the standard Xonsh syntax to reference a Python variable, then invoke the 0th element of the array. Will this result in a problem if I don’t pass one to the alias? Yes. Could I use something more heavy handed like argparse to parse the list of arguments? Also yes. Do I want to go through all of that pain and misery for a simple shell alias? Nope! Just let the Python line error and then I’ll hopefully do it right the next time.

Examples: Aliases with function calls

Like anything else in Python or other languages, I can write and call functions on my command line or from aliases. So here’s an example where I do that. In this example, I have a function that ensures I have unlocked my BitWarden CLI. Then another function will log into a given VPN using the password and OTP stored in BitWarden. And finally my shell alias calls the VPN login function with arguments to specifically log into a particular VPN with a particular set of crecentials.

def bw_unlock():
   if "BW_SESSION" in ${...}:  # This checks if the BW_SESSION environment variable is set, using Xonsh's env dictionary
       return $BW_SESSION  # Return the variable value of the token
   result = $(bw unlock)  # Sub-shell syntax to capture the stdout of the command
   while "BW_SESSION" not in result:
       result = $(bw unlock)  # Loop until I don't fat-finger my master password
   lines = result.split("\n")
   l = [k for k in lines if 'BW_SESSION="' in k][0]  # Find the first line where the session key is output
   left, right = l.split("=", 1)  # The key can include this charater, so only split on the first one
   token = right[1:-1]  # The output of bwunlock wraps the value in quotation marks
   $BW_SESSION = token  # When Xonsh sees a variable assignment that begins with $, it sets that variable as an environment variable automatically
   return token

def vpn(con, bwname):
   bw_unlock()
   base=$(bw get password @(bwname))
   secret=$(bw get totp @(bwname))

   from tempfile import NamedTemporaryFile
   # delete_on_close is a new option in Python 3.12
   with NameTemporaryFile(delete_on_close=False) as fp:
       secret = f"vpn.secrets.password:{base}{secret}"
       fp.write(secret.encode("utf-8"))
       fp.close()
       nmcli c up @(con) passwd-file @(fp.name)

def _workvpn(args):
   vpn("Work VPN", "Work Account")
aliases['workvpn'] = _workvpn

Now, from my shell, I can just invoke the command workvpn and my system will automatically log into my work VPN. If I have not previously unlocked my BitWarden first, it will ask me to unlock that. Hopefully, with the extra comments I have added to the code above, you can see how seamlessly Xonsh and Python are interacting here and how quickly and smoothly I can move between them.

Example: Probably don’t do this - but you can!

Here is a fun little alias that I use sometimes. More rarely since I moved to Nix where I basically already have everything that I could want but I still occasionally use it on other systems:

from pathlib import Path
from os import getcwd
def _rundock(args):
    if Path('/usr/bin/podman').exists():
        e = 'podman'
    else:
        e = 'docker'
    @(e) exec -ti @(args[0]) /bin/bash
aliases['rundock'] = _rundock

def _newdock(args):
    if Path('/usr/bin/podman').exists()
        e = 'podmand'
    else:
        e = 'docker'
    @(e) run -P --privileged=true -e DISPLAY=$DISPLAY -v /tmp/.X11-unix:/tmp/.X11-unix -v @(getcwd()):/dmnt -v /etc/pki:/etc/pki:ro -d --name @(args[1]) @(args[0]) /sbin/iniu
    rundock @(args[1])
aliases['newdock'] = _newdock

The alias newdock debian:12 deb will create a new OCI container running in Podamn, if it is installed locally, or Docker otherwise. This container will be named deb and will be created from the debian:12 image. I use volume mounting to pull in a few values that I want - namely the X11 socket and to mount the current directory under the folder /dmnt. The newdock function then calls the rundock function which puts the user into a bash shell in the running container.

Obviously there are quite a few assumptions being made here, and it definitely does not work all the time. But I have frequently used these commands when I want to check on if something works in a given environment or otherwise quickly jump myself into a container. I used this before tools like Distrobox or Toolbox standardized the way that these alternatives were made available. I keep it around, though, because I like to sometimes remind myself that even the command portion of a line of Xonsh can be dynamic just like it can in other shells.

Caveats

While it is true that nothing you can do in Xonsh you can’t do in raw Python, the shell-friendly syntax sure that Xonsh brings to the Python standard library makes the whole process so much smoother than invoking subcommand processes in raw Python. Now, I’m not advocating you use Xonsh for developing whole applications. But I am saying that you could easily do so without losing any of the power of Python while simultaneously grabbing easier access to shell calls and subprocesses.