You can have a server serve both Gemini and the web, on the same port. Here’s how.

In the context of this page, virtual hosting means that you have a single machine serving multiple hostnames. In my case, the machine is called sibirocobombus. On this machine, on port 1965, I serve different pages depending on whether clients are requesting alexschroeder.ch or transjovian.org, for example. Thus, for virtual hosting to work, the server needs to know what the client is asking for. In the case of Gemini, the hostname is part of the URL; in the case of HTTP/1.1, the hostname is an extra header (and HTTP/1.0 doesn’t support virtual hosting because this header isn’t mentioned). But we’ll get to all that!

The anatomy of a Gemini and a HTTP request

A Gemini request is simply an URL being received by the server:

gemini://transjovian.org/test/testing

A HTTP request is bit more complicated: the first line has the “method” (GET is for reading, HEAD is for checking, PUT and POST are for writing, and so on), the “path”, the “protocol version”, and some headers.

GET /test/testing HTTP/1.1
Host: transjovian.org

There are many more headers a HTTP request can have, but we’re going to ignore them all, except for one: if this is HTTP/1.1 then the host header is going to tell us which hostname the client is requesting, which is how we can do virtual hosting. Remember: if you’re accepting HTTP/1.0 requests, you cannot do virtual hosting.

The Gemini request is terminated by a carriage return and a line feed: \r\n; all the lines of the HTTP request are also terminated by \r\n. The headers of the HTTP request end when there’s an empty line.

We can test this right now, using openssl, gnutls-cli, or ncat (the one with SSL support).

Gemini:

echo -e "gemini://transjovian.org:1965/test/page/testing\r" \
    | ncat --ssl transjovian.org 1965

HTTP:

echo -e "GET /test/page/testing HTTP/1.1\r\nHost: transjovian.org\r\n" \
    | ncat --ssl transjovian.org 1965

The Plan

In your server code, there’s some section where you’re reading the request. The first thing you need to do is parse the entire request:

Note that HTTP/1.0 didn’t support virtual hosting and thus you’re probably not going to get a Host header. Then again, if you do, why not accept it…

The Code

I use Perl for Phoebe. This is where I read one line and try to parse it as a URL, setting $scheme, $authority, $path, $query, and $fragment. If the first line is in fact a HTTP request (starting with a word, whitespace, some stuff, and ending in HTTP/1.0 or HTTP/1.1), I’m going to parse more headers:

$url = <STDIN>;
my($scheme, $authority, $path, $query, $fragment) =
    $url =~ m|(?:([^:/?#]+):)?(?://([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?|;
my $headers;
$headers = $self->headers() if $url =~ m!^[a-z]+ .* HTTP/1\.[01]$!i;

The header parsing means reading more lines. These headers have the same format as mail headers!

Each header field consists of a name followed immediately by a colon (":"), a single space (SP) character, and the field value. Field names are case-insensitive. Header fields can be extended over multiple lines by preceding each extra line with at least one SP or HT, though this is not recommended.

This is why I’m appending lines that start with whitespace to the value of the last key. It’s weird, and I don’t actually use this feature, but that’s what you get when you’re trying to handle HTTP in addition to Gemini. It’s my own fault, and I know it.

while (<STDIN>) {
  if (/^(\S+?): (.+?)\r?$/) {
    ($key, $value) = (lc($1), $2);
    $result{$key} = $value;
  } elsif (/^\s+(.+?)\r?$/) {
    $result{$key} .= " $1";
  } else {
    last;
  }
}

All you have to do now is look at the info you have (hostname, path) to decide what to serve. In my case it’s a huge if/then/else switch.

In the Gemini case we verify the hostname in the URL and extract the page (decoding as necessary) and all that:

} elsif ($url =~ m!gemini://($host_regex)(?::$port)?/page/([^/]+)$!) {
      $self->serve_page(… decode_utf8(uri_unescape(…)) …);
}

And some time later we also handle HTTP requests. In this case $url isn’t actually a URL but the request line of a HTTP request and in order to verify the hostname we need to look at the headers:

} elsif ($url =~ m!^GET /page/([^/]*) HTTP/1\.[01]$!
	 and ($host) = $headers->{host} =~ m!^($host_regex)$!) {
      $self->serve_page_via_http(… decode_utf8(uri_unescape(…)) …);
}

The only tricky part I skipped over, I think, is how to handle the port. In Gemini, the default port may or may not be part of the URL, and in the HTTP case, the default port may or may not be part of the host header value. You’ll just have to figure out how to handle that exactly. If you’re not listening on multiple ports and serving different content depending on the port, you should be fine.

References

RFC 822 (mail headers)

RFC 1945 (HTTP/1.0)

RFC 2616 (HTTP/1.1)