187 Commits

Author SHA1 Message Date
Adnan Maolood
25f441f573 fs: Remove build constraint 2021-02-24 11:01:02 -05:00
Adnan Maolood
cb7879c966 Add NewResponse function 2021-02-24 10:48:17 -05:00
Adnan Maolood
19bfca1cc3 examples/server: Cancel context 2021-02-24 10:27:28 -05:00
Adnan Maolood
991b18d526 examples/server: Add logging middleware 2021-02-24 10:25:08 -05:00
Adnan Maolood
b66b287f94 ResponseWriter: Remove unexported method 2021-02-24 10:01:46 -05:00
Adnan Maolood
bd29d76f66 client: Fix copying of request 2021-02-24 09:48:23 -05:00
Adnan Maolood
1d20a6c3c8 examples/client: Use Response methods 2021-02-24 09:22:25 -05:00
Adnan Maolood
6f46b2fa47 examples/auth: Use Request.TLS method 2021-02-24 09:22:01 -05:00
Adnan Maolood
15385e3095 fs: Fix Go build constraint comment 2021-02-24 09:00:28 -05:00
Adnan Maolood
3101856afa response: Move to methods 2021-02-24 08:43:57 -05:00
Adnan Maolood
094c16297b server: Fix comment 2021-02-24 08:38:08 -05:00
Adnan Maolood
08f5ddd41a TimeoutHandler: Mention returned error 2021-02-24 08:37:52 -05:00
Adnan Maolood
41c95add81 Add unexported method to timeout writer 2021-02-24 08:34:26 -05:00
Adnan Maolood
de339490f4 Move ResponseWriter Conn and TLS methods to Request 2021-02-24 08:24:49 -05:00
Adnan Maolood
b488146cc6 Remove ResponseWriter.Hijack method 2021-02-24 08:22:12 -05:00
Adnan Maolood
069b473c28 Implement TimeoutHandler by wrapping ResponseWriter 2021-02-24 08:18:23 -05:00
Kaleb Elwert
2c2d74bcb2 Only use fs.go when fs.FS is available 2021-02-24 07:41:53 -05:00
Adnan Maolood
3660698a4b Make ResponseWriter an interface
Make ResponseWriter an interface with an unexported method. Implementors
must embed a ResponseWriter from elsewhere. This gives us the
flexibility of an interface while allowing us to add new methods in the
future.
2021-02-24 00:13:46 -05:00
Adnan Maolood
526d232ab0 Remove ErrHandlerTimeout 2021-02-23 22:15:19 -05:00
Adnan Maolood
f08efa330f Move TimeoutHandler to handler.go 2021-02-23 22:12:04 -05:00
Adnan Maolood
310bd16344 Unexport NewResponseWriter 2021-02-23 22:02:47 -05:00
Adnan Maolood
9eae88f00c Reimplement TimeoutHandler 2021-02-23 21:59:16 -05:00
Adnan Maolood
b386a9ba41 response: Change field names 2021-02-23 21:51:42 -05:00
Adnan Maolood
f28a63ff0c Add ResponseWriter.Hijack method 2021-02-23 21:36:29 -05:00
Adnan Maolood
d35dd3d867 ResponseWriter: Add TLS and Conn methods 2021-02-23 20:59:04 -05:00
Adnan Maolood
75abb99518 request: Remove TLS and Conn methods 2021-02-23 20:57:53 -05:00
Adnan Maolood
e8d98ef4ec Move I/O utilities to io.go 2021-02-23 20:49:55 -05:00
Adnan Maolood
a65c3c3d4f Make ResponseWriter a struct
Make ResponseWriter a struct again so that it can be extended in a
backwards-compatible way.
2021-02-23 20:41:16 -05:00
Adnan Maolood
64f9381bbc handler: Mention when the context is canceled 2021-02-23 19:01:12 -05:00
Adnan Maolood
a34cf6dd1b handler: Mention ResponseWriter.Close method 2021-02-23 18:59:20 -05:00
Adnan Maolood
b3e8d9ccf3 client: Clarify usage of contexts 2021-02-23 18:56:18 -05:00
Adnan Maolood
a7c449a3cf Use HandlerFunc to implement StatusHandler 2021-02-23 18:52:00 -05:00
Adnan Maolood
02bbedc330 Update documentation 2021-02-23 18:45:58 -05:00
Adnan Maolood
5cf936d304 Update documentation 2021-02-23 17:52:47 -05:00
Adnan Maolood
f1f933925c Update examples/client.go 2021-02-23 17:52:34 -05:00
Adnan Maolood
e1c04ee605 Make Response an io.ReadCloser 2021-02-23 17:50:47 -05:00
Adnan Maolood
ae3fc2fc73 response: Add Close method 2021-02-23 17:32:23 -05:00
Adnan Maolood
311233a012 request: Fix documentation for TLS 2021-02-23 17:30:46 -05:00
Adnan Maolood
c688defefd request: Add Conn and TLS methods 2021-02-23 17:29:50 -05:00
Adnan Maolood
83c904913f response: Add Conn and TLS methods 2021-02-23 16:36:17 -05:00
Adnan Maolood
833edaf63d server: Cancel context on IO errors 2021-02-23 16:06:57 -05:00
Adnan Maolood
d07e9d99d1 client: Move context handling to do 2021-02-23 16:01:29 -05:00
Adnan Maolood
31e16d5a4c examples/client: Stream response body 2021-02-23 15:56:44 -05:00
Adnan Maolood
9974071657 client: Cancel context on IO errors
Also close the connection when the context expires.
2021-02-23 15:52:47 -05:00
Adnan Maolood
09e3393e4c examples/stream: Simplify 2021-02-23 15:31:41 -05:00
Adnan Maolood
1aa85d0683 timeout: Don't recover from panics 2021-02-23 15:30:44 -05:00
Adnan Maolood
62e22b4cf2 response: Remove TLS field 2021-02-23 15:29:27 -05:00
Adnan Maolood
eee7156b3a Update documentation 2021-02-23 14:29:37 -05:00
Adnan Maolood
d8b5fa716a client: Use present tense in documentation 2021-02-23 12:21:05 -05:00
Adnan Maolood
1080e95bb4 server: Document use of context in ListenAndServe 2021-02-23 12:10:55 -05:00
Adnan Maolood
f722747abd server: Make ErrorLog an interface 2021-02-23 11:10:35 -05:00
Adnan Maolood
4e25d2c3f9 ServeMux.HandleFunc: Take a HandlerFunc argument 2021-02-23 09:49:20 -05:00
Adnan Maolood
5ab7617efd server: Fix Shutdown with no active listeners
Shutdown and Close will hang if there are no active listeners or
connections. Try to close the done channel to avoid that.
2021-02-23 09:28:14 -05:00
Adnan Maolood
89f0b3f94b tofu: Update documentation 2021-02-23 09:21:21 -05:00
Adnan Maolood
964c17b99f text: Update documentation 2021-02-23 09:18:34 -05:00
Adnan Maolood
32f40523ed certificate.Store: Mention GetCertificate usage 2021-02-23 09:10:22 -05:00
Adnan Maolood
8190e819e8 server: Mention certificate store 2021-02-23 09:08:44 -05:00
Adnan Maolood
871a8fe3d2 certificate: Update documentation 2021-02-23 09:05:45 -05:00
Adnan Maolood
a4849c8eef certificate.Store: Update documentation 2021-02-23 09:03:38 -05:00
Adnan Maolood
f6bccb156a certificate.Store: Check '*' scope last 2021-02-23 08:52:12 -05:00
Adnan Maolood
3c9c087a25 certificate.Store: Allow certificate of scope '*' 2021-02-23 08:46:43 -05:00
Adnan Maolood
6de05c4b5d Update examples 2021-02-23 08:43:47 -05:00
Adnan Maolood
4c369072c8 certificate.Store: Remove client certificate support 2021-02-23 08:37:05 -05:00
Adnan Maolood
27299f537d client: Document use of contexts 2021-02-22 21:35:02 -05:00
Adnan Maolood
d61cf6318a server: Document use of contexts 2021-02-22 21:33:23 -05:00
Adnan Maolood
99e6c37d92 server: Remove unused constants 2021-02-22 21:28:18 -05:00
Adnan Maolood
31077afbbe server: Return context.Canceled after Shutdown 2021-02-22 21:27:44 -05:00
Adnan Maolood
3b8b5d6557 examples/stream: Remove /shutdown handler 2021-02-22 21:16:13 -05:00
Adnan Maolood
9aebcd362e examples/server: Shutdown on interrupt signal 2021-02-22 21:14:41 -05:00
Adnan Maolood
35f7958083 server: Revert to closing contexts on Shutdown 2021-02-22 21:13:44 -05:00
Adnan Maolood
c5b304216c examples/stream: Remove usage of Flusher 2021-02-22 20:07:37 -05:00
Adnan Maolood
118e019df0 server: Use channel to communicate shutdown 2021-02-22 20:06:19 -05:00
Adnan Maolood
2c64db3863 Rename ResponseWriter.MediaType to SetMediaType 2021-02-21 18:52:06 -05:00
Adnan Maolood
420f01da2a client: Remove Timeout
Clients should use context.WithTimeout instead.
2021-02-21 16:47:56 -05:00
Adnan Maolood
c3feafa90b Move Flush back to ResponseWriter 2021-02-21 16:06:56 -05:00
Adnan Maolood
0a3db2ce41 server: Don't close pending connections after Shutdown 2021-02-21 16:05:10 -05:00
Adnan Maolood
49dac34aff server: Export ServeConn method 2021-02-21 11:53:15 -05:00
Adnan Maolood
bb444fb364 server: Don't recover from panics 2021-02-21 11:04:45 -05:00
Adnan Maolood
a606c4fcc0 fs: Use better error messages 2021-02-21 09:56:59 -05:00
Adnan Maolood
2ece48b019 Move punycode functions to client.go 2021-02-21 09:43:23 -05:00
Adnan Maolood
a4b976c2dc client: Copy only what is needed from the Request 2021-02-21 09:41:00 -05:00
Adnan Maolood
b784442b6d Use StatusHandler in NotFoundHandler implementation 2021-02-21 09:32:07 -05:00
Adnan Maolood
57e541e103 fs: Remove unused context arguments 2021-02-21 09:29:21 -05:00
Adnan Maolood
c4c616518b Add ErrCertificateExpired 2021-02-21 09:27:12 -05:00
Adnan Maolood
352ad71af8 Remove unused ErrInvalidURL 2021-02-21 09:23:30 -05:00
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
28 changed files with 2303 additions and 819 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,10 +1,12 @@
# go-gemini
[![godocs.io](https://godocs.io/git.sr.ht/~adnano/go-gemini?status.svg)](https://godocs.io/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.
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

View File

@@ -1,4 +1,4 @@
// Package certificate provides utility functions for TLS certificates.
// Package certificate provides functions for creating and storing TLS certificates.
package certificate
import (
@@ -14,103 +14,9 @@ import (
"math/big"
"net"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// Dir represents a directory of certificates.
// The zero value for Dir is an empty directory ready to use.
//
// Dir is safe for concurrent use by multiple goroutines.
type Dir struct {
certs map[string]tls.Certificate
path *string
mu sync.RWMutex
}
// Add adds a certificate for the given scope to the directory.
// It tries to parse the certificate if it is not already parsed.
func (d *Dir) Add(scope string, cert tls.Certificate) error {
d.mu.Lock()
defer d.mu.Unlock()
if d.certs == nil {
d.certs = 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
}
}
if d.path != nil {
// Escape slash character
scope = strings.ReplaceAll(scope, "/", ":")
certPath := filepath.Join(*d.path, scope+".crt")
keyPath := filepath.Join(*d.path, scope+".key")
if err := Write(cert, certPath, keyPath); err != nil {
return err
}
}
d.certs[scope] = cert
return nil
}
// Lookup returns the certificate for the provided scope.
func (d *Dir) Lookup(scope string) (tls.Certificate, bool) {
d.mu.RLock()
defer d.mu.RUnlock()
cert, ok := d.certs[scope]
return cert, ok
}
// Entries returns a map of hostnames to certificates.
func (d *Dir) Entries() map[string]tls.Certificate {
certs := map[string]tls.Certificate{}
for key := range d.certs {
certs[key] = d.certs[key]
}
return certs
}
// Load loads certificates from the provided path.
// Add will write certificates to this path.
//
// The directory should contain certificates and private keys
// named scope.crt and scope.key respectively, where scope is
// the scope of the certificate.
func (d *Dir) 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, ":", "/")
d.Add(scope, cert)
}
d.SetPath(path)
return nil
}
// SetPath sets the directory path.
// Add will write certificates to this path.
func (d *Dir) SetPath(path string) {
d.mu.Lock()
defer d.mu.Unlock()
d.path = &path
}
// CreateOptions configures the creation of a TLS certificate.
type CreateOptions struct {
// Subject Alternate Name values.

174
certificate/store.go Normal file
View File

@@ -0,0 +1,174 @@
package certificate
import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"fmt"
"path/filepath"
"strings"
"sync"
"time"
)
// A Store represents a certificate store.
// It generates certificates as needed and automatically rotates expired certificates.
// The zero value for Store is an empty store ready to use.
//
// Certificate scopes must be registered with Register before calling Get or Load.
// This prevents the Store from creating or loading unnecessary certificates.
//
// 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.
// The provided scope is suitable for use in a certificate's DNSNames.
CreateCertificate func(scope string) (tls.Certificate, error)
certs map[string]tls.Certificate
path string
mu sync.RWMutex
}
// Register registers the provided scope with the certificate store.
// The scope can either be a hostname or a wildcard pattern (e.g. "*.example.com").
// To accept all hostnames, use the special pattern "*".
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 with the given scope to the certificate store.
// If a certificate for the given scope already exists, Add will overwrite it.
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 != "" {
certPath := filepath.Join(s.path, scope+".crt")
keyPath := filepath.Join(s.path, scope+".key")
if err := Write(cert, certPath, keyPath); err != nil {
return err
}
}
s.certs[scope] = cert
return nil
}
// Get retrieves a certificate for the given hostname.
// If no matching scope has been registered, Get returns an error.
// Get generates new certificates as needed and rotates expired certificates.
//
// Get is suitable for use in a gemini.Server's GetCertificate field.
func (s *Store) Get(hostname string) (*tls.Certificate, error) {
s.mu.RLock()
defer s.mu.RUnlock()
cert, ok := s.certs[hostname]
if !ok {
// Try wildcard
wildcard := strings.SplitN(hostname, ".", 2)
if len(wildcard) == 2 {
hostname = "*." + wildcard[1]
cert, ok = s.certs[hostname]
}
}
if !ok {
// Try "*"
cert, ok = s.certs["*"]
}
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(hostname)
if err != nil {
return nil, err
}
if err := s.Add(hostname, cert); err != nil {
return nil, fmt.Errorf("failed to add certificate for %s: %w", hostname, 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.
// Certificates with scopes that have not been registered will be ignored.
func (s *Store) Load(path string) error {
matches, err := filepath.Glob(filepath.Join(path, "*.crt"))
if err != nil {
return err
}
for _, crtPath := range matches {
scope := strings.TrimSuffix(filepath.Base(crtPath), ".crt")
if _, ok := s.certs[scope]; !ok {
continue
}
keyPath := strings.TrimSuffix(crtPath, ".crt") + ".key"
cert, err := tls.LoadX509KeyPair(crtPath, keyPath)
if err != nil {
continue
}
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
}

243
client.go
View File

@@ -1,58 +1,107 @@
package gemini
import (
"bufio"
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"strings"
"net/url"
"time"
"unicode/utf8"
"golang.org/x/net/idna"
)
// Client is a Gemini client.
// A Client is a Gemini client. Its zero value is a usable client.
type Client struct {
// TrustCertificate is called to determine whether the client
// 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
// TrustCertificate is called to determine whether the client should
// trust the certificate provided by the server.
// If TrustCertificate is nil or returns nil, the client will accept
// any certificate. Otherwise, the certificate will not be trusted
// and the request will be aborted.
//
// For a basic trust on first use implementation, see (*KnownHosts).TOFU
// in the tofu submodule.
// See the tofu submodule for an implementation of trust on first use.
TrustCertificate func(hostname string, cert *x509.Certificate) error
// Timeout specifies a time limit for requests made by this
// Client. The timeout includes connection time and reading
// the response body. The timer remains running after
// Get and Do return and will interrupt reading of the Response.Body.
//
// A Timeout of zero means no timeout.
Timeout time.Duration
// DialContext specifies the dial function for creating TCP connections.
// If DialContext is nil, the client dials using package net.
DialContext func(ctx context.Context, network, addr string) (net.Conn, error)
}
// Get performs a Gemini request for the given URL.
func (c *Client) Get(url string) (*Response, error) {
// Get sends a Gemini request for the given URL.
// The context controls the entire lifetime of a request and its response:
// obtaining a connection, sending the request, and reading the response
// header and body.
//
// 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 user is expected to close the Response.
//
// 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(req)
return c.Do(ctx, req)
}
// Do performs a Gemini request and returns a Gemini response.
func (c *Client) Do(req *Request) (*Response, error) {
// Extract hostname
colonPos := strings.LastIndex(req.Host, ":")
if colonPos == -1 {
colonPos = len(req.Host)
// Do sends a Gemini request and returns a Gemini response.
// The context controls the entire lifetime of a request and its response:
// obtaining a connection, sending the request, and reading the response
// header and body.
//
// 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 user is expected to close the Response.
func (c *Client) Do(ctx context.Context, req *Request) (*Response, error) {
if ctx == nil {
panic("nil context")
}
hostname := req.Host[:colonPos]
// 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
// Copy the URL and update the host
u := new(url.URL)
*u = *req.URL
u.Host = net.JoinHostPort(host, port)
// Use the new URL in the request so that the server gets
// the punycoded hostname
r := new(Request)
*r = *req
r.URL = u
req = r
}
// 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
config := &tls.Config{
conn, err := c.dialContext(ctx, "tcp", addr)
if err != nil {
return nil, err
}
// Setup TLS
conn = tls.Client(conn, &tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS12,
GetClientCertificate: func(_ *tls.CertificateRequestInfo) (*tls.Certificate, error) {
@@ -62,95 +111,113 @@ func (c *Client) Do(req *Request) (*Response, error) {
return &tls.Certificate{}, nil
},
VerifyConnection: func(cs tls.ConnectionState) error {
return c.verifyConnection(req, cs)
return c.verifyConnection(cs, host)
},
ServerName: hostname,
}
// Set connection context
ctx := req.Context
if ctx == nil {
ctx = context.Background()
ServerName: host,
})
type result struct {
resp *Response
err error
}
start := time.Now()
dialer := net.Dialer{
Timeout: c.Timeout,
res := make(chan result, 1)
go func() {
resp, err := c.do(ctx, conn, req)
res <- result{resp, err}
}()
select {
case <-ctx.Done():
conn.Close()
return nil, ctx.Err()
case r := <-res:
return r.resp, r.err
}
netConn, err := dialer.DialContext(ctx, "tcp", req.Host)
if err != nil {
return nil, err
}
conn := tls.Client(netConn, config)
// Set connection deadline
if c.Timeout != 0 {
err := conn.SetDeadline(start.Add(c.Timeout))
if err != nil {
return nil, fmt.Errorf(
"failed to set connection deadline: %w", err)
}
}
resp, err := c.do(conn, req)
if err != nil {
// If we fail to perform the request/response we have
// to take responsibility for closing the connection.
_ = conn.Close()
return nil, err
}
// Store connection state
resp.TLS = conn.ConnectionState()
return resp, nil
}
func (c *Client) do(conn *tls.Conn, req *Request) (*Response, error) {
// Write the request
w := bufio.NewWriter(conn)
err := req.Write(w)
if err != nil {
return nil, fmt.Errorf(
"failed to write request data: %w", err)
func (c *Client) do(ctx context.Context, conn net.Conn, req *Request) (*Response, error) {
ctx, cancel := context.WithCancel(ctx)
done := ctx.Done()
w := &contextWriter{
ctx: ctx,
done: done,
cancel: cancel,
wc: conn,
}
rc := &contextReader{
ctx: ctx,
done: done,
cancel: cancel,
rc: conn,
}
if err := w.Flush(); err != nil {
// Write the request
if err := req.Write(w); err != nil {
return nil, err
}
// Read the response
resp, err := ReadResponse(conn)
resp, err := ReadResponse(rc)
if err != nil {
return nil, err
}
resp.conn = conn
return resp, nil
}
func (c *Client) verifyConnection(req *Request, cs tls.ConnectionState) error {
// Verify the hostname
var hostname string
if host, _, err := net.SplitHostPort(req.Host); err == nil {
hostname = host
} else {
hostname = req.Host
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{}).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")
return ErrCertificateExpired
}
// 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
}
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)
}

28
doc.go
View File

@@ -1,14 +1,15 @@
/*
Package gemini implements the Gemini protocol.
Package gemini provides Gemini client and server implementations.
Client is a Gemini client.
client := &gemini.Client{}
resp, err := client.Get("gemini://example.com")
ctx := context.Background()
resp, err := client.Get(ctx, "gemini://example.com")
if err != nil {
// handle error
}
defer resp.Body.Close()
defer resp.Close()
// ...
Server is a Gemini server.
@@ -20,26 +21,33 @@ Server is a Gemini server.
Servers should be configured with certificates:
err := server.Certificates.Load("/var/lib/gemini/certs")
certificates := &certificate.Store{}
certificates.Register("localhost")
err := certificates.Load("/var/lib/gemini/certs")
if err != nil {
// handle error
}
server.GetCertificate = certificates.Get
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")
})
server.RegisterFunc("example.org", func(w *gemini.ResponseWriter, r *gemini.Request) {
fmt.Fprint(w, "Welcome to example.org")
mux.HandleFunc("example.org/about.gmi", func(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
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")
})
server.Handler = mux
To start the server, call ListenAndServe:
err := server.ListenAndServe()
ctx := context.Background()
err := server.ListenAndServe(ctx)
if err != nil {
// handle error
}

View File

@@ -3,10 +3,9 @@
package main
import (
"context"
"crypto/sha512"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"log"
"time"
@@ -25,26 +24,24 @@ var (
)
func main() {
var mux gemini.ServeMux
certificates := &certificate.Store{}
certificates.Register("localhost")
if err := certificates.Load("/var/lib/gemini/certs"); err != nil {
log.Fatal(err)
}
mux := &gemini.ServeMux{}
mux.HandleFunc("/", profile)
mux.HandleFunc("/username", changeUsername)
var server gemini.Server
if err := server.Certificates.Load("/var/lib/gemini/certs"); err != nil {
log.Fatal(err)
server := &gemini.Server{
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 1 * time.Minute,
GetCertificate: certificates.Get,
}
server.CreateCertificate = func(hostname string) (tls.Certificate, error) {
return certificate.Create(certificate.CreateOptions{
Subject: pkix.Name{
CommonName: hostname,
},
DNSNames: []string{hostname},
Duration: time.Hour,
})
}
server.Handle("localhost", &mux)
if err := server.ListenAndServe(); err != nil {
if err := server.ListenAndServe(context.Background()); err != nil {
log.Fatal(err)
}
}
@@ -54,12 +51,13 @@ func fingerprint(cert *x509.Certificate) string {
return string(b[:])
}
func profile(w *gemini.ResponseWriter, r *gemini.Request) {
if r.Certificate == nil {
w.Status(gemini.StatusCertificateRequired)
func profile(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
tls := r.TLS()
if len(tls.PeerCertificates) == 0 {
w.WriteHeader(gemini.StatusCertificateRequired, "Certificate required")
return
}
fingerprint := fingerprint(r.Certificate.Leaf)
fingerprint := fingerprint(tls.PeerCertificates[0])
user, ok := users[fingerprint]
if !ok {
user = &User{}
@@ -69,23 +67,24 @@ func profile(w *gemini.ResponseWriter, r *gemini.Request) {
fmt.Fprintln(w, "=> /username Change username")
}
func changeUsername(w *gemini.ResponseWriter, r *gemini.Request) {
if r.Certificate == nil {
w.Status(gemini.StatusCertificateRequired)
func changeUsername(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
tls := r.TLS()
if len(tls.PeerCertificates) == 0 {
w.WriteHeader(gemini.StatusCertificateRequired, "Certificate required")
return
}
username, err := gemini.QueryUnescape(r.URL.RawQuery)
if err != nil || username == "" {
w.Header(gemini.StatusInput, "Username")
w.WriteHeader(gemini.StatusInput, "Username")
return
}
fingerprint := fingerprint(r.Certificate.Leaf)
fingerprint := fingerprint(tls.PeerCertificates[0])
user, ok := users[fingerprint]
if !ok {
user = &User{}
users[fingerprint] = user
}
user.Name = username
w.Header(gemini.StatusRedirect, "/")
w.WriteHeader(gemini.StatusRedirect, "/")
}

View File

@@ -7,10 +7,11 @@ package main
import (
"bufio"
"bytes"
"context"
"crypto/x509"
"errors"
"fmt"
"io/ioutil"
"io"
"log"
"net/url"
"os"
@@ -19,7 +20,6 @@ import (
"git.sr.ht/~adnano/go-gemini"
"git.sr.ht/~adnano/go-gemini/tofu"
"git.sr.ht/~adnano/go-xdg"
)
var (
@@ -28,15 +28,22 @@ var (
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() {
// Load known hosts file
path := filepath.Join(xdg.DataHome(), "gemini", "known_hosts")
path := filepath.Join(xdgDataHome(), "gemini", "known_hosts")
err := hosts.Load(path)
if err != nil {
log.Fatal(err)
}
hostsfile, err = tofu.NewHostsFile(path)
hostsfile, err = tofu.OpenHostsFile(path)
if err != nil {
log.Fatal(err)
}
@@ -90,14 +97,15 @@ func do(req *gemini.Request, via []*gemini.Request) (*gemini.Response, error) {
client := gemini.Client{
TrustCertificate: trustCertificate,
}
resp, err := client.Do(req)
ctx := context.Background()
resp, err := client.Do(ctx, req)
if err != nil {
return resp, err
}
switch resp.Status.Class() {
case gemini.StatusClassInput:
input, ok := getInput(resp.Meta, resp.Status == gemini.StatusSensitiveInput)
switch resp.Status().Class() {
case gemini.StatusInput:
input, ok := getInput(resp.Meta(), resp.Status() == gemini.StatusSensitiveInput)
if !ok {
break
}
@@ -105,13 +113,13 @@ func do(req *gemini.Request, via []*gemini.Request) (*gemini.Response, error) {
req.URL.RawQuery = gemini.QueryEscape(input)
return do(req, via)
case gemini.StatusClassRedirect:
case gemini.StatusRedirect:
via = append(via, req)
if len(via) > 5 {
return resp, errors.New("too many redirects")
}
target, err := url.Parse(resp.Meta)
target, err := url.Parse(resp.Meta())
if err != nil {
return resp, err
}
@@ -145,15 +153,14 @@ func main() {
fmt.Println(err)
os.Exit(1)
}
defer resp.Body.Close()
defer resp.Close()
// Handle response
if resp.Status.Class() == gemini.StatusClassSuccess {
body, err := ioutil.ReadAll(resp.Body)
if resp.Status().Class() == gemini.StatusSuccess {
_, err := io.Copy(os.Stdout, resp)
if err != nil {
log.Fatal(err)
}
fmt.Print(string(body))
} else {
fmt.Printf("%d %s\n", resp.Status, resp.Meta)
os.Exit(1)

View File

@@ -5,9 +5,10 @@
package main
import (
"crypto/tls"
"crypto/x509/pkix"
"context"
"log"
"os"
"os/signal"
"time"
"git.sr.ht/~adnano/go-gemini"
@@ -15,27 +16,93 @@ import (
)
func main() {
var server gemini.Server
server.ReadTimeout = 30 * time.Second
server.WriteTimeout = 1 * time.Minute
if err := server.Certificates.Load("/var/lib/gemini/certs"); err != nil {
certificates := &certificate.Store{}
certificates.Register("localhost")
if err := certificates.Load("/var/lib/gemini/certs"); err != nil {
log.Fatal(err)
}
server.CreateCertificate = func(hostname string) (tls.Certificate, error) {
return certificate.Create(certificate.CreateOptions{
Subject: pkix.Name{
CommonName: hostname,
},
DNSNames: []string{hostname},
Duration: 365 * 24 * time.Hour,
})
mux := &gemini.ServeMux{}
mux.Handle("/", gemini.FileServer(os.DirFS("/var/www")))
server := &gemini.Server{
Handler: logMiddleware(mux),
ReadTimeout: 30 * time.Second,
WriteTimeout: 1 * time.Minute,
GetCertificate: certificates.Get,
}
var mux gemini.ServeMux
mux.Handle("/", gemini.FileServer(gemini.Dir("/var/www")))
// Listen for interrupt signal
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
server.Handle("localhost", &mux)
if err := server.ListenAndServe(); err != nil {
errch := make(chan error)
go func() {
ctx := context.Background()
errch <- server.ListenAndServe(ctx)
}()
select {
case err := <-errch:
log.Fatal(err)
case <-c:
// Shutdown the server
log.Println("Shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := server.Shutdown(ctx)
if err != nil {
log.Fatal(err)
}
}
}
func logMiddleware(h gemini.Handler) gemini.Handler {
return gemini.HandlerFunc(func(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
lw := &logResponseWriter{rw: w}
h.ServeGemini(ctx, lw, r)
host := r.TLS().ServerName
log.Printf("gemini: %s %q %d %d", host, r.URL, lw.status, lw.wrote)
})
}
type logResponseWriter struct {
rw gemini.ResponseWriter
status gemini.Status
meta string
mediatype string
wroteHeader bool
wrote int
}
func (w *logResponseWriter) SetMediaType(mediatype string) {
w.mediatype = mediatype
}
func (w *logResponseWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(gemini.StatusSuccess, w.mediatype)
}
n, err := w.rw.Write(b)
w.wrote += n
return n, err
}
func (w *logResponseWriter) WriteHeader(status gemini.Status, meta string) {
if w.wroteHeader {
return
}
w.status = status
w.meta = meta
w.wroteHeader = true
w.rw.WriteHeader(status, meta)
w.wrote += len(meta) + 5
}
func (w *logResponseWriter) Flush() error {
return nil
}
func (w *logResponseWriter) Close() error {
return nil
}

View File

@@ -6,8 +6,6 @@ package main
import (
"context"
"crypto/tls"
"crypto/x509/pkix"
"fmt"
"log"
"time"
@@ -17,55 +15,40 @@ import (
)
func main() {
var server gemini.Server
if err := server.Certificates.Load("/var/lib/gemini/certs"); err != nil {
certificates := &certificate.Store{}
certificates.Register("localhost")
if err := certificates.Load("/var/lib/gemini/certs"); err != nil {
log.Fatal(err)
}
server.CreateCertificate = func(hostname string) (tls.Certificate, error) {
return certificate.Create(certificate.CreateOptions{
Subject: pkix.Name{
CommonName: hostname,
},
DNSNames: []string{hostname},
Duration: 365 * 24 * time.Hour,
})
mux := &gemini.ServeMux{}
mux.HandleFunc("/", stream)
server := &gemini.Server{
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 1 * time.Minute,
GetCertificate: certificates.Get,
}
server.HandleFunc("localhost", stream)
if err := server.ListenAndServe(); err != nil {
ctx := context.Background()
if err := server.ListenAndServe(ctx); err != nil {
log.Fatal(err)
}
}
// stream writes an infinite stream to w.
func stream(w *gemini.ResponseWriter, r *gemini.Request) {
ch := make(chan string)
ctx, cancel := context.WithCancel(context.Background())
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)
func stream(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
for {
s, ok := <-ch
if !ok {
break
select {
case <-ctx.Done():
return
default:
}
fmt.Fprintln(w, s)
fmt.Fprintln(w, time.Now().UTC())
if err := w.Flush(); err != nil {
cancel()
return
}
time.Sleep(time.Second)
}
}

265
fs.go
View File

@@ -1,10 +1,16 @@
package gemini
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"mime"
"os"
"net/url"
"path"
"sort"
"strings"
)
func init() {
@@ -13,102 +19,199 @@ func init() {
mime.AddExtensionType(".gemini", "text/gemini")
}
// FileServer takes a filesystem and returns a Responder which uses that filesystem.
// The returned Responder cleans paths before handling them.
// FileServer returns a handler that serves Gemini requests with the contents
// of the provided file system.
//
// TODO: Use io/fs.FS when available.
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 {
FS
type fileServer struct {
fs.FS
}
func (fsh fsHandler) Respond(w *ResponseWriter, r *Request) {
p := path.Clean(r.URL.Path)
f, err := fsh.Open(p)
if err != nil {
w.Status(StatusNotFound)
return
}
// Detect mimetype
ext := path.Ext(p)
func (fs fileServer) ServeGemini(ctx context.Context, w ResponseWriter, r *Request) {
serveFile(w, r, fs, path.Clean(r.URL.Path), true)
}
// 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(w ResponseWriter, r *Request, name string, content io.Reader) {
serveContent(w, name, content)
}
func serveContent(w ResponseWriter, name string, content io.Reader) {
// Detect mimetype from file extension
ext := path.Ext(name)
mimetype := mime.TypeByExtension(ext)
w.Meta(mimetype)
// Copy file to response writer
_, _ = io.Copy(w, f)
}
// FS represents a filesystem.
//
// TODO: Replace with io/fs.FS when available.
type FS interface {
Open(name string) (File, error)
}
// File represents a file.
//
// 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.
//
// TODO: replace with os.DirFS when available.
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)
return openFile(p)
w.SetMediaType(mimetype)
io.Copy(w, content)
}
// ServeFile responds to the request with the contents of the named file
// or directory.
//
// TODO: Use io/fs.FS when available.
func ServeFile(w *ResponseWriter, fs FS, name string) {
f, err := fs.Open(name)
if err != nil {
w.Status(StatusNotFound)
// 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
// path.Join on r.URL.Path without sanitizing it and then use that
// path.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(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 path.Join(myDir, r.URL.Path).
w.WriteHeader(StatusBadRequest, "invalid URL path")
return
}
// Detect mimetype
ext := path.Ext(name)
mimetype := mime.TypeByExtension(ext)
w.Meta(mimetype)
// Copy file to response writer
_, _ = io.Copy(w, f)
serveFile(w, r, fsys, name, false)
}
func openFile(p string) (File, error) {
f, err := os.OpenFile(p, os.O_RDONLY, 0644)
if err != nil {
return nil, err
func containsDotDot(v string) bool {
if !strings.Contains(v, "..") {
return false
}
if stat, err := f.Stat(); err == nil {
if stat.IsDir() {
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, os.ErrNotExist
} else if !stat.Mode().IsRegular() {
return nil, os.ErrNotExist
for _, ent := range strings.FieldsFunc(v, isSlashRune) {
if ent == ".." {
return true
}
}
return f, nil
return false
}
func isSlashRune(r rune) bool { return r == '/' || r == '\\' }
func serveFile(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(toGeminiError(err))
return
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
w.WriteHeader(toGeminiError(err))
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(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())
}
}
func toGeminiError(err error) (status Status, meta string) {
if errors.Is(err, fs.ErrNotExist) {
return StatusNotFound, "Not found"
}
if errors.Is(err, fs.ErrPermission) {
return StatusNotFound, "Forbidden"
}
return StatusTemporaryFailure, "Internal server error"
}

View File

@@ -8,8 +8,12 @@ var crlf = []byte("\r\n")
// Errors.
var (
ErrInvalidURL = errors.New("gemini: invalid URL")
ErrInvalidRequest = errors.New("gemini: invalid request")
ErrInvalidResponse = errors.New("gemini: invalid response")
ErrBodyNotAllowed = errors.New("gemini: response body not allowed")
ErrCertificateExpired = errors.New("gemini: certificate expired")
// ErrBodyNotAllowed is returned by ResponseWriter.Write calls
// when the response status code does not permit a body.
ErrBodyNotAllowed = errors.New("gemini: response status code does not allow body")
)

4
go.mod
View File

@@ -1,3 +1,5 @@
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=

160
handler.go Normal file
View File

@@ -0,0 +1,160 @@
package gemini
import (
"bytes"
"context"
"io"
"net/url"
"strings"
"time"
)
// 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 may also call ResponseWriter.Close to
// manually close the connection.
//
// The provided context is canceled when the client's connection is closed,
// when ResponseWriter.Close is called, or when the ServeGemini method returns.
//
// Handlers should not modify the provided Request.
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 HandlerFunc(func(ctx context.Context, w ResponseWriter, r *Request) {
w.WriteHeader(status, meta)
})
}
// NotFoundHandler returns a simple request handler that replies to each
// request with a “51 Not found” reply.
func NotFoundHandler() Handler {
return StatusHandler(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")
}
})
}
// 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 context.DeadlineExceeded.
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()
buf := &bytes.Buffer{}
tw := &timeoutWriter{
wc: &contextWriter{
ctx: ctx,
cancel: cancel,
done: ctx.Done(),
wc: nopCloser{buf},
},
}
done := make(chan struct{})
go func() {
t.h.ServeGemini(ctx, tw, r)
close(done)
}()
select {
case <-done:
w.WriteHeader(tw.status, tw.meta)
w.Write(buf.Bytes())
case <-ctx.Done():
w.WriteHeader(StatusTemporaryFailure, "Timeout")
}
}
type timeoutWriter struct {
wc io.WriteCloser
status Status
meta string
mediatype string
wroteHeader bool
}
func (w *timeoutWriter) SetMediaType(mediatype string) {
w.mediatype = mediatype
}
func (w *timeoutWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(StatusSuccess, w.mediatype)
}
return w.wc.Write(b)
}
func (w *timeoutWriter) WriteHeader(status Status, meta string) {
if w.wroteHeader {
return
}
w.status = status
w.meta = meta
w.wroteHeader = true
}
func (w *timeoutWriter) Flush() error {
return nil
}
func (w *timeoutWriter) Close() error {
return w.wc.Close()
}

104
io.go Normal file
View File

@@ -0,0 +1,104 @@
package gemini
import (
"bufio"
"context"
"io"
)
type contextReader struct {
ctx context.Context
done <-chan struct{}
cancel func()
rc io.ReadCloser
}
func (r *contextReader) Read(p []byte) (int, error) {
select {
case <-r.done:
r.rc.Close()
return 0, r.ctx.Err()
default:
}
n, err := r.rc.Read(p)
if err != nil {
r.cancel()
}
return n, err
}
func (r *contextReader) Close() error {
r.cancel()
return r.rc.Close()
}
type contextWriter struct {
ctx context.Context
done <-chan struct{}
cancel func()
wc io.WriteCloser
}
func (w *contextWriter) Write(b []byte) (int, error) {
select {
case <-w.done:
w.wc.Close()
return 0, w.ctx.Err()
default:
}
n, err := w.wc.Write(b)
if err != nil {
w.cancel()
}
return n, err
}
func (w *contextWriter) Close() error {
w.cancel()
return w.wc.Close()
}
type nopCloser struct {
io.Writer
}
func (nopCloser) Close() error {
return nil
}
type nopReadCloser struct{}
func (nopReadCloser) Read(p []byte) (int, error) {
return 0, io.EOF
}
func (nopReadCloser) Close() error {
return nil
}
type bufReadCloser struct {
br *bufio.Reader // used until empty
io.ReadCloser
}
func newBufReadCloser(br *bufio.Reader, rc io.ReadCloser) io.ReadCloser {
body := &bufReadCloser{ReadCloser: rc}
if br.Buffered() != 0 {
body.br = br
}
return body
}
func (b *bufReadCloser) 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)
}

207
mux.go
View File

@@ -1,6 +1,8 @@
package gemini
import (
"context"
"net"
"net/url"
"path"
"sort"
@@ -8,12 +10,6 @@ import (
"sync"
)
// 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
@@ -32,6 +28,32 @@ import (
// 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
@@ -45,13 +67,19 @@ import (
// 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.
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 {
r Responder
pattern string
handler Handler
key muxKey
}
// cleanPath returns the canonical path for p, eliminating . and .. elements.
@@ -78,18 +106,25 @@ func cleanPath(p string) string {
// Find a handler on a handler map given a path string.
// Most-specific (longest) pattern wins.
func (mux *ServeMux) match(path string) Responder {
func (mux *ServeMux) match(key muxKey) Handler {
// Check for exact match first.
v, ok := mux.m[path]
if ok {
return v.r
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 strings.HasPrefix(path, e.pattern) {
return e.r
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
@@ -99,89 +134,144 @@ func (mux *ServeMux) match(path string) Responder {
// 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) {
func (mux *ServeMux) redirectToPathSlash(key muxKey, u *url.URL) (*url.URL, bool) {
mux.mu.RLock()
shouldRedirect := mux.shouldRedirectRLocked(path)
shouldRedirect := mux.shouldRedirectRLocked(key)
mux.mu.RUnlock()
if !shouldRedirect {
return u, false
}
path = path + "/"
u = &url.URL{Path: path, RawQuery: u.RawQuery}
return u, true
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(path string) bool {
if _, exist := mux.m[path]; exist {
func (mux *ServeMux) shouldRedirectRLocked(key muxKey) bool {
if _, exist := mux.m[key]; exist {
return false
}
n := len(path)
n := len(key.path)
if n == 0 {
return false
}
if _, exist := mux.m[path+"/"]; exist {
return path[n-1] != '/'
if _, exist := mux.m[muxKey{key.scheme, key.host, key.path + "/"}]; exist {
return key.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) {
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(path, r.URL); ok {
w.Header(StatusRedirect, u.String())
return
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
w.Header(StatusRedirect, u.String())
return
return StatusHandler(StatusPermanentRedirect, u.String())
}
mux.mu.RLock()
defer mux.mu.RUnlock()
resp := mux.match(path)
if resp == nil {
w.Status(StatusNotFound)
return
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})
}
}
resp.Respond(w, r)
if h == nil {
h = NotFoundHandler()
}
return h
}
// 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()
// 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 responder == nil {
panic("gemini: nil responder")
if handler == nil {
panic("gemini: nil handler")
}
if _, exist := mux.m[pattern]; exist {
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[string]muxEntry)
mux.m = make(map[muxKey]Handler)
}
e := muxEntry{responder, pattern}
mux.m[pattern] = e
if pattern[len(pattern)-1] == '/' {
mux.m[key] = handler
e := muxEntry{handler, key}
if key.path[len(key.path)-1] == '/' {
mux.es = appendSorted(mux.es, e)
}
}
@@ -189,7 +279,9 @@ func (mux *ServeMux) Handle(pattern string, responder Responder) {
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)
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)
@@ -201,10 +293,7 @@ func appendSorted(es []muxEntry, e muxEntry) []muxEntry {
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))
// HandleFunc registers the handler function for the given pattern.
func (mux *ServeMux) HandleFunc(pattern string, handler HandlerFunc) {
mux.Handle(pattern, 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)
}
}
}
}
}

View File

@@ -12,7 +12,8 @@ func QueryEscape(query string) string {
return strings.ReplaceAll(url.PathEscape(query), "+", "%2B")
}
// QueryUnescape is identical to url.PathUnescape.
// QueryUnescape unescapes a Gemini URL query.
// It is identical to url.PathUnescape.
func QueryUnescape(query string) (string, error) {
return url.PathUnescape(query)
}

View File

@@ -2,76 +2,57 @@ package gemini
import (
"bufio"
"context"
"crypto/tls"
"io"
"net"
"net/url"
)
// Request represents a Gemini request.
// A Request represents a Gemini request received by a server or to be sent
// by a client.
type Request struct {
// URL specifies the URL being requested.
URL *url.URL
// For client requests, Host specifies the host on which the URL is sought.
// Host must contain a port.
//
// This field is ignored by the server.
// For client requests, Host optionally specifies the server to
// connect to. It may be of the form "host" or "host:port".
// 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.
// This field is ignored by the Gemini server.
Host string
// Certificate specifies the TLS certificate to use for the request.
//
// On the server side, if the client provided a certificate then
// Certificate.Leaf is guaranteed to be non-nil.
// For client requests, Certificate optionally specifies the
// TLS certificate to present to the other side of the connection.
// This field is ignored by the Gemini server.
Certificate *tls.Certificate
// RemoteAddr allows servers and other software to record the network
// address that sent the request.
//
// This field is ignored by the client.
RemoteAddr net.Addr
// TLS allows servers and other software to record information about the TLS
// connection on which the request was received.
//
// This field is ignored by the client.
TLS tls.ConnectionState
// Context specifies the context to use for client requests.
// If Context is nil, the background context will be used.
Context context.Context
conn net.Conn
}
// NewRequest returns a new request. The host is inferred from the URL.
// NewRequest returns a new request.
// The returned Request is suitable for use with Client.Do.
//
// Callers should be careful that the URL query is properly escaped.
// See the documentation for QueryEscape for more information.
func NewRequest(rawurl string) (*Request, error) {
u, err := url.Parse(rawurl)
if err != nil {
return nil, err
}
return NewRequestFromURL(u), nil
return &Request{URL: u}, nil
}
// NewRequestFromURL returns a new request for the given URL.
// The host is inferred from the URL.
// ReadRequest reads and parses an incoming request from r.
//
// Callers should be careful that the URL query is properly escaped.
// See the documentation for QueryEscape for more information.
func NewRequestFromURL(url *url.URL) *Request {
host := url.Host
if url.Port() == "" {
host += ":1965"
}
return &Request{
URL: url,
Host: host,
}
}
// ReadRequest reads a Gemini request from the provided io.Reader
// 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
br := bufio.NewReader(r)
r = io.LimitReader(r, 1026)
br := bufio.NewReaderSize(r, 1026)
rawurl, err := br.ReadString('\r')
if err != nil {
return nil, err
@@ -92,25 +73,37 @@ func ReadRequest(r io.Reader) (*Request, error) {
if err != nil {
return nil, err
}
if u.User != nil {
// User is not allowed
return nil, ErrInvalidURL
}
return &Request{URL: u}, nil
}
// Write writes the Gemini request to the provided buffered writer.
func (r *Request) Write(w *bufio.Writer) error {
// Write writes a Gemini request in wire format.
// This method consults the request URL only.
func (r *Request) Write(w io.Writer) error {
bw := bufio.NewWriterSize(w, 1026)
url := r.URL.String()
// User is invalid
if r.URL.User != nil || len(url) > 1024 {
return ErrInvalidURL
if len(url) > 1024 {
return ErrInvalidRequest
}
if _, err := w.WriteString(url); err != nil {
if _, err := bw.WriteString(url); err != nil {
return err
}
if _, err := w.Write(crlf); err != nil {
if _, err := bw.Write(crlf); err != nil {
return err
}
return bw.Flush()
}
// Conn returns the network connection on which the request was received.
func (r *Request) Conn() net.Conn {
return r.conn
}
// TLS returns information about the TLS connection on which the
// request was received.
func (r *Request) TLS() *tls.ConnectionState {
if tlsConn, ok := r.conn.(*tls.Conn); ok {
state := tlsConn.ConnectionState()
return &state
}
return nil
}

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

@@ -4,35 +4,41 @@ import (
"bufio"
"crypto/tls"
"io"
"net"
"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 response is read. If the network connection fails or the server
// terminates the response, Read calls return an error.
//
// It is the caller's responsibility to close the response.
type Response struct {
// Status contains the response status code.
Status Status
status Status
meta string
body io.ReadCloser
conn net.Conn
}
// Meta contains more information related to the response status.
// For successful responses, Meta should contain the media type of the response.
// For failure responses, Meta should contain a short description of the failure.
// Meta should not be longer than 1024 bytes.
Meta string
// Body represents the response body.
// Body is guaranteed to always be non-nil.
//
// The response body is streamed on demand as the Body field is read.
Body io.ReadCloser
// TLS contains information about the TLS connection on which the response
// was received.
TLS tls.ConnectionState
// NewResponse returns a new response with the provided status, meta, and body.
func NewResponse(status Status, meta string, body io.ReadCloser) *Response {
return &Response{
status: status,
meta: meta,
body: body,
}
}
// ReadResponse reads a Gemini response from the provided io.ReadCloser.
func ReadResponse(rc io.ReadCloser) (*Response, error) {
func ReadResponse(r io.ReadCloser) (*Response, error) {
resp := &Response{}
br := bufio.NewReader(rc)
br := bufio.NewReader(r)
// Read the status
statusB := make([]byte, 2)
@@ -41,16 +47,9 @@ func ReadResponse(rc io.ReadCloser) (*Response, error) {
}
status, err := strconv.Atoi(string(statusB))
if err != nil {
return nil, err
}
resp.Status = Status(status)
// Disregard invalid status codes
const minStatus, maxStatus = 1, 6
statusClass := resp.Status.Class()
if statusClass < minStatus || statusClass > maxStatus {
return nil, ErrInvalidResponse
}
resp.status = Status(status)
// Read one space
if b, err := br.ReadByte(); err != nil {
@@ -70,11 +69,11 @@ func ReadResponse(rc io.ReadCloser) (*Response, error) {
if len(meta) > 1024 {
return nil, ErrInvalidResponse
}
// Default mime type of text/gemini; charset=utf-8
if statusClass == StatusClassSuccess && meta == "" {
meta = "text/gemini; charset=utf-8"
if resp.status.Class() == StatusSuccess && meta == "" {
// Use default media type
meta = defaultMediaType
}
resp.Meta = meta
resp.meta = meta
// Read terminating newline
if b, err := br.ReadByte(); err != nil {
@@ -83,137 +82,153 @@ func ReadResponse(rc io.ReadCloser) (*Response, error) {
return nil, ErrInvalidResponse
}
if resp.Status.Class() == StatusClassSuccess {
resp.Body = newReadCloserBody(br, rc)
if resp.status.Class() == StatusSuccess {
resp.body = newBufReadCloser(br, r)
} else {
resp.Body = nopReadCloser{}
rc.Close()
resp.body = nopReadCloser{}
r.Close()
}
return resp, nil
}
type nopReadCloser struct{}
func (nopReadCloser) Read(p []byte) (int, error) {
return 0, io.EOF
// Status returns the response status code.
func (r *Response) Status() Status {
return r.status
}
func (nopReadCloser) Close() error {
// Meta returns the response meta.
// For successful responses, the meta should contain the media type of the response.
// For failure responses, the meta should contain a short description of the failure.
func (r *Response) Meta() string {
return r.meta
}
// Read reads data from the response body.
// The response body is streamed on demand as Read is called.
func (r *Response) Read(p []byte) (n int, err error) {
return r.body.Read(p)
}
// Close closes the response body.
func (r *Response) Close() error {
return r.body.Close()
}
// Conn returns the network connection on which the response was received.
func (r *Response) Conn() net.Conn {
return r.conn
}
// TLS returns information about the TLS connection on which the
// response was received.
func (r *Response) TLS() *tls.ConnectionState {
if tlsConn, ok := r.conn.(*tls.Conn); ok {
state := tlsConn.ConnectionState()
return &state
}
return nil
}
type readCloserBody struct {
br *bufio.Reader // used until empty
io.ReadCloser
// 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 {
// SetMediaType 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.
SetMediaType(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 SetMediaType 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 SetMediaType.
//
// 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)
// Flush sends any buffered data to the client.
Flush() error
// Close closes the connection.
// Any blocked Write operations will be unblocked and return errors.
Close() error
}
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)
}
// ResponseWriter is used to construct a Gemini response.
type ResponseWriter struct {
b *bufio.Writer
status Status
meta string
setHeader bool
type responseWriter struct {
bw *bufio.Writer
cl io.Closer
mediatype string
wroteHeader bool
bodyAllowed bool
}
// NewResponseWriter returns a ResponseWriter that uses the provided io.Writer.
func NewResponseWriter(w io.Writer) *ResponseWriter {
return &ResponseWriter{
b: bufio.NewWriter(w),
func newResponseWriter(w io.WriteCloser) *responseWriter {
return &responseWriter{
bw: bufio.NewWriter(w),
cl: w,
}
}
// Header sets the response header.
func (w *ResponseWriter) Header(status Status, meta string) {
w.status = status
w.meta = meta
func (w *responseWriter) SetMediaType(mediatype string) {
w.mediatype = mediatype
}
// Status sets the response status code.
// It also sets the response meta to status.Meta().
func (w *ResponseWriter) Status(status Status) {
w.status = status
w.meta = status.Meta()
}
// Meta sets the response meta.
//
// For successful responses, meta should contain the media type of the response.
// For failure responses, meta should contain a short description of the failure.
// The response meta should not be greater than 1024 bytes.
func (w *ResponseWriter) Meta(meta string) {
w.meta = meta
}
// Write writes data to the connection as part of the response body.
// If the response status does not allow for a response body, Write returns
// ErrBodyNotAllowed.
//
// Write writes the response header if it has not already been written.
// It writes a successful status code if one is not set.
func (w *ResponseWriter) Write(b []byte) (int, error) {
func (w *responseWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
w.writeHeader(StatusSuccess)
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)
return w.bw.Write(b)
}
func (w *ResponseWriter) writeHeader(defaultStatus Status) {
status := w.status
if status == 0 {
status = defaultStatus
func (w *responseWriter) WriteHeader(status Status, meta string) {
if w.wroteHeader {
return
}
meta := w.meta
if status.Class() == StatusClassSuccess {
if status.Class() == StatusSuccess {
w.bodyAllowed = true
if meta == "" {
meta = "text/gemini"
}
}
w.b.WriteString(strconv.Itoa(int(status)))
w.b.WriteByte(' ')
w.b.WriteString(meta)
w.b.Write(crlf)
w.bw.WriteString(strconv.Itoa(int(status)))
w.bw.WriteByte(' ')
w.bw.WriteString(meta)
w.bw.Write(crlf)
w.wroteHeader = true
}
// Flush writes any buffered data to the underlying io.Writer.
//
// Flush writes the response header if it has not already been written.
// It writes a failure status code if one is not set.
func (w *ResponseWriter) Flush() error {
func (w *responseWriter) Flush() error {
if !w.wroteHeader {
w.writeHeader(StatusTemporaryFailure)
w.WriteHeader(StatusTemporaryFailure, "Temporary failure")
}
// Write errors from writeHeader will be returned here.
return w.b.Flush()
// Write errors from WriteHeader will be returned here.
return w.bw.Flush()
}
func (w *responseWriter) Close() error {
return w.cl.Close()
}

126
response_test.go Normal file
View File

@@ -0,0 +1,126 @@
package gemini
import (
"io"
"io/ioutil"
"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(ioutil.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, _ := ioutil.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
}
var b strings.Builder
w := newResponseWriter(nopCloser{&b})
w.WriteHeader(test.Status, test.Meta)
io.Copy(w, strings.NewReader(test.Body))
if err := w.Flush(); err != nil {
t.Error(err)
continue
}
got := b.String()
if got != test.Raw {
t.Errorf("expected %#v, got %#v", test.Raw, got)
}
}
}

457
server.go
View File

@@ -1,114 +1,267 @@
package gemini
import (
"context"
"crypto/tls"
"errors"
"log"
"net"
"strings"
"sync"
"time"
"git.sr.ht/~adnano/go-gemini/certificate"
)
// 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 {
// Addr specifies the address that the server should listen on.
// If Addr is empty, the server will listen on the address ":1965".
// Addr optionally specifies the TCP address for the server to listen on,
// in the form "host:port". If empty, ":1965" (port 1965) is used.
// See net.Dial for details of the address format.
Addr string
// ReadTimeout is the maximum duration for reading a request.
// The Handler to invoke.
Handler Handler
// ReadTimeout is the maximum duration for reading the entire
// request.
//
// A ReadTimeout of zero means no timeout.
ReadTimeout time.Duration
// WriteTimeout is the maximum duration before timing out
// writes of the response.
//
// A WriteTimeout of zero means no timeout.
WriteTimeout time.Duration
// Certificates contains the certificates used by the server.
Certificates certificate.Dir
// 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.
//
// See the certificate submodule for a certificate store that creates
// and rotates certificates as needed.
GetCertificate func(hostname string) (*tls.Certificate, error)
// CreateCertificate, if not nil, will be called to create a new certificate
// if the current one is expired or missing.
CreateCertificate func(hostname string) (tls.Certificate, error)
// ErrorLog specifies an optional logger for errors accepting connections
// and file system errors.
// 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
ErrorLog interface {
Printf(format string, v ...interface{})
}
// registered responders
responders map[responderKey]Responder
hosts map[string]bool
listeners map[*net.Listener]context.CancelFunc
conns map[*net.Conn]context.CancelFunc
closed bool // true if Close or Shutdown called
shutdown bool // true if Shutdown called
doneChan chan struct{}
mu sync.Mutex
}
type responderKey struct {
scheme string
hostname string
func (srv *Server) isClosed() bool {
srv.mu.Lock()
defer srv.mu.Unlock()
return srv.closed
}
// Handle registers a responder for the given pattern.
// done returns a channel that's closed when the server is closed and
// all listeners and connections are closed.
func (srv *Server) done() chan struct{} {
srv.mu.Lock()
defer srv.mu.Unlock()
return srv.doneLocked()
}
func (srv *Server) doneLocked() chan struct{} {
if srv.doneChan == nil {
srv.doneChan = make(chan struct{})
}
return srv.doneChan
}
// tryCloseDone closes srv.done() if the server is closed and
// there are no active listeners or connections.
func (srv *Server) tryCloseDone() {
srv.mu.Lock()
defer srv.mu.Unlock()
srv.tryCloseDoneLocked()
}
func (srv *Server) tryCloseDoneLocked() {
if !srv.closed {
return
}
if len(srv.listeners) == 0 && len(srv.conns) == 0 {
ch := srv.doneLocked()
select {
case <-ch:
default:
close(ch)
}
}
}
// Close immediately closes all active net.Listeners and connections.
// For a graceful shutdown, use Shutdown.
func (srv *Server) Close() error {
srv.mu.Lock()
{
if srv.closed {
srv.mu.Unlock()
return nil
}
srv.closed = true
srv.tryCloseDoneLocked()
// Close all active connections and listeners.
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.
// If the provided context expires before the shutdown is complete,
// Shutdown returns the context's error.
//
// The pattern must be in the form of "hostname" or "scheme://hostname".
// If no scheme is specified, a scheme of "gemini://" is implied.
// Wildcard patterns are supported (e.g. "*.example.com").
func (s *Server) Handle(pattern string, responder Responder) {
if pattern == "" {
panic("gemini: invalid pattern")
}
if responder == nil {
panic("gemini: nil responder")
}
if s.responders == nil {
s.responders = map[responderKey]Responder{}
s.hosts = map[string]bool{}
}
// When Shutdown is called, Serve and ListenAndServe immediately
// return an error. 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 an error.
func (srv *Server) Shutdown(ctx context.Context) error {
srv.mu.Lock()
{
if srv.closed {
srv.mu.Unlock()
return nil
}
srv.closed = true
srv.shutdown = true
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]
}
srv.tryCloseDoneLocked()
if _, ok := s.responders[key]; ok {
panic("gemini: multiple registrations for " + pattern)
// Close all active listeners.
for _, cancel := range srv.listeners {
cancel()
}
}
s.responders[key] = responder
s.hosts[key.hostname] = true
}
srv.mu.Unlock()
// HandleFunc registers a responder function for the given pattern.
func (s *Server) HandleFunc(pattern string, responder func(*ResponseWriter, *Request)) {
s.Handle(pattern, ResponderFunc(responder))
// 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.
func (s *Server) ListenAndServe() error {
addr := s.Addr
// ListenAndServe listens on the TCP network address srv.Addr and then calls
// Serve to handle requests on incoming connections. If the provided
// context expires, ListenAndServe closes l and returns the context's error.
//
// If srv.Addr is blank, ":1965" is used.
//
// ListenAndServe always returns a non-nil error.
// After Shutdown or Closed, the returned error is context.Canceled.
func (srv *Server) ListenAndServe(ctx context.Context) error {
if srv.isClosed() {
return context.Canceled
}
addr := srv.Addr
if addr == "" {
addr = ":1965"
}
ln, err := net.Listen("tcp", addr)
l, err := net.Listen("tcp", addr)
if err != nil {
return err
}
defer ln.Close()
return s.Serve(tls.NewListener(ln, &tls.Config{
l = tls.NewListener(l, &tls.Config{
ClientAuth: tls.RequestClientCert,
MinVersion: tls.VersionTLS12,
GetCertificate: s.getCertificate,
}))
GetCertificate: srv.getCertificate,
})
return srv.Serve(ctx, l)
}
// Serve listens for requests on the provided listener.
func (s *Server) Serve(l net.Listener) error {
var tempDelay time.Duration // how long to sleep on accept failure
func (srv *Server) getCertificate(h *tls.ClientHelloInfo) (*tls.Certificate, error) {
if srv.GetCertificate == nil {
return nil, errors.New("gemini: GetCertificate is nil")
}
return srv.GetCertificate(h.ServerName)
}
func (srv *Server) trackListener(l *net.Listener, cancel context.CancelFunc) bool {
srv.mu.Lock()
defer srv.mu.Unlock()
if srv.closed {
return false
}
if srv.listeners == nil {
srv.listeners = make(map[*net.Listener]context.CancelFunc)
}
srv.listeners[l] = cancel
return true
}
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 the requests and
// then call the appropriate Handler to reply to them. If the provided
// context expires, Serve closes l and returns the context's error.
//
// Serve always closes l and returns a non-nil error.
// After Shutdown or Close, the returned error is context.Canceled.
func (srv *Server) Serve(ctx context.Context, l net.Listener) error {
defer l.Close()
lnctx, cancel := context.WithCancel(ctx)
defer cancel()
if !srv.trackListener(&l, cancel) {
return context.Canceled
}
defer srv.tryCloseDone()
defer srv.deleteListener(&l)
errch := make(chan error, 1)
go func() {
errch <- srv.serve(ctx, l)
}()
select {
case <-lnctx.Done():
return lnctx.Err()
case err := <-errch:
return err
}
}
func (srv *Server) serve(ctx context.Context, l net.Listener) error {
var tempDelay time.Duration // how long to sleep on accept failure
for {
rw, err := l.Accept()
if err != nil {
@@ -122,127 +275,113 @@ func (s *Server) Serve(l net.Listener) error {
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
}
s.logf("gemini: Accept error: %v; retrying in %v", err, tempDelay)
srv.logf("gemini: Accept error: %v; retrying in %v", err, tempDelay)
time.Sleep(tempDelay)
continue
}
// Otherwise, return the error
return err
}
tempDelay = 0
go s.respond(rw)
go srv.ServeConn(ctx, rw)
}
}
func (s *Server) getCertificate(h *tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := s.getCertificateFor(h.ServerName)
if err != nil {
// Try wildcard
wildcard := strings.SplitN(h.ServerName, ".", 2)
if len(wildcard) == 2 {
cert, err = s.getCertificateFor("*." + wildcard[1])
}
func (srv *Server) trackConn(conn *net.Conn, cancel context.CancelFunc) bool {
srv.mu.Lock()
defer srv.mu.Unlock()
if srv.closed && !srv.shutdown {
return false
}
return cert, err
if srv.conns == nil {
srv.conns = make(map[*net.Conn]context.CancelFunc)
}
srv.conns[conn] = cancel
return true
}
func (s *Server) getCertificateFor(hostname string) (*tls.Certificate, error) {
if _, ok := s.hosts[hostname]; !ok {
return nil, errors.New("hostname not registered")
}
// Generate a new certificate if it is missing or expired
cert, ok := s.Certificates.Lookup(hostname)
if !ok || cert.Leaf != nil && cert.Leaf.NotAfter.Before(time.Now()) {
if s.CreateCertificate != nil {
cert, err := s.CreateCertificate(hostname)
if err == nil {
if err := s.Certificates.Add(hostname, cert); err != nil {
s.logf("gemini: Failed to write new certificate for %s: %s", hostname, err)
}
}
return &cert, err
}
return nil, errors.New("no certificate")
}
return &cert, nil
func (srv *Server) deleteConn(conn *net.Conn) {
srv.mu.Lock()
defer srv.mu.Unlock()
delete(srv.conns, conn)
}
// respond responds to a connection.
func (s *Server) respond(conn net.Conn) {
// ServeConn serves a Gemini response over the provided connection.
// It closes the connection when the response has been completed.
// If the provided context expires before the response has completed,
// ServeConn closes the connection and returns the context's error.
//
// Note that ServeConn can be used during a Shutdown.
func (srv *Server) ServeConn(ctx context.Context, conn net.Conn) error {
defer conn.Close()
if d := s.ReadTimeout; d != 0 {
_ = conn.SetReadDeadline(time.Now().Add(d))
ctx, cancel := context.WithCancel(ctx)
defer cancel()
if !srv.trackConn(&conn, cancel) {
return context.Canceled
}
if d := s.WriteTimeout; d != 0 {
_ = conn.SetWriteDeadline(time.Now().Add(d))
defer srv.tryCloseDone()
defer srv.deleteConn(&conn)
if d := srv.ReadTimeout; d != 0 {
conn.SetReadDeadline(time.Now().Add(d))
}
if d := srv.WriteTimeout; d != 0 {
conn.SetWriteDeadline(time.Now().Add(d))
}
w := NewResponseWriter(conn)
defer func() {
_ = w.Flush()
errch := make(chan error, 1)
go func() {
errch <- srv.serveConn(ctx, conn)
}()
req, err := ReadRequest(conn)
select {
case <-ctx.Done():
return ctx.Err()
case err := <-errch:
return err
}
}
func (srv *Server) serveConn(ctx context.Context, conn net.Conn) error {
ctx, cancel := context.WithCancel(ctx)
done := ctx.Done()
cw := &contextWriter{
ctx: ctx,
done: done,
cancel: cancel,
wc: conn,
}
r := &contextReader{
ctx: ctx,
done: done,
cancel: cancel,
rc: conn,
}
w := newResponseWriter(cw)
req, err := ReadRequest(r)
if err != nil {
w.Status(StatusBadRequest)
return
w.WriteHeader(StatusBadRequest, "Bad request")
return w.Flush()
}
req.conn = conn
h := srv.Handler
if h == nil {
w.WriteHeader(StatusNotFound, "Not found")
return w.Flush()
}
// Store information about the TLS connection
if tlsConn, ok := conn.(*tls.Conn); ok {
req.TLS = tlsConn.ConnectionState()
if len(req.TLS.PeerCertificates) > 0 {
peerCert := req.TLS.PeerCertificates[0]
// Store the TLS certificate
req.Certificate = &tls.Certificate{
Certificate: [][]byte{peerCert.Raw},
Leaf: peerCert,
}
}
}
resp := s.responder(req)
if resp == nil {
w.Status(StatusNotFound)
return
}
resp.Respond(w, req)
h.ServeGemini(ctx, w, req)
return w.Flush()
}
func (s *Server) responder(r *Request) Responder {
if h, ok := s.responders[responderKey{r.URL.Scheme, r.URL.Hostname()}]; 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]}]; ok {
return h
}
}
return nil
}
func (s *Server) logf(format string, args ...interface{}) {
if s.ErrorLog != nil {
s.ErrorLog.Printf(format, args...)
func (srv *Server) logf(format string, args ...interface{}) {
if srv.ErrorLog != nil {
srv.ErrorLog.Printf(format, args...)
} else {
log.Printf(format, args...)
}
}
// A Responder responds to a Gemini request.
type Responder interface {
// Respond accepts a Request and constructs a Response.
Respond(*ResponseWriter, *Request)
}
// 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)
}

View File

@@ -1,8 +1,9 @@
package gemini
// Status codes.
// Status represents a Gemini status code.
type Status int
// Gemini status codes.
const (
StatusInput Status = 10
StatusSensitiveInput Status = 11
@@ -24,28 +25,26 @@ const (
StatusCertificateNotValid Status = 62
)
// Status code categories.
type StatusClass int
const (
StatusClassInput StatusClass = 1
StatusClassSuccess StatusClass = 2
StatusClassRedirect StatusClass = 3
StatusClassTemporaryFailure StatusClass = 4
StatusClassPermanentFailure StatusClass = 5
StatusClassCertificateRequired StatusClass = 6
)
// Class returns the status class for this status code.
func (s Status) Class() StatusClass {
return StatusClass(s / 10)
// 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
}
// Meta returns a description of the status code appropriate for use in a response.
//
// Meta returns an empty string for input, success, and redirect status codes.
func (s Status) Meta() string {
// 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:

19
text.go
View File

@@ -9,38 +9,39 @@ import (
// Line represents a line of a Gemini text response.
type Line interface {
// String formats the line for use in a Gemini text response.
String() string
line() // private function to prevent other packages from implementing Line
}
// A link line.
// LineLink is a link line.
type LineLink struct {
URL string
Name string
}
// A preformatting toggle line.
// LinePreformattingToggle is a preformatting toggle line.
type LinePreformattingToggle string
// A preformatted text line.
// LinePreformattedText is a preformatted text line.
type LinePreformattedText string
// A first-level heading line.
// LineHeading1 is a first-level heading line.
type LineHeading1 string
// A second-level heading line.
// LineHeading2 is a second-level heading line.
type LineHeading2 string
// A third-level heading line.
// LineHeading3 is a third-level heading line.
type LineHeading3 string
// An unordered list item line.
// LineListItem is an unordered list item line.
type LineListItem string
// A quote line.
// LineQuote is a quote line.
type LineQuote string
// A text line.
// LineText is a text line.
type LineText string
func (l LineLink) String() string {

View File

@@ -27,7 +27,7 @@ type KnownHosts struct {
}
// Add adds a host to the list of known hosts.
func (k *KnownHosts) Add(h Host) error {
func (k *KnownHosts) Add(h Host) {
k.mu.Lock()
defer k.mu.Unlock()
if k.hosts == nil {
@@ -35,7 +35,6 @@ func (k *KnownHosts) Add(h Host) error {
}
k.hosts[h.Hostname] = h
return nil
}
// Lookup returns the known host entry corresponding to the given hostname.
@@ -144,7 +143,7 @@ func (k *KnownHosts) Parse(r io.Reader) error {
// 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, it is replaced with the provided host.
// 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)
@@ -181,9 +180,9 @@ func NewHostWriter(w io.WriteCloser) *HostWriter {
}
}
// NewHostsFile returns a new host writer that appends to the file at the given path.
// 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 NewHostsFile(path string) (*HostWriter, error) {
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
@@ -212,6 +211,84 @@ func (h *HostWriter) Close() error {
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 that stores
// known hosts in hosts and writes new hosts to writer.
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
@@ -259,8 +336,7 @@ func (h *Host) UnmarshalText(text []byte) error {
parts := bytes.Split(text, []byte(" "))
if len(parts) != 4 {
return fmt.Errorf(
"expected the format %q", format)
return fmt.Errorf("expected the format %q", format)
}
if len(parts[0]) == 0 {
@@ -271,8 +347,7 @@ func (h *Host) UnmarshalText(text []byte) error {
algorithm := string(parts[1])
if algorithm != "SHA-512" {
return fmt.Errorf(
"unsupported algorithm %q", algorithm)
return fmt.Errorf("unsupported algorithm %q", algorithm)
}
h.Algorithm = algorithm
@@ -298,8 +373,7 @@ func (h *Host) UnmarshalText(text []byte) error {
unix, err := strconv.ParseInt(string(parts[3]), 10, 0)
if err != nil {
return fmt.Errorf(
"invalid unix timestamp: %w", err)
return fmt.Errorf("invalid unix timestamp: %w", err)
}
h.Expires = time.Unix(unix, 0)