250 Commits

Author SHA1 Message Date
Adnan Maolood
0722f4008a Update documentation 2021-02-21 00:56:37 -05:00
Adnan Maolood
e3d48b2cad server: Return ErrServerClosed 2021-02-21 00:51:02 -05:00
Adnan Maolood
3fa55b52dd server: Use separate context to cancel listeners
Use a separate context to cancel listeners so that cancelling the
listener does not cancel it's connections.
2021-02-21 00:41:41 -05:00
Adnan Maolood
6c701ad9fe examples/stream: Showcase Server.Shutdown method 2021-02-21 00:29:41 -05:00
Adnan Maolood
7084a226f4 examples: Use context 2021-02-21 00:26:30 -05:00
Adnan Maolood
f6505ae4c4 server: Use explicit context arguments
Replace the Server.Context field with explicit context.Context arguments
to most Server functions.
2021-02-21 00:21:31 -05:00
Adnan Maolood
0c8c945eba client: Inline result type 2021-02-21 00:20:42 -05:00
Adnan Maolood
7668345daa server: Add Context field 2021-02-20 18:52:33 -05:00
Adnan Maolood
0baa66a4e7 Update examples 2021-02-20 18:30:49 -05:00
Adnan Maolood
d479c6391c certificate.Store: Support "*" wildcard pattern 2021-02-20 18:26:15 -05:00
Adnan Maolood
423914d6e0 certificate.Store: Generate certificates by default 2021-02-20 18:25:02 -05:00
Adnan Maolood
15f3e764c5 server: Populate Request.Host field 2021-02-20 18:12:51 -05:00
Adnan Maolood
fadb2aed97 mux: Use StatusHandler instead of RedirectHandler 2021-02-20 16:45:37 -05:00
Adnan Maolood
252fe678fd Rename RedirectHandler to StatusHandler 2021-02-20 16:44:42 -05:00
Adnan Maolood
351fb92c7e Remove NotFound function 2021-02-20 16:42:18 -05:00
Adnan Maolood
2308c6407f server: Make Request.RemoteAddr a net.Addr
A concrete type is better.
2021-02-20 16:27:35 -05:00
Adnan Maolood
8938038797 Make Status a type
Using a type is better than using an integer.
2021-02-20 16:16:32 -05:00
Adnan Maolood
99a8f09c22 TimeoutHandler: Use provided context 2021-02-20 15:52:54 -05:00
Adnan Maolood
e9a68917c9 handler: Make ServeGemini accept a Context 2021-02-20 15:49:14 -05:00
Adnan Maolood
eca2afeb32 examples/client: Remove dependency on go-xdg 2021-02-20 15:42:06 -05:00
Adnan Maolood
28b6232fbf examples/client: Use context 2021-02-20 15:39:47 -05:00
Adnan Maolood
3f4fd10b6d client: Make Get and Do accept a Context
This removes the need for Request.Context.
2021-02-20 15:34:21 -05:00
Adnan Maolood
a7f958b20d server: Make Request.RemoteAddr a string 2021-02-20 13:31:55 -05:00
Adnan Maolood
0ab236c736 client: Allow Request.Host to omit a port 2021-02-20 13:30:55 -05:00
Adnan Maolood
5922cff2e5 Implement TimeoutHandler 2021-02-20 00:49:21 -05:00
Adnan Maolood
64dbb3eecb server: Clarify GetCertificate documentation 2021-02-19 18:53:06 -05:00
Adnan Maolood
69674fcdd5 examples/server: Use certificate.Store 2021-02-19 18:45:19 -05:00
Adnan Maolood
66e03ef1e4 certificate: Implement GetCertificate callback 2021-02-19 18:42:53 -05:00
Adnan Maolood
76967dad2e mux: Reject empty patterns 2021-02-19 18:06:54 -05:00
Adnan Maolood
2e149c9ccd server: Remove Certificates field
Use GetCertificate to retrieve certificates without consulting
Certificates.
2021-02-18 22:22:20 -05:00
Adnan Maolood
229ebb4106 request.Write: Accept an io.Writer 2021-02-18 21:58:37 -05:00
Adnan Maolood
c70ef5c470 client: Remove superfluous call to Flush 2021-02-18 21:55:17 -05:00
Adnan Maolood
6928a1efef request.Write: return error from Flush 2021-02-18 21:54:38 -05:00
Adnan Maolood
a80aae44a9 doc: Update package documentation 2021-02-18 00:38:03 -05:00
Adnan Maolood
aab3ac4dfe response: Implement Write method 2021-02-18 00:07:43 -05:00
Adnan Maolood
a3a995df35 response: Rename statusCode to status 2021-02-18 00:06:34 -05:00
Adnan Maolood
9ed2363b66 response: Ensure that only one header is written 2021-02-17 23:05:24 -05:00
Adnan Maolood
33a1fa4e0d Remove .gitignore 2021-02-17 20:40:53 -05:00
Adnan Maolood
7475687caa examples: Use Server.Handler 2021-02-17 20:35:27 -05:00
Adnan Maolood
6edde376c4 server: Add Handler field and remove Handle methods 2021-02-17 20:31:03 -05:00
Adnan Maolood
f3cd70612b mux: Implement matching of schemes and hostnames 2021-02-17 20:09:42 -05:00
Adnan Maolood
3d6ac90e08 Reverse order of RedirectHandler arguments 2021-02-17 19:53:00 -05:00
Adnan Maolood
b5a3c0adc5 Add utility Handler functions 2021-02-17 19:27:25 -05:00
Adnan Maolood
f81c32a211 examples: Use new ResponseWriter interface 2021-02-17 13:39:23 -05:00
Adnan Maolood
110c2de6de Redesign ResponseWriter interface 2021-02-17 13:36:16 -05:00
Adnan Maolood
8543eca416 status: Fix typo 2021-02-17 12:23:03 -05:00
Adnan Maolood
ec22e762c3 Rename Meta to StatusText
Rename Meta to StatusText and support all status codes.
2021-02-17 12:06:43 -05:00
Adnan Maolood
a3c1804395 Move ResponseWriter.Flush to Flusher interface 2021-02-17 11:44:13 -05:00
Adnan Maolood
fb9b50871c fs: Reject potentially unsafe requests in ServeFile
Reject requests where r.URL.Path contains a ".." path element to protect
against callers who might unsafely use filepath.Join on r.URL.Path
without sanitizing it.
2021-02-17 11:17:13 -05:00
Adnan Maolood
96dc161b4a fs: Add ServeContent function 2021-02-17 11:15:30 -05:00
Adnan Maolood
246b252fd7 examples/server: Use os.DirFS 2021-02-17 09:25:44 -05:00
Adnan Maolood
2e5569d5b5 fs: Fix redirect to canonical file path 2021-02-17 09:24:09 -05:00
Adnan Maolood
8eccefb8c9 fs: Add redirects 2021-02-17 01:38:18 -05:00
Adnan Maolood
995769556c fs: Trim trailing slash from name in ServeFile 2021-02-17 01:36:53 -05:00
Adnan Maolood
73bf1a31b0 fs: Clean paths before handling with FileServer 2021-02-17 00:59:15 -05:00
Adnan Maolood
fa7ec1ac87 fs: Show listing for directories without index files 2021-02-17 00:08:14 -05:00
Adnan Maolood
e3d1fc2785 fs: Remove leading slash before opening files 2021-02-16 23:18:37 -05:00
Adnan Maolood
332dd253d0 Replace uses of ioutil with io 2021-02-16 18:57:24 -05:00
Adnan Maolood
d2001de5f3 fs: Replace FileSystem with io/fs.FS 2021-02-16 18:53:56 -05:00
Adnan Maolood
cf995c86c9 Require Go 1.16 2021-02-16 18:50:42 -05:00
Adnan Maolood
dfa37aaeb8 client: Don't try to verify unicode hostname 2021-02-16 11:27:53 -05:00
Adnan Maolood
7c1a5184c9 Update examples/auth.go 2021-02-16 11:26:09 -05:00
Adnan Maolood
779be8b95b request: Allow User in URLs 2021-02-16 00:55:56 -05:00
Adnan Maolood
2157b35c0b Add build status badge to README.md 2021-02-16 00:07:01 -05:00
Adnan Maolood
1cb31e2d65 Add build manifest 2021-02-16 00:05:10 -05:00
Adnan Maolood
1d6cbddc5b server: Prevent adding Listeners after Close
Check done after calling trackListener to prevent the listener from
being registered after the server is closed.
2021-02-15 20:19:44 -05:00
Adnan Maolood
a05fa6d6bd server: Avoid creating a new Listener after Close 2021-02-15 20:16:32 -05:00
Adnan Maolood
f158bb5f1d server: Use separate mutex for handlers 2021-02-15 20:05:47 -05:00
Adnan Maolood
ec269c5c9d Add some tests 2021-02-15 19:20:37 -05:00
Adnan Maolood
bf4959a8ba Return ErrInvalidResponse on error reading status
Return ErrInvalidResponse when unable to read the response status code
instead of returning the error from strconv.
2021-02-15 19:18:23 -05:00
Adnan Maolood
19678ef934 Remove NewRequestFromURL method
Use a Request struct literal instead.
2021-02-15 17:23:56 -05:00
Adnan Maolood
5a784693ef server: Rename responder to handler 2021-02-15 01:15:23 -05:00
Adnan Maolood
2c7f8273e9 server: Recover from ServeGemini panics 2021-02-15 00:36:33 -05:00
Adnan Maolood
96a84ddd38 request: Don't read more than 1026 bytes 2021-02-15 00:16:21 -05:00
Adnan Maolood
3f2d540579 server: Implement Close and Shutdown methods 2021-02-14 23:58:33 -05:00
Adnan Maolood
92e7a309c6 Tweak returned error for requests that are too long
Return ErrInvalidRequest instead of ErrInvalidURL in Request.Write.
2021-02-14 23:33:18 -05:00
Adnan Maolood
c5ccbf023a fs: Refactor 2021-02-14 19:50:38 -05:00
Adnan Maolood
ff06e50df5 status: Update documentation 2021-02-14 19:28:29 -05:00
Adnan Maolood
5ec8dea1ba fs: Update documentation 2021-02-14 19:27:56 -05:00
Adnan Maolood
46e10da3a8 Make Request.Host optional 2021-02-14 19:02:34 -05:00
Adnan Maolood
41eec39a1d Update examples/client.go 2021-02-14 18:59:33 -05:00
Adnan Maolood
198a0b31c8 Remove faulty status code check in ReadResponse 2021-02-14 18:57:13 -05:00
Adnan Maolood
6f7c183662 server: Don't populate Request.Certificate field
Handlers should instead use the certificate provided in Request.TLS.
2021-02-14 17:34:57 -05:00
Adnan Maolood
20e1b14108 Update Client documentation 2021-02-14 17:11:05 -05:00
Adnan Maolood
0c303588a4 Update Response documentation 2021-02-14 16:23:38 -05:00
Adnan Maolood
37e5686764 Remove StatusClass* constants
Re-use the existing Status* constants and adjust StatusClass to return a
valid Status.
2021-02-14 16:01:39 -05:00
Adnan Maolood
7c703e95de Update documentation 2021-02-14 15:50:41 -05:00
Adnan Maolood
595b0d0490 server: Populate Request.RemoteAddr field 2021-02-13 21:10:19 -05:00
Adnan Maolood
d2c70a33d5 client: Punycode request URL 2021-02-09 16:55:14 -05:00
Adnan Maolood
79e0296bed client: Support IDNs
Convert IDNs to punycode before performing DNS lookups.
2021-02-09 15:59:47 -05:00
Adnan Maolood
f0e9150663 Add Gemini specification version to README.md 2021-02-09 15:50:54 -05:00
Adnan Maolood
f4b80ef305 Update documentation 2021-02-09 10:00:04 -05:00
Adnan Maolood
0e3b61ed00 examples/client: Fix opening of known hosts file 2021-02-09 09:48:51 -05:00
Adnan Maolood
f6824bd813 Make ResponseWriter an interface 2021-02-09 09:46:18 -05:00
Adnan Maolood
5ef5824d6f Use plain integers to represent status codes 2021-02-09 09:46:13 -05:00
Adnan Maolood
9bfb007581 Update README.md 2021-02-08 12:53:37 -05:00
Adnan Maolood
7910ed433b Rename Responder to Handler 2021-02-08 12:50:52 -05:00
Adnan Maolood
29f2b3738d Make TLS field nil for unencrypted connections 2021-02-08 12:32:49 -05:00
Adnan Maolood
1f39cab063 Remove unused field 2021-02-08 12:30:53 -05:00
Adnan Maolood
62960266ac tofu: Implement PersistentHosts 2021-01-25 12:11:59 -05:00
Adnan Maolood
3efa17f6fb Update examples 2021-01-25 10:59:50 -05:00
Adnan Maolood
9e89b93bab server: Allow handling any hostname with "*"
Allow registering a responder with the special pattern "*" to handle any
hostname.
2021-01-25 10:55:40 -05:00
Adnan Maolood
31de8d49b0 Guarantee that (*Response).Body is not nil 2021-01-15 15:18:00 -05:00
Adnan Maolood
2b17f3d8eb fs: Remove unused import 2021-01-14 22:45:09 -05:00
Adnan Maolood
f36a1c5c87 client: Add note about TOFU 2021-01-14 22:34:12 -05:00
Adnan Maolood
af61c1b60a fs: Update comments 2021-01-14 22:27:56 -05:00
Adnan Maolood
ad18ae601c fs: Don't panic on mime.AddExtensionType error
It's probably best not to panic if this fails.
2021-01-14 22:25:09 -05:00
Adnan Maolood
8473f3b9d4 fs: Update comments 2021-01-14 22:24:26 -05:00
Adnan Maolood
06c53cc5b1 server: Rename Register to Handle 2021-01-14 22:12:07 -05:00
Adnan Maolood
4b643523fb Update examples 2021-01-14 21:23:13 -05:00
Adnan Maolood
79a4dfd43f certificate: Add Dir.Entries function 2021-01-14 21:19:27 -05:00
Adnan Maolood
14d89f304a Move cert.go to a subpackage 2021-01-14 20:42:12 -05:00
Adnan Maolood
7a00539f75 tofu: Fix example 2021-01-14 19:57:52 -05:00
Adnan Maolood
a0adc42c95 tofu: Update documentation 2021-01-14 19:56:04 -05:00
Adnan Maolood
da8af5dbcb tofu: Update documentation 2021-01-14 19:40:19 -05:00
Adnan Maolood
ced6b06d76 Update examples/auth.go 2021-01-14 19:04:11 -05:00
Adnan Maolood
4a0f8e5e73 tofu: Rename KnownHosts.Hosts to Entries 2021-01-14 18:52:43 -05:00
Adnan Maolood
e701ceff71 Add KnownHosts.Hosts function 2021-01-14 18:50:03 -05:00
Adnan Maolood
1a3974b3a3 Update examples/client.go 2021-01-14 17:28:03 -05:00
Adnan Maolood
3fd55c5cee tofu: Add KnownHosts.Load function 2021-01-14 17:09:31 -05:00
Adnan Maolood
6f11910dff tofu: Add NewHostsFile function 2021-01-14 16:54:38 -05:00
Adnan Maolood
da3e9ac0fe tofu: Protect HostWriter with a mutex 2021-01-14 16:35:54 -05:00
Adnan Maolood
9fe837ffac tofu: Refactor known hosts
This commit introduces the KnownHosts struct, whose purpose is simply to
store known hosts entries. The HostWriter struct is now in charge of
appending hosts to files, and the two are not dependent on each other.
Users are now responsible for opening the known hosts file and closing
it when they are finished with it.
2021-01-14 16:26:43 -05:00
Adnan Maolood
4b8bb16a3d tofu: Rename KnownHost to Host 2021-01-14 14:15:08 -05:00
Hugo Wetterberg
95aff9c573 tofu: Refactor
This commit changes underlying file handling and known hosts parsing.

A known hosts file opened through Load() never closed the underlying
file. During known hosts parsing most errors were unchecked, or just
led to the line being skipped.

I removed the KnownHosts type, which didn't really have a role after
the refactor. The embedding of KnownHosts in KnownHosts file has been
removed as it also leaked the map unprotected by the mutex.

The Fingerprint type is now KnownHost and has taken over the
responsibility of marshalling and unmarshalling.

SetOutput now takes a WriteCloser so that we can close the underlying
writer when it's replaced, or when it's explicitly closed through the
new Close() function.

KnownHostsFile.Add() now also writes the known host to the output if
set. I think that makes sense expectation-wise for the type.

Turned WriteAll() into WriteTo() to conform with the io.WriterTo
interface.

Load() is now Open() to better reflect the fact that a file is opened,
and kept open. It can now also return errors from the parsing process.

The parser does a lot more error checking, and this might be an area
where I've changed a desired behaviour as invalid entries no longer
are ignored, but aborts the parsing process. That could be changed to
a warning, or some kind of parsing feedback.

I added KnownHostsFile.TOFU() to fill the developer experience gap
that was left after the client no longer knows about
KnownHostsFile. It implements a basic non-interactive TOFU flow.
2021-01-14 13:48:57 -05:00
Hugo Wetterberg
de042e4724 client: set the client timout on the dialer, close connection on err
Client.Timout isn't respected for the dial. Requests will hang on dial
until OS-level timouts kick in unless there is a Request.Context with
a deadline. We also fail to close the connection on errors.

This change sets the client timeout as the dialer timeout so that it
will be respected. It also ensures that we close the connection if we
fail to make the request.
2021-01-13 17:13:56 -05:00
Adnan Maolood
d78052ce08 Move tofu.go to a subpackage 2021-01-10 16:46:12 -05:00
Adnan Maolood
1f2888c54a Update documentation 2021-01-10 01:21:56 -05:00
Adnan Maolood
41d5f8d31b Move documentation back to doc.go 2021-01-10 01:16:50 -05:00
Adnan Maolood
24026422b2 Update examples/stream.go 2021-01-10 01:13:07 -05:00
Adnan Maolood
5e977250ec Update comments 2021-01-10 01:07:38 -05:00
Adnan Maolood
d8c5da1c7c Update link to documentation 2021-01-10 00:55:39 -05:00
Adnan Maolood
d01d50ff1a Simplify ResponseWriter implementation 2021-01-10 00:50:35 -05:00
Adnan Maolood
3ed39e62d8 Rename status.Message to status.Meta 2021-01-10 00:10:57 -05:00
Hugo Wetterberg
f2921a396f Add missing error handling
Error handling is currently missing is a couple of places. Most of
them are i/o related.

This change adds checks, an therefore sometimes also has to change
function signatures by adding an error return value. In the case of
the response writer the status and meta handling is changed and this
also breaks the API.

In some places where we don't have any reasonable I've added
assignment to a blank identifier to make it clear that we're ignoring
an error.

text: read the Err() that can be set by the scanner.

client: check if conn.SetDeadline() returns an error.

client: check if req.Write() returns an error.

fs: panic if mime type registration fails.

server: stop performing i/o in Header/Status functions

By deferring the actual header write to the first Write() or Flush()
call we don't have to do any error handling in Header() or Status().

As Server.respond() now defers a ResponseWriter.Flush() instead of
directly flushing the underlying bufio.Writer this has the added
benefit of ensuring that we always write a header
to the client, even if the responder is a complete NOOP.

tofu: return an error if we fail to write to the known hosts writer.
2021-01-09 23:53:07 -05:00
Hugo Wetterberg
efef44c2f9 server: abort request handling on bad requests
A request to a hostname that hasn't been registered with the server
currently results in a nil pointer deref panic in server.go:215 as
request handling continues even if ReadRequest() returns an error.

This change changes all if-else error handling in Server.respond() to
a WriteStatus-call and early return. This makes it clear when request
handling is aborted (and actually aborts when ReadRequest() fails).
2021-01-05 18:33:36 -05:00
Adnan Maolood
c8626bae17 client: Close connection for unsuccessful responses 2020-12-22 19:22:01 -05:00
Adnan Maolood
48fa6a724e examples/client: Fix fingerprint check 2020-12-19 13:44:33 -05:00
Adnan Maolood
80ffa72863 client: Verify expiration time 2020-12-19 13:43:47 -05:00
Adnan Maolood
61b417a5c4 Add ResponseWriter.Flush function 2020-12-18 13:15:34 -05:00
Adnan Maolood
a912ef996a Add examples/stream.go 2020-12-18 12:31:37 -05:00
Adnan Maolood
d9a690a98f Make NewResponseWriter take an io.Writer 2020-12-18 01:47:29 -05:00
Adnan Maolood
04bd0f4520 Update Request documentation 2020-12-18 01:43:18 -05:00
Adnan Maolood
d34d5df89e Add ReadRequest and ReadResponse functions 2020-12-18 01:42:05 -05:00
Adnan Maolood
decd72cc23 Expose Request.Write and Response.Read functions 2020-12-18 01:14:06 -05:00
Adnan Maolood
c329a2487e server: Don't always assume TLS is used 2020-12-18 01:02:04 -05:00
Adnan Maolood
df1794c803 examples: Add missing descriptions 2020-12-18 00:47:30 -05:00
Adnan Maolood
5af1acbd54 examples/html: Read from stdin and write to stdout 2020-12-18 00:45:09 -05:00
Adnan Maolood
36c2086c82 Remove unnecessary variable 2020-12-18 00:35:08 -05:00
Adnan Maolood
d52d0af783 Update QueryEscape documentation 2020-12-18 00:26:47 -05:00
Adnan Maolood
35836f2ff7 Remove Input function 2020-12-18 00:25:06 -05:00
Adnan Maolood
824887eab9 Remove Response.Request field 2020-12-18 00:19:53 -05:00
Adnan Maolood
e2c907a7f6 client: Remove GetInput and CheckRedirect callbacks 2020-12-18 00:12:32 -05:00
Adnan Maolood
a09cb5a23c Update switch statement 2020-12-17 23:03:33 -05:00
Adnan Maolood
7ca7053f66 client: Remove GetCertificate callback 2020-12-17 22:56:48 -05:00
Adnan Maolood
ca35aadaea examples/auth: Fix crash on changing username 2020-12-17 21:10:53 -05:00
Adnan Maolood
805a80dddf Update GetCertificate documentation 2020-12-17 19:54:46 -05:00
Adnan Maolood
28c5c857dc Decouple Client from KnownHostsFile 2020-12-17 19:50:26 -05:00
Adnan Maolood
176b260468 Allow Request.Context to be nil 2020-12-17 17:16:55 -05:00
Adnan Maolood
a1dd8de337 Fix locking up of KnownHostsFile and CertificateDir 2020-12-17 17:15:24 -05:00
Adnan Maolood
7be0715d39 Use RWMutex instead of Mutex 2020-12-17 17:08:45 -05:00
Adnan Maolood
4704b8fbcf Add missing imports 2020-12-17 17:07:00 -05:00
Adnan Maolood
aeafd57956 Make CertificateDir safe for concurrent use by multiple goroutines 2020-12-17 16:52:08 -05:00
Adnan Maolood
e687a05170 Make KnownHostsFile safe for concurrent use 2020-12-17 16:49:59 -05:00
Adnan Maolood
846fa2ac41 client: Add GetCertificate callback 2020-12-17 16:46:16 -05:00
Adnan Maolood
611a7d54c0 Revert to using hexadecimal to encode fingerprints 2020-12-16 23:58:02 -05:00
Adnan Maolood
16739d20d0 Fix escaping of queries 2020-11-27 22:27:52 -05:00
Adnan Maolood
24e488a4cb examples/server: Increase certificate duration 2020-11-27 17:54:26 -05:00
Adnan Maolood
e0ac1685d2 Fix server name in TLS connections 2020-11-27 17:45:15 -05:00
Adnan Maolood
82688746dd Add context to requests 2020-11-26 00:42:25 -05:00
Adnan Maolood
3b9cc7f168 Update examples/auth.go 2020-11-25 19:10:01 -05:00
Adnan Maolood
3c7940f153 Fix known hosts expiration timestamps 2020-11-25 14:24:49 -05:00
Adnan Maolood
8ee55ee009 Fix certificate fingerprint check 2020-11-25 14:20:31 -05:00
Adnan Maolood
7ee0ea8b7f Use base64 to encode fingerprints 2020-11-25 14:16:51 -05:00
Adnan Maolood
ab1db34f02 Fix client locking up on redirects 2020-11-24 21:49:24 -05:00
Adnan Maolood
35e984fbba Escape path character in certificate scopes 2020-11-24 20:24:38 -05:00
Adnan Maolood
cab23032c0 Don't assume a default scheme of gemini 2020-11-24 17:13:52 -05:00
Adnan Maolood
4b653032e4 Make Client safe for concurrent use 2020-11-24 16:28:58 -05:00
Adnan Maolood
0c75e5d5ad Expose KnownHosts and CertificateStore internals 2020-11-23 12:17:54 -05:00
Adnan Maolood
f6b0443a62 Update KnownHosts documentation 2020-11-09 13:57:30 -05:00
Adnan Maolood
3dee6dcff3 Add (*CertificateStore).Write function 2020-11-09 13:54:15 -05:00
Adnan Maolood
85f8e84bd5 Rename (*ResponseWriter).SetMimetype to SetMediaType 2020-11-09 13:44:42 -05:00
Adnan Maolood
9338681256 Add (*KnownHosts).SetOutput function 2020-11-09 12:26:08 -05:00
Adnan Maolood
f2a1510375 Move documentation to gemini.go 2020-11-09 12:07:49 -05:00
Adnan Maolood
46cbcfcaa4 Remove top-level Get and Do functions 2020-11-09 12:04:53 -05:00
Adnan Maolood
76dfe257f1 Remove (*KnownHosts).LoadDefault function 2020-11-09 09:28:44 -05:00
Adnan Maolood
5332dc6280 Don't guarantee that (*Response).Body is always non-nil 2020-11-08 18:38:08 -05:00
Adnan Maolood
6b3cf1314b Fix relative redirects 2020-11-07 23:43:07 -05:00
Adnan Maolood
fe92db1e9c Allow redirects to non-gemini schemes 2020-11-06 11:18:58 -05:00
Adnan Maolood
ff6c95930b Fix TOFU 2020-11-05 22:30:13 -05:00
Adnan Maolood
a5712c7705 Don't check if certificate is expired 2020-11-05 18:35:25 -05:00
Adnan Maolood
520d0a7fb1 Don't redirect by default 2020-11-05 15:44:01 -05:00
Adnan Maolood
bf185e4091 update examples/cert.go 2020-11-05 15:38:41 -05:00
Adnan Maolood
8101fbe473 Update examples/auth.go 2020-11-05 15:37:46 -05:00
Adnan Maolood
b76080c863 Refactor KnownHosts 2020-11-05 15:27:12 -05:00
Adnan Maolood
53390dad6b Document CertificateOptions 2020-11-05 00:04:58 -05:00
Adnan Maolood
cec1f118fb Remove some unnecessary errors 2020-11-04 23:46:05 -05:00
Adnan Maolood
95716296b4 Use ECDSA keys by default 2020-11-03 19:43:04 -05:00
Adnan Maolood
1490bf6a75 Update examples/auth.go 2020-11-03 16:29:39 -05:00
Adnan Maolood
610c6fc533 Add ErrorLog field to Server 2020-11-03 16:11:31 -05:00
Adnan Maolood
01670647d2 Add Subject option in CertificateOptions 2020-11-02 23:11:46 -05:00
Adnan Maolood
5b3194695f Store request certificate to prevent infinite loop 2020-11-02 13:47:07 -05:00
Adnan Maolood
b6475aa7d9 server: Populate (*Request).Certificate field 2020-11-01 16:25:59 -05:00
Adnan Maolood
cc372e8768 Prevent infinite loop in client requests 2020-11-01 15:14:56 -05:00
adnano
8e442146c3 Update examples/auth.go 2020-11-01 14:47:26 -05:00
adnano
e4dea6f2c8 Refactor Certificate and Input functions 2020-11-01 14:35:03 -05:00
adnano
b57ea57fec Don't expose DefaultClient 2020-11-01 14:27:49 -05:00
adnano
c3fc9a4e9f examples: Tweak client and server timeouts 2020-11-01 14:20:24 -05:00
adnano
22d57dfc9e Update examples/cert.go 2020-11-01 14:19:18 -05:00
Adnan Maolood
12bdb2f997 Update examples/html.go 2020-11-01 00:58:34 -04:00
Adnan Maolood
7fb1b6c6a4 Update documentation 2020-11-01 00:10:30 -04:00
Adnan Maolood
0d3230a7d5 Rename InsecureTrustAlways to InsecureSkipTrust 2020-10-31 23:41:30 -04:00
Adnan Maolood
79b3b22e69 Update documentation 2020-10-31 23:05:31 -04:00
Adnan Maolood
33c1dc435d Guarantee that (*Response).Body is non-nil 2020-10-31 23:04:47 -04:00
Adnan Maolood
dad8f38bfb Fix examples/client.go 2020-10-31 22:50:42 -04:00
Adnan Maolood
8181b86759 Add option to skip trust checks 2020-10-31 22:45:21 -04:00
Adnan Maolood
65a5065250 Refactor client.TrustCertificate workflow 2020-10-31 22:34:51 -04:00
Adnan Maolood
b9cb7fe71d Update log.Printf calls 2020-10-31 21:33:59 -04:00
Adnan Maolood
7d470c5fb1 Implement Server read and write timeouts 2020-10-31 21:07:02 -04:00
Adnan Maolood
42c95f8c8d Implement Client connection timeout 2020-10-31 20:55:56 -04:00
Adnan Maolood
a2fc1772bf Set default mimetype if META is empty 2020-10-31 20:32:38 -04:00
Adnan Maolood
63b9b484d1 Remove Redirect and PermanentRedirect functions
Use (*ResponseWriter).WriteHeader instead.
2020-10-31 16:51:10 -04:00
Adnan Maolood
ca8e0166fc Add ErrCertificateNotFound 2020-10-31 16:45:38 -04:00
Adnan Maolood
14ef3be6fe server: Automatically write new certificates to disk 2020-10-31 16:33:56 -04:00
Adnan Maolood
3aa254870a Call CreateCertificate for missing certificates 2020-10-31 15:38:39 -04:00
Adnan Maolood
a89065babb Fix handling of wildcard hostnames 2020-10-31 15:11:05 -04:00
Adnan Maolood
eb466ad02f Add ParseLines function 2020-10-29 09:42:53 -04:00
Adnan Maolood
66e4dc86d5 Add optional host argument in examples/client.go 2020-10-28 16:50:17 -04:00
Adnan Maolood
5e4a38dccb Fix documentation 2020-10-28 16:04:14 -04:00
Adnan Maolood
b5fbd197a1 Update documentation 2020-10-28 16:02:04 -04:00
Adnan Maolood
34ae2a9066 Use strings.Builder in Fingerprint 2020-10-28 15:14:24 -04:00
Adnan Maolood
7f0b1fa8a1 Refactor server certificates 2020-10-28 15:03:54 -04:00
Adnan Maolood
32f22a3e2c Fix examples/cert.go 2020-10-28 13:47:52 -04:00
Adnan Maolood
fbd97a62de Refactor client certificates 2020-10-28 13:41:24 -04:00
Adnan Maolood
768664e0c5 Add ErrInputRequired and ErrCertificateRequired 2020-10-28 01:06:08 -04:00
Adnan Maolood
7a1a33513a Store a reference to the Request in Response 2020-10-28 00:21:27 -04:00
Adnan Maolood
e6072d8bbc Ensure absolute paths in client certificate store 2020-10-27 23:47:13 -04:00
Adnan Maolood
4c5167f590 Add Client.GetInput field 2020-10-27 23:35:22 -04:00
Adnan Maolood
d1dcf070ff Restrict client certificates to certain paths 2020-10-27 23:34:06 -04:00
Adnan Maolood
fc72224ce9 client: Follow redirects 2020-10-27 22:12:10 -04:00
Adnan Maolood
b84811668c Reject schemes other than gemini:// in NewRequest 2020-10-27 21:18:05 -04:00
Adnan Maolood
239ec885f7 Add (*Client).Get function 2020-10-27 19:22:34 -04:00
Adnan Maolood
12a9deb1a6 Make (*Response).Body an io.ReadCloser 2020-10-27 19:16:55 -04:00
Adnan Maolood
860a33f5a2 Fix examples 2020-10-27 14:17:14 -04:00
Adnan Maolood
9079be9019 Add ServeFile function 2020-10-27 13:32:48 -04:00
Adnan Maolood
79165833de Add (*ResponseWriter).WriteStatus function 2020-10-27 13:30:35 -04:00
Adnan Maolood
8ab4064841 Add NewRequestFromURL function 2020-10-27 13:27:52 -04:00
Adnan Maolood
c44f011b15 Remove (Text).HTML function 2020-10-26 12:49:16 -04:00
Adnan Maolood
192065d0e6 Add contributing instructions to README.md 2020-10-24 21:55:58 -04:00
Adnan Maolood
255eef9e31 Add import path to README.md 2020-10-24 16:58:35 -04:00
33 changed files with 3372 additions and 1649 deletions

9
.build.yml Normal file
View File

@@ -0,0 +1,9 @@
image: alpine/edge
packages:
- go
sources:
- https://git.sr.ht/~adnano/go-gemini
tasks:
- test: |
cd go-gemini
go test ./...

2
.gitignore vendored
View File

@@ -1,2 +0,0 @@
*.crt
*.key

View File

@@ -1,14 +1,26 @@
# go-gemini # go-gemini
[![GoDoc](https://godoc.org/git.sr.ht/~adnano/go-gemini?status.svg)](https://godoc.org/git.sr.ht/~adnano/go-gemini) [![godocs.io](https://godocs.io/git.sr.ht/~adnano/go-gemini?status.svg)](https://godocs.io/git.sr.ht/~adnano/go-gemini) [![builds.sr.ht status](https://builds.sr.ht/~adnano/go-gemini.svg)](https://builds.sr.ht/~adnano/go-gemini?)
Package gemini implements the [Gemini protocol](https://gemini.circumlunar.space) in Go. Package gemini implements the [Gemini protocol](https://gemini.circumlunar.space) in Go.
It aims to provide an API similar to that of net/http to make it easy to develop Gemini clients and servers. It provides an API similar to that of net/http to make it easy to develop Gemini clients and servers.
Compatible with version v0.14.3 of the Gemini specification.
## Usage
import "git.sr.ht/~adnano/go-gemini"
## Examples ## Examples
There are a few examples provided in the `examples` directory. There are a few examples provided in the examples directory.
To run the examples: To run an example:
go run examples/server.go go run examples/server.go
## Contributing
Send patches and questions to [~adnano/go-gemini-devel](https://lists.sr.ht/~adnano/go-gemini-devel).
Subscribe to release announcements on [~adnano/go-gemini-announce](https://lists.sr.ht/~adnano/go-gemini-announce).

135
cert.go
View File

@@ -1,135 +0,0 @@
package gemini
import (
"crypto"
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"math/big"
"net"
"path/filepath"
"strings"
"time"
)
// CertificateStore maps hostnames to certificates.
// The zero value of CertificateStore is an empty store ready to use.
type CertificateStore struct {
store map[string]tls.Certificate
}
// Add adds a certificate for the given hostname to the store.
// It tries to parse the certificate if it is not already parsed.
func (c *CertificateStore) Add(hostname string, cert tls.Certificate) {
if c.store == nil {
c.store = map[string]tls.Certificate{}
}
// Parse certificate if not already parsed
if cert.Leaf == nil {
parsed, err := x509.ParseCertificate(cert.Certificate[0])
if err == nil {
cert.Leaf = parsed
}
}
c.store[hostname] = cert
}
// Lookup returns the certificate for the given hostname.
func (c *CertificateStore) Lookup(hostname string) (*tls.Certificate, error) {
cert, ok := c.store[hostname]
if !ok {
return nil, ErrCertificateUnknown
}
// Ensure that the certificate is not expired
if cert.Leaf != nil && cert.Leaf.NotAfter.Before(time.Now()) {
return &cert, ErrCertificateExpired
}
return &cert, nil
}
// Load loads certificates from the given path.
// The path should lead to a directory containing certificates and private keys
// in the form hostname.crt and hostname.key.
// For example, the hostname "localhost" would have the corresponding files
// localhost.crt (certificate) and localhost.key (private key).
func (c *CertificateStore) Load(path string) error {
matches, err := filepath.Glob(filepath.Join(path, "*.crt"))
if err != nil {
return err
}
for _, crtPath := range matches {
keyPath := strings.TrimSuffix(crtPath, ".crt") + ".key"
cert, err := tls.LoadX509KeyPair(crtPath, keyPath)
if err != nil {
continue
}
hostname := strings.TrimSuffix(filepath.Base(crtPath), ".crt")
c.Add(hostname, cert)
}
return nil
}
// NewCertificate creates and returns a new parsed certificate.
func NewCertificate(host string, duration time.Duration) (tls.Certificate, error) {
crt, priv, err := newX509KeyPair(host, duration)
if err != nil {
return tls.Certificate{}, err
}
var cert tls.Certificate
cert.Leaf = crt
cert.Certificate = append(cert.Certificate, crt.Raw)
cert.PrivateKey = priv
return cert, nil
}
// newX509KeyPair creates and returns a new certificate and private key.
func newX509KeyPair(host string, duration time.Duration) (*x509.Certificate, crypto.PrivateKey, error) {
// Generate an ED25519 private key
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, err
}
public := priv.Public()
// ED25519 keys should have the DigitalSignature KeyUsage bits set
// in the x509.Certificate template
keyUsage := x509.KeyUsageDigitalSignature
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return nil, nil, err
}
notBefore := time.Now()
notAfter := notBefore.Add(duration)
template := x509.Certificate{
SerialNumber: serialNumber,
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: keyUsage,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
hosts := strings.Split(host, ",")
for _, h := range hosts {
if ip := net.ParseIP(h); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
} else {
template.DNSNames = append(template.DNSNames, h)
}
}
crt, err := x509.CreateCertificate(rand.Reader, &template, &template, public, priv)
if err != nil {
return nil, nil, err
}
cert, err := x509.ParseCertificate(crt)
if err != nil {
return nil, nil, err
}
return cert, priv, nil
}

142
certificate/create.go Normal file
View File

@@ -0,0 +1,142 @@
// Package certificate provides utility functions for TLS certificates.
package certificate
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"time"
)
// CreateOptions configures the creation of a TLS certificate.
type CreateOptions struct {
// Subject Alternate Name values.
// Should contain the DNS names that this certificate is valid for.
// E.g. example.com, *.example.com
DNSNames []string
// Subject Alternate Name values.
// Should contain the IP addresses that the certificate is valid for.
IPAddresses []net.IP
// Subject specifies the certificate Subject.
//
// Subject.CommonName can contain the DNS name that this certificate
// is valid for. Server certificates should specify both a Subject
// and a Subject Alternate Name.
Subject pkix.Name
// Duration specifies the amount of time that the certificate is valid for.
Duration time.Duration
// Ed25519 specifies whether to generate an Ed25519 key pair.
// If false, an ECDSA key will be generated instead.
// Ed25519 is not as widely supported as ECDSA.
Ed25519 bool
}
// Create creates a new TLS certificate.
func Create(options CreateOptions) (tls.Certificate, error) {
crt, priv, err := newX509KeyPair(options)
if err != nil {
return tls.Certificate{}, err
}
var cert tls.Certificate
cert.Leaf = crt
cert.Certificate = append(cert.Certificate, crt.Raw)
cert.PrivateKey = priv
return cert, nil
}
// newX509KeyPair creates and returns a new certificate and private key.
func newX509KeyPair(options CreateOptions) (*x509.Certificate, crypto.PrivateKey, error) {
var pub crypto.PublicKey
var priv crypto.PrivateKey
if options.Ed25519 {
// Generate an Ed25519 private key
var err error
pub, priv, err = ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, err
}
} else {
// Generate an ECDSA private key
private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, err
}
priv = private
pub = &private.PublicKey
}
// ECDSA and Ed25519 keys should have the DigitalSignature KeyUsage bits
// set in the x509.Certificate template
keyUsage := x509.KeyUsageDigitalSignature
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return nil, nil, err
}
notBefore := time.Now()
notAfter := notBefore.Add(options.Duration)
template := x509.Certificate{
SerialNumber: serialNumber,
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: keyUsage,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IPAddresses: options.IPAddresses,
DNSNames: options.DNSNames,
Subject: options.Subject,
}
crt, err := x509.CreateCertificate(rand.Reader, &template, &template, pub, priv)
if err != nil {
return nil, nil, err
}
cert, err := x509.ParseCertificate(crt)
if err != nil {
return nil, nil, err
}
return cert, priv, nil
}
// Write writes the provided certificate and its private key
// to certPath and keyPath respectively.
func Write(cert tls.Certificate, certPath, keyPath string) error {
certOut, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer certOut.Close()
if err := pem.Encode(certOut, &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Leaf.Raw,
}); err != nil {
return err
}
keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer keyOut.Close()
privBytes, err := x509.MarshalPKCS8PrivateKey(cert.PrivateKey)
if err != nil {
return err
}
return pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
}

170
certificate/store.go Normal file
View File

@@ -0,0 +1,170 @@
package certificate
import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"fmt"
"path/filepath"
"strings"
"sync"
"time"
)
// Store represents a certificate store.
// The zero value for Store is an empty store ready to use.
//
// Store is safe for concurrent use by multiple goroutines.
type Store struct {
// CreateCertificate, if not nil, is called to create a new certificate
// to replace a missing or expired certificate. If CreateCertificate
// is nil, a certificate with a duration of 1 year will be created.
CreateCertificate func(scope string) (tls.Certificate, error)
certs map[string]tls.Certificate
path string
mu sync.RWMutex
}
// Register registers the provided scope in the certificate store.
// The certificate will be created upon calling GetCertificate.
func (s *Store) Register(scope string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.certs == nil {
s.certs = make(map[string]tls.Certificate)
}
s.certs[scope] = tls.Certificate{}
}
// Add adds a certificate for the given scope to the certificate store.
func (s *Store) Add(scope string, cert tls.Certificate) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.certs == nil {
s.certs = make(map[string]tls.Certificate)
}
// Parse certificate if not already parsed
if cert.Leaf == nil {
parsed, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return err
}
cert.Leaf = parsed
}
if s.path != "" {
// Escape slash character
path := strings.ReplaceAll(scope, "/", ":")
certPath := filepath.Join(s.path, path+".crt")
keyPath := filepath.Join(s.path, path+".key")
if err := Write(cert, certPath, keyPath); err != nil {
return err
}
}
s.certs[scope] = cert
return nil
}
// Lookup returns the certificate for the provided scope.
func (s *Store) Lookup(scope string) (tls.Certificate, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
cert, ok := s.certs[scope]
return cert, ok
}
// GetCertificate retrieves the certificate for the given scope.
// If the retrieved certificate is expired or the scope is registered but
// has no certificate, it calls CreateCertificate to create a new certificate.
func (s *Store) GetCertificate(scope string) (*tls.Certificate, error) {
cert, ok := s.Lookup(scope)
if !ok {
// Try wildcard
wildcard := strings.SplitN(scope, ".", 2)
if len(wildcard) == 2 {
cert, ok = s.Lookup("*." + wildcard[1])
}
}
if !ok {
// Try "*"
_, ok = s.Lookup("*")
}
if !ok {
return nil, errors.New("unrecognized scope")
}
// If the certificate is empty or expired, generate a new one.
if cert.Leaf == nil || cert.Leaf.NotAfter.Before(time.Now()) {
var err error
cert, err = s.createCertificate(scope)
if err != nil {
return nil, err
}
if err := s.Add(scope, cert); err != nil {
return nil, fmt.Errorf("failed to add certificate for %s: %w", scope, err)
}
}
return &cert, nil
}
func (s *Store) createCertificate(scope string) (tls.Certificate, error) {
if s.CreateCertificate != nil {
return s.CreateCertificate(scope)
}
return Create(CreateOptions{
DNSNames: []string{scope},
Subject: pkix.Name{
CommonName: scope,
},
Duration: 365 * 24 * time.Hour,
})
}
// Load loads certificates from the provided path.
// New certificates will be written to this path.
//
// The path should lead to a directory containing certificates
// and private keys named "scope.crt" and "scope.key" respectively,
// where "scope" is the scope of the certificate.
func (s *Store) Load(path string) error {
matches, err := filepath.Glob(filepath.Join(path, "*.crt"))
if err != nil {
return err
}
for _, crtPath := range matches {
keyPath := strings.TrimSuffix(crtPath, ".crt") + ".key"
cert, err := tls.LoadX509KeyPair(crtPath, keyPath)
if err != nil {
continue
}
scope := strings.TrimSuffix(filepath.Base(crtPath), ".crt")
// Unescape slash character
scope = strings.ReplaceAll(scope, ":", "/")
s.Add(scope, cert)
}
s.SetPath(path)
return nil
}
// Entries returns a map of scopes to certificates.
func (s *Store) Entries() map[string]tls.Certificate {
s.mu.RLock()
defer s.mu.RUnlock()
certs := make(map[string]tls.Certificate)
for key := range s.certs {
certs[key] = s.certs[key]
}
return certs
}
// SetPath sets the path that new certificates will be written to.
func (s *Store) SetPath(path string) {
s.mu.Lock()
defer s.mu.Unlock()
s.path = path
}

238
client.go
View File

@@ -1,102 +1,204 @@
package gemini package gemini
import ( import (
"bufio" "context"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"errors"
"net"
"net/url"
"time"
) )
// Client represents a Gemini client. // A Client is a Gemini client. Its zero value is a usable client.
type Client struct { type Client struct {
// KnownHosts is a list of known hosts that the client trusts. // TrustCertificate is called to determine whether the client
KnownHosts KnownHosts // should trust the certificate provided by the server.
// If TrustCertificate is nil, the client will accept any certificate.
// If the returned error is not nil, the certificate will not be trusted
// and the request will be aborted.
//
// See the tofu submodule for an implementation of trust on first use.
TrustCertificate func(hostname string, cert *x509.Certificate) error
// CertificateStore maps hostnames to certificates. // Timeout specifies a time limit for requests made by this
// It is used to determine which certificate to use when the server requests // Client. The timeout includes connection time and reading
// a certificate. // the response body. The timer remains running after
CertificateStore CertificateStore // Get or Do return and will interrupt reading of the Response.Body.
//
// A Timeout of zero means no timeout.
Timeout time.Duration
// GetCertificate, if not nil, will be called when a server requests a certificate. // DialContext specifies the dial function for creating TCP connections.
// The returned certificate will be used when sending the request again. // If DialContext is nil, the client dials using package net.
// If the certificate is nil, the request will not be sent again and DialContext func(ctx context.Context, network, addr string) (net.Conn, error)
// the response will be returned.
GetCertificate func(hostname string, store *CertificateStore) *tls.Certificate
// TrustCertificate, if not nil, will be called to determine whether the
// client should trust the given certificate.
// If error is not nil, the connection will be aborted.
TrustCertificate func(hostname string, cert *x509.Certificate, knownHosts *KnownHosts) error
} }
// Send sends a Gemini request and returns a Gemini response. // Get sends a Gemini request for the given URL.
func (c *Client) Send(req *Request) (*Response, error) { //
// An error is returned if there was a Gemini protocol error.
// A non-2x status code doesn't cause an error.
//
// If the returned error is nil, the Response will contain a non-nil Body
// which the user is expected to close.
//
// For more control over requests, use NewRequest and Client.Do.
func (c *Client) Get(ctx context.Context, url string) (*Response, error) {
req, err := NewRequest(url)
if err != nil {
return nil, err
}
return c.Do(ctx, req)
}
// Do sends a Gemini request and returns a Gemini response, following
// policy as configured on the client.
//
// An error is returned if there was a Gemini protocol error.
// A non-2x status code doesn't cause an error.
//
// If the returned error is nil, the Response will contain a non-nil Body
// which the user is expected to close.
//
// Generally Get will be used instead of Do.
func (c *Client) Do(ctx context.Context, req *Request) (*Response, error) {
if ctx == nil {
panic("nil context")
}
// Punycode request URL host
host, port := splitHostPort(req.URL.Host)
punycode, err := punycodeHostname(host)
if err != nil {
return nil, err
}
if host != punycode {
host = punycode
// Make a copy of the request
r2 := new(Request)
*r2 = *req
r2.URL = new(url.URL)
*r2.URL = *req.URL
req = r2
// Set the host
req.URL.Host = net.JoinHostPort(host, port)
}
// Use request host if provided
if req.Host != "" {
host, port = splitHostPort(req.Host)
host, err = punycodeHostname(host)
if err != nil {
return nil, err
}
}
addr := net.JoinHostPort(host, port)
// Connect to the host // Connect to the host
config := &tls.Config{ start := time.Now()
conn, err := c.dialContext(ctx, "tcp", addr)
if err != nil {
return nil, err
}
// Set the connection deadline
if c.Timeout != 0 {
conn.SetDeadline(start.Add(c.Timeout))
}
// Setup TLS
conn = tls.Client(conn, &tls.Config{
InsecureSkipVerify: true, InsecureSkipVerify: true,
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
GetClientCertificate: func(info *tls.CertificateRequestInfo) (*tls.Certificate, error) { GetClientCertificate: func(_ *tls.CertificateRequestInfo) (*tls.Certificate, error) {
// Request certificates take precedence over client certificates
if req.Certificate != nil { if req.Certificate != nil {
return req.Certificate, nil return req.Certificate, nil
} }
// If we have already stored the certificate, return it
if cert, err := c.CertificateStore.Lookup(hostname(req.Host)); err == nil {
return cert, nil
}
return &tls.Certificate{}, nil return &tls.Certificate{}, nil
}, },
VerifyConnection: func(cs tls.ConnectionState) error { VerifyConnection: func(cs tls.ConnectionState) error {
cert := cs.PeerCertificates[0] return c.verifyConnection(cs, host)
// Verify the hostname
if err := verifyHostname(cert, hostname(req.Host)); err != nil {
return err
}
// Check that the client trusts the certificate
if c.TrustCertificate == nil {
if err := c.KnownHosts.Lookup(hostname(req.Host), cert); err != nil {
return err
}
} else if err := c.TrustCertificate(hostname(req.Host), cert, &c.KnownHosts); err != nil {
return err
}
return nil
}, },
} ServerName: host,
conn, err := tls.Dial("tcp", req.Host, config) })
if err != nil {
return nil, err
}
defer conn.Close()
type result struct {
resp *Response
err error
}
res := make(chan result, 1)
go func() {
resp, err := c.do(conn, req)
res <- result{resp, err}
}()
select {
case <-ctx.Done():
conn.Close()
return nil, ctx.Err()
case r := <-res:
return r.resp, r.err
}
}
func (c *Client) do(conn net.Conn, req *Request) (*Response, error) {
// Write the request // Write the request
w := bufio.NewWriter(conn) if err := req.Write(conn); err != nil {
req.write(w)
if err := w.Flush(); err != nil {
return nil, err return nil, err
} }
// Read the response // Read the response
resp := &Response{} resp, err := ReadResponse(conn)
r := bufio.NewReader(conn) if err != nil {
if err := resp.read(r); err != nil {
return nil, err return nil, err
} }
// Store connection information
resp.TLS = conn.ConnectionState()
// Resend the request with a certificate if the server responded // Store TLS connection state
// with CertificateRequired if tlsConn, ok := conn.(*tls.Conn); ok {
if resp.Status == StatusCertificateRequired { state := tlsConn.ConnectionState()
// Check to see if a certificate was already provided to prevent an infinite loop resp.TLS = &state
if req.Certificate != nil {
return resp, nil
}
if c.GetCertificate != nil {
if cert := c.GetCertificate(hostname(req.Host), &c.CertificateStore); cert != nil {
req.Certificate = cert
return c.Send(req)
}
}
} }
return resp, nil return resp, nil
} }
func (c *Client) dialContext(ctx context.Context, network, addr string) (net.Conn, error) {
if c.DialContext != nil {
return c.DialContext(ctx, network, addr)
}
return (&net.Dialer{
Timeout: c.Timeout,
}).DialContext(ctx, network, addr)
}
func (c *Client) verifyConnection(cs tls.ConnectionState, hostname string) error {
cert := cs.PeerCertificates[0]
// Verify hostname
if err := verifyHostname(cert, hostname); err != nil {
return err
}
// Check expiration date
if !time.Now().Before(cert.NotAfter) {
return errors.New("gemini: certificate expired")
}
// See if the client trusts the certificate
if c.TrustCertificate != nil {
return c.TrustCertificate(hostname, cert)
}
return nil
}
func splitHostPort(hostport string) (host, port string) {
var err error
host, port, err = net.SplitHostPort(hostport)
if err != nil {
// Likely no port
host = hostport
port = "1965"
}
return
}

76
doc.go
View File

@@ -1,82 +1,50 @@
/* /*
Package gemini implements the Gemini protocol. Package gemini provides Gemini client and server implementations.
Send makes a Gemini request with the default client: Client is a Gemini client.
req := gemini.NewRequest("gemini://example.com") client := &gemini.Client{}
resp, err := gemini.Send(req) resp, err := client.Get("gemini://example.com")
if err != nil { if err != nil {
// handle error // handle error
} }
defer resp.Body.Close()
// ... // ...
For control over client behavior, create a custom Client:
var client gemini.Client
resp, err := client.Send(req)
if err != nil {
// handle error
}
// ...
The default client loads known hosts from "$XDG_DATA_HOME/gemini/known_hosts".
Custom clients can load their own list of known hosts:
err := client.KnownHosts.Load("path/to/my/known_hosts")
if err != nil {
// handle error
}
Clients can control when to trust certificates with TrustCertificate:
client.TrustCertificate = func(hostname string, cert *x509.Certificate, knownHosts *gemini.KnownHosts) error {
return knownHosts.Lookup(hostname, cert)
}
If a server responds with StatusCertificateRequired, the default client will generate a certificate and resend the request with it. Custom clients can do so in GetCertificate:
client.GetCertificate = func(hostname string, store *gemini.CertificateStore) *tls.Certificate {
// If the certificate is in the store, return it
if cert, err := store.Lookup(hostname); err == nil {
return &cert
}
// Otherwise, generate a certificate
duration := time.Hour
cert, err := gemini.NewCertificate(hostname, duration)
if err != nil {
return nil
}
// Store and return the certificate
store.Add(hostname, cert)
return &cert
}
Server is a Gemini server. Server is a Gemini server.
var server gemini.Server server := &gemini.Server{
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
Servers must be configured with certificates: Servers should be configured with certificates:
err := server.CertificateStore.Load("/var/lib/gemini/certs") certificates := &certificate.Store{}
err := certificates.Load("/var/lib/gemini/certs")
if err != nil { if err != nil {
// handle error // handle error
} }
server.GetCertificate = certificates.GetCertificate
Servers can accept requests for multiple hosts and schemes: ServeMux is a Gemini request multiplexer.
ServeMux can handle requests for multiple hosts and schemes.
server.RegisterFunc("example.com", func(w *gemini.ResponseWriter, r *gemini.Request) { mux := &gemini.ServeMux{}
mux.HandleFunc("example.com", func(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
fmt.Fprint(w, "Welcome to example.com") fmt.Fprint(w, "Welcome to example.com")
}) })
server.RegisterFunc("example.org", func(w *gemini.ResponseWriter, r *gemini.Request) { mux.HandleFunc("example.org/about.gmi", func(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
fmt.Fprint(w, "Welcome to example.org") fmt.Fprint(w, "About example.org")
}) })
server.RegisterFunc("http://example.net", func(w *gemini.ResponseWriter, r *gemini.Request) { mux.HandleFunc("http://example.net", func(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
fmt.Fprint(w, "Proxied content from http://example.net") fmt.Fprint(w, "Proxied content from http://example.net")
}) })
server.Handler = mux
To start the server, call ListenAndServe: To start the server, call ListenAndServe:
err := server.ListenAndServe() err := server.ListenAndServe(context.Background())
if err != nil { if err != nil {
// handle error // handle error
} }

View File

@@ -3,146 +3,86 @@
package main package main
import ( import (
"context"
"crypto/sha512"
"crypto/x509" "crypto/x509"
"fmt" "fmt"
"log" "log"
"time"
gmi "git.sr.ht/~adnano/go-gemini" "git.sr.ht/~adnano/go-gemini"
"git.sr.ht/~adnano/go-gemini/certificate"
) )
type user struct { type User struct {
password string // TODO: use hashes Name string
admin bool
}
type session struct {
username string
authorized bool // whether or not the password was supplied
} }
var ( var (
// Map of usernames to user data // Map of certificate hashes to users
logins = map[string]user{ users = map[string]*User{}
"admin": {"p@ssw0rd", true}, // NOTE: These are bad passwords!
"user1": {"password1", false},
"user2": {"password2", false},
}
// Map of certificate fingerprints to sessions
sessions = map[string]*session{}
) )
func main() { func main() {
var mux gmi.ServeMux certificates := &certificate.Store{}
mux.HandleFunc("/", welcome) certificates.Register("localhost")
mux.HandleFunc("/login", login) if err := certificates.Load("/var/lib/gemini/certs"); err != nil {
mux.HandleFunc("/login/password", loginPassword)
mux.HandleFunc("/profile", profile)
mux.HandleFunc("/admin", admin)
mux.HandleFunc("/logout", logout)
var server gmi.Server
if err := server.CertificateStore.Load("/var/lib/gemini/certs"); err != nil {
log.Fatal(err) log.Fatal(err)
} }
server.Register("localhost", &mux)
if err := server.ListenAndServe(); err != nil { mux := &gemini.ServeMux{}
mux.HandleFunc("/", profile)
mux.HandleFunc("/username", changeUsername)
server := &gemini.Server{
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 1 * time.Minute,
GetCertificate: certificates.GetCertificate,
}
if err := server.ListenAndServe(context.Background()); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
func getSession(crt *x509.Certificate) (*session, bool) { func fingerprint(cert *x509.Certificate) string {
fingerprint := gmi.Fingerprint(crt) b := sha512.Sum512(cert.Raw)
session, ok := sessions[fingerprint] return string(b[:])
return session, ok
} }
func welcome(w *gmi.ResponseWriter, r *gmi.Request) { func profile(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
fmt.Fprintln(w, "Welcome to this example.") if len(r.TLS.PeerCertificates) == 0 {
fmt.Fprintln(w, "=> /login Login") w.WriteHeader(gemini.StatusCertificateRequired, "Certificate required")
return
}
fingerprint := fingerprint(r.TLS.PeerCertificates[0])
user, ok := users[fingerprint]
if !ok {
user = &User{}
users[fingerprint] = user
}
fmt.Fprintln(w, "Username:", user.Name)
fmt.Fprintln(w, "=> /username Change username")
} }
func login(w *gmi.ResponseWriter, r *gmi.Request) { func changeUsername(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
cert, ok := gmi.Certificate(w, r) if len(r.TLS.PeerCertificates) == 0 {
if !ok { w.WriteHeader(gemini.StatusCertificateRequired, "Certificate required")
return
}
username, ok := gmi.Input(w, r, "Username")
if !ok {
return
}
fingerprint := gmi.Fingerprint(cert)
sessions[fingerprint] = &session{
username: username,
}
gmi.Redirect(w, r, "/login/password")
}
func loginPassword(w *gmi.ResponseWriter, r *gmi.Request) {
cert, ok := gmi.Certificate(w, r)
if !ok {
return
}
session, ok := getSession(cert)
if !ok {
gmi.CertificateNotAuthorized(w, r)
return return
} }
password, ok := gmi.SensitiveInput(w, r, "Password") username, err := gemini.QueryUnescape(r.URL.RawQuery)
if !ok { if err != nil || username == "" {
w.WriteHeader(gemini.StatusInput, "Username")
return return
} }
expected := logins[session.username].password fingerprint := fingerprint(r.TLS.PeerCertificates[0])
if password == expected { user, ok := users[fingerprint]
session.authorized = true if !ok {
gmi.Redirect(w, r, "/profile") user = &User{}
} else { users[fingerprint] = user
gmi.SensitiveInput(w, r, "Wrong password. Try again")
} }
} user.Name = username
w.WriteHeader(gemini.StatusRedirect, "/")
func logout(w *gmi.ResponseWriter, r *gmi.Request) {
cert, ok := gmi.Certificate(w, r)
if !ok {
return
}
fingerprint := gmi.Fingerprint(cert)
delete(sessions, fingerprint)
fmt.Fprintln(w, "Successfully logged out.")
}
func profile(w *gmi.ResponseWriter, r *gmi.Request) {
cert, ok := gmi.Certificate(w, r)
if !ok {
return
}
session, ok := getSession(cert)
if !ok {
gmi.CertificateNotAuthorized(w, r)
return
}
user := logins[session.username]
fmt.Fprintln(w, "Username:", session.username)
fmt.Fprintln(w, "Admin:", user.admin)
fmt.Fprintln(w, "=> /logout Logout")
}
func admin(w *gmi.ResponseWriter, r *gmi.Request) {
cert, ok := gmi.Certificate(w, r)
if !ok {
return
}
session, ok := getSession(cert)
if !ok {
gmi.CertificateNotAuthorized(w, r)
return
}
user := logins[session.username]
if !user.admin {
gmi.CertificateNotAuthorized(w, r)
return
}
fmt.Fprintln(w, "Welcome to the admin portal.")
} }

View File

@@ -1,83 +1,43 @@
// +build ignore // +build ignore
// This example illustrates a certificate generation tool.
package main package main
import ( import (
"bytes" "crypto/x509/pkix"
"crypto/tls" "fmt"
"crypto/x509"
"encoding/pem"
"log" "log"
"os" "os"
"time" "time"
gmi "git.sr.ht/~adnano/go-gemini" "git.sr.ht/~adnano/go-gemini/certificate"
) )
func main() { func main() {
host := "localhost" if len(os.Args) < 3 {
duration := 365 * 24 * time.Hour fmt.Printf("usage: %s [hostname] [duration]\n", os.Args[0])
cert, err := gmi.NewCertificate(host, duration) os.Exit(1)
}
host := os.Args[1]
duration, err := time.ParseDuration(os.Args[2])
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
if err := writeCertificate(host, cert); err != nil { options := certificate.CreateOptions{
Subject: pkix.Name{
CommonName: host,
},
DNSNames: []string{host},
Duration: duration,
}
cert, err := certificate.Create(options)
if err != nil {
log.Fatal(err)
}
certPath := host + ".crt"
keyPath := host + ".key"
if err := certificate.Write(cert, certPath, keyPath); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
// writeCertificate writes the provided certificate and private key
// to path.crt and path.key respectively.
func writeCertificate(path string, cert tls.Certificate) error {
crt, err := marshalX509Certificate(cert.Leaf.Raw)
if err != nil {
return err
}
key, err := marshalPrivateKey(cert.PrivateKey)
if err != nil {
return err
}
// Write the certificate
crtPath := path + ".crt"
crtOut, err := os.OpenFile(crtPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
if _, err := crtOut.Write(crt); err != nil {
return err
}
// Write the private key
keyPath := path + ".key"
keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
if _, err := keyOut.Write(key); err != nil {
return err
}
return nil
}
// marshalX509Certificate returns a PEM-encoded version of the given raw certificate.
func marshalX509Certificate(cert []byte) ([]byte, error) {
var b bytes.Buffer
if err := pem.Encode(&b, &pem.Block{Type: "CERTIFICATE", Bytes: cert}); err != nil {
return nil, err
}
return b.Bytes(), nil
}
// marshalPrivateKey returns PEM encoded versions of the given certificate and private key.
func marshalPrivateKey(priv interface{}) ([]byte, error) {
var b bytes.Buffer
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
if err := pem.Encode(&b, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil {
return nil, err
}
return b.Bytes(), nil
}

View File

@@ -1,118 +1,57 @@
// +build ignore // +build ignore
// This example illustrates a Gemini client.
package main package main
import ( import (
"bufio" "bufio"
"crypto/tls" "bytes"
"context"
"crypto/x509" "crypto/x509"
"errors"
"fmt" "fmt"
"io"
"log"
"net/url"
"os" "os"
"path/filepath"
"time" "time"
gmi "git.sr.ht/~adnano/go-gemini" "git.sr.ht/~adnano/go-gemini"
"git.sr.ht/~adnano/go-gemini/tofu"
) )
var ( var (
scanner = bufio.NewScanner(os.Stdin) hosts tofu.KnownHosts
client = &gmi.Client{} hostsfile *tofu.HostWriter
scanner *bufio.Scanner
) )
func xdgDataHome() string {
if s, ok := os.LookupEnv("XDG_DATA_HOME"); ok {
return s
}
return filepath.Join(os.Getenv("HOME"), ".local", "share")
}
func init() { func init() {
// Initialize the client // Load known hosts file
client.KnownHosts.LoadDefault() // Load known hosts path := filepath.Join(xdgDataHome(), "gemini", "known_hosts")
client.TrustCertificate = func(hostname string, cert *x509.Certificate, knownHosts *gmi.KnownHosts) error { err := hosts.Load(path)
err := knownHosts.Lookup(hostname, cert)
if err != nil {
switch err {
case gmi.ErrCertificateNotTrusted:
// Alert the user that the certificate is not trusted
fmt.Printf("Warning: Certificate for %s is not trusted!\n", hostname)
fmt.Println("This could indicate a Man-in-the-Middle attack.")
case gmi.ErrCertificateUnknown:
// Prompt the user to trust the certificate
trust := trustCertificate(cert)
switch trust {
case trustOnce:
// Temporarily trust the certificate
knownHosts.AddTemporary(hostname, cert)
return nil
case trustAlways:
// Add the certificate to the known hosts file
knownHosts.Add(hostname, cert)
return nil
}
}
}
return err
}
client.GetCertificate = func(hostname string, store *gmi.CertificateStore) *tls.Certificate {
// If the certificate is in the store, return it
if cert, err := store.Lookup(hostname); err == nil {
return cert
}
// Otherwise, generate a certificate
fmt.Println("Generating client certificate for", hostname)
duration := time.Hour
cert, err := gmi.NewCertificate(hostname, duration)
if err != nil {
return nil
}
// Store and return the certificate
store.Add(hostname, cert)
return &cert
}
}
// sendRequest sends a request to the given URL.
func sendRequest(req *gmi.Request) error {
resp, err := client.Send(req)
if err != nil { if err != nil {
return err log.Fatal(err)
} }
// TODO: More fine-grained analysis of the status code. hostsfile, err = tofu.OpenHostsFile(path)
switch resp.Status / 10 { if err != nil {
case gmi.StatusClassInput: log.Fatal(err)
fmt.Printf("%s: ", resp.Meta)
scanner.Scan()
req.URL.RawQuery = scanner.Text()
return sendRequest(req)
case gmi.StatusClassSuccess:
fmt.Print(string(resp.Body))
return nil
case gmi.StatusClassRedirect:
fmt.Println("Redirecting to", resp.Meta)
// Make the request to the same host
red, err := gmi.NewRequestTo(resp.Meta, req.Host)
if err != nil {
return err
}
// Handle relative redirects
red.URL = req.URL.ResolveReference(red.URL)
return sendRequest(red)
case gmi.StatusClassTemporaryFailure:
return fmt.Errorf("Temporary failure: %s", resp.Meta)
case gmi.StatusClassPermanentFailure:
return fmt.Errorf("Permanent failure: %s", resp.Meta)
case gmi.StatusClassCertificateRequired:
// Note that this should not happen unless the server responds with
// CertificateRequired even after we send a certificate.
// CertificateNotAuthorized and CertificateNotValid are handled here.
return fmt.Errorf("Certificate required: %s", resp.Meta)
} }
panic("unreachable")
scanner = bufio.NewScanner(os.Stdin)
} }
type trust int const trustPrompt = `The certificate offered by %s is of unknown trust. Its fingerprint is:
const (
trustAbort trust = iota
trustOnce
trustAlways
)
const trustPrompt = `The certificate offered by this server is of unknown trust. Its fingerprint is:
%s %s
If you knew the fingerprint to expect in advance, verify that this matches. If you knew the fingerprint to expect in advance, verify that this matches.
@@ -121,45 +60,109 @@ Otherwise, this should be safe to trust.
[t]rust always; trust [o]nce; [a]bort [t]rust always; trust [o]nce; [a]bort
=> ` => `
func trustCertificate(cert *x509.Certificate) trust { func trustCertificate(hostname string, cert *x509.Certificate) error {
fmt.Printf(trustPrompt, gmi.Fingerprint(cert)) host := tofu.NewHost(hostname, cert.Raw, cert.NotAfter)
knownHost, ok := hosts.Lookup(hostname)
if ok && time.Now().Before(knownHost.Expires) {
// Check fingerprint
if bytes.Equal(knownHost.Fingerprint, host.Fingerprint) {
return nil
}
return errors.New("error: fingerprint does not match!")
}
fmt.Printf(trustPrompt, hostname, host.Fingerprint)
scanner.Scan() scanner.Scan()
switch scanner.Text() { switch scanner.Text() {
case "t": case "t":
return trustAlways hosts.Add(host)
hostsfile.WriteHost(host)
return nil
case "o": case "o":
return trustOnce hosts.Add(host)
return nil
default: default:
return trustAbort return errors.New("certificate not trusted")
} }
} }
func getInput(prompt string, sensitive bool) (input string, ok bool) {
fmt.Printf("%s ", prompt)
scanner.Scan()
return scanner.Text(), true
}
func do(req *gemini.Request, via []*gemini.Request) (*gemini.Response, error) {
client := gemini.Client{
TrustCertificate: trustCertificate,
}
resp, err := client.Do(context.Background(), req)
if err != nil {
return resp, err
}
switch resp.Status.Class() {
case gemini.StatusInput:
input, ok := getInput(resp.Meta, resp.Status == gemini.StatusSensitiveInput)
if !ok {
break
}
req.URL.ForceQuery = true
req.URL.RawQuery = gemini.QueryEscape(input)
return do(req, via)
case gemini.StatusRedirect:
via = append(via, req)
if len(via) > 5 {
return resp, errors.New("too many redirects")
}
target, err := url.Parse(resp.Meta)
if err != nil {
return resp, err
}
target = req.URL.ResolveReference(target)
redirect := *req
redirect.URL = target
return do(&redirect, via)
}
return resp, err
}
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {
fmt.Printf("usage: %s gemini://...", os.Args[0]) fmt.Printf("usage: %s <url> [host]\n", os.Args[0])
os.Exit(1) os.Exit(1)
} }
var host string // Do the request
if len(os.Args) >= 3 {
host = os.Args[2]
}
url := os.Args[1] url := os.Args[1]
var req *gmi.Request req, err := gemini.NewRequest(url)
var err error
if host != "" {
req, err = gmi.NewRequestTo(url, host)
} else {
req, err = gmi.NewRequest(url)
}
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
if len(os.Args) == 3 {
if err := sendRequest(req); err != nil { req.Host = os.Args[2]
}
resp, err := do(req, nil)
if err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
defer resp.Body.Close()
// Handle response
if resp.Status.Class() == gemini.StatusSuccess {
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Print(string(body))
} else {
fmt.Printf("%d %s\n", resp.Status, resp.Meta)
os.Exit(1)
}
} }

83
examples/html.go Normal file
View File

@@ -0,0 +1,83 @@
// +build ignore
// This example illustrates a gemtext to HTML converter.
package main
import (
"fmt"
"html"
"io"
"os"
"git.sr.ht/~adnano/go-gemini"
)
func main() {
hw := HTMLWriter{
out: os.Stdout,
}
gemini.ParseLines(os.Stdin, hw.Handle)
hw.Finish()
}
type HTMLWriter struct {
out io.Writer
pre bool
list bool
}
func (h *HTMLWriter) Handle(line gemini.Line) {
if _, ok := line.(gemini.LineListItem); ok {
if !h.list {
h.list = true
fmt.Fprint(h.out, "<ul>\n")
}
} else if h.list {
h.list = false
fmt.Fprint(h.out, "</ul>\n")
}
switch line := line.(type) {
case gemini.LineLink:
url := html.EscapeString(line.URL)
name := html.EscapeString(line.Name)
if name == "" {
name = url
}
fmt.Fprintf(h.out, "<p><a href='%s'>%s</a></p>\n", url, name)
case gemini.LinePreformattingToggle:
h.pre = !h.pre
if h.pre {
fmt.Fprint(h.out, "<pre>\n")
} else {
fmt.Fprint(h.out, "</pre>\n")
}
case gemini.LinePreformattedText:
fmt.Fprintf(h.out, "%s\n", html.EscapeString(string(line)))
case gemini.LineHeading1:
fmt.Fprintf(h.out, "<h1>%s</h1>\n", html.EscapeString(string(line)))
case gemini.LineHeading2:
fmt.Fprintf(h.out, "<h2>%s</h2>\n", html.EscapeString(string(line)))
case gemini.LineHeading3:
fmt.Fprintf(h.out, "<h3>%s</h3>\n", html.EscapeString(string(line)))
case gemini.LineListItem:
fmt.Fprintf(h.out, "<li>%s</li>\n", html.EscapeString(string(line)))
case gemini.LineQuote:
fmt.Fprintf(h.out, "<blockquote>%s</blockquote>\n", html.EscapeString(string(line)))
case gemini.LineText:
if line == "" {
fmt.Fprint(h.out, "<br>\n")
} else {
fmt.Fprintf(h.out, "<p>%s</p>\n", html.EscapeString(string(line)))
}
}
}
func (h *HTMLWriter) Finish() {
if h.pre {
fmt.Fprint(h.out, "</pre>\n")
}
if h.list {
fmt.Fprint(h.out, "</ul>\n")
}
}

View File

@@ -1,112 +1,37 @@
// +build ignore // +build ignore
// This example illustrates a Gemini server.
package main package main
import ( import (
"bytes" "context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"log" "log"
"os" "os"
"time" "time"
gmi "git.sr.ht/~adnano/go-gemini" "git.sr.ht/~adnano/go-gemini"
"git.sr.ht/~adnano/go-gemini/certificate"
) )
func main() { func main() {
var server gmi.Server certificates := &certificate.Store{}
if err := server.CertificateStore.Load("/var/lib/gemini/certs"); err != nil { certificates.Register("localhost")
if err := certificates.Load("/var/lib/gemini/certs"); err != nil {
log.Fatal(err) log.Fatal(err)
} }
server.GetCertificate = func(hostname string, store *gmi.CertificateStore) *tls.Certificate {
cert, err := store.Lookup(hostname) mux := &gemini.ServeMux{}
if err != nil { mux.Handle("/", gemini.FileServer(os.DirFS("/var/www")))
switch err {
case gmi.ErrCertificateExpired: server := &gemini.Server{
// Generate a new certificate if the current one is expired. Handler: mux,
log.Print("Old certificate expired, creating new one") ReadTimeout: 30 * time.Second,
fallthrough WriteTimeout: 1 * time.Minute,
case gmi.ErrCertificateUnknown: GetCertificate: certificates.GetCertificate,
// Generate a certificate if one does not exist.
cert, err := gmi.NewCertificate(hostname, time.Minute)
if err != nil {
// Failed to generate new certificate, abort
return nil
}
// Store and return the new certificate
err = writeCertificate("/var/lib/gemini/certs/"+hostname, cert)
if err != nil {
return nil
}
store.Add(hostname, cert)
return &cert
}
}
return cert
} }
var mux gmi.ServeMux if err := server.ListenAndServe(context.Background()); err != nil {
mux.Handle("/", gmi.FileServer(gmi.Dir("/var/www")))
server.Register("localhost", &mux)
if err := server.ListenAndServe(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
// writeCertificate writes the provided certificate and private key
// to path.crt and path.key respectively.
func writeCertificate(path string, cert tls.Certificate) error {
crt, err := marshalX509Certificate(cert.Leaf.Raw)
if err != nil {
return err
}
key, err := marshalPrivateKey(cert.PrivateKey)
if err != nil {
return err
}
// Write the certificate
crtPath := path + ".crt"
crtOut, err := os.OpenFile(crtPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
if _, err := crtOut.Write(crt); err != nil {
return err
}
// Write the private key
keyPath := path + ".key"
keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
if _, err := keyOut.Write(key); err != nil {
return err
}
return nil
}
// marshalX509Certificate returns a PEM-encoded version of the given raw certificate.
func marshalX509Certificate(cert []byte) ([]byte, error) {
var b bytes.Buffer
if err := pem.Encode(&b, &pem.Block{Type: "CERTIFICATE", Bytes: cert}); err != nil {
return nil, err
}
return b.Bytes(), nil
}
// marshalPrivateKey returns PEM encoded versions of the given certificate and private key.
func marshalPrivateKey(priv interface{}) ([]byte, error) {
var b bytes.Buffer
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
if err := pem.Encode(&b, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil {
return nil, err
}
return b.Bytes(), nil
}

92
examples/stream.go Normal file
View File

@@ -0,0 +1,92 @@
// +build ignore
// This example illustrates a streaming Gemini server.
package main
import (
"context"
"fmt"
"log"
"sync"
"time"
"git.sr.ht/~adnano/go-gemini"
"git.sr.ht/~adnano/go-gemini/certificate"
)
func main() {
certificates := &certificate.Store{}
certificates.Register("localhost")
if err := certificates.Load("/var/lib/gemini/certs"); err != nil {
log.Fatal(err)
}
mux := &gemini.ServeMux{}
mux.HandleFunc("/", stream)
server := &gemini.Server{
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 1 * time.Minute,
GetCertificate: certificates.GetCertificate,
}
var shutdownOnce sync.Once
var wg sync.WaitGroup
wg.Add(1)
defer wg.Wait()
mux.HandleFunc("/shutdown", func(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
fmt.Fprintln(w, "Shutting down...")
if flusher, ok := w.(gemini.Flusher); ok {
flusher.Flush()
}
go shutdownOnce.Do(func() {
server.Shutdown(context.Background())
wg.Done()
})
})
if err := server.ListenAndServe(context.Background()); err != nil {
log.Println(err)
}
}
// stream writes an infinite stream to w.
func stream(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
flusher, ok := w.(gemini.Flusher)
if !ok {
w.WriteHeader(gemini.StatusTemporaryFailure, "Internal error")
return
}
ch := make(chan string)
ctx, cancel := context.WithCancel(ctx)
go func(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
ch <- fmt.Sprint(time.Now().UTC())
}
time.Sleep(time.Second)
}
// Close channel when finished.
// In this example this will never be reached.
close(ch)
}(ctx)
for {
s, ok := <-ch
if !ok {
break
}
fmt.Fprintln(w, s)
if err := flusher.Flush(); err != nil {
cancel()
return
}
}
}

239
fs.go
View File

@@ -1,11 +1,15 @@
package gemini package gemini
import ( import (
"context"
"fmt"
"io" "io"
"io/fs"
"mime" "mime"
"os" "net/url"
"path" "path"
"path/filepath" "sort"
"strings"
) )
func init() { func init() {
@@ -14,72 +18,189 @@ func init() {
mime.AddExtensionType(".gemini", "text/gemini") mime.AddExtensionType(".gemini", "text/gemini")
} }
// FileServer takes a filesystem and returns a Responder which uses that filesystem. // FileServer returns a handler that serves Gemini requests with the contents
// The returned Responder sanitizes paths before handling them. // of the provided file system.
func FileServer(fsys FS) Responder { //
return fsHandler{fsys} // To use the operating system's file system implementation, use os.DirFS:
//
// gemini.FileServer(os.DirFS("/tmp"))
func FileServer(fsys fs.FS) Handler {
return fileServer{fsys}
} }
type fsHandler struct { type fileServer struct {
FS fs.FS
} }
func (fsh fsHandler) Respond(w *ResponseWriter, r *Request) { func (fs fileServer) ServeGemini(ctx context.Context, w ResponseWriter, r *Request) {
path := path.Clean(r.URL.Path) serveFile(ctx, w, r, fs, path.Clean(r.URL.Path), true)
f, err := fsh.Open(path) }
if err != nil {
NotFound(w, r) // ServeContent replies to the request using the content in the
// provided Reader. The main benefit of ServeContent over io.Copy
// is that it sets the MIME type of the response.
//
// ServeContent tries to deduce the type from name's file extension.
// The name is otherwise unused; it is never sent in the response.
func ServeContent(ctx context.Context, w ResponseWriter, r *Request, name string, content io.Reader) {
serveContent(ctx, w, name, content)
}
func serveContent(ctx context.Context, w ResponseWriter, name string, content io.Reader) {
// Detect mimetype from file extension
ext := path.Ext(name)
mimetype := mime.TypeByExtension(ext)
w.MediaType(mimetype)
io.Copy(w, content)
}
// ServeFile responds to the request with the contents of the named file
// or directory.
//
// If the provided file or directory name is a relative path, it is interpreted
// relative to the current directory and may ascend to parent directories. If
// the provided name is constructed from user input, it should be sanitized
// before calling ServeFile.
//
// As a precaution, ServeFile will reject requests where r.URL.Path contains a
// ".." path element; this protects against callers who might unsafely use
// filepath.Join on r.URL.Path without sanitizing it and then use that
// filepath.Join result as the name argument.
//
// As another special case, ServeFile redirects any request where r.URL.Path
// ends in "/index.gmi" to the same path, without the final "index.gmi". To
// avoid such redirects either modify the path or use ServeContent.
//
// Outside of those two special cases, ServeFile does not use r.URL.Path for
// selecting the file or directory to serve; only the file or directory
// provided in the name argument is used.
func ServeFile(ctx context.Context, w ResponseWriter, r *Request, fsys fs.FS, name string) {
if containsDotDot(r.URL.Path) {
// Too many programs use r.URL.Path to construct the argument to
// serveFile. Reject the request under the assumption that happened
// here and ".." may not be wanted.
// Note that name might not contain "..", for example if code (still
// incorrectly) used filepath.Join(myDir, r.URL.Path).
w.WriteHeader(StatusBadRequest, "invalid URL path")
return return
} }
// Detect mimetype serveFile(ctx, w, r, fsys, name, false)
ext := filepath.Ext(path)
mimetype := mime.TypeByExtension(ext)
w.SetMimetype(mimetype)
// Copy file to response writer
io.Copy(w, f)
} }
// TODO: replace with io/fs.FS when available func containsDotDot(v string) bool {
type FS interface { if !strings.Contains(v, "..") {
Open(name string) (File, error) return false
}
// TODO: replace with io/fs.File when available
type File interface {
Stat() (os.FileInfo, error)
Read([]byte) (int, error)
Close() error
}
// Dir implements FS using the native filesystem restricted to a specific directory.
type Dir string
// Open tries to open the file with the given name.
// If the file is a directory, it tries to open the index file in that directory.
func (d Dir) Open(name string) (File, error) {
p := path.Join(string(d), name)
f, err := os.OpenFile(p, os.O_RDONLY, 0644)
if err != nil {
return nil, err
} }
for _, ent := range strings.FieldsFunc(v, isSlashRune) {
if stat, err := f.Stat(); err == nil { if ent == ".." {
if stat.IsDir() { return true
f, err := os.Open(path.Join(p, "index.gmi"))
if err != nil {
return nil, err
}
stat, err := f.Stat()
if err != nil {
return nil, err
}
if stat.Mode().IsRegular() {
return f, nil
}
return nil, ErrNotAFile
} else if !stat.Mode().IsRegular() {
return nil, ErrNotAFile
} }
} }
return f, nil return false
}
func isSlashRune(r rune) bool { return r == '/' || r == '\\' }
func serveFile(ctx context.Context, w ResponseWriter, r *Request, fsys fs.FS, name string, redirect bool) {
const indexPage = "/index.gmi"
// Redirect .../index.gmi to .../
if strings.HasSuffix(r.URL.Path, indexPage) {
w.WriteHeader(StatusPermanentRedirect, "./")
return
}
if name == "/" {
name = "."
} else {
name = strings.Trim(name, "/")
}
f, err := fsys.Open(name)
if err != nil {
w.WriteHeader(StatusNotFound, "Not found")
return
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
w.WriteHeader(StatusTemporaryFailure, "Temporary failure")
return
}
// Redirect to canonical path
if redirect {
url := r.URL.Path
if stat.IsDir() {
// Add trailing slash
if url[len(url)-1] != '/' {
w.WriteHeader(StatusPermanentRedirect, path.Base(url)+"/")
return
}
} else {
// Remove trailing slash
if url[len(url)-1] == '/' {
w.WriteHeader(StatusPermanentRedirect, "../"+path.Base(url))
return
}
}
}
if stat.IsDir() {
// Redirect if the directory name doesn't end in a slash
url := r.URL.Path
if url[len(url)-1] != '/' {
w.WriteHeader(StatusRedirect, path.Base(url)+"/")
return
}
// Use contents of index.gmi if present
index, err := fsys.Open(path.Join(name, indexPage))
if err == nil {
defer index.Close()
istat, err := index.Stat()
if err == nil {
f = index
stat = istat
}
}
}
if stat.IsDir() {
// Failed to find index file
dirList(w, f)
return
}
serveContent(ctx, w, name, f)
}
func dirList(w ResponseWriter, f fs.File) {
var entries []fs.DirEntry
var err error
d, ok := f.(fs.ReadDirFile)
if ok {
entries, err = d.ReadDir(-1)
}
if !ok || err != nil {
w.WriteHeader(StatusTemporaryFailure, "Error reading directory")
return
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() {
name += "/"
}
link := LineLink{
Name: name,
URL: (&url.URL{Path: name}).EscapedPath(),
}
fmt.Fprintln(w, link.String())
}
} }

110
gemini.go
View File

@@ -1,98 +1,32 @@
package gemini package gemini
import ( import (
"crypto/tls"
"crypto/x509"
"errors" "errors"
"sync"
"time"
) )
// Status codes. var crlf = []byte("\r\n")
const (
StatusInput = 10
StatusSensitiveInput = 11
StatusSuccess = 20
StatusRedirect = 30
StatusRedirectPermanent = 31
StatusTemporaryFailure = 40
StatusServerUnavailable = 41
StatusCGIError = 42
StatusProxyError = 43
StatusSlowDown = 44
StatusPermanentFailure = 50
StatusNotFound = 51
StatusGone = 52
StatusProxyRequestRefused = 53
StatusBadRequest = 59
StatusCertificateRequired = 60
StatusCertificateNotAuthorized = 61
StatusCertificateNotValid = 62
)
// Status code categories.
const (
StatusClassInput = 1
StatusClassSuccess = 2
StatusClassRedirect = 3
StatusClassTemporaryFailure = 4
StatusClassPermanentFailure = 5
StatusClassCertificateRequired = 6
)
// Errors. // Errors.
var ( var (
ErrInvalidURL = errors.New("gemini: invalid URL") ErrInvalidURL = errors.New("gemini: invalid URL")
ErrInvalidResponse = errors.New("gemini: invalid response") ErrInvalidRequest = errors.New("gemini: invalid request")
ErrCertificateUnknown = errors.New("gemini: unknown certificate") ErrInvalidResponse = errors.New("gemini: invalid response")
ErrCertificateExpired = errors.New("gemini: certificate expired")
ErrCertificateNotTrusted = errors.New("gemini: certificate is not trusted") // ErrBodyNotAllowed is returned by ResponseWriter.Write calls
ErrNotAFile = errors.New("gemini: not a file") // when the response status code does not permit a body.
ErrBodyNotAllowed = errors.New("gemini: response status code does not allow for body") ErrBodyNotAllowed = errors.New("gemini: response status code does not allow body")
// ErrServerClosed is returned by the Server's Serve and ListenAndServe
// methods after a call to Shutdown or Close.
ErrServerClosed = errors.New("gemini: server closed")
// ErrAbortHandler is a sentinel panic value to abort a handler.
// While any panic from ServeGemini aborts the response to the client,
// panicking with ErrAbortHandler also suppresses logging of a stack
// trace to the server's error log.
ErrAbortHandler = errors.New("gemini: abort Handler")
// ErrHandlerTimeout is returned on ResponseWriter Write calls
// in handlers which have timed out.
ErrHandlerTimeout = errors.New("gemini: Handler timeout")
) )
// DefaultClient is the default client. It is used by Send.
//
// On the first request, DefaultClient will load the default list of known hosts.
var DefaultClient Client
var (
crlf = []byte("\r\n")
)
func init() {
DefaultClient.TrustCertificate = func(hostname string, cert *x509.Certificate, knownHosts *KnownHosts) error {
// Load the hosts only once. This is so that the hosts don't have to be loaded
// for those using their own clients.
setupDefaultClientOnce.Do(setupDefaultClient)
return knownHosts.Lookup(hostname, cert)
}
DefaultClient.GetCertificate = func(hostname string, store *CertificateStore) *tls.Certificate {
// If the certificate is in the store, return it
if cert, err := store.Lookup(hostname); err == nil {
return cert
}
// Otherwise, generate a certificate
duration := time.Hour
cert, err := NewCertificate(hostname, duration)
if err != nil {
return nil
}
// Store and return the certificate
store.Add(hostname, cert)
return &cert
}
}
var setupDefaultClientOnce sync.Once
func setupDefaultClient() {
DefaultClient.KnownHosts.LoadDefault()
}
// Send sends a Gemini request and returns a Gemini response.
//
// Send is a wrapper around DefaultClient.Send.
func Send(req *Request) (*Response, error) {
return DefaultClient.Send(req)
}

4
go.mod
View File

@@ -1,3 +1,5 @@
module git.sr.ht/~adnano/go-gemini module git.sr.ht/~adnano/go-gemini
go 1.15 go 1.16
require golang.org/x/net v0.0.0-20210119194325-5f4716e94777

7
go.sum Normal file
View File

@@ -0,0 +1,7 @@
golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

86
handler.go Normal file
View File

@@ -0,0 +1,86 @@
package gemini
import (
"context"
"net/url"
"strings"
)
// A Handler responds to a Gemini request.
//
// ServeGemini should write the response header and data to the ResponseWriter
// and then return. Returning signals that the request is finished; it is not
// valid to use the ResponseWriter after or concurrently with the completion
// of the ServeGemini call.
//
// Handlers should not modify the provided Request.
//
// If ServeGemini panics, the server (the caller of ServeGemini) assumes that
// the effect of the panic was isolated to the active request. It recovers
// the panic, logs a stack trace to the server error log, and closes the
// network connection. To abort a handler so the client sees an interrupted
// response but the server doesn't log an error, panic with the value
// ErrAbortHandler.
type Handler interface {
ServeGemini(context.Context, ResponseWriter, *Request)
}
// The HandlerFunc type is an adapter to allow the use of ordinary functions
// as Gemini handlers. If f is a function with the appropriate signature,
// HandlerFunc(f) is a Handler that calls f.
type HandlerFunc func(context.Context, ResponseWriter, *Request)
// ServeGemini calls f(ctx, w, r).
func (f HandlerFunc) ServeGemini(ctx context.Context, w ResponseWriter, r *Request) {
f(ctx, w, r)
}
// StatusHandler returns a request handler that responds to each request
// with the provided status code and meta.
func StatusHandler(status Status, meta string) Handler {
return &statusHandler{status, meta}
}
type statusHandler struct {
status Status
meta string
}
func (h *statusHandler) ServeGemini(ctx context.Context, w ResponseWriter, r *Request) {
w.WriteHeader(h.status, h.meta)
}
// NotFoundHandler returns a simple request handler that replies to each
// request with a “51 Not found” reply.
func NotFoundHandler() Handler {
return HandlerFunc(func(ctx context.Context, w ResponseWriter, r *Request) {
w.WriteHeader(StatusNotFound, "Not found")
})
}
// StripPrefix returns a handler that serves Gemini requests by removing the
// given prefix from the request URL's Path (and RawPath if set) and invoking
// the handler h. StripPrefix handles a request for a path that doesn't begin
// with prefix by replying with a Gemini 51 not found error. The prefix must
// match exactly: if the prefix in the request contains escaped characters the
// reply is also a Gemini 51 not found error.
func StripPrefix(prefix string, h Handler) Handler {
if prefix == "" {
return h
}
return HandlerFunc(func(ctx context.Context, w ResponseWriter, r *Request) {
p := strings.TrimPrefix(r.URL.Path, prefix)
rp := strings.TrimPrefix(r.URL.RawPath, prefix)
if len(p) < len(r.URL.Path) && (r.URL.RawPath == "" || len(rp) < len(r.URL.RawPath)) {
r2 := new(Request)
*r2 = *r
r2.URL = new(url.URL)
*r2.URL = *r.URL
r2.URL.Path = p
r2.URL.RawPath = rp
h.ServeGemini(ctx, w, r2)
} else {
w.WriteHeader(StatusNotFound, "Not found")
}
})
}

302
mux.go Normal file
View File

@@ -0,0 +1,302 @@
package gemini
import (
"context"
"net"
"net/url"
"path"
"sort"
"strings"
"sync"
)
// ServeMux is a Gemini request multiplexer.
// It matches the URL of each incoming request against a list of registered
// patterns and calls the handler for the pattern that
// most closely matches the URL.
//
// Patterns name fixed, rooted paths, like "/favicon.ico",
// or rooted subtrees, like "/images/" (note the trailing slash).
// Longer patterns take precedence over shorter ones, so that
// if there are handlers registered for both "/images/"
// and "/images/thumbnails/", the latter handler will be
// called for paths beginning "/images/thumbnails/" and the
// former will receive requests for any other paths in the
// "/images/" subtree.
//
// Note that since a pattern ending in a slash names a rooted subtree,
// the pattern "/" matches all paths not matched by other registered
// patterns, not just the URL with Path == "/".
//
// Patterns may also contain schemes and hostnames.
// Wildcard patterns can be used to match multiple hostnames (e.g. "*.example.com").
//
// The following are examples of valid patterns, along with the scheme,
// hostname, and path that they match.
//
// Pattern │ Scheme │ Hostname │ Path
// ─────────────────────────────┼────────┼──────────┼─────────────
// /file │ gemini │ * │ /file
// /directory/ │ gemini │ * │ /directory/*
// hostname/file │ gemini │ hostname │ /file
// hostname/directory/ │ gemini │ hostname │ /directory/*
// scheme://hostname/file │ scheme │ hostname │ /file
// scheme://hostname/directory/ │ scheme │ hostname │ /directory/*
// //hostname/file │ * │ hostname │ /file
// //hostname/directory/ │ * │ hostname │ /directory/*
// scheme:///file │ scheme │ * │ /file
// scheme:///directory/ │ scheme │ * │ /directory/*
// ///file │ * │ * │ /file
// ///directory/ │ * │ * │ /directory/*
//
// A pattern without a hostname will match any hostname.
// If a pattern begins with "//", it will match any scheme.
// Otherwise, a pattern with no scheme is treated as though it has a
// scheme of "gemini".
//
// If a subtree has been registered and a request is received naming the
// subtree root without its trailing slash, ServeMux redirects that
// request to the subtree root (adding the trailing slash). This behavior can
// be overridden with a separate registration for the path without
// the trailing slash. For example, registering "/images/" causes ServeMux
// to redirect a request for "/images" to "/images/", unless "/images" has
// been registered separately.
//
// ServeMux also takes care of sanitizing the URL request path and
// redirecting any request containing . or .. elements or repeated slashes
// to an equivalent, cleaner URL.
type ServeMux struct {
mu sync.RWMutex
m map[muxKey]Handler
es []muxEntry // slice of entries sorted from longest to shortest
}
type muxKey struct {
scheme string
host string
path string
}
type muxEntry struct {
handler Handler
key muxKey
}
// cleanPath returns the canonical path for p, eliminating . and .. elements.
func cleanPath(p string) string {
if p == "" {
return "/"
}
if p[0] != '/' {
p = "/" + p
}
np := path.Clean(p)
// path.Clean removes trailing slash except for root;
// put the trailing slash back if necessary.
if p[len(p)-1] == '/' && np != "/" {
// Fast path for common case of p being the string we want:
if len(p) == len(np)+1 && strings.HasPrefix(p, np) {
np = p
} else {
np += "/"
}
}
return np
}
// Find a handler on a handler map given a path string.
// Most-specific (longest) pattern wins.
func (mux *ServeMux) match(key muxKey) Handler {
// Check for exact match first.
if r, ok := mux.m[key]; ok {
return r
} else if r, ok := mux.m[muxKey{"", key.host, key.path}]; ok {
return r
} else if r, ok := mux.m[muxKey{key.scheme, "", key.path}]; ok {
return r
} else if r, ok := mux.m[muxKey{"", "", key.path}]; ok {
return r
}
// Check for longest valid match. mux.es contains all patterns
// that end in / sorted from longest to shortest.
for _, e := range mux.es {
if (e.key.scheme == "" || key.scheme == e.key.scheme) &&
(e.key.host == "" || key.host == e.key.host) &&
strings.HasPrefix(key.path, e.key.path) {
return e.handler
}
}
return nil
}
// redirectToPathSlash determines if the given path needs appending "/" to it.
// This occurs when a handler for path + "/" was already registered, but
// not for path itself. If the path needs appending to, it creates a new
// URL, setting the path to u.Path + "/" and returning true to indicate so.
func (mux *ServeMux) redirectToPathSlash(key muxKey, u *url.URL) (*url.URL, bool) {
mux.mu.RLock()
shouldRedirect := mux.shouldRedirectRLocked(key)
mux.mu.RUnlock()
if !shouldRedirect {
return u, false
}
return u.ResolveReference(&url.URL{Path: key.path + "/"}), true
}
// shouldRedirectRLocked reports whether the given path and host should be redirected to
// path+"/". This should happen if a handler is registered for path+"/" but
// not path -- see comments at ServeMux.
func (mux *ServeMux) shouldRedirectRLocked(key muxKey) bool {
if _, exist := mux.m[key]; exist {
return false
}
n := len(key.path)
if n == 0 {
return false
}
if _, exist := mux.m[muxKey{key.scheme, key.host, key.path + "/"}]; exist {
return key.path[n-1] != '/'
}
return false
}
func getWildcard(hostname string) (string, bool) {
if net.ParseIP(hostname) == nil {
split := strings.SplitN(hostname, ".", 2)
if len(split) == 2 {
return "*." + split[1], true
}
}
return "", false
}
// Handler returns the handler to use for the given request, consulting
// r.URL.Scheme, r.URL.Host, and r.URL.Path. It always returns a non-nil handler. If
// the path is not in its canonical form, the handler will be an
// internally-generated handler that redirects to the canonical path. If the
// host contains a port, it is ignored when matching handlers.
func (mux *ServeMux) Handler(r *Request) Handler {
scheme := r.URL.Scheme
host := r.URL.Hostname()
path := cleanPath(r.URL.Path)
// If the given path is /tree and its handler is not registered,
// redirect for /tree/.
if u, ok := mux.redirectToPathSlash(muxKey{scheme, host, path}, r.URL); ok {
return StatusHandler(StatusPermanentRedirect, u.String())
}
if path != r.URL.Path {
u := *r.URL
u.Path = path
return StatusHandler(StatusPermanentRedirect, u.String())
}
mux.mu.RLock()
defer mux.mu.RUnlock()
h := mux.match(muxKey{scheme, host, path})
if h == nil {
// Try wildcard
if wildcard, ok := getWildcard(host); ok {
h = mux.match(muxKey{scheme, wildcard, path})
}
}
if h == nil {
h = NotFoundHandler()
}
return h
}
// ServeGemini dispatches the request to the handler whose
// pattern most closely matches the request URL.
func (mux *ServeMux) ServeGemini(ctx context.Context, w ResponseWriter, r *Request) {
h := mux.Handler(r)
h.ServeGemini(ctx, w, r)
}
// Handle registers the handler for the given pattern.
// If a handler already exists for pattern, Handle panics.
func (mux *ServeMux) Handle(pattern string, handler Handler) {
if pattern == "" {
panic("gemini: invalid pattern")
}
if handler == nil {
panic("gemini: nil handler")
}
mux.mu.Lock()
defer mux.mu.Unlock()
var key muxKey
if strings.HasPrefix(pattern, "//") {
// match any scheme
key.scheme = ""
pattern = pattern[2:]
} else {
// extract scheme
cut := strings.Index(pattern, "://")
if cut == -1 {
// default scheme of gemini
key.scheme = "gemini"
} else {
key.scheme = pattern[:cut]
pattern = pattern[cut+3:]
}
}
// extract hostname and path
cut := strings.Index(pattern, "/")
if cut == -1 {
key.host = pattern
key.path = "/"
} else {
key.host = pattern[:cut]
key.path = pattern[cut:]
}
// strip port from hostname
if hostname, _, err := net.SplitHostPort(key.host); err == nil {
key.host = hostname
}
if _, exist := mux.m[key]; exist {
panic("gemini: multiple registrations for " + pattern)
}
if mux.m == nil {
mux.m = make(map[muxKey]Handler)
}
mux.m[key] = handler
e := muxEntry{handler, key}
if key.path[len(key.path)-1] == '/' {
mux.es = appendSorted(mux.es, e)
}
}
func appendSorted(es []muxEntry, e muxEntry) []muxEntry {
n := len(es)
i := sort.Search(n, func(i int) bool {
return len(es[i].key.scheme) < len(e.key.scheme) ||
len(es[i].key.host) < len(es[i].key.host) ||
len(es[i].key.path) < len(e.key.path)
})
if i == n {
return append(es, e)
}
// we now know that i points at where we want to insert
es = append(es, muxEntry{}) // try to grow the slice in place, any entry works.
copy(es[i+1:], es[i:]) // move shorter entries down
es[i] = e
return es
}
// HandleFunc registers the handler function for the given pattern.
func (mux *ServeMux) HandleFunc(pattern string, handler func(context.Context, ResponseWriter, *Request)) {
if handler == nil {
panic("gemini: nil handler")
}
mux.Handle(pattern, HandlerFunc(handler))
}

315
mux_test.go Normal file
View File

@@ -0,0 +1,315 @@
package gemini
import (
"context"
"net/url"
"testing"
)
type nopHandler struct{}
func (*nopHandler) ServeGemini(context.Context, ResponseWriter, *Request) {}
func TestServeMuxMatch(t *testing.T) {
type Match struct {
URL string
Ok bool
}
tests := []struct {
Pattern string
Matches []Match
}{
{
// scheme: gemini, hostname: *, path: /*
Pattern: "/",
Matches: []Match{
{"gemini://example.com/path", true},
{"gemini://example.com/", true},
{"gemini://example.com/path.gmi", true},
{"gemini://example.com/path/", true},
{"gemini://example.org/path", true},
{"http://example.com/path", false},
{"http://example.org/path", false},
},
},
{
// scheme: gemini, hostname: *, path: /path
Pattern: "/path",
Matches: []Match{
{"gemini://example.com/path", true},
{"gemini://example.com/", false},
{"gemini://example.com/path.gmi", false},
{"gemini://example.com/path/", false},
{"gemini://example.org/path", true},
{"http://example.com/path", false},
{"http://example.org/path", false},
},
},
{
// scheme: gemini, hostname: *, path: /subtree/*
Pattern: "/subtree/",
Matches: []Match{
{"gemini://example.com/subtree/", true},
{"gemini://example.com/subtree/nested/", true},
{"gemini://example.com/subtree/nested/file", true},
{"gemini://example.org/subtree/", true},
{"gemini://example.org/subtree/nested/", true},
{"gemini://example.org/subtree/nested/file", true},
{"gemini://example.com/subtree", false},
{"gemini://www.example.com/subtree/", true},
{"http://example.com/subtree/", false},
},
},
{
// scheme: gemini, hostname: example.com, path: /*
Pattern: "example.com",
Matches: []Match{
{"gemini://example.com/path", true},
{"gemini://example.com/", true},
{"gemini://example.com/path.gmi", true},
{"gemini://example.com/path/", true},
{"gemini://example.org/path", false},
{"http://example.com/path", false},
{"http://example.org/path", false},
},
},
{
// scheme: gemini, hostname: example.com, path: /path
Pattern: "example.com/path",
Matches: []Match{
{"gemini://example.com/path", true},
{"gemini://example.com/", false},
{"gemini://example.com/path.gmi", false},
{"gemini://example.com/path/", false},
{"gemini://example.org/path", false},
{"http://example.com/path", false},
{"http://example.org/path", false},
},
},
{
// scheme: gemini, hostname: example.com, path: /subtree/*
Pattern: "example.com/subtree/",
Matches: []Match{
{"gemini://example.com/subtree/", true},
{"gemini://example.com/subtree/nested/", true},
{"gemini://example.com/subtree/nested/file", true},
{"gemini://example.org/subtree/", false},
{"gemini://example.org/subtree/nested/", false},
{"gemini://example.org/subtree/nested/file", false},
{"gemini://example.com/subtree", false},
{"gemini://www.example.com/subtree/", false},
{"http://example.com/subtree/", false},
},
},
{
// scheme: http, hostname: example.com, path: /*
Pattern: "http://example.com",
Matches: []Match{
{"http://example.com/path", true},
{"http://example.com/", true},
{"http://example.com/path.gmi", true},
{"http://example.com/path/", true},
{"http://example.org/path", false},
{"gemini://example.com/path", false},
{"gemini://example.org/path", false},
},
},
{
// scheme: http, hostname: example.com, path: /path
Pattern: "http://example.com/path",
Matches: []Match{
{"http://example.com/path", true},
{"http://example.com/", false},
{"http://example.com/path.gmi", false},
{"http://example.com/path/", false},
{"http://example.org/path", false},
{"gemini://example.com/path", false},
{"gemini://example.org/path", false},
},
},
{
// scheme: http, hostname: example.com, path: /subtree/*
Pattern: "http://example.com/subtree/",
Matches: []Match{
{"http://example.com/subtree/", true},
{"http://example.com/subtree/nested/", true},
{"http://example.com/subtree/nested/file", true},
{"http://example.org/subtree/", false},
{"http://example.org/subtree/nested/", false},
{"http://example.org/subtree/nested/file", false},
{"http://example.com/subtree", false},
{"http://www.example.com/subtree/", false},
{"gemini://example.com/subtree/", false},
},
},
{
// scheme: *, hostname: example.com, path: /*
Pattern: "//example.com",
Matches: []Match{
{"gemini://example.com/path", true},
{"gemini://example.com/", true},
{"gemini://example.com/path.gmi", true},
{"gemini://example.com/path/", true},
{"gemini://example.org/path", false},
{"http://example.com/path", true},
{"http://example.org/path", false},
},
},
{
// scheme: *, hostname: example.com, path: /path
Pattern: "//example.com/path",
Matches: []Match{
{"gemini://example.com/path", true},
{"gemini://example.com/", false},
{"gemini://example.com/path.gmi", false},
{"gemini://example.com/path/", false},
{"gemini://example.org/path", false},
{"http://example.com/path", true},
{"http://example.org/path", false},
},
},
{
// scheme: *, hostname: example.com, path: /subtree/*
Pattern: "//example.com/subtree/",
Matches: []Match{
{"gemini://example.com/subtree/", true},
{"gemini://example.com/subtree/nested/", true},
{"gemini://example.com/subtree/nested/file", true},
{"gemini://example.org/subtree/", false},
{"gemini://example.org/subtree/nested/", false},
{"gemini://example.org/subtree/nested/file", false},
{"gemini://example.com/subtree", false},
{"gemini://www.example.com/subtree/", false},
{"http://example.com/subtree/", true},
},
},
{
// scheme: http, hostname: *, path: /*
Pattern: "http://",
Matches: []Match{
{"http://example.com/path", true},
{"http://example.com/", true},
{"http://example.com/path.gmi", true},
{"http://example.com/path/", true},
{"http://example.org/path", true},
{"gemini://example.com/path", false},
{"gemini://example.org/path", false},
},
},
{
// scheme: http, hostname: *, path: /path
Pattern: "http:///path",
Matches: []Match{
{"http://example.com/path", true},
{"http://example.com/", false},
{"http://example.com/path.gmi", false},
{"http://example.com/path/", false},
{"http://example.org/path", true},
{"gemini://example.com/path", false},
{"gemini://example.org/path", false},
},
},
{
// scheme: http, hostname: *, path: /subtree/*
Pattern: "http:///subtree/",
Matches: []Match{
{"http://example.com/subtree/", true},
{"http://example.com/subtree/nested/", true},
{"http://example.com/subtree/nested/file", true},
{"http://example.org/subtree/", true},
{"http://example.org/subtree/nested/", true},
{"http://example.org/subtree/nested/file", true},
{"http://example.com/subtree", false},
{"http://www.example.com/subtree/", true},
{"gemini://example.com/subtree/", false},
},
},
{
// scheme: *, hostname: *, path: /*
Pattern: "//",
Matches: []Match{
{"gemini://example.com/path", true},
{"gemini://example.com/", true},
{"gemini://example.com/path.gmi", true},
{"gemini://example.com/path/", true},
{"gemini://example.org/path", true},
{"http://example.com/path", true},
{"http://example.org/path", true},
},
},
{
// scheme: *, hostname: *, path: /path
Pattern: "///path",
Matches: []Match{
{"gemini://example.com/path", true},
{"gemini://example.com/", false},
{"gemini://example.com/path.gmi", false},
{"gemini://example.com/path/", false},
{"gemini://example.org/path", true},
{"http://example.com/path", true},
{"http://example.org/path", true},
},
},
{
// scheme: *, hostname: *, path: /subtree/*
Pattern: "///subtree/",
Matches: []Match{
{"gemini://example.com/subtree/", true},
{"gemini://example.com/subtree/nested/", true},
{"gemini://example.com/subtree/nested/file", true},
{"gemini://example.org/subtree/", true},
{"gemini://example.org/subtree/nested/", true},
{"gemini://example.org/subtree/nested/file", true},
{"gemini://example.com/subtree", false},
{"gemini://www.example.com/subtree/", true},
{"http://example.com/subtree/", true},
},
},
{
// scheme: gemini, hostname: *.example.com, path: /*
Pattern: "*.example.com",
Matches: []Match{
{"gemini://mail.example.com/", true},
{"gemini://www.example.com/index.gmi", true},
{"gemini://example.com/", false},
{"gemini://a.b.example.com/", false},
{"http://www.example.com/", false},
},
},
{
// scheme: http, hostname: *.example.com, path: /*
Pattern: "http://*.example.com",
Matches: []Match{
{"http://mail.example.com/", true},
{"http://www.example.com/index.gmi", true},
{"http://example.com/", false},
{"http://a.b.example.com/", false},
{"gemini://www.example.com/", false},
},
},
}
for i, test := range tests {
h := &nopHandler{}
var mux ServeMux
mux.Handle(test.Pattern, h)
for _, match := range tests[i].Matches {
u, err := url.Parse(match.URL)
if err != nil {
panic(err)
}
got := mux.Handler(&Request{URL: u})
if match.Ok {
if h != got {
t.Errorf("expected %s to match %s", test.Pattern, match.URL)
}
} else {
if h == got {
t.Errorf("expected %s not to match %s", test.Pattern, match.URL)
}
}
}
}
}

28
punycode.go Normal file
View File

@@ -0,0 +1,28 @@
package gemini
import (
"net"
"unicode/utf8"
"golang.org/x/net/idna"
)
func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= utf8.RuneSelf {
return false
}
}
return true
}
// punycodeHostname returns the punycoded version of hostname.
func punycodeHostname(hostname string) (string, error) {
if net.ParseIP(hostname) != nil {
return hostname, nil
}
if isASCII(hostname) {
return hostname, nil
}
return idna.Lookup.ToASCII(hostname)
}

18
query.go Normal file
View File

@@ -0,0 +1,18 @@
package gemini
import (
"net/url"
"strings"
)
// QueryEscape escapes a string for use in a Gemini URL query.
// It is like url.PathEscape except that it also replaces plus signs
// with their percent-encoded counterpart.
func QueryEscape(query string) string {
return strings.ReplaceAll(url.PathEscape(query), "+", "%2B")
}
// QueryUnescape is identical to url.PathUnescape.
func QueryUnescape(query string) (string, error) {
return url.PathUnescape(query)
}

View File

@@ -3,90 +3,111 @@ package gemini
import ( import (
"bufio" "bufio"
"crypto/tls" "crypto/tls"
"io"
"net" "net"
"net/url" "net/url"
) )
// Request represents a Gemini request. // A Request represents a Gemini request received by a server or to be sent
// by a client.
//
// The field semantics differ slightly between client and server usage.
type Request struct { type Request struct {
// URL specifies the URL being requested. // URL specifies the URL being requested (for server
// requests) or the URL to access (for client requests).
URL *url.URL URL *url.URL
// For client requests, Host specifies the host on which the URL is sought. // For client requests, Host optionally specifies the server to
// Host must contain a port. // connect to. It may be of the form "host" or "host:port".
// This field is ignored by the server. // If empty, the value of URL.Host is used.
// For international domain names, Host may be in Punycode or
// Unicode form. Use golang.org/x/net/idna to convert it to
// either format if needed.
//
// For server requests, Host specifies the host on which the URL
// is sought.
Host string Host string
// Certificate specifies the TLS certificate to use for the request. // For client requests, Certificate optionally specifies the
// Request certificates take precedence over client certificates. // TLS certificate to present to the other side of the connection.
// This field is ignored by the server. // This field is ignored by the Gemini server.
Certificate *tls.Certificate Certificate *tls.Certificate
// RemoteAddr allows servers and other software to record the network // RemoteAddr allows Gemini servers and other software to record
// address that sent the request. // the network address that sent the request, usually for
// This field is ignored by the client. // logging. This field is not filled in by ReadRequest.
// This field is ignored by the Gemini client.
RemoteAddr net.Addr RemoteAddr net.Addr
// TLS allows servers and other software to record information about the TLS // TLS allows Gemini servers and other software to record
// connection on which the request was received. // information about the TLS connection on which the request
// This field is ignored by the client. // was received. This field is not filled in by ReadRequest.
TLS tls.ConnectionState // The Gemini server in this package sets the field for
// TLS-enabled connections before invoking a handler;
// otherwise it leaves the field nil.
// This field is ignored by the Gemini client.
TLS *tls.ConnectionState
} }
// hostname returns the host without the port. // NewRequest returns a new request.
func hostname(host string) string { //
hostname, _, err := net.SplitHostPort(host) // The returned Request is suitable for use with Client.Do.
if err != nil { //
return host // Callers should be careful that the URL query is properly escaped.
} // See the documentation for QueryEscape for more information.
return hostname
}
// NewRequest returns a new request. The host is inferred from the provided URL.
func NewRequest(rawurl string) (*Request, error) { func NewRequest(rawurl string) (*Request, error) {
u, err := url.Parse(rawurl) u, err := url.Parse(rawurl)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &Request{URL: u}, nil
// If there is no port, use the default port of 1965
host := u.Host
if u.Port() == "" {
host += ":1965"
}
return &Request{
Host: host,
URL: u,
}, nil
} }
// NewRequestTo returns a new request for the provided URL to the provided host. // ReadRequest reads and parses an incoming request from r.
// The host must contain a port. //
func NewRequestTo(rawurl, host string) (*Request, error) { // ReadRequest is a low-level function and should only be used
// for specialized applications; most code should use the Server
// to read requests and handle them via the Handler interface.
func ReadRequest(r io.Reader) (*Request, error) {
// Read URL
r = io.LimitReader(r, 1026)
br := bufio.NewReaderSize(r, 1026)
rawurl, err := br.ReadString('\r')
if err != nil {
return nil, err
}
// Read terminating line feed
if b, err := br.ReadByte(); err != nil {
return nil, err
} else if b != '\n' {
return nil, ErrInvalidRequest
}
// Trim carriage return
rawurl = rawurl[:len(rawurl)-1]
// Validate URL
if len(rawurl) > 1024 {
return nil, ErrInvalidRequest
}
u, err := url.Parse(rawurl) u, err := url.Parse(rawurl)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &Request{URL: u}, nil
return &Request{
Host: host,
URL: u,
}, nil
} }
// write writes the Gemini request to the provided buffered writer. // Write writes a Gemini request in wire format.
func (r *Request) write(w *bufio.Writer) error { // This method consults the request URL only.
func (r *Request) Write(w io.Writer) error {
bw := bufio.NewWriterSize(w, 1026)
url := r.URL.String() url := r.URL.String()
// User is invalid if len(url) > 1024 {
if r.URL.User != nil || len(url) > 1024 { return ErrInvalidRequest
return ErrInvalidURL
} }
if _, err := w.WriteString(url); err != nil { if _, err := bw.WriteString(url); err != nil {
return err return err
} }
if _, err := w.Write(crlf); err != nil { if _, err := bw.Write(crlf); err != nil {
return err return err
} }
return nil return bw.Flush()
} }

132
request_test.go Normal file
View File

@@ -0,0 +1,132 @@
package gemini
import (
"bufio"
"io"
"net/url"
"strings"
"testing"
)
// 1024 bytes
const maxURL = "gemini://example.net/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
func TestReadRequest(t *testing.T) {
tests := []struct {
Raw string
URL *url.URL
Err error
}{
{
Raw: "gemini://example.com\r\n",
URL: &url.URL{
Scheme: "gemini",
Host: "example.com",
},
},
{
Raw: "http://example.org/path/?query#fragment\r\n",
URL: &url.URL{
Scheme: "http",
Host: "example.org",
Path: "/path/",
RawQuery: "query",
Fragment: "fragment",
},
},
{
Raw: "\r\n",
URL: &url.URL{},
},
{
Raw: "gemini://example.com\n",
Err: io.EOF,
},
{
Raw: "gemini://example.com",
Err: io.EOF,
},
{
// 1030 bytes
Raw: maxURL + "xxxxxx",
Err: io.EOF,
},
{
// 1027 bytes
Raw: maxURL + "x" + "\r\n",
Err: io.EOF,
},
{
// 1024 bytes
Raw: maxURL[:len(maxURL)-2] + "\r\n",
URL: &url.URL{
Scheme: "gemini",
Host: "example.net",
Path: maxURL[len("gemini://example.net") : len(maxURL)-2],
},
},
}
for _, test := range tests {
t.Logf("%#v", test.Raw)
req, err := ReadRequest(strings.NewReader(test.Raw))
if err != test.Err {
t.Errorf("expected err = %v, got %v", test.Err, err)
}
if req == nil && test.URL != nil {
t.Errorf("expected url = %s, got nil", test.URL)
} else if req != nil && test.URL == nil {
t.Errorf("expected req = nil, got %v", req)
} else if req != nil && *req.URL != *test.URL {
t.Errorf("expected url = %v, got %v", *test.URL, *req.URL)
}
}
}
func newRequest(rawurl string) *Request {
req, err := NewRequest(rawurl)
if err != nil {
panic(err)
}
return req
}
func TestWriteRequest(t *testing.T) {
tests := []struct {
Req *Request
Raw string
Err error
}{
{
Req: newRequest("gemini://example.com"),
Raw: "gemini://example.com\r\n",
},
{
Req: newRequest("gemini://example.com/path/?query#fragment"),
Raw: "gemini://example.com/path/?query#fragment\r\n",
},
{
Req: newRequest(maxURL),
Raw: maxURL + "\r\n",
},
{
Req: newRequest(maxURL + "x"),
Err: ErrInvalidRequest,
},
}
for _, test := range tests {
t.Logf("%s", test.Req.URL)
var b strings.Builder
bw := bufio.NewWriter(&b)
err := test.Req.Write(bw)
if err != test.Err {
t.Errorf("expected err = %v, got %v", test.Err, err)
}
bw.Flush()
got := b.String()
if got != test.Raw {
t.Errorf("expected %#v, got %#v", test.Raw, got)
}
}
}

View File

@@ -3,83 +3,260 @@ package gemini
import ( import (
"bufio" "bufio"
"crypto/tls" "crypto/tls"
"io/ioutil" "fmt"
"io"
"strconv" "strconv"
) )
// Response is a Gemini response. // The default media type for responses.
const defaultMediaType = "text/gemini; charset=utf-8"
// Response represents the response from a Gemini request.
//
// The Client returns Responses from servers once the response
// header has been received. The response body is streamed on demand
// as the Body field is read.
type Response struct { type Response struct {
// Status represents the response status. // Status contains the response status code.
Status int Status Status
// Meta contains more information related to the response status. // Meta contains more information related to the response status.
// For successful responses, Meta should contain the mimetype of the response. // For successful responses, Meta should contain the media type of the response.
// For failure responses, Meta should contain a short description of the failure. // For failure responses, Meta should contain a short description of the failure.
// Meta should not be longer than 1024 bytes. // Meta should not be longer than 1024 bytes.
Meta string Meta string
// Body contains the response body. // Body represents the response body.
Body []byte //
// The response body is streamed on demand as the Body field
// is read. If the network connection fails or the server
// terminates the response, Body.Read calls return an error.
//
// The Gemini client guarantees that Body is always
// non-nil, even on responses without a body or responses with
// a zero-length body. It is the caller's responsibility to
// close Body.
Body io.ReadCloser
// TLS contains information about the TLS connection on which the response // TLS contains information about the TLS connection on which the
// was received. // response was received. It is nil for unencrypted responses.
TLS tls.ConnectionState TLS *tls.ConnectionState
} }
// read reads a Gemini response from the provided buffered reader. // ReadResponse reads a Gemini response from the provided io.ReadCloser.
func (resp *Response) read(r *bufio.Reader) error { func ReadResponse(rc io.ReadCloser) (*Response, error) {
resp := &Response{}
br := bufio.NewReader(rc)
// Read the status // Read the status
statusB := make([]byte, 2) statusB := make([]byte, 2)
if _, err := r.Read(statusB); err != nil { if _, err := br.Read(statusB); err != nil {
return err return nil, err
} }
status, err := strconv.Atoi(string(statusB)) status, err := strconv.Atoi(string(statusB))
if err != nil { if err != nil {
return err return nil, ErrInvalidResponse
}
resp.Status = status
// Disregard invalid status codes
const minStatus, maxStatus = 1, 6
statusClass := status / 10
if statusClass < minStatus || statusClass > maxStatus {
return ErrInvalidResponse
} }
resp.Status = Status(status)
// Read one space // Read one space
if b, err := r.ReadByte(); err != nil { if b, err := br.ReadByte(); err != nil {
return err return nil, err
} else if b != ' ' { } else if b != ' ' {
return ErrInvalidResponse return nil, ErrInvalidResponse
} }
// Read the meta // Read the meta
meta, err := r.ReadString('\r') meta, err := br.ReadString('\r')
if err != nil { if err != nil {
return err return nil, err
} }
// Trim carriage return // Trim carriage return
meta = meta[:len(meta)-1] meta = meta[:len(meta)-1]
// Ensure meta is less than or equal to 1024 bytes // Ensure meta is less than or equal to 1024 bytes
if len(meta) > 1024 { if len(meta) > 1024 {
return ErrInvalidResponse return nil, ErrInvalidResponse
}
if resp.Status.Class() == StatusSuccess && meta == "" {
// Use default media type
meta = defaultMediaType
} }
resp.Meta = meta resp.Meta = meta
// Read terminating newline // Read terminating newline
if b, err := r.ReadByte(); err != nil { if b, err := br.ReadByte(); err != nil {
return err return nil, err
} else if b != '\n' { } else if b != '\n' {
return ErrInvalidResponse return nil, ErrInvalidResponse
} }
// Read response body if resp.Status.Class() == StatusSuccess {
if status/10 == StatusClassSuccess { resp.Body = newReadCloserBody(br, rc)
var err error } else {
resp.Body, err = ioutil.ReadAll(r) resp.Body = nopReadCloser{}
if err != nil { rc.Close()
}
return resp, nil
}
type nopReadCloser struct{}
func (nopReadCloser) Read(p []byte) (int, error) {
return 0, io.EOF
}
func (nopReadCloser) Close() error {
return nil
}
type readCloserBody struct {
br *bufio.Reader // used until empty
io.ReadCloser
}
func newReadCloserBody(br *bufio.Reader, rc io.ReadCloser) io.ReadCloser {
body := &readCloserBody{ReadCloser: rc}
if br.Buffered() != 0 {
body.br = br
}
return body
}
func (b *readCloserBody) Read(p []byte) (n int, err error) {
if b.br != nil {
if n := b.br.Buffered(); len(p) > n {
p = p[:n]
}
n, err = b.br.Read(p)
if b.br.Buffered() == 0 {
b.br = nil
}
return n, err
}
return b.ReadCloser.Read(p)
}
// Write writes r to w in the Gemini response format, including the
// header and body.
//
// This method consults the Status, Meta, and Body fields of the response.
// The Response Body is closed after it is sent.
func (r *Response) Write(w io.Writer) error {
if _, err := fmt.Fprintf(w, "%02d %s\r\n", r.Status, r.Meta); err != nil {
return err
}
if r.Body != nil {
defer r.Body.Close()
if _, err := io.Copy(w, r.Body); err != nil {
return err return err
} }
} }
return nil return nil
} }
// A ResponseWriter interface is used by a Gemini handler to construct
// a Gemini response.
//
// A ResponseWriter may not be used after the Handler.ServeGemini method
// has returned.
type ResponseWriter interface {
// MediaType sets the media type that will be sent by Write for a
// successful response. If no media type is set, a default of
// "text/gemini; charset=utf-8" will be used.
//
// Setting the media type after a call to Write or WriteHeader has
// no effect.
MediaType(string)
// Write writes the data to the connection as part of a Gemini response.
//
// If WriteHeader has not yet been called, Write calls WriteHeader with
// StatusSuccess and the media type set in MediaType before writing the data.
// If no media type was set, Write uses a default media type of
// "text/gemini; charset=utf-8".
Write([]byte) (int, error)
// WriteHeader sends a Gemini response header with the provided
// status code and meta.
//
// If WriteHeader is not called explicitly, the first call to Write
// will trigger an implicit call to WriteHeader with a successful
// status code and the media type set in MediaType.
//
// The provided code must be a valid Gemini status code.
// The provided meta must not be longer than 1024 bytes.
// Only one header may be written.
WriteHeader(status Status, meta string)
}
// The Flusher interface is implemented by ResponseWriters that allow a
// Gemini handler to flush buffered data to the client.
//
// The default Gemini ResponseWriter implementation supports Flusher,
// but ResponseWriter wrappers may not. Handlers should always test
// for this ability at runtime.
type Flusher interface {
// Flush sends any buffered data to the client.
Flush() error
}
type responseWriter struct {
b *bufio.Writer
mediatype string
wroteHeader bool
bodyAllowed bool
}
// NewResponseWriter returns a ResponseWriter that uses the provided io.Writer.
func NewResponseWriter(w io.Writer) ResponseWriter {
return newResponseWriter(w)
}
func newResponseWriter(w io.Writer) *responseWriter {
return &responseWriter{
b: bufio.NewWriter(w),
}
}
func (w *responseWriter) MediaType(mediatype string) {
w.mediatype = mediatype
}
func (w *responseWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
meta := w.mediatype
if meta == "" {
// Use default media type
meta = defaultMediaType
}
w.WriteHeader(StatusSuccess, meta)
}
if !w.bodyAllowed {
return 0, ErrBodyNotAllowed
}
return w.b.Write(b)
}
func (w *responseWriter) WriteHeader(status Status, meta string) {
if w.wroteHeader {
return
}
if status.Class() == StatusSuccess {
w.bodyAllowed = true
}
w.b.WriteString(strconv.Itoa(int(status)))
w.b.WriteByte(' ')
w.b.WriteString(meta)
w.b.Write(crlf)
w.wroteHeader = true
}
func (w *responseWriter) Flush() error {
if !w.wroteHeader {
w.WriteHeader(StatusTemporaryFailure, "Temporary failure")
}
// Write errors from writeHeader will be returned here.
return w.b.Flush()
}

127
response_test.go Normal file
View File

@@ -0,0 +1,127 @@
package gemini
import (
"io"
"strings"
"testing"
)
func TestReadWriteResponse(t *testing.T) {
tests := []struct {
Raw string
Status Status
Meta string
Body string
Err error
SkipWrite bool
}{
{
Raw: "20 text/gemini\r\nHello, world!\nWelcome to my capsule.",
Status: 20,
Meta: "text/gemini",
Body: "Hello, world!\nWelcome to my capsule.",
},
{
Raw: "10 Search query\r\n",
Status: 10,
Meta: "Search query",
},
{
Raw: "30 /redirect\r\n",
Status: 30,
Meta: "/redirect",
},
{
Raw: "31 /redirect\r\nThis body is ignored.",
Status: 31,
Meta: "/redirect",
SkipWrite: true, // skip write test since result won't match Raw
},
{
Raw: "99 Unknown status code\r\n",
Status: 99,
Meta: "Unknown status code",
},
{
Raw: "\r\n",
Err: ErrInvalidResponse,
},
{
Raw: "\n",
Err: ErrInvalidResponse,
},
{
Raw: "1 Bad response\r\n",
Err: ErrInvalidResponse,
},
{
Raw: "",
Err: io.EOF,
},
{
Raw: "10 Search query",
Err: io.EOF,
},
{
Raw: "20 text/gemini\nHello, world!",
Err: io.EOF,
},
{
Raw: "20 text/gemini\rHello, world!",
Err: ErrInvalidResponse,
},
{
Raw: "20 text/gemini\r",
Err: io.EOF,
},
{
Raw: "abcdefghijklmnopqrstuvwxyz",
Err: ErrInvalidResponse,
},
}
for _, test := range tests {
t.Logf("%#v", test.Raw)
resp, err := ReadResponse(io.NopCloser(strings.NewReader(test.Raw)))
if err != test.Err {
t.Errorf("expected err = %v, got %v", test.Err, err)
}
if test.Err != nil {
// No response
continue
}
if resp.Status != test.Status {
t.Errorf("expected status = %d, got %d", test.Status, resp.Status)
}
if resp.Meta != test.Meta {
t.Errorf("expected meta = %s, got %s", test.Meta, resp.Meta)
}
b, _ := io.ReadAll(resp.Body)
body := string(b)
if body != test.Body {
t.Errorf("expected body = %#v, got %#v", test.Body, body)
}
}
for _, test := range tests {
if test.Err != nil || test.SkipWrite {
continue
}
resp := &Response{
Status: test.Status,
Meta: test.Meta,
Body: io.NopCloser(strings.NewReader(test.Body)),
}
var b strings.Builder
if err := resp.Write(&b); err != nil {
t.Error(err)
continue
}
got := b.String()
if got != test.Raw {
t.Errorf("expected %#v, got %#v", test.Raw, got)
}
}
}

758
server.go
View File

@@ -1,116 +1,249 @@
package gemini package gemini
import ( import (
"bufio" "context"
"crypto/tls" "crypto/tls"
"crypto/x509" "errors"
"log" "log"
"net" "net"
"net/url" "runtime"
"path"
"sort"
"strconv"
"strings"
"sync" "sync"
"sync/atomic"
"time" "time"
) )
// Server is a Gemini server. // A Server defines parameters for running a Gemini server. The zero value for
// Server is a valid configuration.
type Server struct { type Server struct {
// Addr specifies the address that the server should listen on. // Addr optionally specifies the TCP address for the server to listen on,
// If Addr is empty, the server will listen on the address ":1965". // in the form "host:port". If empty, ":1965" (port 1965) is used.
// See net.Dial for details of the address format.
Addr string Addr string
// CertificateStore contains the certificates used by the server. // The Handler to invoke.
CertificateStore CertificateStore Handler Handler
// GetCertificate, if not nil, will be called to retrieve the certificate // ReadTimeout is the maximum duration for reading the entire
// to use for a given hostname. // request.
// If the certificate is nil, the connection will be aborted. //
GetCertificate func(hostname string, store *CertificateStore) *tls.Certificate // A ReadTimeout of zero means no timeout.
ReadTimeout time.Duration
// registered responders // WriteTimeout is the maximum duration before timing out
responders map[responderKey]Responder // writes of the response.
//
// A WriteTimeout of zero means no timeout.
WriteTimeout time.Duration
// GetCertificate returns a TLS certificate based on the given
// hostname.
//
// If GetCertificate is nil or returns nil, then no certificate
// will be used and the connection will be aborted.
GetCertificate func(hostname string) (*tls.Certificate, error)
// ErrorLog specifies an optional logger for errors accepting connections,
// unexpected behavior from handlers, and underlying file system errors.
// If nil, logging is done via the log package's standard logger.
ErrorLog *log.Logger
listeners map[*net.Listener]context.CancelFunc
conns map[*net.Conn]context.CancelFunc
doneChan chan struct{}
closed int32
mu sync.Mutex
} }
type responderKey struct { // done returns a channel that's closed when the server has finished closing.
scheme string func (srv *Server) done() chan struct{} {
hostname string srv.mu.Lock()
wildcard bool defer srv.mu.Unlock()
return srv.doneLocked()
} }
// Register registers a responder for the given pattern. func (srv *Server) doneLocked() chan struct{} {
// Patterns must be in the form of scheme://hostname (e.g. gemini://example.com). if srv.doneChan == nil {
// If no scheme is specified, a default scheme of gemini:// is assumed. srv.doneChan = make(chan struct{})
// Wildcard patterns are supported (e.g. *.example.com).
func (s *Server) Register(pattern string, responder Responder) {
if pattern == "" {
panic("gemini: invalid pattern")
} }
if responder == nil { return srv.doneChan
panic("gemini: nil responder")
}
if s.responders == nil {
s.responders = map[responderKey]Responder{}
}
split := strings.SplitN(pattern, "://", 2)
var key responderKey
if len(split) == 2 {
key.scheme = split[0]
key.hostname = split[1]
} else {
key.scheme = "gemini"
key.hostname = split[0]
}
split = strings.SplitN(key.hostname, ".", 2)
if len(split) == 2 && split[0] == "*" {
key.hostname = split[1]
key.wildcard = true
}
s.responders[key] = responder
} }
// RegisterFunc registers a responder function for the given pattern. // tryFinishShutdown closes srv.done() if there are no active listeners or requests.
func (s *Server) RegisterFunc(pattern string, responder func(*ResponseWriter, *Request)) { func (srv *Server) tryFinishShutdown() {
s.Register(pattern, ResponderFunc(responder)) srv.mu.Lock()
defer srv.mu.Unlock()
if len(srv.listeners) == 0 && len(srv.conns) == 0 {
done := srv.doneLocked()
select {
case <-done:
default:
close(done)
}
}
}
// Close immediately closes all active net.Listeners and connections.
// For a graceful shutdown, use Shutdown.
func (srv *Server) Close() error {
if !atomic.CompareAndSwapInt32(&srv.closed, 0, 1) {
return ErrServerClosed
}
// Close active listeners and connections.
srv.mu.Lock()
for _, cancel := range srv.listeners {
cancel()
}
for _, cancel := range srv.conns {
cancel()
}
srv.mu.Unlock()
select {
case <-srv.done():
return nil
}
}
// Shutdown gracefully shuts down the server without interrupting any
// active connections. Shutdown works by first closing all open
// listeners and then waiting indefinitely for connections
// to close and then shut down.
// If the provided context expires before the shutdown is complete,
// Shutdown returns the context's error.
//
// When Shutdown is called, Serve and ListenAndServer immediately
// return ErrServerClosed. Make sure the program doesn't exit and
// waits instead for Shutdown to return.
//
// Once Shutdown has been called on a server, it may not be reused;
// future calls to methods such as Serve will return ErrServerClosed.
func (srv *Server) Shutdown(ctx context.Context) error {
if !atomic.CompareAndSwapInt32(&srv.closed, 0, 1) {
return ErrServerClosed
}
// Close active listeners.
srv.mu.Lock()
for _, cancel := range srv.listeners {
cancel()
}
srv.mu.Unlock()
// Wait for active connections to finish.
select {
case <-ctx.Done():
return ctx.Err()
case <-srv.done():
return nil
}
} }
// ListenAndServe listens for requests at the server's configured address. // ListenAndServe listens for requests at the server's configured address.
func (s *Server) ListenAndServe() error { // ListenAndServe listens on the TCP network address srv.Addr and then calls
addr := s.Addr // Serve to handle requests on incoming connections.
//
// If srv.Addr is blank, ":1965" is used.
//
// ListenAndServe always returns a non-nil error. After Shutdown or Close, the
// returned error is ErrServerClosed.
func (srv *Server) ListenAndServe(ctx context.Context) error {
if atomic.LoadInt32(&srv.closed) == 1 {
return ErrServerClosed
}
addr := srv.Addr
if addr == "" { if addr == "" {
addr = ":1965" addr = ":1965"
} }
ln, err := net.Listen("tcp", addr) l, err := net.Listen("tcp", addr)
if err != nil { if err != nil {
return err return err
} }
defer ln.Close()
config := &tls.Config{ l = tls.NewListener(l, &tls.Config{
ClientAuth: tls.RequestClientCert, ClientAuth: tls.RequestClientCert,
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
GetCertificate: func(h *tls.ClientHelloInfo) (*tls.Certificate, error) { GetCertificate: srv.getCertificate,
if s.GetCertificate != nil { })
return s.GetCertificate(h.ServerName, &s.CertificateStore), nil return srv.Serve(ctx, l)
}
return s.CertificateStore.Lookup(h.ServerName)
},
}
tlsListener := tls.NewListener(ln, config)
return s.Serve(tlsListener)
} }
// Serve listens for requests on the provided listener. func (srv *Server) getCertificate(h *tls.ClientHelloInfo) (*tls.Certificate, error) {
func (s *Server) Serve(l net.Listener) error { if srv.GetCertificate == nil {
var tempDelay time.Duration // how long to sleep on accept failure return nil, errors.New("gemini: GetCertificate is nil")
}
return srv.GetCertificate(h.ServerName)
}
func (srv *Server) trackListener(l *net.Listener, cancel context.CancelFunc) {
srv.mu.Lock()
defer srv.mu.Unlock()
if srv.listeners == nil {
srv.listeners = make(map[*net.Listener]context.CancelFunc)
}
srv.listeners[l] = cancel
}
func (srv *Server) deleteListener(l *net.Listener) {
srv.mu.Lock()
defer srv.mu.Unlock()
delete(srv.listeners, l)
}
// Serve accepts incoming connections on the Listener l, creating a new
// service goroutine for each. The service goroutines read requests and
// then calls the appropriate Handler to reply to them.
//
// Serve always returns a non-nil error and closes l. After Shutdown or Close,
// the returned error is ErrServerClosed.
func (srv *Server) Serve(ctx context.Context, l net.Listener) error {
defer l.Close()
if atomic.LoadInt32(&srv.closed) == 1 {
return ErrServerClosed
}
lnctx, cancel := context.WithCancel(ctx)
defer cancel()
srv.trackListener(&l, cancel)
defer srv.tryFinishShutdown()
defer srv.deleteListener(&l)
errch := make(chan error, 1)
go func() {
errch <- srv.serve(ctx, l)
}()
select {
case <-lnctx.Done():
if atomic.LoadInt32(&srv.closed) == 1 {
return ErrServerClosed
}
return lnctx.Err()
case err := <-errch:
return err
}
}
func (srv *Server) serve(ctx context.Context, l net.Listener) error {
// how long to sleep on accept failure
var tempDelay time.Duration
for { for {
rw, err := l.Accept() rw, err := l.Accept()
if err != nil { if err != nil {
select {
case <-ctx.Done():
if atomic.LoadInt32(&srv.closed) == 1 {
return ErrServerClosed
}
return ctx.Err()
default:
}
// If this is a temporary error, sleep // If this is a temporary error, sleep
if ne, ok := err.(net.Error); ok && ne.Temporary() { if ne, ok := err.(net.Error); ok && ne.Temporary() {
if tempDelay == 0 { if tempDelay == 0 {
@@ -121,410 +254,111 @@ func (s *Server) Serve(l net.Listener) error {
if max := 1 * time.Second; tempDelay > max { if max := 1 * time.Second; tempDelay > max {
tempDelay = max tempDelay = max
} }
log.Printf("gemini: Accept error: %v; retrying in %v", err, tempDelay) srv.logf("gemini: Accept error: %v; retrying in %v", err, tempDelay)
time.Sleep(tempDelay) time.Sleep(tempDelay)
continue continue
} }
// Otherwise, return the error
return err return err
} }
tempDelay = 0 tempDelay = 0
go s.respond(rw) go srv.serveConn(ctx, rw)
} }
} }
// respond responds to a connection. func (srv *Server) trackConn(conn *net.Conn, cancel context.CancelFunc) {
func (s *Server) respond(conn net.Conn) { srv.mu.Lock()
r := bufio.NewReader(conn) defer srv.mu.Unlock()
if srv.conns == nil {
srv.conns = make(map[*net.Conn]context.CancelFunc)
}
srv.conns[conn] = cancel
}
func (srv *Server) deleteConn(conn *net.Conn) {
srv.mu.Lock()
defer srv.mu.Unlock()
delete(srv.conns, conn)
}
// serveConn serves a Gemini response over the provided connection.
// It closes the connection when the response has been completed.
func (srv *Server) serveConn(ctx context.Context, conn net.Conn) {
defer conn.Close()
if atomic.LoadInt32(&srv.closed) == 1 {
return
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
srv.trackConn(&conn, cancel)
defer srv.tryFinishShutdown()
defer srv.deleteConn(&conn)
defer func() {
if err := recover(); err != nil && err != ErrAbortHandler {
const size = 64 << 10
buf := make([]byte, size)
buf = buf[:runtime.Stack(buf, false)]
srv.logf("gemini: panic serving %v: %v\n%s", conn.RemoteAddr(), err, buf)
}
}()
if d := srv.ReadTimeout; d != 0 {
conn.SetReadDeadline(time.Now().Add(d))
}
if d := srv.WriteTimeout; d != 0 {
conn.SetWriteDeadline(time.Now().Add(d))
}
done := make(chan struct{})
go func() {
srv.respond(ctx, conn)
close(done)
}()
select {
case <-ctx.Done():
case <-done:
}
}
func (srv *Server) respond(ctx context.Context, conn net.Conn) {
w := newResponseWriter(conn) w := newResponseWriter(conn)
// Read requested URL defer w.Flush()
rawurl, err := r.ReadString('\r')
req, err := ReadRequest(conn)
if err != nil { if err != nil {
w.WriteHeader(StatusBadRequest, "Bad request")
return return
} }
// Read terminating line feed
if b, err := r.ReadByte(); err != nil { // Store the TLS connection state
return if tlsConn, ok := conn.(*tls.Conn); ok {
} else if b != '\n' { state := tlsConn.ConnectionState()
w.WriteHeader(StatusBadRequest, "Bad request") req.TLS = &state
req.Host = state.ServerName
} }
// Trim carriage return
rawurl = rawurl[:len(rawurl)-1] // Store remote address
// Ensure URL is valid req.RemoteAddr = conn.RemoteAddr()
if len(rawurl) > 1024 {
w.WriteHeader(StatusBadRequest, "Bad request") h := srv.Handler
} else if url, err := url.Parse(rawurl); err != nil || url.User != nil { if h == nil {
// Note that we return an error status if User is specified in the URL w.WriteHeader(StatusNotFound, "Not found")
w.WriteHeader(StatusBadRequest, "Bad request") return
}
h.ServeGemini(ctx, w, req)
}
func (srv *Server) logf(format string, args ...interface{}) {
if srv.ErrorLog != nil {
srv.ErrorLog.Printf(format, args...)
} else { } else {
// If no scheme is specified, assume a default scheme of gemini:// log.Printf(format, args...)
if url.Scheme == "" {
url.Scheme = "gemini"
}
req := &Request{
URL: url,
RemoteAddr: conn.RemoteAddr(),
TLS: conn.(*tls.Conn).ConnectionState(),
}
s.responder(req).Respond(w, req)
}
w.b.Flush()
conn.Close()
}
func (s *Server) responder(r *Request) Responder {
if h, ok := s.responders[responderKey{r.URL.Scheme, r.URL.Hostname(), false}]; ok {
return h
}
wildcard := strings.SplitN(r.URL.Hostname(), ".", 2)
if len(wildcard) == 2 {
if h, ok := s.responders[responderKey{r.URL.Scheme, wildcard[1], true}]; ok {
return h
}
}
return ResponderFunc(NotFound)
}
// ResponseWriter is used by a Gemini handler to construct a Gemini response.
type ResponseWriter struct {
b *bufio.Writer
bodyAllowed bool
wroteHeader bool
mimetype string
}
func newResponseWriter(conn net.Conn) *ResponseWriter {
return &ResponseWriter{
b: bufio.NewWriter(conn),
} }
} }
// WriteHeader writes the response header.
// If the header has already been written, WriteHeader does nothing.
//
// Meta contains more information related to the response status.
// For successful responses, Meta should contain the mimetype of the response.
// For failure responses, Meta should contain a short description of the failure.
// Meta should not be longer than 1024 bytes.
func (w *ResponseWriter) WriteHeader(status int, meta string) {
if w.wroteHeader {
return
}
w.b.WriteString(strconv.Itoa(status))
w.b.WriteByte(' ')
w.b.WriteString(meta)
w.b.Write(crlf)
// Only allow body to be written on successful status codes.
if status/10 == StatusClassSuccess {
w.bodyAllowed = true
}
w.wroteHeader = true
}
// SetMimetype sets the mimetype that will be written for a successful response.
// The provided mimetype will only be used if Write is called without calling
// WriteHeader.
// If the mimetype is not set, it will default to "text/gemini".
func (w *ResponseWriter) SetMimetype(mimetype string) {
w.mimetype = mimetype
}
// Write writes the response body.
// If the response status does not allow for a response body, Write returns
// ErrBodyNotAllowed.
//
// If WriteHeader has not yet been called, Write calls
// WriteHeader(StatusSuccess, mimetype) where mimetype is the mimetype set in
// SetMimetype. If no mimetype is set, a default of "text/gemini" will be used.
func (w *ResponseWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
mimetype := w.mimetype
if mimetype == "" {
mimetype = "text/gemini"
}
w.WriteHeader(StatusSuccess, mimetype)
}
if !w.bodyAllowed {
return 0, ErrBodyNotAllowed
}
return w.b.Write(b)
}
// A Responder responds to a Gemini request.
type Responder interface {
// Respond accepts a Request and constructs a Response.
Respond(*ResponseWriter, *Request)
}
// Input returns the request query.
// If no input is provided, it responds with StatusInput.
func Input(w *ResponseWriter, r *Request, prompt string) (string, bool) {
if r.URL.ForceQuery || r.URL.RawQuery != "" {
return r.URL.RawQuery, true
}
w.WriteHeader(StatusInput, prompt)
return "", false
}
// SensitiveInput returns the request query.
// If no input is provided, it responds with StatusSensitiveInput.
func SensitiveInput(w *ResponseWriter, r *Request, prompt string) (string, bool) {
if r.URL.ForceQuery || r.URL.RawQuery != "" {
return r.URL.RawQuery, true
}
w.WriteHeader(StatusSensitiveInput, prompt)
return "", false
}
// Redirect replies to the request with a redirect to the given URL.
func Redirect(w *ResponseWriter, r *Request, url string) {
w.WriteHeader(StatusRedirect, url)
}
// PermanentRedirect replies to the request with a permanent redirect to the given URL.
func PermanentRedirect(w *ResponseWriter, r *Request, url string) {
w.WriteHeader(StatusRedirectPermanent, url)
}
// NotFound replies to the request with the NotFound status code.
func NotFound(w *ResponseWriter, r *Request) {
w.WriteHeader(StatusNotFound, "Not found")
}
// Gone replies to the request with the Gone status code.
func Gone(w *ResponseWriter, r *Request) {
w.WriteHeader(StatusGone, "Gone")
}
// CertificateRequired responds to the request with the CertificateRequired
// status code.
func CertificateRequired(w *ResponseWriter, r *Request) {
w.WriteHeader(StatusCertificateRequired, "Certificate required")
}
// CertificateNotAuthorized responds to the request with
// the CertificateNotAuthorized status code.
func CertificateNotAuthorized(w *ResponseWriter, r *Request) {
w.WriteHeader(StatusCertificateNotAuthorized, "Certificate not authorized")
}
// Certificate returns the request certificate. If one is not provided,
// it returns nil and responds with StatusCertificateRequired.
func Certificate(w *ResponseWriter, r *Request) (*x509.Certificate, bool) {
if len(r.TLS.PeerCertificates) == 0 {
CertificateRequired(w, r)
return nil, false
}
return r.TLS.PeerCertificates[0], true
}
// ResponderFunc is a wrapper around a bare function that implements Responder.
type ResponderFunc func(*ResponseWriter, *Request)
func (f ResponderFunc) Respond(w *ResponseWriter, r *Request) {
f(w, r)
}
// The following code is modified from the net/http package.
// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// ServeMux is a Gemini request multiplexer.
// It matches the URL of each incoming request against a list of registered
// patterns and calls the handler for the pattern that
// most closely matches the URL.
//
// Patterns name fixed, rooted paths, like "/favicon.ico",
// or rooted subtrees, like "/images/" (note the trailing slash).
// Longer patterns take precedence over shorter ones, so that
// if there are handlers registered for both "/images/"
// and "/images/thumbnails/", the latter handler will be
// called for paths beginning "/images/thumbnails/" and the
// former will receive requests for any other paths in the
// "/images/" subtree.
//
// Note that since a pattern ending in a slash names a rooted subtree,
// the pattern "/" matches all paths not matched by other registered
// patterns, not just the URL with Path == "/".
//
// If a subtree has been registered and a request is received naming the
// subtree root without its trailing slash, ServeMux redirects that
// request to the subtree root (adding the trailing slash). This behavior can
// be overridden with a separate registration for the path without
// the trailing slash. For example, registering "/images/" causes ServeMux
// to redirect a request for "/images" to "/images/", unless "/images" has
// been registered separately.
//
// ServeMux also takes care of sanitizing the URL request path and
// redirecting any request containing . or .. elements or repeated slashes
// to an equivalent, cleaner URL.
type ServeMux struct {
mu sync.RWMutex
m map[string]muxEntry
es []muxEntry // slice of entries sorted from longest to shortest.
}
type muxEntry struct {
r Responder
pattern string
}
// cleanPath returns the canonical path for p, eliminating . and .. elements.
func cleanPath(p string) string {
if p == "" {
return "/"
}
if p[0] != '/' {
p = "/" + p
}
np := path.Clean(p)
// path.Clean removes trailing slash except for root;
// put the trailing slash back if necessary.
if p[len(p)-1] == '/' && np != "/" {
// Fast path for common case of p being the string we want:
if len(p) == len(np)+1 && strings.HasPrefix(p, np) {
np = p
} else {
np += "/"
}
}
return np
}
// Find a handler on a handler map given a path string.
// Most-specific (longest) pattern wins.
func (mux *ServeMux) match(path string) Responder {
// Check for exact match first.
v, ok := mux.m[path]
if ok {
return v.r
}
// Check for longest valid match. mux.es contains all patterns
// that end in / sorted from longest to shortest.
for _, e := range mux.es {
if strings.HasPrefix(path, e.pattern) {
return e.r
}
}
return nil
}
// redirectToPathSlash determines if the given path needs appending "/" to it.
// This occurs when a handler for path + "/" was already registered, but
// not for path itself. If the path needs appending to, it creates a new
// URL, setting the path to u.Path + "/" and returning true to indicate so.
func (mux *ServeMux) redirectToPathSlash(path string, u *url.URL) (*url.URL, bool) {
mux.mu.RLock()
shouldRedirect := mux.shouldRedirectRLocked(path)
mux.mu.RUnlock()
if !shouldRedirect {
return u, false
}
path = path + "/"
u = &url.URL{Path: path, RawQuery: u.RawQuery}
return u, true
}
// shouldRedirectRLocked reports whether the given path and host should be redirected to
// path+"/". This should happen if a handler is registered for path+"/" but
// not path -- see comments at ServeMux.
func (mux *ServeMux) shouldRedirectRLocked(path string) bool {
if _, exist := mux.m[path]; exist {
return false
}
n := len(path)
if n == 0 {
return false
}
if _, exist := mux.m[path+"/"]; exist {
return path[n-1] != '/'
}
return false
}
// Respond dispatches the request to the responder whose
// pattern most closely matches the request URL.
func (mux *ServeMux) Respond(w *ResponseWriter, r *Request) {
path := cleanPath(r.URL.Path)
// If the given path is /tree and its handler is not registered,
// redirect for /tree/.
if u, ok := mux.redirectToPathSlash(path, r.URL); ok {
Redirect(w, r, u.String())
return
}
if path != r.URL.Path {
u := *r.URL
u.Path = path
Redirect(w, r, u.String())
return
}
mux.mu.RLock()
defer mux.mu.RUnlock()
resp := mux.match(path)
if resp == nil {
NotFound(w, r)
return
}
resp.Respond(w, r)
}
// Handle registers the responder for the given pattern.
// If a responder already exists for pattern, Handle panics.
func (mux *ServeMux) Handle(pattern string, responder Responder) {
mux.mu.Lock()
defer mux.mu.Unlock()
if pattern == "" {
panic("gemini: invalid pattern")
}
if responder == nil {
panic("gemini: nil responder")
}
if _, exist := mux.m[pattern]; exist {
panic("gemini: multiple registrations for " + pattern)
}
if mux.m == nil {
mux.m = make(map[string]muxEntry)
}
e := muxEntry{responder, pattern}
mux.m[pattern] = e
if pattern[len(pattern)-1] == '/' {
mux.es = appendSorted(mux.es, e)
}
}
func appendSorted(es []muxEntry, e muxEntry) []muxEntry {
n := len(es)
i := sort.Search(n, func(i int) bool {
return len(es[i].pattern) < len(e.pattern)
})
if i == n {
return append(es, e)
}
// we now know that i points at where we want to insert
es = append(es, muxEntry{}) // try to grow the slice in place, any entry works.
copy(es[i+1:], es[i:]) // move shorter entries down
es[i] = e
return es
}
// HandleFunc registers the responder function for the given pattern.
func (mux *ServeMux) HandleFunc(pattern string, responder func(*ResponseWriter, *Request)) {
if responder == nil {
panic("gemini: nil responder")
}
mux.Handle(pattern, ResponderFunc(responder))
}

76
status.go Normal file
View File

@@ -0,0 +1,76 @@
package gemini
// Status represents a Gemini status code.
type Status int
// Gemini status codes.
const (
StatusInput Status = 10
StatusSensitiveInput Status = 11
StatusSuccess Status = 20
StatusRedirect Status = 30
StatusPermanentRedirect Status = 31
StatusTemporaryFailure Status = 40
StatusServerUnavailable Status = 41
StatusCGIError Status = 42
StatusProxyError Status = 43
StatusSlowDown Status = 44
StatusPermanentFailure Status = 50
StatusNotFound Status = 51
StatusGone Status = 52
StatusProxyRequestRefused Status = 53
StatusBadRequest Status = 59
StatusCertificateRequired Status = 60
StatusCertificateNotAuthorized Status = 61
StatusCertificateNotValid Status = 62
)
// Class returns the status class for the status code.
// 1x becomes 10, 2x becomes 20, and so on.
func (s Status) Class() Status {
return (s / 10) * 10
}
// String returns a text for the status code.
// It returns the empty string if the status code is unknown.
func (s Status) String() string {
switch s {
case StatusInput:
return "Input"
case StatusSensitiveInput:
return "Sensitive input"
case StatusSuccess:
return "Success"
case StatusRedirect:
return "Redirect"
case StatusPermanentRedirect:
return "Permanent redirect"
case StatusTemporaryFailure:
return "Temporary failure"
case StatusServerUnavailable:
return "Server unavailable"
case StatusCGIError:
return "CGI error"
case StatusProxyError:
return "Proxy error"
case StatusSlowDown:
return "Slow down"
case StatusPermanentFailure:
return "Permanent failure"
case StatusNotFound:
return "Not found"
case StatusGone:
return "Gone"
case StatusProxyRequestRefused:
return "Proxy request refused"
case StatusBadRequest:
return "Bad request"
case StatusCertificateRequired:
return "Certificate required"
case StatusCertificateNotAuthorized:
return "Certificate not authorized"
case StatusCertificateNotValid:
return "Certificate not valid"
}
return ""
}

158
text.go
View File

@@ -3,7 +3,6 @@ package gemini
import ( import (
"bufio" "bufio"
"fmt" "fmt"
"html"
"io" "io"
"strings" "strings"
) )
@@ -88,58 +87,70 @@ func (l LineText) line() {}
// Text represents a Gemini text response. // Text represents a Gemini text response.
type Text []Line type Text []Line
// Parse parses Gemini text from the provided io.Reader. // ParseText parses Gemini text from the provided io.Reader.
func Parse(r io.Reader) Text { func ParseText(r io.Reader) (Text, error) {
const spacetab = " \t"
var t Text var t Text
err := ParseLines(r, func(line Line) {
t = append(t, line)
})
return t, err
}
// ParseLines parses Gemini text from the provided io.Reader.
// It calls handler with each line that it parses.
func ParseLines(r io.Reader, handler func(Line)) error {
const spacetab = " \t"
var pre bool var pre bool
scanner := bufio.NewScanner(r) scanner := bufio.NewScanner(r)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() var line Line
if strings.HasPrefix(line, "```") { text := scanner.Text()
if strings.HasPrefix(text, "```") {
pre = !pre pre = !pre
line = line[3:] text = text[3:]
t = append(t, LinePreformattingToggle(line)) line = LinePreformattingToggle(text)
} else if pre { } else if pre {
t = append(t, LinePreformattedText(line)) line = LinePreformattedText(text)
} else if strings.HasPrefix(line, "=>") { } else if strings.HasPrefix(text, "=>") {
line = line[2:] text = text[2:]
line = strings.TrimLeft(line, spacetab) text = strings.TrimLeft(text, spacetab)
split := strings.IndexAny(line, spacetab) split := strings.IndexAny(text, spacetab)
if split == -1 { if split == -1 {
// line is a URL // text is a URL
t = append(t, LineLink{URL: line}) line = LineLink{URL: text}
} else { } else {
url := line[:split] url := text[:split]
name := line[split:] name := text[split:]
name = strings.TrimLeft(name, spacetab) name = strings.TrimLeft(name, spacetab)
t = append(t, LineLink{url, name}) line = LineLink{url, name}
} }
} else if strings.HasPrefix(line, "*") { } else if strings.HasPrefix(text, "*") {
line = line[1:] text = text[1:]
line = strings.TrimLeft(line, spacetab) text = strings.TrimLeft(text, spacetab)
t = append(t, LineListItem(line)) line = LineListItem(text)
} else if strings.HasPrefix(line, "###") { } else if strings.HasPrefix(text, "###") {
line = line[3:] text = text[3:]
line = strings.TrimLeft(line, spacetab) text = strings.TrimLeft(text, spacetab)
t = append(t, LineHeading3(line)) line = LineHeading3(text)
} else if strings.HasPrefix(line, "##") { } else if strings.HasPrefix(text, "##") {
line = line[2:] text = text[2:]
line = strings.TrimLeft(line, spacetab) text = strings.TrimLeft(text, spacetab)
t = append(t, LineHeading2(line)) line = LineHeading2(text)
} else if strings.HasPrefix(line, "#") { } else if strings.HasPrefix(text, "#") {
line = line[1:] text = text[1:]
line = strings.TrimLeft(line, spacetab) text = strings.TrimLeft(text, spacetab)
t = append(t, LineHeading1(line)) line = LineHeading1(text)
} else if strings.HasPrefix(line, ">") { } else if strings.HasPrefix(text, ">") {
line = line[1:] text = text[1:]
line = strings.TrimLeft(line, spacetab) text = strings.TrimLeft(text, spacetab)
t = append(t, LineQuote(line)) line = LineQuote(text)
} else { } else {
t = append(t, LineText(line)) line = LineText(text)
} }
handler(line)
} }
return t
return scanner.Err()
} }
// String writes the Gemini text response to a string and returns it. // String writes the Gemini text response to a string and returns it.
@@ -151,70 +162,3 @@ func (t Text) String() string {
} }
return b.String() return b.String()
} }
// HTML returns the Gemini text response as HTML.
func (t Text) HTML() string {
var b strings.Builder
var pre bool
var list bool
for _, l := range t {
if _, ok := l.(LineListItem); ok {
if !list {
list = true
fmt.Fprint(&b, "<ul>\n")
}
} else if list {
list = false
fmt.Fprint(&b, "</ul>\n")
}
switch l.(type) {
case LineLink:
link := l.(LineLink)
url := html.EscapeString(link.URL)
name := html.EscapeString(link.Name)
if name == "" {
name = url
}
fmt.Fprintf(&b, "<p><a href='%s'>%s</a></p>\n", url, name)
case LinePreformattingToggle:
pre = !pre
if pre {
fmt.Fprint(&b, "<pre>\n")
} else {
fmt.Fprint(&b, "</pre>\n")
}
case LinePreformattedText:
text := string(l.(LinePreformattedText))
fmt.Fprintf(&b, "%s\n", html.EscapeString(text))
case LineHeading1:
text := string(l.(LineHeading1))
fmt.Fprintf(&b, "<h1>%s</h1>\n", html.EscapeString(text))
case LineHeading2:
text := string(l.(LineHeading2))
fmt.Fprintf(&b, "<h2>%s</h2>\n", html.EscapeString(text))
case LineHeading3:
text := string(l.(LineHeading3))
fmt.Fprintf(&b, "<h3>%s</h3>\n", html.EscapeString(text))
case LineListItem:
text := string(l.(LineListItem))
fmt.Fprintf(&b, "<li>%s</li>\n", html.EscapeString(text))
case LineQuote:
text := string(l.(LineQuote))
fmt.Fprintf(&b, "<blockquote>%s</blockquote>\n", html.EscapeString(text))
case LineText:
text := string(l.(LineText))
if text == "" {
fmt.Fprint(&b, "<br>\n")
} else {
fmt.Fprintf(&b, "<p>%s</p>\n", html.EscapeString(text))
}
}
}
if pre {
fmt.Fprint(&b, "</pre>\n")
}
if list {
fmt.Fprint(&b, "</ul>\n")
}
return b.String()
}

110
timeout.go Normal file
View File

@@ -0,0 +1,110 @@
package gemini
import (
"bytes"
"context"
"sync"
"time"
)
// TimeoutHandler returns a Handler that runs h with the given time limit.
//
// The new Handler calls h.ServeGemini to handle each request, but
// if a call runs for longer than its time limit, the handler responds with a
// 40 Temporary Failure error. After such a timeout, writes by h to its
// ResponseWriter will return ErrHandlerTimeout.
//
// TimeoutHandler does not support the Hijacker or Flusher interfaces.
func TimeoutHandler(h Handler, dt time.Duration) Handler {
return &timeoutHandler{
h: h,
dt: dt,
}
}
type timeoutHandler struct {
h Handler
dt time.Duration
}
func (t *timeoutHandler) ServeGemini(ctx context.Context, w ResponseWriter, r *Request) {
ctx, cancel := context.WithTimeout(ctx, t.dt)
defer cancel()
done := make(chan struct{})
tw := &timeoutWriter{}
panicChan := make(chan interface{}, 1)
go func() {
defer func() {
if p := recover(); p != nil {
panicChan <- p
}
}()
t.h.ServeGemini(ctx, tw, r)
close(done)
}()
select {
case p := <-panicChan:
panic(p)
case <-done:
tw.mu.Lock()
defer tw.mu.Unlock()
if !tw.wroteHeader {
tw.status = StatusSuccess
}
w.WriteHeader(tw.status, tw.meta)
w.Write(tw.b.Bytes())
case <-ctx.Done():
tw.mu.Lock()
defer tw.mu.Unlock()
w.WriteHeader(StatusTemporaryFailure, "Timeout")
tw.timedOut = true
}
}
type timeoutWriter struct {
mu sync.Mutex
b bytes.Buffer
status Status
meta string
mediatype string
wroteHeader bool
timedOut bool
}
func (w *timeoutWriter) MediaType(mediatype string) {
w.mu.Lock()
defer w.mu.Unlock()
w.mediatype = mediatype
}
func (w *timeoutWriter) Write(b []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.timedOut {
return 0, ErrHandlerTimeout
}
if !w.wroteHeader {
w.writeHeaderLocked(StatusSuccess, w.mediatype)
}
return w.b.Write(b)
}
func (w *timeoutWriter) WriteHeader(status Status, meta string) {
w.mu.Lock()
defer w.mu.Unlock()
if w.timedOut {
return
}
w.writeHeaderLocked(status, meta)
}
func (w *timeoutWriter) writeHeaderLocked(status Status, meta string) {
if w.wroteHeader {
return
}
w.status = status
w.meta = meta
w.wroteHeader = true
}

197
tofu.go
View File

@@ -1,197 +0,0 @@
package gemini
import (
"bufio"
"bytes"
"crypto/sha512"
"crypto/x509"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// KnownHosts represents a list of known hosts.
// The zero value for KnownHosts is an empty list ready to use.
type KnownHosts struct {
hosts map[string]certInfo
file *os.File
}
// LoadDefault loads the known hosts from the default known hosts path, which is
// $XDG_DATA_HOME/gemini/known_hosts.
// It creates the path and any of its parent directories if they do not exist.
// KnownHosts will append to the file whenever a certificate is added.
func (k *KnownHosts) LoadDefault() error {
path, err := defaultKnownHostsPath()
if err != nil {
return err
}
return k.Load(path)
}
// Load loads the known hosts from the provided path.
// It creates the path and any of its parent directories if they do not exist.
// KnownHosts will append to the file whenever a certificate is added.
func (k *KnownHosts) Load(path string) error {
if dir := filepath.Dir(path); dir != "." {
err := os.MkdirAll(dir, 0755)
if err != nil {
return err
}
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDONLY, 0644)
if err != nil {
return err
}
k.Parse(f)
f.Close()
// Open the file for append-only use
f, err = os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return err
}
k.file = f
return nil
}
// Add adds a certificate to the list of known hosts.
// If KnownHosts was loaded from a file, Add will append to the file.
func (k *KnownHosts) Add(hostname string, cert *x509.Certificate) {
k.add(hostname, cert, true)
}
// AddTemporary adds a certificate to the list of known hosts
// without writing it to the known hosts file.
func (k *KnownHosts) AddTemporary(hostname string, cert *x509.Certificate) {
k.add(hostname, cert, false)
}
func (k *KnownHosts) add(hostname string, cert *x509.Certificate, write bool) {
if k.hosts == nil {
k.hosts = map[string]certInfo{}
}
info := certInfo{
Algorithm: "SHA-512",
Fingerprint: Fingerprint(cert),
Expires: cert.NotAfter.Unix(),
}
k.hosts[hostname] = info
// Append to the file
if write && k.file != nil {
appendKnownHost(k.file, hostname, info)
}
}
// Lookup looks for the provided certificate in the list of known hosts.
// If the hostname is in the list, but the fingerprint differs,
// Lookup returns ErrCertificateNotTrusted.
// If the hostname is not in the list, Lookup returns ErrCertificateUnknown.
// If the certificate is found and the fingerprint matches, error will be nil.
func (k *KnownHosts) Lookup(hostname string, cert *x509.Certificate) error {
now := time.Now().Unix()
fingerprint := Fingerprint(cert)
if c, ok := k.hosts[hostname]; ok {
if c.Expires <= now {
// Certificate is expired
return ErrCertificateUnknown
}
if c.Fingerprint != fingerprint {
// Fingerprint does not match
return ErrCertificateNotTrusted
}
// Certificate is trusted
return nil
}
return ErrCertificateUnknown
}
// Parse parses the provided reader and adds the parsed known hosts to the list.
// Invalid lines are ignored.
func (k *KnownHosts) Parse(r io.Reader) {
if k.hosts == nil {
k.hosts = map[string]certInfo{}
}
scanner := bufio.NewScanner(r)
for scanner.Scan() {
text := scanner.Text()
parts := strings.Split(text, " ")
if len(parts) < 4 {
continue
}
hostname := parts[0]
algorithm := parts[1]
if algorithm != "SHA-512" {
continue
}
fingerprint := parts[2]
expires, err := strconv.ParseInt(parts[3], 10, 0)
if err != nil {
continue
}
k.hosts[hostname] = certInfo{
Algorithm: algorithm,
Fingerprint: fingerprint,
Expires: expires,
}
}
}
// Write writes the known hosts to the provided io.Writer.
func (k *KnownHosts) Write(w io.Writer) {
for h, c := range k.hosts {
appendKnownHost(w, h, c)
}
}
type certInfo struct {
Algorithm string // fingerprint algorithm e.g. SHA-512
Fingerprint string // fingerprint in hexadecimal, with ':' between each octet
Expires int64 // unix time of certificate notAfter date
}
func appendKnownHost(w io.Writer, hostname string, c certInfo) (int, error) {
return fmt.Fprintf(w, "%s %s %s %d\n", hostname, c.Algorithm, c.Fingerprint, c.Expires)
}
// Fingerprint returns the SHA-512 fingerprint of the provided certificate.
func Fingerprint(cert *x509.Certificate) string {
sum512 := sha512.Sum512(cert.Raw)
var buf bytes.Buffer
for i, f := range sum512 {
if i > 0 {
fmt.Fprintf(&buf, ":")
}
fmt.Fprintf(&buf, "%02X", f)
}
return buf.String()
}
// defaultKnownHostsPath returns the default known_hosts path.
// The default path is $XDG_DATA_HOME/gemini/known_hosts
func defaultKnownHostsPath() (string, error) {
dataDir, err := userDataDir()
if err != nil {
return "", err
}
return filepath.Join(dataDir, "gemini", "known_hosts"), nil
}
// userDataDir returns the user data directory.
func userDataDir() (string, error) {
dataDir, ok := os.LookupEnv("XDG_DATA_HOME")
if ok {
return dataDir, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".local", "share"), nil
}

417
tofu/tofu.go Normal file
View File

@@ -0,0 +1,417 @@
// Package tofu implements trust on first use using hosts and fingerprints.
package tofu
import (
"bufio"
"bytes"
"crypto/sha512"
"crypto/x509"
"errors"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// KnownHosts represents a list of known hosts.
// The zero value for KnownHosts represents an empty list ready to use.
//
// KnownHosts is safe for concurrent use by multiple goroutines.
type KnownHosts struct {
hosts map[string]Host
mu sync.RWMutex
}
// Add adds a host to the list of known hosts.
func (k *KnownHosts) Add(h Host) {
k.mu.Lock()
defer k.mu.Unlock()
if k.hosts == nil {
k.hosts = map[string]Host{}
}
k.hosts[h.Hostname] = h
}
// Lookup returns the known host entry corresponding to the given hostname.
func (k *KnownHosts) Lookup(hostname string) (Host, bool) {
k.mu.RLock()
defer k.mu.RUnlock()
c, ok := k.hosts[hostname]
return c, ok
}
// Entries returns the known host entries sorted by hostname.
func (k *KnownHosts) Entries() []Host {
keys := make([]string, 0, len(k.hosts))
for key := range k.hosts {
keys = append(keys, key)
}
sort.Strings(keys)
hosts := make([]Host, 0, len(k.hosts))
for _, key := range keys {
hosts = append(hosts, k.hosts[key])
}
return hosts
}
// WriteTo writes the list of known hosts to the provided io.Writer.
func (k *KnownHosts) WriteTo(w io.Writer) (int64, error) {
k.mu.RLock()
defer k.mu.RUnlock()
var written int
bw := bufio.NewWriter(w)
for _, h := range k.hosts {
n, err := bw.WriteString(h.String())
written += n
if err != nil {
return int64(written), err
}
bw.WriteByte('\n')
written += 1
}
return int64(written), bw.Flush()
}
// Load loads the known hosts entries from the provided path.
func (k *KnownHosts) Load(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
return k.Parse(f)
}
// Parse parses the provided io.Reader and adds the parsed hosts to the list.
// Invalid entries are ignored.
//
// For more control over errors encountered during parsing, use bufio.Scanner
// in combination with ParseHost. For example:
//
// var knownHosts tofu.KnownHosts
// scanner := bufio.NewScanner(r)
// for scanner.Scan() {
// host, err := tofu.ParseHost(scanner.Bytes())
// if err != nil {
// // handle error
// } else {
// knownHosts.Add(host)
// }
// }
// err := scanner.Err()
// if err != nil {
// // handle error
// }
//
func (k *KnownHosts) Parse(r io.Reader) error {
k.mu.Lock()
defer k.mu.Unlock()
if k.hosts == nil {
k.hosts = map[string]Host{}
}
scanner := bufio.NewScanner(r)
for scanner.Scan() {
text := scanner.Bytes()
if len(text) == 0 {
continue
}
h, err := ParseHost(text)
if err != nil {
continue
}
k.hosts[h.Hostname] = h
}
return scanner.Err()
}
// TOFU implements basic trust on first use.
//
// If the host is not on file, it is added to the list.
// If the host on file is expired, a new entry is added to the list.
// If the fingerprint does not match the one on file, an error is returned.
func (k *KnownHosts) TOFU(hostname string, cert *x509.Certificate) error {
host := NewHost(hostname, cert.Raw, cert.NotAfter)
knownHost, ok := k.Lookup(hostname)
if !ok || time.Now().After(knownHost.Expires) {
k.Add(host)
return nil
}
// Check fingerprint
if !bytes.Equal(knownHost.Fingerprint, host.Fingerprint) {
return fmt.Errorf("fingerprint for %q does not match", hostname)
}
return nil
}
// HostWriter writes host entries to an io.WriteCloser.
//
// HostWriter is safe for concurrent use by multiple goroutines.
type HostWriter struct {
bw *bufio.Writer
cl io.Closer
mu sync.Mutex
}
// NewHostWriter returns a new host writer that writes to
// the provided io.WriteCloser.
func NewHostWriter(w io.WriteCloser) *HostWriter {
return &HostWriter{
bw: bufio.NewWriter(w),
cl: w,
}
}
// OpenHostsFile returns a new host writer that appends to the file at the given path.
// The file is created if it does not exist.
func OpenHostsFile(path string) (*HostWriter, error) {
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return nil, err
}
return NewHostWriter(f), nil
}
// WriteHost writes the host to the underlying io.Writer.
func (h *HostWriter) WriteHost(host Host) error {
h.mu.Lock()
defer h.mu.Unlock()
h.bw.WriteString(host.String())
h.bw.WriteByte('\n')
if err := h.bw.Flush(); err != nil {
return fmt.Errorf("failed to write host: %w", err)
}
return nil
}
// Close closes the underlying io.Closer.
func (h *HostWriter) Close() error {
h.mu.Lock()
defer h.mu.Unlock()
return h.cl.Close()
}
// PersistentHosts represents a persistent set of known hosts.
type PersistentHosts struct {
hosts *KnownHosts
writer *HostWriter
}
// NewPersistentHosts returns a new persistent set of known hosts.
func NewPersistentHosts(hosts *KnownHosts, writer *HostWriter) *PersistentHosts {
return &PersistentHosts{
hosts,
writer,
}
}
// LoadPersistentHosts loads persistent hosts from the file at the given path.
func LoadPersistentHosts(path string) (*PersistentHosts, error) {
hosts := &KnownHosts{}
if err := hosts.Load(path); err != nil {
return nil, err
}
writer, err := OpenHostsFile(path)
if err != nil {
return nil, err
}
return &PersistentHosts{
hosts,
writer,
}, nil
}
// Add adds a host to the list of known hosts.
// It returns an error if the host could not be persisted.
func (p *PersistentHosts) Add(h Host) error {
err := p.writer.WriteHost(h)
if err != nil {
return fmt.Errorf("failed to persist host: %w", err)
}
p.hosts.Add(h)
return nil
}
// Lookup returns the known host entry corresponding to the given hostname.
func (p *PersistentHosts) Lookup(hostname string) (Host, bool) {
return p.hosts.Lookup(hostname)
}
// Entries returns the known host entries sorted by hostname.
func (p *PersistentHosts) Entries() []Host {
return p.hosts.Entries()
}
// TOFU implements trust on first use with a persistent set of known hosts.
//
// If the host is not on file, it is added to the list.
// If the host on file is expired, a new entry is added to the list.
// If the fingerprint does not match the one on file, an error is returned.
func (p *PersistentHosts) TOFU(hostname string, cert *x509.Certificate) error {
host := NewHost(hostname, cert.Raw, cert.NotAfter)
knownHost, ok := p.Lookup(hostname)
if !ok || time.Now().After(knownHost.Expires) {
return p.Add(host)
}
// Check fingerprint
if !bytes.Equal(knownHost.Fingerprint, host.Fingerprint) {
return fmt.Errorf("fingerprint for %q does not match", hostname)
}
return nil
}
// Close closes the underlying HostWriter.
func (p *PersistentHosts) Close() error {
return p.writer.Close()
}
// Host represents a host entry with a fingerprint using a certain algorithm.
type Host struct {
Hostname string // hostname
Algorithm string // fingerprint algorithm e.g. SHA-512
Fingerprint Fingerprint // fingerprint
Expires time.Time // unix time of the fingerprint expiration date
}
// NewHost returns a new host with a SHA-512 fingerprint of
// the provided raw data.
func NewHost(hostname string, raw []byte, expires time.Time) Host {
sum := sha512.Sum512(raw)
return Host{
Hostname: hostname,
Algorithm: "SHA-512",
Fingerprint: sum[:],
Expires: expires,
}
}
// ParseHost parses a host from the provided text.
func ParseHost(text []byte) (Host, error) {
var h Host
err := h.UnmarshalText(text)
return h, err
}
// String returns a string representation of the host.
func (h Host) String() string {
var b strings.Builder
b.WriteString(h.Hostname)
b.WriteByte(' ')
b.WriteString(h.Algorithm)
b.WriteByte(' ')
b.WriteString(h.Fingerprint.String())
b.WriteByte(' ')
b.WriteString(strconv.FormatInt(h.Expires.Unix(), 10))
return b.String()
}
// UnmarshalText unmarshals the host from the provided text.
func (h *Host) UnmarshalText(text []byte) error {
const format = "hostname algorithm hex-fingerprint expiry-unix-ts"
parts := bytes.Split(text, []byte(" "))
if len(parts) != 4 {
return fmt.Errorf("expected the format %q", format)
}
if len(parts[0]) == 0 {
return errors.New("empty hostname")
}
h.Hostname = string(parts[0])
algorithm := string(parts[1])
if algorithm != "SHA-512" {
return fmt.Errorf("unsupported algorithm %q", algorithm)
}
h.Algorithm = algorithm
fingerprint := make([]byte, 0, sha512.Size)
scanner := bufio.NewScanner(bytes.NewReader(parts[2]))
scanner.Split(scanFingerprint)
for scanner.Scan() {
b, err := strconv.ParseUint(scanner.Text(), 16, 8)
if err != nil {
return fmt.Errorf("failed to parse fingerprint hash: %w", err)
}
fingerprint = append(fingerprint, byte(b))
}
if len(fingerprint) != sha512.Size {
return fmt.Errorf("invalid fingerprint size %d, expected %d",
len(fingerprint), sha512.Size)
}
h.Fingerprint = fingerprint
unix, err := strconv.ParseInt(string(parts[3]), 10, 0)
if err != nil {
return fmt.Errorf("invalid unix timestamp: %w", err)
}
h.Expires = time.Unix(unix, 0)
return nil
}
func scanFingerprint(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexByte(data, ':'); i >= 0 {
// We have a full newline-terminated line.
return i + 1, data[0:i], nil
}
// If we're at EOF, we have a final, non-terminated hex byte
if atEOF {
return len(data), data, nil
}
// Request more data.
return 0, nil, nil
}
// Fingerprint represents a fingerprint.
type Fingerprint []byte
// String returns a string representation of the fingerprint.
func (f Fingerprint) String() string {
var sb strings.Builder
for i, b := range f {
if i > 0 {
sb.WriteByte(':')
}
fmt.Fprintf(&sb, "%02X", b)
}
return sb.String()
}