An experimental TLS server written in Rust trying to be agnostic to both the HTTPS and Gemini protocols, and with lots of love for mutual TLS authentication.
I built this mainly to learn more about Gemini and play around in Rust more, but also to host ruby.sh.
If you ran all the required commands in the "Initial setup" section below in the repository root, on most operating systems the server should start with just:
cargo runHowever, there will be no content to serve. You might want to make an index.html.hbs in the public_root folder in the repository.
To get detailed debug messages about what's going on, run with RUST_LOG=DEBUG:
RUST_LOG=DEBUG cargo runIf running OpenBSD, see the OpenBSD-specific information at the bottom of the file.
rubyshd uses 4 folders and 3 files for serving content which are configurable with these environment variables:
PUBLIC_ROOT_PATH- Acts as the public root from which files are served. Defaults to thepublic_rootfolder in the repository root.ERRDOCS_PATH- Stores files to be used for error pages (only used for HTTPS as Gemini has no such concept). See the error status code slugs insrc/response.rsfor the possible filenames (i.e.not_found.html.hbs) Defaults to theerrdocsfolder in the repository root.PARTIALS_PATH- Stores Handlebars template partials which can be referenced by other partials and Handlebar template files in thePUBLIC_ROOT_PATHorERRDOCS_PATH. Files without thehbsextension are ignored. Defaults to thepartialsfolder in the repository root.DATA_PATH- Stores JSON files which are loaded and available under thedatavariable when Handlebars template files are rendered. Files without thejsonextension are ignored. Defaults to thedatafolder in the repository root.TLS_CLIENT_CA_CERTIFICATE_PEM_FILENAME- A file with PEM-formatted certificate used to verify client certificates during mutual TLS authentication. Defaults to theca.cert.pemfile in the repository root.TLS_SERVER_CERTIFICATE_PEM_FILENAME- A PEM-formatted certificate used for the server. Defaults to thelocalhost.cert.pemfile in the repository root.TLS_SERVER_PRIVATE_KEY_PEM_FILENAME- A PEM-formatted key used for the server. Defaults to thelocalhost.pemfile in the repository root.
When running on OpenBSD, the application will lock filesystem access down to just these with unveil(2).
These other configuration options are also configurable by environment variable:
MAX_REQUEST_HEADER_SIZE- The maximum acceptable size for a request. Defaults to 2048.TLS_LISTEN_BIND- The address/port to listen on. Both HTTPS and Gemini will be served from this single bind - consider usingrelayd(8)or similar if you want to serve on both ports 443/1965 - an examplerelayd.conf(5)is provided below. Defaults to127.0.0.1:4443.DEFAULT_HOSTNAME- The default hostname used to generate aurl::Urlwhen aHostheader is not present in an HTTPS request. Defaults toruby.sh.
The below flow is provided as a reference for how rubyshd routes requests, as this works rather differently than other web/Gemini servers. rubyshd will use the first file it can successfully load for the response.
- User makes a request to
/path - If
{PUBLIC_ROOT_PATH}/pathis a directory...- Try
{PUBLIC_ROOT_PATH}/path/index.hbs - If request is HTTPS protocol...
- Try
{PUBLIC_ROOT_PATH}/path/index.htm - Try
{PUBLIC_ROOT_PATH}/path/index.htm.hbs - Try
{PUBLIC_ROOT_PATH}/path/index.html - Try
{PUBLIC_ROOT_PATH}/path/index.html.hbs
- Try
- If request is Gemini protocol...
- Try
{PUBLIC_ROOT_PATH}/path/index.gmi - Try
{PUBLIC_ROOT_PATH}/path/index.gmi.hbs
- Try
- Try
- Else...
- Try
{PUBLIC_ROOT_PATH}/path - Try
{PUBLIC_ROOT_PATH}/path.hbs - If request is HTTPS protocol...
- Try
{PUBLIC_ROOT_PATH}/path.htm - Try
{PUBLIC_ROOT_PATH}/path.htm.hbs - Try
{PUBLIC_ROOT_PATH}/path.html - Try
{PUBLIC_ROOT_PATH}/path.html.hbs
- Try
- If request is Gemini protocol...
- Try
{PUBLIC_ROOT_PATH}/path.gmi - Try
{PUBLIC_ROOT_PATH}/path.gmi.hbs
- Try
- Try
{PUBLIC_ROOT_PATH}/path.md - Try
{PUBLIC_ROOT_PATH}/path.md.hbs
- Try
All HTTPS responses for static files (i.e. everything except rendered templates/redirects/errors) are marked as cacheable with the max-age value set to CACHEABLE_MAX_AGE_SECONDS.
The handlebars-rust project is used for templating and the original handlebarsjs.com documentation is a sufficient reference. However, these rubyshd-specific decorators/helpers/quirks are useful to know. Unless otherwise stated, this applies to requests from both the HTTPS and Gemini protocols.
- Only files ending in
.hbsare treated as templates. - Files ending in
.md.hbsare rendered as handlebars templates, converted from Markdown to HTML/Gemtext if necessary, and then rendered again as a template through Handlebars. - All
.hbsfiles inPARTIALS_PATHcan be loaded in any Handlebars template using the filename without the.hbsextension. For example,{PARTIALS_PATH}/layout.html.hbscan be used with{{#> layout.html}}or similar. - All
.jsonfiles inDATA_PATHare automatically loaded and made available under thedataproperty using the filename without the.jsonextension. For example,{DATA_PATH}/navbar.jsoncan be used with{{#each data.navbar}}...{{/each}}or similar. - If a YAML Front Matter is present at the start of the file, it will be available under the
metaproperty... - The
*statusdecorator can be used to set the status code used for the response. The value in the last call to the decorator will be the one used. The parameter must be one of theStatusslugs insrc/response.rs. For example,{{*status "unauthenticated"}}and{{*status "other_server_error"}}are valid calls. - The
*media-typedecorator can be used to set the response media type (i.e.Content-Typein HTTPS responses). For example,{{*media-type "text/csv"}}and{{*media-type "application/json"}}are valid calls. - The
*temporary-redirectand*permanent-redirectdecorators can be used to set temporary and permanent redirects respectively. For example,{{*temporary-redirect "https://google.com/"}}will return a temporary redirect tohttps://google.com. For consistency with Gemini, no response body will be returned with HTTPS responses when a redirect is made regardless of it's position in the template (templates will always render in full unless an error occurs). - The
pick-randomhelper takes an array and chooses a random value from it. For example, ifrandom_photos.jsoncontains an array of random photo URLs,pick-random data.random_photoswill return one of the values from the array. - The
partial-for-markuphelper takes a name and returns the markup-dependent partial name. For example,{{partial-for-markup "header"}}will returnheader.gmion Gemini protocol requests. - The following request-specific properties are also available:
peer_addr- client IP addresspath- the requested pathcommon_name- the common name of the client if they authenticated successfully with a client certificate, otherwiseanonymousprotocol- the protocol name (GeminiorHTTPS)is_authenticated- if the request was authenticated successfully by mutual TLS with a client certificateis_anonymous- opposite ofis_authenticatedis_https- if the request was made with HTTPS protocolis_gemini- if the request was made with Gemini protocolos_platform- the OS platform the server is running on (seestd::env::consts::OSfor a list of possible values)
An example template combining some of these decorators and properties might look like:
openssl genpkey -algorithm ed25519 -out ca.pem
openssl pkey -in ca.pem -pubout -out ca.pub.pem
openssl req -x509 -sha256 -new -nodes -key ca.pem -days 9999 -out ca.cert.pemThis is only necessary if you want to test with the TLS mutual client authentication.
ECC
openssl req -newkey ed25519 -days 1000 -nodes -keyout client.pem > client.certreq.pem
openssl x509 -req -in client.certreq.pem -days 1000 -CA ca.cert.pem -CAkey ca.pem -set_serial 01 > client.cert.pem
openssl pkcs12 -export -legacy -in client.cert.pem -inkey client.pem -out client.pfx
rm client.certreq.pem
rm client.cert.pem
rm client.pemRSA (macOS etc)
openssl req -newkey rsa:2048 -days 1000 -nodes -keyout client.pem > client.certreq.pem
openssl x509 -req -in client.certreq.pem -days 1000 -CA ca.cert.pem -CAkey ca.pem -set_serial 01 > client.cert.pem
openssl pkcs12 -export -legacy -in client.cert.pem -inkey client.pem -out client.pfx
rm client.certreq.pem
rm client.cert.pem
rm client.pemopenssl req -x509 -newkey rsa:4096 -sha256 -days 3650 \
-nodes -keyout localhost.pem -out localhost.cert.pem -subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"This uses CloudFlare DNS verification and generates a wildcard certificate.
export CF_Token="TOKEN"
export CF_Email="[email protected]"
acme.sh --issue --dns dns_cf -d ruby.sh -d '*.ruby.sh' --server letsencrypt
acme.sh --renew -d ruby.sh -d '*.ruby.sh' --server letsencrypt
cp /Users/ruby/.acme.sh/ruby.sh_ecc/ruby.sh.cer ruby.sh.cert.pem
cp /Users/ruby/.acme.sh/ruby.sh_ecc/ruby.sh.key ruby.sh.pem
cp /Users/ruby/.acme.sh/ruby.sh_ecc/ca.cer ruby.sh.intermediate.pem
cp /Users/ruby/.acme.sh/ruby.sh_ecc/fullchain.cer ruby.sh.fullchain.pemSome additional ports and environment variables are required to build on OpenBSD:
doas pkg_add llvm cmake
export LIBCLANG_PATH=/usr/local/llvm17/lib
cargo build # or cargo build --releaseOne-liner to rebuild for release:
LIBCLANG_PATH=/usr/local/llvm17/lib cargo build --releaseYou might also want to setup relayd(8) to bind 0.0.0.0:443 and 0.0.0.0:1965 to the single listen port 4443.
Enable:
doas vi /etc/relayd.conf
doas rcctl enable relayd
doas rcctl start relayd
An example relayd.conf(5):
protocol "rubyshd" {
tcp { nodelay, sack, socket buffer 65536, backlog 100 }
}
relay "rubyshd_gemini" {
listen on 0.0.0.0 port 1965
protocol "rubyshd"
forward to 127.0.0.1 port 4443
}
relay "rubyshd_https" {
listen on 0.0.0.0 port 443
protocol "rubyshd"
forward to 127.0.0.1 port 4443
}
An example rc.d daemon control script:
#!/bin/ksh
daemon="/home/ruby/rubyshd/target/release/rubyshd"
daemon_user="ruby"
daemon_logger="daemon.info"
daemon_execdir="/home/ruby/rubyshd"
. /etc/rc.d/rc.subr
rc_exec() {
local _rcexec="su -fl -c ${daemon_class} -s /bin/sh ${daemon_user} -c"
[ "${daemon_rtable}" -eq "$(id -R)" ] ||
_rcexec="route -T ${daemon_rtable} exec ${_rcexec}"
local _set_monitor=":"
# Run non-daemons services in a different process group to avoid SIGHUP
# at boot.
if [ X"${rc_bg}" = X"YES" ]; then
_set_monitor="set -o monitor"
fi
${_rcexec} "${_set_monitor}; \
${daemon_logger:+set -o pipefail; } \
${daemon_execdir:+cd ${daemon_execdir} && } \
export RUST_LOG="info" && \
export DEFAULT_HOSTNAME="ruby.sh" && \
export TLS_SERVER_CERTIFICATE_PEM_FILENAME="ruby.sh.fullchain.pem" && \
export TLS_SERVER_PRIVATE_KEY_PEM_FILENAME="ruby.sh.pem" && \
$@ \
${daemon_logger:+ 2>&1 |
logger -isp ${daemon_logger} -t ${_name}}"
}
rc_bg=YES
rc_reload=NO
rc_cmd $1rubyshd doesn't support plaintext HTTP, so you may also want to redirect HTTP traffic on that port to 443. An example httpd.conf(5)
server "ruby.sh" {
listen on * port 80
location * {
block return 301 "https://$HTTP_HOST$REQUEST_URI"
}
}
- Better tests and CI
- Macro or similar for quickly creating handlebars helpers
- Overall code cleanup