||= (or equal operator) in bash?

If you’re used to writing code in ruby, perl, or other languages that provide an ||= (or equal) type operator, you may someday find yourself wanting to do the same in a shell script. While this isn’t directly provided in bash, for example (at least not that I could find), you can emulate the same functionality.

The answer lies in using a little bit of of tomfoolery from the Advanced Bash Scripting Guide, specifically under the Parameter Substitution section.

The part we’re interested in at this point is the ability to use a default parameter. These constructs in bash look like so:

${parameter-default}
and
${parameter:-default}

Note that as pointed out in the documentation, the difference between the two is that the former will not evaluate if parameter is null whereas the latter will.

So just how do we accomplish something like:

$foo ||= $bar ||= $baz

Like so:

FOO=${FOO:-$BAR}
FOO=${FOO:-$BAZ}
FOO=${FOO:-foo}

So this short circuits just like the ||= type operator above. So say, for example, you wanted to spit out a diff of some code before confirmation of a submit or something. Ideally you want to fire up the diff program the user prefers. So why not do something like:

DIFF=${DIFF:-$MYOTHERDIFF}
DIFF=${DIFF:-diff}

In this example, we use what is in $DIFF first. If there’s nothing in $DIFF, we use $MYOTHERDIFF. Finally, if there is nothing in either, we use straight up diff. This way if certain diff variables are set to programs like gvimdiff, tkdiff, etc., they will be invoked.

Clearing konsole history with dcop

Here’s another quick tip for manipulating your Konsole sessions using DCOP. Need to clear the history buffer in an automated fashion for some reason? Use DCOP!

There are three steps to this endeavor:

  1. Find out the reference to the application.
  2. List the available methods (just so you know what’s available).
  3. Send your Konsole session a command!

Here’s an example:

rwoodrum@frums:~$ echo $KONSOLE_DCOP_SESSION
DCOPRef(konsole-12065,session-1)
rwoodrum@frums:~$
rwoodrum@frums:~$ dcop konsole-12065 session-1
QCStringList interfaces()
QCStringList functions()
bool closeSession()
bool sendSignal(int signal)
void clearHistory()
void renameSession(QString name)
QString sessionName()
int sessionPID()
QString schema()
void setSchema(QString schema)
QString encoding()
void setEncoding(QString encoding)
QString keytab()
void setKeytab(QString keyboard)
QSize size()
void setSize(QSize size)
rwoodrum@frums:~$
rwoodrum@frums:~$ dcop konsole-12065 session-1 clearHistory
rwoodrum@frums:~$

Voila! History cleared.

iPhone laptop internet access

Long time no post. I’ve been in the process of both moving and changing jobs, so I’ve definitely neglected the blog. The move, however, has left me without Internet access (akin to being without oxygen) and so I’ve been surviving on my iPhone. Of course, given that my iPhone functions as yet another *nix box to me, I set out to get my laptop some internet by way of my iPhone. If I’m breaking terms of agreement here, then this is all just pretend.

This post is for linux users. It doesn’t explain how to do it on your crappy windows box, although if you’re at all savvy, you should be able to figure it out in the same way. Or just google around for keywords like iphone tether laptop.

In my scenario, I’ve got a linux laptop running Debian/sid and a jail-freed iPhone. There are quite a few howto’s already out there, such as this one which I followed for awhile… until I thought I found a less complicated way. There was also this one, but again, I thought it seemed easier to do it my way. Not saying, of course, that my way is the best way… this is just how I did it. I also stumbled upon a couple of gotchas that I had not seen outlined elsewhere, so read on for more awesomeness.

I use the Cydia package installer by Jay Freeman on my iPhone because it’s gloriously built on top of one of my favorite tools, APT. He has made the keen move of not reinventing the wheel here, and instead improving it. Good design principles. Via Cydia, I have simply installed ssh on my iPhone.

So the steps of setting up your little iPhone <-> laptop network will be:

First, let’s create the ad-hoc wireless network. On your linux laptop, assuming you’ve ever used your wireless before, you should have ‘iwconfig’ installed. The goal here is to create an ad-hoc network that your iPhone will be able to join. You can do so with something similar to the following:

In this example, my wireless interface is ‘wlan0′.

rwoodrum@shamp:~$ sudo iwconfig wlan0 mode ad-hoc
rwoodrum@shamp:~$ sudo iwconfig wlan0 essid "iphone_adhoc"
rwoodrum@shamp:~$ sudo ifconfig wlan0 up
rwoodrum@shamp:~$ sudo iwconfig
lo        no wireless extensions.

eth2      no wireless extensions.

wmaster0  no wireless extensions.

wlan0     IEEE 802.11g  ESSID:"iphone_adhoc"
          Mode:Ad-Hoc  Frequency:2.412 GHz  Cell: 8A:B3:63:C0:DB:B2
          Tx-Power=27 dBm
          Retry min limit:7   RTS thr:off   Fragment thr=2352 B
          Encryption key:off
          Link Quality:0  Signal level:0  Noise level:0
          Rx invalid nwid:0  Rx invalid crypt:0  Rx invalid frag:0
          Tx excessive retries:0  Invalid misc:0   Missed beacon:0

So you can see above that my wireless card is configured in ad-hoc mode and that the ssid is “iphone_adhoc”.

Now if you search for wireless networks with your iPhone, you should see the “iphone_adhoc” network in your network list under Settings -> Wi-Fi. Choose to join the network by tapping it. Note that by joining an ad-hoc network, your iPhone will retain its network configuration on the Edge network (interface ip1) and it will retain a default route to the Internet based on the Edge network. This is important.

Now you need to get them on the same network. If you elect to use the link-local addresses in the 169.254/16 network, just note the address on your iPhone and subsequently configure your laptop to be on the same network. For example, if your iPhone comes up with address 169.254.105.88/16, you could put your laptop as 169.254.105.89/16. Alternatively, you could go the extra step to put them on a different network of your choosing. Because extra steps are for the uncool, I’ll just let the iPhone choose it’s address in the 169.254/16 network and put my laptop on the same network like this:

sudo ifconfig wlan0 169.254.105.89 netmask 255.255.0.0

You should now be able to ping your iPhone from your laptop.

At this point, I’ll bring up gotcha #1. When your iPhone auto-locks, your connection will die. From this point out, it’s probably a lot easier to simply disable this. To do so, go under Settings -> General -> Auto-Lock and change it to “Never.” Just don’t forget to change it back.

Now, we need to ssh into the iPhone using it as a socks proxy. This will allow the connections of our laptop to go through the iPhone. To establish this connection, ssh to your iPhone using something like the following. In my example, I’m simply ssh’ing as the ‘mobile’ user (the default user on the iPhone):

rwoodrum@shamp$ ssh -f -N -D 9999 mobile@169.254.105.88

This will put ssh into the background listening on tcp/9999. If a connection is received, it will forward it over the secure connection and subsequently use the application level protocol to determine where to connect from there. Before you can use this, however, you should configure your Iceweasel/Firefox to shunt DNS lookups over this channel as well. You do this by editing the ‘network.proxy.socks_remote_dns’ value to ‘true’ under ‘about:config’ in the browser.

You must also configure your socks proxy under Edit -> Preferences -> Advanced tab -> Settings button to point to your new ssh socks proxy. To do this, enter under ‘SOCKS Host’ the value 127.0.0.1 with port 9999. You must ensure that no other proxy values exist. All others must be blank.

Now, on your laptop, modify the routing table to add the iPhone as the default route to the Internet. If my iPhone has an address of 169.254.105.88, I would do this like so:

rwoodrum@shamp$ sudo route add -net 0.0.0.0/0 gw 169.254.105.88

You’re almost done.

You may need to shutdown Iceweasel/Firefox entirely just to make sure all the application level changes take effect.

You should now be able to fire up your browser and hit something like google.com. It’ll be slow… but it’ll work.

One thing you may experience is that during your internet browsing, Iceweasel/Firefox will suddenly complain about timeouts. You may also notice that the Edge icon on your iPhone has changed to a wireless connection. The Edge connection seems to periodically “shut down” when not used directly from the iPhone. To remedy this situation, I simply fired up the “weather” button and periodically switched it from city to city so the update would take place. This fires up the Edge connection from the iPhone itself and seems to breathe life back into the connection via the laptop. Not ideal… but at the time of this writing, it’s what worked.  Alternatively from a shell on the phone you could just start a ping or something.  Given limited bandwidth, however, you may want to increase to every x number of seconds or something.

Comments appreciated! I’d be happy to help anyone in need with a beloved iPhone and no beloved Internet!

SSL/TLS Virtual Hosting with a single IP

The question:
Is it possible, via name-based virtual hosting, to have multiple SSL/TLS sites using a single IP address on standard ports? i.e. If the host in question is at 4.4.4.4, can I host www.foo.com and www.bar.com for HTTP over SSL/TLS using the standard port tcp/443?

The answer:
No. Not on standard ports.

Why?

The nature of TLS prevents this sort of thing from happening. In a non-HTTP over SSL connection, the host to serve up is determined by the host header. You can try this manually on a host by telnet’ing to it:

rwoodrum@slard:~$ telnet www.google.com 80
Trying 209.85.173.147...
Connected to www.l.google.com.
Escape character is '^]'.
GET / HTTP/1.1
Host: www.google.com
.
HTTP/1.1 200 OK
... snip everything else ...

In the above example, if for some crazy reason, google also virtual hosted www.bar.com, in the line ‘Host: www.google.com’ could be substituted “www.bar.com” and the contents of bar.com would be served.

So why does SSL/TLS prevent this from working?

The answer is in the handshake of an SSL connection. In a normal RSA handshake, immediately after the ClientHello is a ServerHello followed by the certificate. The certificate is used to decide if the host is trusted and later on plays a role in the key exchange algorithm. You can see a state diagram of an RSA handshake here.

The issue is really the fact that, in order to ensure all contents are secure, the secure tunnel has to be negotiated before any data can pass. The “data” in this case is our HTTP request! So… you can’t issue your HTTP request, and therefore the host header, until the SSL/TLS connection is negotiated. But wait… how will your web server know which certificate (the one for www.foo.com or the one for www.bar.com) to serve up? The answer is… it won’t.

So. You can’t do name-based virtual hosting using SSL/TLS on a standard port with a single IP address. Now… if you wanted to run those virtual hosts on separate ports, that’s a different story.

Note that this doesn’t have anything to do wirth reverse DNS lookups. Reverse DNS lookups in the context of SSL/TLS is something entirely different and is suitable for perhaps a separate post.

Login incorrect; valid user, no password prompt

I recently released a linux machine using the Samba/Winbind combo from the grips of an active directory server - a product I absolutely detest for a myriad of reasons which I won’t go into here.

So… in my haste, I forgot to do something and ended up with the following symptoms on a tty login:
Debian GNU/Linux lenny/sid slard tty1
slard login:

So I try to login…
Debian GNU/Linux lenny/sid slard tty1
slard login: root
LOGIN INCORRECT

Eh? What gives?

Turns out I forgot to fix my pam configuration under /etc/pam.d/common-password.

The solution? Assuming it’s your PAM configuration, boot into single user mode* and make sure your PAM configuration isn’t borked. I merely switched back to the default config:
password required pam_unix.so nullok obscure min=4 max=8 md5

*Not sure how to boot into single user mode? If you’re using grub:
Debian automatically adds a line for each kernel installed via debs that includes the normal kernel boot as well as a single user. If you don’t have that for whatever reason, just do it manually. At the grub menu, select the kernel you want to boot into and choose ‘e’ to edit the config. On the ‘kernel’ line, simply add the token ’single’. Then press ‘b’ to boot. You’ll boot into single-user mode; run level 1.

Postfix: Sending “do not reply” mail to /dev/null

If you want to set up a do-not-reply address in Postfix for the local delivery transport, it’s easy enough to do so via aliases:
do-not-reply: /dev/null

This will throw all the locally delivered mail to the do-not-reply alias into the file /dev/null.

What about if you have a more complex setup with stuff like relay_domains, transport_maps, relay_recipient_maps, etc.? Here’s how you do it.

In my setup, I have a relay_domain specified for a domain; let’s say foo.com:
relay_domains = $mydestination, foo.com

Furthermore, I have a relay_recipient_map defined to weed out certain mail such as to only relay to users that exactly exist where I’m about to relay for foo.com:
relay_recipient_maps = hash:/etc/postfix/relay_recipients

The contents of /etc/postfix/relay_recipients might look like:
bob@foo.com na
bill@foo.com na

I also have a transport_maps specified:
transport_maps = /etc/postfix/transport

This is where the bulk of the magic happens. The contents of /etc/postfix/transport might look like:
do-not-reply@foo.com local:
foo.com :[mail-exchanger.bar.com]

In this transport map, I’m specifying that I want postfix to use the local transport agent for ‘do-not-reply@foo.com’. For everything else, I want it to send it to a relay, in this case I want to relay all foo.com mail (for which I am the MX record) to a different provider/service where the actual mail boxes live. So I relay foo.com mail to mail-exchanger.bar.com. (See the manpage on transport for more information and also check out the main.cf documentation [and everything else on postfix.org].)

So… you end up with all this in the end:

  • ‘do-not-reply@foo.com na’ in your relay_recipient_map
  • ‘do-not-reply@foo.com local:’ in your transport
  • ‘do-not-reply: /dev/null’ in your aliases file

Mail to do-not-reply@foo.com (for which a mailbox doesn’t really exist at the bar.com service):

  • is checked against relay recipients
  • moves on to transport where it is decided that it should be delivered locally
  • delivered to the do-not-reply local user
  • is sent to the bit bucket

Voila. Yay postfix configuration!

postfix/qmgr warning: connect to transport spamassassin: Connection refused

While setting up a postfix/spamassassin/spamc combo, I got everything up and running but was being harried by log messages like so:

postfix/qmgr[547]: warning: connect to transport spamassassin: Connection refused

It turns out that this message isn’t necessarily indicative of continued problems of messages being delivered through postfix and spamassassin, but rather of messages in the queue when the setup was screwy. I found this post about bad mail stuck in qmgr with a one-line reply with the clue that pointed me in the right direction.

During the course of the setup, I fat-fingered some stuff, made some minor mistakes etc. On each iteration, of course, I was sending myself test emails. So again, the log message noted above was showing up in my mail.log. When I got everything working, it persisted.

The cause? Mail in the queue sent previously that was still trying to do Bad Things. In my case, this manifested itself like so on a run of `sudo postqueue -p`:

AFEDB98077      495 Sat Jan 19 17:56:56  rwoodrum@domain.net
                                                  (mail transport unavailable)
                                         rwoodrum@domain.com

The solution? Delete those messages from the queue (or requeue them) with postsuper and restart postfix. Log messages gone, mail being delivered through spamc/spamd combo. Now, of course, this is only to get rid of the log message after you’ve got everything setup correctly. If your config is still borked, the message is obviously going to persist. ^_^

ruby 1.8.5 not handling SIGTERM

I’ve deployed some applications using the nifty runit project because it offers very nice mechanisms for process supervision. I’ve been using it flawlessly in several environments for at least a year… until just the other day when I was sure I had found a problem with it. My investigation of sv term handling with a slow child process is available on the runit mailing list.

The problem I was experiencing was with a ruby application running under runit and not honoring SIGTERMs. Looking at the 1.8.0 runit source via `apt-get source runit` on debian sid, I learned more about how runit’s pieces work together to manage and supervise child processes. Riddling the code with debug info, I was able to trace the offending code to the sv.c source file in the control() function. A snippet of code was disallowing writing of another command to a pipe for later processing because the process was already in a particular state. In my case, runit thought the process was already down because I had previously sent it a TERM signal. Unfortunately, my process never handled the original TERM because of delayed registration of the handler and therefore never would receive another from runit.

This repro’d 100% of the time on ruby 1.8.5 with this snippet of code:

puts "doing initial sleep for 10..."
sleep(10)

puts "registering term handler..."
trap("TERM") do
  puts "got term"
  exit
end

while(true) do
  puts "in loop, sleeping for 2..."
  sleep 2
end

Here’s where it gets a little messy…

As I outline in my post to the mailing list, runit does indeed refrain from sending a TERM signal again to the process, but the real offender was ruby v1.8.5. The Google led me to a post on ruby-forum about a signal handling behavior change on an upgrade to ruby v1.8.5. Matsumoto chimes in and indicates that this seemingly related(?) issue is indeed a bug.

I tried a most base case running a ruby script consisting of ’sleep 30′. Running this under:
rwoodrum@fs1sea:~/tmp$ ruby -v
ruby 1.8.5 (2006-08-25) [i486-linux]

and subsequently sending the process a TERM does nothing. A la this annotated strace:
..... snip .....
..... we received a TERM .....
) = ? ERESTARTNOHAND (To be restarted)
--- SIGTERM (Terminated) @ 0 (0) ---
sigreturn() = ? (mask now [])
select(0, NULL, NULL, NULL, {12, 476000}

..... riding out the sleep .....

) = 0 (Timeout)
time(NULL) = 1200560526
sigprocmask(SIG_BLOCK, NULL, []) = 0
sigprocmask(SIG_BLOCK, NULL, []) = 0
rt_sigaction(SIGINT, {SIG_DFL}, {0xb7f180d0, [], 0}, 8) = 0
exit_group(0) = ?

I believe at this point that the SIGTERM handler is going to be SIG_DFL. I don’t understand why this doesn’t perk up and terminate the process however, so that is still somewhat unsolved. This doesn’t repro with a more recent version of ruby.

Lessons learned? The version of Ruby1.8 in debian etch (package ruby1.8 version 1.8.5-4etch1) does not appear to handle SIGTERM correctly, even in cases where a signal handler is registered.

Apache2 - error running shared postrotate script

So something happened not too long ago such that in one of our Xen development environments (running Debian etch), apache was bailing every Sunday morning at a conspicuously consistent time.

This fairly obviously pointed to something going on inside of cron with the likely suspect being log rotation. Even more of a no-brainer given cron’s weekly complaints:
/etc/cron.daily/logrotate:
apache2: Could not reliably determine the server's fully qualified domain name, using 10.1.4.24 for ServerName
apache2: Could not reliably determine the server's fully qualified domain name, using 10.1.4.24 for ServerName
(98)Address already in use: make_sock: could not bind to address [::]:80
(98)Address already in use: make_sock: could not bind to address 0.0.0.0:80
no listening sockets available, shutting down
Unable to open logs
error: error running shared postrotate script for /var/log/apache2/*.log
run-parts: /etc/cron.daily/logrotate exited with return code 1

So a bit of googling on this brought up surprisingly few good explanations as to exactly why it was going on. There are, of course, a lot of voodoo, crap explanations all over discussion boards, but little in the way of concrete evidence. Said voodoo even mentioned in this blog post as deadends describing the exact same thing I was experiencing. It wasn’t too long before I stumbled across Debian Bug report #301702 with a host of duplicates all describing the same problem but with little solidfying something such as a repro case, so I set out to do just that - repro the problem. Here’s how I did it.

First of all, cron quickly points to the following logrotate configuration (in package apache2.2-common or some variant) as a good starting point for investigation:
/var/log/apache2/*.log {
weekly
missingok
rotate 52
compress
delaycompress
notifempty
create 640 root adm
sharedscripts
postrotate
if [ -f /var/run/apache2.pid ]; then
/etc/init.d/apache2 restart &gt; /dev/null
fi
endscript
}

But what on earth is going on such that Apache bails on a restart? In all of the other development and test environments we use, including production, this was only happening in a single one. The answer turns out to be that the problem is load related, or at least that’s how I was able to repro the problem. Slow apache children in general, however, are the cause of the problem.

I could not get this to repro simply by running the logrotate cron tasks as they would run normally (except for the –force, of course):
$ sudo /usr/sbin/logrotate --verbose --force /etc/logrotate.conf
or
$ sudo /usr/sbin/logrotate --verbose --force /etc/logrotate.d/apache2
In the above cases, apache would restart with no problems. So I introduced some load onto the machine. I did this basically by running updatedb (because this is also cron’d to run at the same time, in my case) as well as forking off a couple of while(1){}’s to use a bit of cpu.

With the introduction of load, I could repro the failure of apache to start during the logrotate, but I still didn’t understand what was going on with the log files. I had tunnel vision on the “Unable to open logs” red herring from the original cron complaint. Turns out the real problem is just a slow child and arguably poor behavior of the apache2 init.d scripts as well as the etch logrotate.d/apache2 configuration posted above.

In the repro scenario, I noticed apache complaining about slow children just before SIGKILLing them:
[Wed Jan 02 21:19:55 2008] [notice] Apache/2.2.3 configured -- resuming normal operations
[Wed Jan 02 21:20:15 2008] [warn] child process 18762 still did not exit, sending a SIGTERM
[Wed Jan 02 21:20:19 2008] [warn] child process 18766 still did not exit, sending a SIGTERM
... snip ...
[Wed Jan 02 21:20:21 2008] [error] child process 18765 still did not exit, sending a SIGKILL
[Wed Jan 02 21:20:22 2008] [error] could not make child process 18765 exit, attempting to continue anyway
[Wed Jan 02 21:20:22 2008] [notice] caught SIGTERM, shutting down

The introduction of load was causing children to be delayed in exiting. The /etc/init.d/apache2 scripts in etch attempt to kill the main apache2 process and once it is ascertained that it can no longer receive a signal (via kill -0, such as if the process is already gone), the init scripts continue on their way and attempt to start apache back up. At this point, they assume that apache and all of its children are gone, which is not necessarily the case. This is where the real problem lies. Long story short, those children which haven’t exited quite yet are still holding onto their sockets. As you can see in a trivial example where you try to run apache when it’s already running:
rwoodrum@slard:~$ sudo apache2
(98)Address already in use: make_sock: could not bind to address [::]:80
(98)Address already in use: make_sock: could not bind to address 0.0.0.0:80
no listening sockets available, shutting down
Unable to open logs

There’s that pesky “Unable to open logs” message that was throwing my feeble brain off course.

The solution? A couple of things…

  • In Debian lenny/sid+, the logrotate.d/apache2 package config no longer calls /etc/init.d/apache2 restart and instead uses the graceful restart by way of /etc/init.d/apache2 reload. Apache is able to function much more cleanly in this fashion by holding onto its connections, not bailing on errors, etc.
  • In Debian lenny/sid+ the /etc/init.d/apache2 init scripts have been modified (hacked?) to add a long sleep time between the stop and start. Certainly not foolproof, but apache2 really should be able to exit in 10 whole seconds.

So now I have satisifed my who, what, when, where, and why.

Installing Windows xp on a xen domU

Unfortunately, even super-cool elite web 2.0 startups such as where I work have to deal with shitty browsers such as the ones made by Microsoft. (Let’s be fair, however, 99% of software churned out by the bloated Microsoft machine is shitty, not just the browsers.)So… we have to test the browsers. But wait, where do we do it? Who on earth wants to dedicate real hardware to running such lesser-born bits? No one, is the answer, but we can sacrifice a bit of space on a Xen instance to make it happen. Hence this post: How do I install Windows XP on a Xen instance?

It’s not hard, really. In my case, I’m using logical volumes via LVM to host my xen instances, so that’s what my example will outline. I’m also installing from a Windows XP CD which came with a computer no longer running such a crappy operating system. This assumes you have already created a Xen dom0 and have available space in some sort of volume group which I will call your_vg_name.

Create a logical volume to house your windows xp instance:
$ lvcreate --size 5G --name winxp01 your_vg_name

Now, in your dom0, use something like the following for your “winxp.cfg”. In my case, I have configuration for browser testing machines for ie6 and ie7. So this is an example ie6 machine config in /etc/xen:
rwoodrum@xen1test:/etc/xen$ cat ie6-01.cfg
kernel = "/usr/lib/xen-default/boot/hvmloader"
builder = 'hvm'
memory = '768'
name = "ie6-01"
vcpus = 1
vif = [ 'type=ioemu, bridge=br-xen' ]
disk = [ 'phy:/dev/xen/winxp01,ioemu:hdc,w' ,'phy:/dev/hda,ioemu:hda:cdrom,r']
device_model = '/usr/lib/xen-default/bin/qemu-dm'
cdrom='/dev/hda'
ne2000=0
boot='c'
sdl=0
vnc=1
vncviewer=1

On a debian box, make sure you have the xen-ioemu package installed.

In this example, /dev/xen/winxp01 is my logical volume. /dev/hda points to my cdrom drive where the winxp disk will boot the installer. I had difficulties in specifying this as anything other than “hda”, so give it a shot, even if your cdrom is definitely not at /dev/hda.

Once the installer boots, you should be able to connect to the “console” with a vncviewer such as krdc at the address of your dom0. Note that if you are installing multiple instances requiring the vnc console, you may have to specify a specific display. e.g. instead of just vnc://10.1.1.1, you may have to say vnc://10.1.1.1:23 if your local display is “23″. This notation uses that of krdc to address vnc clients.

Once you work through the installer, you set up the instance like normal and give it an ip address. From then on, you can simply access it via a remote desktop client and use it/destroy it to hearts content. Works great for browser testing.

I wrote this up quite a bit of time after I actually did all of this, so I realize I may have described some steps in a non-intuitive fashion. Please don’t hesitate to comment for details!

keep looking »