Skip to main content

CLI Tricks: reboot/shutdown mollyguard function

Over on Twitter I was having a discussion about creating a mollyguard to protect yourself from accidentally rebooting the wrong machine by requiring you to type the hostname of the machine you wanted to reboot. If you need such, you can add these functions to your ~/.bashrc or ~/.profile to create such a reboot function. Note: The function shown on Twitter had the logic reversed so make sure you get the "=" and "!=" correct. If $1 equals the hostname, you want to proceed with the shutdown; if they are not equal you want a warning.

reboot() { [ "x$1" = "x$(hostname)" ] && sudo /sbin/shutdown -r now || echo "Failed to specify $(hostname)" ; }
halt() { [ "x$1" = "x$(hostname)" ] && sudo /sbin/shutdown -p now || echo "Failed to specify $(hostname)" ; }

If you run on OpenBSD you can specify doas instead of sudo.

CLI Tricks: Find the Nth word of a text file

While I'm not sure when others would use this I wanted to find the Nth word of a text file so I used this to find the, say, 318th word of the document:

tr -cs "a-zA-Z'" '\012' < document.txt | sed -n '318{p;q;}'

Yes, it can get tripped up by non-Latin characters, hyphenated words, or other edge-cases, but for my 7-bit ASCII input text, it did the job.

CLI Tricks: diff files in git and then add them

Occasionally I want to diff one or more files in git and, if everything looks good, then add them. To do so, I often take advantage of bash's ^ notation to do a replacement:

git diff -- file1.txt file2.txt
^diff^add
git commit -m "Looks good to me"

The second command replaces diff with add turning the command into git add -- file1.txt file2.txt without having to retype the entire command.

CLI Tricks: Compare directory sizes when hard-linking

I have directories with lots of image files (or mail files), some of which I hard-link into other directories to save space or make sure they're in sync. How much space am I saving by hard-linking?

(du -sl ; printf '-\n' ; du -s) | awk '{print $1}' | paste -s - | bc

With a little math, most of it can happen in one awk statement:

(du -sl ; du -s) | awk '{t+=(-NR*2+3)*$1} END{print t}'

Aggressive pf(4) configuration for SSH protection

If some unknown person came up to your house and started trying to open each window, jiggling each door handle, and punching in random codes on your garage-door opener, you could safely assume that they sought trouble and that you should stop them. Similarly, if some unknown computer on the internet started probing at ports on your server where you offer no services, it should set off alarm-bells and you should prevent this miscreant from interacting with your server. Especially SSH access. You know what services your machine provides. If anybody attempts to connect to some other service on your machine, in all likelihood they don't have your best interests in mind.

In this post, we configure pf on OpenBSD to catch inappropriate connection-attempts and block the offender.

  1. Assumptions
  2. Setup
  3. Goals
  4. Move the SSH port
    1. Change the listening port
    2. Change the default connecting port
  5. Creating a minefield
  6. Rate-limiting connections
  7. Limiting by country
    1. Download GeoIP data
    2. Automating GeoIP downloading
    3. Create a GeoIP-specific user
      1. Modify permissions on GeoIP data directory
      2. Allow access to restart pf
      3. Create a script to download & install GeoIP info
      4. Scheduling the download
  8. Final file
  9. Conclusion

Assumptions

This post assumes a few things about you:

OS installed that can run pf
While I wrote this article using pf on OpenBSD, many of the aspects should translate to FreeBSD even though FreeBSD's version of pf represents an older snapshot from when their codebases diverged. Particularly,
  • I think the abstract egress interface-group may not exist so you'd have to specify the interface directly (either inline or as a macro)
  • Where I use doas, you either have to install sudo or doas or else you must su - to become root for many of these commands.
  • in the _geoip script, you would need to replace the use of ftp with either fetch, wget, or curl
Administrative rights
This should go without saying, but if you don't administer the machine, you can't modify the firewall configuration.
Basic CLI
In this case, we have minimal work to do at the command-line but it helps to know your way around. OpenBSD has a template /etc/examples/pf.conf you might want to copy as a starting-point; you might find it easier to cd into /etc directory; or you might use tmux and ssh to access the server. Note that editing /etc/pf.conf can leave your server inaccessible (like cutting off the tree-branch while sitting on it), so you'll want to make sure that you have console access — locally, via a serial connection/KVM, or VNC connection.
Basic networking knowledge
The basic networking ideas of UDP, TCP, ports, services, etc. If you need to get up to speed here you might want a resource like Michael W. Lucas's Networking for System Administrators, O'Reilly's TCP/IP Network Administration, or some other such book.
Edit text-files
This focuses primarily on modifying your /etc/pf.conf which you can edit with your favorite $EDITOR, whether nano, emacs/mg, vi/vim/neovim, ed, cat, a magnetized needle and a steady hand, or butterflies. But this guide assumes that you can edit text files and save them.

Setup

For purposes here, I'll use OpenBSD's doas command to elevate privileges but you can use sudo if you have it installed on FreeBSD or execute these commands under a su - to root if you like to live dangerously. This assumes that doas (or sudo) has a configuration that lets you run the commands in question.

This also assumes starting with an empty (or mostly-empty if you did doas cp /etc/examples/pf.conf /etc/ ) pf.conf file. If you already have an existing configuration, you'll need to work out how these changes integrate with your existing setup.

Goals

This post covers several tricks to protect the actual SSH port.

  1. change port on which SSH listens
  2. flag any IP addresses that touch our server's minefield and prevent them from accessing SSH
  3. if anybody actually finds the SSH port and hammers on it, ban them too
  4. limit the country/countries from which we can SSH

Change the sshd port number

For purposes of this post, I configure sshd to listen on port 2345 but you should choose a different one that suits you. This doesn't give much in the way of security, but it reduces some of the noise in your log-files and makes the minefield work by making it unknown and surrounded by trip-ports.

Change the listening port

Edit your /etc/ssh/sshd_config file and specify the Port by adding/editing the line to read

Port 2345
/etc/ssh/sshd_config
Save the file and restart sshd
doas rcctl restart sshd
Note: If you make this configuration change while SSHed into the server, you might cut off your access. As mentioned, make sure that you have console, serial, or VNC access in case you need to recover from making an error here.

Change the default connecting port

On my local machine, I find it painful to remember how to specify the port to various ssh commands. Some take -p $PORTNUM or -P $PORTNUM, while others take -o Port=$PORTNUM and yet others expect it as an environment variable. To simplify this, edit (or create) your ~/.ssh/config to specify the port-number there

host example
    HostName example.com
    Port 2345
~/.ssh/config
Now ssh, scp, sftp, rsync, etc, should all default to the right port-number without you needing to specify it explicitly.

Creating a minefield

Here we configure a minefield of ports such that if anybody tries to connect on any of these fake ports, they get added to a table of troublemakers. For the minefield, I default to 1024:9999 but if you want a broader range, check the output of

sysctl net.inet.ip.port{first,last}
which gives a larger swath. If you extend it below 1024, make sure you don't interfere with other services that you might actually want to provide.

##########
# macros #
##########
minefield="1024:9999"
ssh_alternate_port=2345

##########
# tables #
##########
table <troublemakers> persist

#########
# rules #
#########
block quick proto tcp from <troublemakers> \
  to (egress) port $ssh_alternate_port

## these are just randomly probing

# port 22 is a dead-giveaway
# because we know we moved our SSH server elsewhere
# Also, we have no telnet or SMB
# so anyone poking there
# is up to no good
pass in on egress proto tcp \
  from any \
  to (egress) port { \
    telnet, \
    ssh, \
    netbios-ns, \
    netbios-ssn, \
    microsoft-ds \
  } \
  synproxy state \
  tag trouble

# tag stuff in the range $minefield as trouble
pass in on egress proto tcp from any to (egress) port $minefield \
  synproxy state \
  tag trouble

# unless it's to our hole-in-one
pass in proto tcp from any to (egress) port $ssh_alternate_port tag good

# if we're still trouble, add to the troublemakers
pass proto tcp from any to (egress) port $ssh_alternate_port \
  tagged trouble \
  synproxy state \
  (max-src-conn 1, max-src-conn-rate 1/10, \
  overload <troublemakers> flush global \
  )

/etc/pf.conf

We only specify TCP because malicious actors could spoof UDP source-addresses, blocking innocent folks.

Feel free to add other services to the list of services you don't provide (I have telnet, SSH, and SMB in my list) if you notice strangers knocking elsewhere.

We tag packets as "trouble", and then tag the one port we know as "good". If a packet still has the "trouble" tag, we use some trickery (involving max-src-conn & max-src-conn-rate to limit them to one connection and to one attempt every 10 seconds) to add them to the troublemakers table, banning all future SSH access for them. The flush global keyword kills any other existing connections this offending visitor currently has.

We also need the synproxy state which allows pf to handle the connection to ports that don't actually have services backing them. (Aside: I think that all ports that don't have a real service behind them need the synproxy state but it might turn out that only the overload item needs it.)

With all that in place, any attempt to connect to a port in our minefield (or select services we know we don't run) results in the remote IP address getting added to the troublemakers table and subsequently blocked from our SSH port.

Rate-limiting connections

If an attacker does manage to guess our actual port 2345 on the first try, they will likely try to hammer on it. So we want to rate-limit them:

##########
# Tables #
##########
table <bruteforce> persist

#########
# Rules #
#########
block quick proto tcp \
  from <bruteforce> \
  to (egress) port $ssh_alternate_port

# these made it through to the SSH port but abusing it
pass proto tcp from any to (egress) \
  port {$ssh_alternate_port} \
  flags S/SA keep state \
  (max-src-conn 5, max-src-conn-rate 5/5, \
  overload <bruteforce> flush global \
  )
/etc/pf.conf
This limits to 5 concurrent connections (max-src-conn 5) and 5 connection-attempts during a 10-second window (max-src-conn-rate 5/10).

Limiting by country

To begin with, I know that I will almost certainly never connect to SSH from outside the United States. IPDeny.com provides regularly-updated lists of IPv4 & IPv6 ranges for each country that I can use as a rough determination of country-of-origin. Yes, this can get thrown off by VPNs but it cuts off a surprising number of attackers.

Download GeoIP data

First, create the folder where pf will find the GeoIP tables. Inside that, create a tmp/ directory so we can download there, compare the results with what we already have, and move the new data atop the old data atomically:

DEST="/usr/local/share/geoip"
doas mkdir -p "$DEST/tmp"
doas chmod 755 "$DEST"
doas chmod 750 "$DEST/tmp"

For now, we will manually download the IPv4 and IPv6 GeoIP blocks:

DEST="/usr/local/share/geoip"
V4URL="https://www.ipdeny.com/ipblocks/data/aggregated/us-aggregated.zone"
V6URL="https://www.ipdeny.com/ipv6/ipaddresses/aggregated/us-aggregated.zone"
V4DEST="$DEST/ipv4_us-aggregated.zone"
V6DEST="$DEST/ipv6_us-aggregated.zone"
ftp -o "$V4DEST" "$V4URL"
ftp -o "$V6DEST" "$V6URL"
Now with these files, we can point pf at them and prohibit access from non-US visitors:
##########
# Tables #
##########
table <domesticv4> \
  persist file "/usr/local/share/geoip/ipv4_us-aggregated.zone"
table <domesticv6> \
  persist file "/usr/local/share/geoip/ipv6_us-aggregated.zone"

#########
# Rules #
#########
# non-domestic IPv4/IPv6 connections simply not allowed to touch SSH
block quick inet proto tcp \
  from ! <domesticv4> \
  to (egress) port $ssh_alternate_port
block quick inet6 proto tcp \
  from ! <domesticv6> \
  to (egress) port $ssh_alternate_port
/etc/pf.conf
With this in place, any visitors from a non-US IP address simply cannot access the alternate SSH port.

Automating GeoIP downloading

Occasionally the GeoIP ranges change so you may want to download them automatically and have pf pick them up automatically. For this we'll create a stand-alone _geoip user, modify the permissions of the GeoIP data directory, set up a script to automatically download GeoIP data, compare it to existing data, and (if it changed) replace the old data and restart pf.

Create a _geoip user

Here we use the adduser command to create a new user. I put this user in the "daemon" login-class, specify an empty password, and then disable password logins:

doas adduser _geoip
Use option ``-silent'' if you don't want to see all warnings and questions.

Reading /etc/shells
Check /etc/master.passwd
Check /etc/group

Ok, let's go.
Don't worry about mistakes. There will be a chance later to correct any input.
Enter username []: _geoip
Enter full name []: GeoIP downloader
Enter shell csh git-shell ksh nologin sh [ksh]:
Uid [1002]:
Login group _geoip [_geoip]:
Login group is ``_geoip''. Invite _geoip into other groups: guest no 
[no]:
Login class authpf bgpd daemon default pbuild staff unbound
[default]: daemon
Enter password []:
Disable password logins for the user? (y/n) [n]: y

Name:  _geoip
Password:    ****
Fullname:    GeoIP downloader
Uid:   1002
Gid:   1002 (_geoip)
Groups:      _geoip
Login Class: daemon
HOME:  /home/_geoip
Shell:       /bin/ksh

You could specify the shell as nologin but then have to do some work-arounds to get a shell to create the script and set up the cron job. After configuring the setup below, you can go back and use chsh to change the shell to nologin for added lockdown.

Modify permissions on GeoIP data directory

Now we need to make sure that the _geoip user can write to the directories

doas chown -R _geoip:_geoip "$DEST"

Allow the _geoip user access to restart pf

In order for the _geoip user to instruct pf to reload its configuration and pick up the new GeoIP files, we need to add a line in our /etc/doas.conf file:

permit nopass _geoip cmd /sbin/pfctl args -f /etc/pf.conf
/etc/doas.conf
This allows the _geoip user to run only pfctl as specified by full path, and only with the arguments -f /etc/pf.conf to minimize things. If you want to lock it down even further you can limit the _geoip user to only reload those two tables with something like this (untested):
permit nopass _geoip \
  cmd /sbin/pfctl \
  args -t domesticv4 -T replace -f "/usr/local/share/geoip/ipv4_us-aggregated.zone"
permit nopass _geoip \
  cmd /sbin/pfctl \
  args -t domesticv6 -T replace -f "/usr/local/share/geoip/ipv6_us-aggregated.zone"
/etc/doas.conf
If you choose to go this route, make sure that you update the script below to use
/sbin/pfctl -t domesticv4 -T replace -f "/usr/local/share/geoip/ipv4_us-aggregated.zone"
/sbin/pfctl -t domesticv6 -T replace -f "/usr/local/share/geoip/ipv6_us-aggregated.zone"
~_geoip/bin/update_zone.sh
instead.

Create a script to download & install GeoIP info

I switch to the _geoip user and create the script to use

doas su - _geoip
mkdir -p ~/bin
touch ~/bin/update_zone.sh
chmod +x ~/bin/update_zone.sh
I then used my $EDITOR to populate that ~/bin/update_zone.sh file with the following script:
#!/bin/sh
FTPOPTS="-MV"
DEST="/usr/local/share/geoip"
V4URL="https://www.ipdeny.com/ipblocks/data/aggregated/us-aggregated.zone"
V6URL="https://www.ipdeny.com/ipv6/ipaddresses/aggregated/us-aggregated.zone"
V4TMP="$DEST/tmp/ipv4_us-aggregated.zone"
V4DEST="$DEST/ipv4_us-aggregated.zone"
V6TMP="$DEST/tmp/ipv6_us-aggregated.zone"
V6DEST="$DEST/ipv6_us-aggregated.zone"

CHANGED=false
# fetch them to the temp location
# and if successful and they differ,
# replace the original
ftp $FTPOPTS -o $V4TMP "$V4URL" && ! cmp -s "$V4TMP" "$V4DEST" && mv "$V4TMP" "$V4DEST" && CHANGED=true
ftp $FTPOPTS -o $V6TMP "$V6URL" && ! cmp -s "$V6TMP" "$V6DEST" && mv "$V6TMP" "$V6DEST" && CHANGED=true

# if it changed, reload pf.conf
$CHANGED && (echo "Loading new domestic zones for SSH blocking" ; doas /sbin/pfctl -f /etc/pf.conf )
~_geoip/bin/update_zone.sh" id="script

Scheduling the download

On OpenBSD 6.7 and later cron allows the use of the ~ operator to indicate a randomly chosen time to help distribute the running of tasks without everything happening at one time. So after issuing su - _geoip to become the _geoip user, I used

crontab -e
to edit the crontab and add an entry to run the script:
# min, hr, day-of-mon, mon, day-of-wk
~ 1~3 * * 2 $HOME/bin/install_us_zone.sh
crontab -e
This picks some random time between 1:00am and 3:59am on Tuesday ("2") to run our script. If you use another cron or an older version on OpenBSD, choose an appropriate time. The IPDeny terms-and-conditions offer very liberal usage limits so you could download them more frequently. However, I find that the data doesn't change too frequently, so weekly works for me.

Final file

Putting it all together my final file looks something like this (including the initial entries from /etc/examples/pf.conf)

##########
# Macros #
##########
# see also `sysctl net.inet.ip.port{first,last}`
minefield="1024:9999"
ssh_alternate_port=2345


##########
# Tables #
##########
table <bruteforce> persist
table <troublemakers> persist
table <domesticv4> persist \
  file "/usr/local/share/geoip/ipv4_us-aggregated.zone"
table <domesticv6> persist \
  file "/usr/local/share/geoip/ipv6_us-aggregated.zone"


###########
# Options #
###########
set skip on lo


#########
# Rules #
#########
block return    # block stateless traffic
pass      # establish keep-state

# block brute-forcers
block quick proto tcp from <bruteforce> \
  to (egress) port $ssh_alternate_port
block quick proto tcp from <troublemakers> \
  to (egress) port $ssh_alternate_port

# non-domestic v4/v6 connections simply not allowed to touch SSH
block quick inet proto tcp \
  from ! <domesticv4> \
  to (egress) port $ssh_alternate_port
block quick inet6 proto tcp \
  from ! <domesticv6> \
  to (egress) port $ssh_alternate_port

# Port build user does not need network
block return out log proto {tcp udp} user _pbuild

# these made it through to the SSH port but abusing it
pass proto tcp from any to (egress) \
  port {$ssh_alternate_port} \
  flags S/SA keep state \
  (max-src-conn 5, max-src-conn-rate 5/5, \
  overload <bruteforce> flush global \
  )

## these are just randomly probing

# port 22 is a dead-giveaway
# because we know we moved our SSH server elsewhere
# Also, we have no telnet or SMB
# so anyone poking there
# is up to no good
pass in on egress proto tcp \
  from any \
  to (egress) port { \
    telnet, \
    ssh, \
    netbios-ns, \
    netbios-ssn, \
    microsoft-ds \
  } \
  synproxy state \
  tag trouble

# tag stuff in the range $minefield as trouble
pass in on egress proto tcp from any to (egress) port $minefield \
  synproxy state \
  tag trouble

# unless it's to our hole-in-one
pass in proto tcp from any to (egress) port $ssh_alternate_port tag good

# if we're still trouble, add to the troublemakers
pass proto tcp from any to (egress) port $ssh_alternate_port \
  tagged trouble \
  synproxy state \
  (max-src-conn 1, max-src-conn-rate 1/10, \
  overload <troublemakers> flush global \
  )

pass proto tcp from any to any port {http https} \
  keep state (max-src-conn 10, max-src-conn-rate 10/3)


/etc/pf.conf

Conclusion

With all this in place, the SSH port now has protection from non-domestic IP addresses, folks probing around randomly to ports they have no business connecting to, and folks who actually find the port but bang against it too hard. From here, one might also implement fail2ban, blacklistd, or sshguard to monitor your sshd logs, watching for failed logins and then banning based on repeated login failures.

Setting up a terminal screen-reader on OpenBSD

This post walks through installing the screen-reader yasr and configuring it to use speech-dispatcher with a TTS engine.

This assumes you have successfully installed a stock OpenBSD system and that your user has doas access to install software.

Install required packages

First, install the requisite packages from the repos:

$ doas pkg_add speech-dispatcher espeak flite
The flite package is optional, but can provide you with an alternate set of voices.

Currently yasr isn't available as an OpenBSD package, but it's fairly easy to install. You should be able to obtain the source code from the yasr source repository.

 git clone https://github.com/mgorse/yasr
and to build it
$ cd yasr/yasr
$ make
At some point, hopefully yasr will be in the package repositories, making it as easy as pkg_add yasr.

Ensure that sound works

If you haven't already, make sure that your OpenBSD box can play audio. You can check this by using

$ aucat -i somefile.wav
If you don't have any .wav files on your machine, speech-dispatcher provides /usr/local/share/sounds/speech-dispatcher/test.wav so
aucat -i /usr/local/share/sounds/speech-dispatcher/test.wav
should play a short flourish. If it is too quiet, use mixerctl to set the volume or try additional OpenBSD audio troubleshooting techniques.

Set up speech-dispatcher

To create a user configuration file in ~/.config/speech-dispatcher/ use

$ spd-conf
with the default output of "espeak" (you can later try changing this to "flite" but I leave that as an exercise for the reader) and output via libao. You can also set the default output speed. I prefer it a little faster than the default but it might be good to start with the default and then tune it later. As you configure, it should offer you the option to run some tests. If you choose "yes" for the first test, you should hear "Speech dispatcher works." I recommend skipping the other tests. With this in place, you should now have a ~/.config/speech-dispatcher/ directory with various configuration settings within.

The yasr screen-reader requires speech-dispatcher to listen on an inet socket rather than a Unix socket, so edit the config file with your favorite text editor and uncomment/change the CommunicationMethod from "unix_socket" to "inet_socket"

$ ed ~/.config/speech-dispatcher/speechd.conf
/CommunicationMethod.*unix_socket
a
CommunicationMethod "inet_socket"
.

wq
This does open a local port (6560) allowing other processes on the same machine to connect to speech-dispatcher just so you're aware. If you choose to change the port, just make sure to also change it in the yasr configuration later.

Configure yasr

Copy the default yasr configuration file for modification:

$ cp ~/tmp/yasr-0.6.9/yasr.conf ~/.yasr.conf
Make the following changes:

  • Comment out all the synthesizer=… lines except the synthesizer=speech dispatcher line, uncommenting that one
  • Uncomment the synthesizer port=127.0.0.1:6560 line
  • Set the shell=… to your preferred shell. OpenBSD doesn't install bash at /bin/bash so you'll either want to use /bin/ksh or, (if you have bash installed) /usr/local/bin/bash, or your preferred default shell.
You can change other aspects of yasr in here, but for now, leave the defaults.

Testing

It took me a bit to figure out that speech-dispatcher listens for a connection but times-out (by default, 5 seconds) and then quits. So you need to invoke it and then immediately launch yasr

$ speech-dispatcher && ~/tmp/yasr-0.6.9/yasr/yasr
If all is working successfully it should start yasr and you should hear it read your prompt.

Getting started with yasr

The ~/.yasr.conf file lists all of the keyboard commands for both normal/interactive mode (the default) and for review mode. Keys are given by ASCII value written in hex.

Normal/interactive mode commands
Hex value(s) Keyboard command Action
0x01 Ctrl+A say cursor
0x0c Ctrl+L say line
0x0e Ctrl+N bypass
0x18 Ctrl+X flush
0x1b0b Ctrl+Alt+K keyboard wizard
0x1b0f Ctrl+Alt+O options menu
0x1b13 Ctrl+Alt+S write configuration
0x1b30 Alt+0 say line:256
0x1b62 Alt+b say character:-1
0x1b63 Alt+c say character
0x1b64 Alt+d say word
0x1b65 Alt+e read cursor to bottom
0x1b69 Alt+i reinitialize
0x1b6b Alt+k say line:-1
0x1b6c Alt+l say line
0x1b6d Alt+m say line:1
0x1b72 Alt+r toggle review mode
0x1b74 Alt+t read top to cursor
0x1b77 Alt+w read screen
0x1b78 Alt+x flush:1

When you enter review-mode, commands go to yasr directly instead of being passed to the application.

Review-mode commands
Hex value(s) Keyboard command Action
0x20 space say review cursor
0x24 $ end of line
0x28 ( previous paragraph
0x29 ) next paragraph
0x3c < search back
0x3e > search forward
0x5e ^ beginning of line
0x60 ` ascii
0x62 b say character:-1
0x63 c say character
0x65 e read cursor to bottom
0x66 f find
0x6a j say line:1:0
0x6b k say line:-1:0
0x6c l say line
0x6d m say line:1:0
0x6e n bypass
0x74 t read top to cursor
0x76 v set option:6
0x77 w read screen
0x78 x say word:1
0x7a z say word:-1
0x1b73 Alt+s set option:5
0x1b5b41 Up arrow say line:-1:1
0x1b5b42 Down arrow say line:1:1
0x1b5b44 Left arrow say character:-1
0x1b5b43 Right arrow say character:1

I'm not quite sure what several of those do, but feel free to experiment with them.

Next steps

From here, you can tweak your yasr keyboard commands in your ~/.yasr.conf or change your speech-dispatcher settings in your ~/.config/speech-dispatcher/speechd.conf and ~/.config/speech-dispatcher/modules/espeak.conf files.

You might copy/link the yasr binary into some place on your $PATH such as

$ mkdir ~/bin
$ ln -s ~/tmp/yasr-0.6.9/yasr/yasr ~/bin

If you're feeling adventuous and prefer flite you might try configuring speech-dispatcher to use that instead.

Finally, it helps to have a suite of applications that play nicely with text-to-speech output. A few you might want to try:

  • Local mail: mail/mailx
  • Remote mail: s-nail (available as a package)
  • Calendar: remind (available as a package)
  • Web-browsing: lynx or edbrowse
  • RSS: rss2email or newsboat (both available as as packages)
  • IRC: ii (available as a package) or tinyirc (not currently available as a package)
  • Task management: taskwarrior or devtodo (both available as as packages)
  • Music: cmus or mpd/mpc (both available as as packages)
  • Terminal multiplexing: tmux or GNU screen (available as packages) It helps to either hide the status-bar or remove the clock so that it doesn't constantly spam you with time updates
  • Games:
    • bsdgames collection
    • frotz interpreter for Infocom-style text adventures

CLI best practices

This article pulls together a number of tips and best practices for building CLI applications.

Input

  • Use standard library input functions to read from stdin that can play nicely with rlwrap(1) and other input sources.
  • If using readline functionality, provide an option to disable it.
  • Only use readline functionality if you have used isatty(3) to test if stdin is a terminal.
  • Don't pass sensitive information like passwords and API tokens on the command-line since they will appear in the output of ps(1). Instead store them in a file with appropriate ownership & permissions or pass them to the application in an environment variable.
  • If you need to interactively read sensitive information such as passwords, read directly from /dev/tty or use curses/ncurses calls to prevent sensitive input from displaying on the screen.

Output

  • Though obvious, put errors on stderr and output on stdout.
  • Only show progress meters if requested via a command-line option.
  • Allow tunable output verbosity, preferrably including a completely silent mode (often -q/--quiet or -s/--silent) and increasing levels of info/debugging output (often one or more -v/--verbose options).
  • Make color optional/smart by respecting $NO_COLOR if set, or $COLOR={always,never,auto}. If $COLOR (or $PROGNAME_COLOR) is not set, use isatty(3) to establish a default.
  • Default output format should have one record per line with easy-to-discern/use column delimiters, not one record spanning multiple lines (unless requested as an output option like sqlite's ".mode line"). Use command-line options to specify other output formats such as JSON, XML, YAML, S-expressions, CSV or TSV.
  • Don't produce binary output on stdout unless explicitly requested.
  • Print numbers unformatted unless requested otherwise. This might include internationalizing/localizing things like currency markers, thousands separators, and decimal separators. Or this might involve humanizing units, often with a -k (Kilobytes), -m (Megabytes), -g (Gigibytes), or -h/--humanize flag.
  • Offer formatting control for dates/time including internationalizing/localizing or explicitly specifying a format string. If using a format string, stick to one standard: preferrably strftime(3) strings (%Y-%m-%d %H:%M), but CLDR format date strings, or PHP style date format strings are also common in certain communities.

Command-line options & switches

  • Provide both short (-x) and long (--long-option) versions of options. On non-GNU systems, long options are less expected.
  • Use common command-line option conventions:
    • -h/--help should give an example of usage, a description of what the program does, along with a list of options and their descriptions. Note that -h may conflict with "human output".
    • -v/--version should give version information. Note that -v may conflict with verbosity flags.
    • If a command has side-effets such as deleting/renaming/modifying files or transferring large volumes of data, providea -n/--dry-run to let the user see what will happen without actually performing the requested action.
    • -- to separate options and arguments.
    • Use - as a file-name to read from stdin.
    • Use @filename to read data from filename instead of data provided directly on the command-line.
  • For long options that take arguments, allow either an equals sign or a space to separate the option from its arguments. E.g. --name=Sam or --name Sam.
  • It can be beneficial to provide an option to expose expected completions for shell command-line completion functions.

Defaults

  • If an option can have a default value, provide one.
  • If output is truncated to the width of the tty, use curses calls to determine the width and fall back to 80 columns otherwise.
  • If spawning an external editor, check the following environment variables and binaries in order
    1. $PROGNAME_EDITOR
    2. $VISUAL
    3. $EDITOR
    4. vi(1)
    5. ed(1)
    using the first one that matches.

Other considerations

  • Where possible, allow the user to specify options in any order. Requiring options in a particular order makes things more fragile.
  • If one tool provides multiple actions, use the form "programname {global options} action {action-specific options}". Examples include
    • git (git commit)
    • Subversion (svn commit)
    • Mercurial (hg commit)
    • CVS (cvs commit)
    • taskwarrior (task list)
    • managing services (service httpd start)
  • Use $LC_CTYPE to determine the encoding of stdin/stdout but allow the user to specify alternate encodings and error/replacement strategies.
  • Return a 0 exit status on success. Return a non-0 exit status if any errors occurred, preferrably using standard values from /usr/include/sysexits.h.
  • Store local user configuration in ~/.config/$PROGNAME/
  • Settings should be determined in the following order (from lowest priority to highest precedence)
    1. built in configuration
    2. system-wide configuration from either /etc/${PROGNAME}rc, /etc/${PROGNAME}.conf, /etc/${PROGNAME}/*, /usr/local/etc/${PROGNAME}rc or /usr/local/etc/${PROGNAME}.conf
    3. user configuration file in ~/.config/$PROGNAME/*
    4. environment variables
    5. command-line options
  • On Microsoft Windows, these conventions may differ. Notably, the use of /option:value instead of --option=value

Other resources