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
$1equals
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.
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.
Occasionally I have multiple windows open in
vim
and want to add all of those files to
git.
Rather than specifying each filename explicitly,
I use this:
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.txtfile2.txt
^diff^add
git commit -m "Looks good to me"
The second command replaces
diff
with
add
turning the command into
git add -- file1.txtfile2.txt
without having to retype the entire command.
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}'
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.
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.
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.
change port on which SSH listens
flag any IP addresses that touch our server's minefield
and prevent them from accessing SSH
if anybody actually finds the SSH port and hammers on it,
ban them too
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:
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:
/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):
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:
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.
The
remind
calendar utility offers an amazing degree of power
for doing calendar-related scheduling unmatched by
any other calendar program I've used.
If you haven't encountered it before,
this
presentation (PDF)
gives a good overview of some of the features,
as do
Linux Journal
and
43 Folders.
In this post,
I'll walk through
how I use both the basic
and advanced features.
In this article
we install
rss2email
(along with its dependencies),
configure it to send mail via the local MDA
(usually
sendmail),
add some RSS feeds,
and then read them via
mail/mailx.
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
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_socketa
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
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
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
$PROGNAME_EDITOR
$VISUAL
$EDITOR
vi(1)
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)
built in configuration
system-wide configuration from either
/etc/${PROGNAME}rc,
/etc/${PROGNAME}.conf,
/etc/${PROGNAME}/*,
/usr/local/etc/${PROGNAME}rc
or
/usr/local/etc/${PROGNAME}.conf
user configuration file in
~/.config/$PROGNAME/*
environment variables
command-line options
On Microsoft Windows,
these conventions may differ.
Notably, the use of
/option:value
instead of
--option=value