Compare commits

...

171 Commits

Author SHA1 Message Date
Alex Kotov 2068c3b02a Allow to disable directory listing
Signed-off-by: Solderpunk <solderpunk@posteo.net>
2023-08-20 14:30:51 +02:00
Solderpunk 64a4ff72f0 Remove debugging Println. 2023-04-09 15:24:34 +02:00
Solderpunk 1b7d661abd Type trashing to fix last commit. 2023-04-09 14:24:39 +02:00
Solderpunk 051df29604 Add a write deadline with maximum allowed download time derived from filesize. See #35. 2023-04-09 14:12:38 +02:00
Solderpunk 6f0865447d Adds leaky token bucket rate limiting with bans for non-compliant clients. 2023-04-09 13:57:59 +02:00
Solderpunk 2c3225c1c0 Fix crash when CGI processes end without writing anything at all to stdout. Closes #38. 2023-03-22 21:03:30 +01:00
Solderpunk 4b54eb6134 Set 30 second deadline for reading requests. See #35. 2023-03-19 11:51:44 +01:00
Solderpunk 8e618a6304 Double hard limit ban durations each time. 2023-03-19 10:31:06 +01:00
Solderpunk 4b9a7e8ad5 Correctly implement bans for clients exceeding hard limit. 2023-03-19 10:30:08 +01:00
Solderpunk efde852c54 Refactor rate limiting to have soft and hard limits, block clients exceeding hard limits for one hour. 2023-03-18 16:40:23 +01:00
Solderpunk 3c5835f033 Continue to increment drips once bucket is overflowing. 2023-03-18 15:45:35 +01:00
Solderpunk a6170a355d Make rate limiting configurable. 2023-03-17 19:52:39 +01:00
Solderpunk 5016f40edb Initial implementation of leaky bucket rate limiting. 2023-03-16 20:27:45 +01:00
Solderpunk c4866d2965 Check for a CGI path prefix before insisting that an exact path exists on disk. Closes #36. 2023-03-16 19:23:32 +01:00
Solderpunk 72a94cab00 Restore Go 1.15 compatibility. 2023-03-04 14:27:01 +01:00
Solderpunk e30f39b196 Fix typo in error message. 2023-03-02 19:43:24 +01:00
Solderpunk 3a03995f26 Actually, be *more* clever about client certs...(see e70ec) 2023-03-02 17:24:34 +01:00
Solderpunk bd07cb3507 Check for errors when parsing TLS certificates even after successful PEM decoding. 2023-03-01 19:50:45 +01:00
Solderpunk 81b4f1dcc0 Fix small variable name error. 2023-02-27 08:35:11 +01:00
Solderpunk d3d415b612 Add missing return. 2023-02-26 19:42:49 +01:00
Solderpunk eefb1bc3a6 Further simplifications of config parsing code. 2023-02-26 19:42:30 +01:00
Solderpunk f9585ff2b7 Rearrange the logic of handling requests without changing behaviour.
The new order handles certificate zones and redirects defined in the
system-wide config file as well as SCGI paths as early as possible
without doing any unecessary filesystem operations and especially
without the potentially expensive search for .molly files.
2023-02-25 12:06:34 +01:00
Solderpunk eb85a6e94c Another big refactor, splitting the Config struct in two.
The split reflects that between variables which can and cannot be
overridden by .molly files, and this greatly simplifies the
processing of said files, getting rid of the need for lots of
ugly temporary variable thrashing.
2023-02-25 11:29:13 +01:00
Solderpunk e70ec82594 Don't try to be clever about when to request client certs: we never know what could be in a .molly file. 2023-02-24 19:12:52 +01:00
Solderpunk bff3d6d486 Restore logging functionality after some subtle variable declaration scoping bugs wiped it out! 2023-02-23 20:49:28 +01:00
Solderpunk a9dab7b48c Argh, fix stupid typo. 2023-02-23 20:04:48 +01:00
Solderpunk c50accfaec Only drop supplementary groups if root is amongst them. 2023-02-23 20:03:21 +01:00
Solderpunk 0274ef8f35 Print warning about expired certificates. 2023-02-23 19:59:11 +01:00
Solderpunk 800c181668 Ensure supplied TLS certificate is valid for configured hostname. 2023-02-23 19:47:14 +01:00
Solderpunk d67f896b84 Add AllowTLS12 option to switch minimum TLS version between 1.2 and 1.3. 2023-02-23 19:31:16 +01:00
Solderpunk 67386cd118 Update README to reflect movement of unix security stuff out of config file into command line switches. 2023-02-23 18:57:56 +01:00
Solderpunk 212c9f79fb A rather extensive refactor.
Basically the function formerly known as do_main() in main.go has
been renamed launch() and moved into launch.go.  Now there are
main.go and main_unix.go files implementing minmial main()
functions which load a config and pass it to launch.  This allows
separating unix-specific security stuff (both the actual system
calls which won't compile on other platforms and the definition
of command line switches) out from the platform agnostic
implementation of the main server logic.  It also simplifies the
interaction of relative paths in config files with chrooting.

Docs still need updating...
2023-02-23 18:49:15 +01:00
Solderpunk 8d1a04cb27 Fix minor bugs on OpenBSD-only code, after discovering easy of cross-compilation in Go. 2023-02-22 21:16:11 +01:00
Solderpunk 40203a8856 Use net/http.DetectContentType as a last resort for MIME, rather than hardcoding application/octet-stream. 2023-02-21 19:22:19 +01:00
Solderpunk 75c283fc74 Restore documented setuid behaviour. 2023-02-19 18:28:52 +01:00
Solderpunk f63fcdb6d1 Do not request client certificates if we're never going to need them. 2023-02-19 15:17:45 +01:00
Solderpunk 7a89b307a1 Just use the log package's default logger as the error log. 2023-02-19 15:04:34 +01:00
Solderpunk 072669a167 Avoid use of log.Fatal() or os.Exit() in main so defers are guaranteed to run. 2023-02-19 14:40:54 +01:00
Solderpunk 7fad754ff2 Drop privileges much more thoroughly, thanks nervuri! (see issue #16) 2023-02-19 13:17:24 +01:00
Solderpunk 182e58ffe3 Make unprivileged user configurable, thanks nervuri! (see issue #16) 2023-02-15 21:16:49 +01:00
Solderpunk c0c67f7ba6 Whoops, don't ignore error from filepath.Abs. 2023-02-15 21:15:14 +01:00
Solderpunk 8372142843 Add support for chroot()ing server early after startup, more work toward issue #16. 2023-02-15 21:10:22 +01:00
Solderpunk 06c6d190a6 Guard against symbolic links escaping the document base. 2023-02-13 22:15:42 +01:00
Solderpunk bb0a04d2c7 Add a little bit of extra security advice to the README, a tiny extra step toward closing issue #16. 2023-02-13 21:52:08 +01:00
Solderpunk 4e6a8fcd05 Use setuid() systemcall wherever possible to reduce privileges before accepting network connections. First step toward solving issue #16. 2023-02-13 20:26:52 +01:00
Solderpunk 5258b29c6b Big ol' gofmt. 2023-02-10 17:19:21 +01:00
Solderpunk 56d8dde14a Chdir to / so that Molly doesn't interfere with unmounting. 2023-02-10 16:16:57 +01:00
Solderpunk b16fe0b8d4 Absolutise DocBase before trying to absolutise anything else relative to it. 2023-02-08 20:32:17 +01:00
Solderpunk 17d17a1629 Catch SIGTERM and shutdown gracefully. 2023-02-08 19:56:27 +01:00
Solderpunk 86720131d3 Declare dependenc upon x/sys to support OpenBSD security features. 2023-02-08 19:54:58 +01:00
Solderpunk b16a8584a6 Merge pull request 'Added pledge(2) and unveil(2) system calls to improve security on OpenBSD.' (#13) from kvothe/molly-brown:master into master
Reviewed-on: solderpunk/molly-brown#13
2023-02-08 17:54:29 +00:00
Solderpunk 0d5d67c86d Forcibly ingest Kool-Aid. 2023-02-08 18:53:29 +01:00
Solderpunk 3be10b82d7 Allow no access logging with empty string log file path. 2023-02-07 19:59:43 +01:00
Solderpunk 443bfd4bbd Change to error logging behaviour (stderr instead of stdout, by default). 2023-02-07 19:33:14 +01:00
Solderpunk 16bf8e0534 Refuse to use a world-readable TLS key. 2023-02-07 19:23:35 +01:00
Solderpunk c0d0c0991c Update date and email address in LICENSE. 2023-02-07 19:12:24 +01:00
Solderpunk 8541b6194b Resolve non-absolute values of CGIPaths relative to DocBase. Closes #24. 2023-02-05 16:54:07 +01:00
Solderpunk 2d6f4db38e Add -v flag to print version and exit. Closes #23. 2023-02-05 15:36:18 +01:00
Solderpunk d9e0fed193 Tidy up DirectorySubdirsFirst sorting code by doing two consecutive sorts. Closes #30. 2023-02-05 15:04:49 +01:00
Solderpunk 8446885f56 Rename DirectoriesFirst option to DirectorySubdirsFirst and document in README. 2023-02-05 14:35:29 +01:00
Russ Magee 67d509a234 Sort directory listings with directories before files 2023-02-05 13:36:16 +01:00
Solderpunk 733e518392 Accept requests where the URL has a FQDN hostname with a trailing dot. Closes #20. 2023-01-29 12:29:01 +01:00
Solderpunk a41898b012 Add DefaultEncoding option to config/.molly files. Closes #19. 2023-01-29 12:07:52 +01:00
Solderpunk f05bab2b73 Make test of request URL hostname against configured hostname case insensitive. Closes #29. 2023-01-28 19:22:31 +01:00
Solderpunk 16ed9e5cff Allow redirects to other hosts. Closes #26. 2023-01-28 19:16:11 +01:00
Solderpunk e42c366565 Merge pull request 'Add FreeBSD example rc script' (#25) from ecliptik/molly-brown:freebsd-rc-example into master
Reviewed-on: solderpunk/molly-brown#25
2021-05-01 14:48:48 +00:00
Micheal Waltz b73e10ad58
Add FreeBSD example rc script 2021-04-25 01:11:15 -07:00
Solderpunk 92cd40db12 Allow access and error logging to stdout by configuring a path of "-".
Thanks to @icedquinn@blob.cat for the suggestion.
2021-01-24 17:09:47 +01:00
Solderpunk e06f8bddbc Fix infinite redirect bug.
Previously, URLs without trailing slashes in the path which
resolved to directories caused infinite redirects if there was
anything in the URL after the path (like a query).

Thanks to both Luke Emmet and Stephane Bortzmeyer for reporting
this!
2021-01-24 16:27:53 +01:00
Solderpunk 3d4d830e98 Merge pull request 'Add "AUTH_TYPE" environment variable when client cert is present' (#14) from khuxkm/molly-brown:master into master
Reviewed-on: solderpunk/molly-brown#14
2020-12-27 20:21:49 +00:00
Solderpunk 2e4a10297e Merge pull request 'Don't include port in REMOTE_ADDR' (#18) from makeworld/molly-brown:master into master
Reviewed-on: solderpunk/molly-brown#18
2020-12-27 17:40:18 +00:00
makeworld 99ba34c2b7 Merge branch 'master' into master 2020-12-26 23:33:41 +00:00
makeworld e0e0cf7dd6 Don't include port in REMOTE_ADDR 2020-12-26 18:23:36 -05:00
Solderpunk 34e05cc0b5 Merge pull request 'Use io.Copy over ioutil.ReadFile' (#17) from makeworld/molly-brown:master into master
Reviewed-on: solderpunk/molly-brown#17
2020-12-10 07:12:26 +00:00
makeworld c6c6e544d6 Use io.Copy over ioutil.ReadFile 2020-12-09 17:59:00 -05:00
Robert Miles 06ae7c0442 Add "AUTH_TYPE" environment variable when client cert is present
This makes it easier to detect when a client cert is available without having to look before you leap and attempt to access any of the TLS_* variables.
2020-11-28 10:07:27 +00:00
kvothe. 1c0fb0d856 Fixed a typo in the OpenBSD enableSecurityRestrictions docs. 2020-09-16 23:49:03 -04:00
kvothe. a8f59868f3 Update requirements list for OpenBSD. 2020-09-16 23:32:35 -04:00
kvothe. fb77a13088 Finished the OpenBSD pledge/unveil implementation after testing SCGI procs. 2020-09-16 23:24:41 -04:00
kvothe. 69a253f820 Tested unveiling CGI dirs and globs as executable. 2020-09-15 22:14:12 -04:00
kvothe. 03ca12d0c1 First pass at a pledge/unveil implementation for OpenBSD. 2020-09-14 22:21:05 -04:00
Solderpunk 48f9a206c0 Merge pull request 'Added more details on the OpenBSD setup and pointed molly-brown.openbsd.example to the default molly.conf.' (#12) from kvothe/molly-brown:master into master
Reviewed-on: solderpunk/molly-brown#12
2020-08-19 18:48:24 +00:00
kvothe. d71b43f35c Added more details on the OpenBSD setup and pointed molly-brown.openbsd.example to the default molly.conf. 2020-08-02 09:41:57 -04:00
Solderpunk 02dc6a82c6 Add trailing slash to subdirectory links in directory listings. 2020-07-30 18:49:59 +02:00
Solderpunk b26fb875a7 Fix configuration option name typo in README. Thanks, acdw! Closes #11. 2020-07-26 17:35:58 +02:00
Solderpunk 62b22a9471 Mention OpenBSD support, add example init script. 2020-07-12 16:28:41 +02:00
Solderpunk 2e510328ef Fix serious bug whereby config changes made in .molly files apply everywhere and persist until server restart! 2020-07-06 19:08:03 +02:00
Solderpunk 7d8bacdc90 Log stderr from CGI processes which exit with non-zero status to the error log. Closes #7. 2020-07-06 16:13:56 +02:00
Solderpunk b9334e07a9 Fix bug with long filenames or titles. 2020-07-04 13:13:58 +02:00
Solderpunk 48aed1398d Log remote IP address when reading a request fails. 2020-07-03 11:16:07 +02:00
Solderpunk 2241302856 Order log columns more sensibly and drop port from remote addresses. 2020-07-01 23:44:59 +02:00
Solderpunk e547818455 Remove unused import. 2020-07-01 23:06:05 +02:00
Solderpunk 37e177fbe0 Move .molly file parsing function into config.go. 2020-07-01 23:05:49 +02:00
Solderpunk 3cd4a072fd More and better error logging. 2020-07-01 22:58:07 +02:00
Solderpunk f7e588dfae More and better error logging. 2020-07-01 20:15:52 +02:00
Solderpunk 821a862036 Use standard library logging facilities for error log. 2020-07-01 19:57:39 +02:00
Solderpunk 3c4c447bd3 Broader config file error handling. 2020-07-01 19:56:43 +02:00
Solderpunk 54f659c03f Add example OpenRC script, and put all init examples in a contrib directory. 2020-07-01 17:00:18 +02:00
Solderpunk 2a263e5e70 Rewrite SCGI support to use prefixes, not regexps, and provide the same SCRIPT_PATH / PATH_INFO break as CGI. 2020-07-01 16:40:51 +02:00
Solderpunk d87ecfd20f gofmt. 2020-07-01 16:19:38 +02:00
Solderpunk f4f14320df Expand CGI path globs once on startup, not for each request. 2020-07-01 16:18:49 +02:00
Solderpunk 35bf4d16c3 Handle all requests which don't involve the filesystem before all which do. 2020-07-01 16:05:39 +02:00
Solderpunk 9bce54882a Consolidate some quick-failing path tests. 2020-07-01 16:05:09 +02:00
Solderpunk 36378eb1c6 Make sure CGI files are world-readable as well as world-executable. 2020-07-01 16:04:48 +02:00
Solderpunk f291e5863c Enforce certificate zones before anything else. 2020-07-01 14:16:27 +02:00
Solderpunk 4ae154faed Convert CGIPath handling from regexs to prefixes. 2020-07-01 14:10:20 +02:00
Solderpunk cc5410494e Handle redirects using regular expressions, not just literal paths. 2020-07-01 11:13:38 +02:00
Solderpunk b30fc0923b Handle CGI requests with URI components after the script. 2020-07-01 10:38:31 +02:00
Solderpunk 68398ef0be Extensive refactor, trying to keep the main request handling function relatively simple. 2020-06-30 22:25:37 +02:00
Solderpunk fd31094cb6 Allow .molly files to set certificate zones. 2020-06-30 20:39:26 +02:00
Solderpunk 702cc73a63 Add redirect members to Molly File struct. Should have been part of an earlier commit! 2020-06-30 20:38:46 +02:00
Solderpunk 35f58f50e7 Correct README to indicate that redirects are not done using regular expressions but literal path matches. 2020-06-30 20:23:50 +02:00
Solderpunk 1794ff643b Allow .molly files to configure redirects. 2020-06-30 20:19:13 +02:00
Solderpunk d7663ab688 Removing debugging print. 2020-06-30 19:34:01 +02:00
Solderpunk e43fc7877c Big rearrange: resolve URL to filesystem earlier, so we can check for .molly files ASAP, so that they can handle redirects, certificate zones, etc. 2020-06-30 19:31:27 +02:00
Solderpunk 990b7071d0 gofmt fixes. 2020-06-30 19:13:02 +02:00
Solderpunk 44af303de6 Fix silly mistake in hasty port checking fix. 2020-06-30 19:11:49 +02:00
Solderpunk 4c27911e8f Check that request port matches configured server port. Should fix #9. 2020-06-30 17:27:53 +02:00
Solderpunk b0fd07f58a Provide full coverage of settings in example.conf. 2020-06-30 17:19:27 +02:00
Solderpunk 6c8e11c9ed Change default port in example.conf 2020-06-30 17:02:18 +02:00
Solderpunk 4dbe52adf8 Permit .molly files to specify MIME overrides. 2020-06-30 17:01:19 +02:00
Solderpunk 520e1ecc95 Big documentation push. 2020-06-29 21:08:29 +02:00
Solderpunk 18c056167f Make it possible to toggle handling of .molly files on and off via main config. 2020-06-29 17:17:43 +02:00
Solderpunk 77691d6983 Introduce error log. 2020-06-28 18:34:50 +02:00
Solderpunk 31161cf21c Permit multiple authorised certificates per zone. 2020-06-28 14:47:36 +02:00
Solderpunk a0dacf4bbd Basic implementation of certificate zones - only one authorised cert per zone. 2020-06-28 13:47:30 +02:00
Solderpunk 5377c2941f Allow overriding MIME types based on path regexes. 2020-06-28 00:12:32 +02:00
Solderpunk a07645dd2e Allow overriding directory listing options with .molly files. 2020-06-27 23:44:15 +02:00
Solderpunk 7066bad570 Use headings in .gmi files instead of filenames in directory listings. 2020-06-27 22:57:03 +02:00
Solderpunk 3f98a9edf1 Permit sorting of files in automatic directory listings by various factors. 2020-06-27 18:52:29 +02:00
Solderpunk 6da5ec79dd Fix typo. 2020-06-17 15:22:21 +02:00
Solderpunk 30a5369f8a Add missing return, fixes #4. 2020-06-13 09:08:04 +02:00
Solderpunk 15863eb253 Fix typo in CGI environment variable. 2020-06-12 19:00:36 +02:00
Solderpunk e57a3d5ecf Read header for directory listings from .mollyhead. 2020-06-11 22:43:13 +02:00
Solderpunk b0b18971f4 Run gofmt on everything for the first time ever! 2020-06-10 21:31:13 +02:00
Solderpunk 3e80488f92 Add DefaultLang config variable to set text/gemini lang parameter. Overridable via .molly file. 2020-06-10 21:22:15 +02:00
Solderpunk 7fb5ca052b Fix parent directory walking logic to work at the DocRoot. 2020-06-10 21:20:01 +02:00
Solderpunk cb1e0da7d5 Read .molly files from parent directories of served file, permitting overrides to text/gemini file extension. 2020-06-10 20:40:13 +02:00
Solderpunk 599bbf4b52 Proper tilde path clean up. 2020-06-10 19:43:49 +02:00
Solderpunk 8d2309f889 Quick hacky fix to tilde path transformation. 2020-06-08 23:03:19 +02:00
Solderpunk 3e5e4353e6 Update example config file. 2020-06-08 21:48:35 +02:00
Solderpunk b8034c1576 Make text/gemini extension configurable. 2020-06-08 21:47:33 +02:00
Solderpunk 4681d3f971 Support multiple CGI paths. 2020-06-08 21:46:39 +02:00
Solderpunk bec952c66a Use log.Fatal instead of fmt.Println and os.Exit. 2020-06-08 20:02:29 +02:00
Solderpunk 8f395d7932 Add link to parent directory in directory listings. 2020-06-08 20:01:03 +02:00
Solderpunk 301d3409f1 Support temporary and permanent redirects. 2020-06-08 19:59:16 +02:00
Solderpunk 433c43e98e Add file size and modification dates to automatically generated directory listings. 2020-06-06 21:28:12 +02:00
Solderpunk 11beddcfb1 Add rudimentary support for specifying redirects. 2020-06-06 13:36:10 +02:00
Solderpunk 548697b094 Get rid of debugging prints. 2020-06-06 13:35:47 +02:00
Solderpunk 2c99228610 Pass pointers to the log entry to functions which can set the status code, so changes are reflected in the main handle function. 2020-06-06 12:08:34 +02:00
Solderpunk b0a08f8231 Break CGI and SCGI stuff out into its own file. 2020-06-06 11:46:29 +02:00
Solderpunk 54ed1ab265 Merge CGI and SCGI variable preparation. 2020-06-06 11:33:00 +02:00
Solderpunk d78c840056 First step toward unstubbing SCGI. 2020-06-06 00:12:46 +02:00
Solderpunk b384105d86 Factor out request reading code. 2020-06-05 20:13:48 +02:00
Solderpunk 7ffbb6c6ef Pass some client cert information to CGI programs. 2020-06-05 19:55:24 +02:00
Solderpunk 6f3887bdc4 Request client certificates, check validity dates of received certs and pass certs to handleCGI. 2020-06-05 19:39:28 +02:00
Solderpunk 4e262d634a Fix line endings in response headers. 2020-06-05 19:37:56 +02:00
Solderpunk fc730c8b1c Refuse to serve any sensitive files. 2020-06-04 23:24:19 +02:00
Solderpunk 532dd83414 Make sure to always provide a MIME type. 2020-06-04 23:12:09 +02:00
Solderpunk a16a5fac12 Switch CGI implementation to actual CGI. 2020-06-04 22:38:22 +02:00
Solderpunk de119aa0bb Fix trailing slash redirects. 2020-06-04 21:32:20 +02:00
Solderpunk 920f06597f Stub implementation of SCGI. 2020-06-04 20:41:40 +02:00
Solderpunk 44d72c2bf2 Make it clear what is wrong with a config file. 2020-06-04 20:36:03 +02:00
Solderpunk 93f3c9e620 Break handleGeminiRequest up into smaller, clearer functions. 2020-06-04 20:35:14 +02:00
Solderpunk 9c51f26d74 Do not crash if os.Stat returns an error other than not found or no permission, which seems to mysteriously happen sometimes... 2020-06-01 21:27:15 +02:00
Solderpunk 5fdb3622e9 Remove vestigial generic response code. 2020-06-01 21:23:24 +02:00
solderpunk 79dd948a9f Merge pull request 'Add initial systemd service example' (#3) from strega-nil/molly-brown:add-systemd into master 2020-05-23 11:21:25 -04:00
Solderpunk d14198aeba Add missing import from previous commit. 2020-05-21 22:50:33 +02:00
Nicole Mazzuca 85a94a43b9 modify README 2020-05-21 10:15:24 -07:00
Nicole Mazzuca dcf23cbd97 add initial systemd service example 2020-05-21 10:15:19 -07:00
Solderpunk 203c259e7f Honour port setting from config file. 2020-05-19 22:05:48 +02:00
24 changed files with 2153 additions and 279 deletions

View File

@ -1,4 +1,4 @@
Copyright (c) 2019 Solderpunk <solderpunk@sdf.org> . All rights reserved.
Copyright (c) 2019-23 Solderpunk <solderpunk@posteo.net>. All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:

442
README.md
View File

@ -1,9 +1,69 @@
# molly-brown
# Molly Brown
The Unsinkable Molly Brown: a full-featured Gemini server implemented in Go.
The Unsinkable Molly Brown is a full-featured Gemini server
implemented in Go.
For more info on Gemini see https://gemini.circumlunar.space or
gopher://gemini.circumlunar.space.
For more information on the Gemini protocol see:
* https://gemini.circumlunar.space
* gopher://gemini.circumlunar.space
* gemini://gemini.circumlunar.space
## Overview
Molly Brown is intended to be a full-featured Gemini server which is
suitable for use in pubnix or similar shared-hosting environments,
where users can upload their content but do not have access to the
main configuration file (of course, it is also perfectly suitable for
single user environments, but its multi-user supports sets it apart
from many other Gemini servers).
Molly Brown features:
* Support for traditional `~username` URLs.
* Automatic directory listings, with support for customised headers
and footers, control over file sorting order and the ability to
use headings from `text/gemini` content in place of filenames.
* Determination of MIME type via filename extension, which can be
manually overridden to allow, e.g., serving Atom feeds as
`application/atom+xml` instead of `application/xml` or `text/xml`.
The file extension for `text/gemini` defaults to `gmi`, but this can
be overrideen too.
* Support for temporary and permanent redirects, specified via regular
expressions.
* Dynamic content via CGI and SCGI.
* Support for "certificate zones", where access to certain paths is
restricted to clients providing TLS certificates whose SHA256
fingerprints have been added to a list of approved fingerprints,
analogous to SSH's `authorized_keys` file.
* The ability for users to override some configuration settings on a
per-directory basis using `.molly` files, analogous to Apache's
`.htaccess` files.
The follow features are planned for the future:
* Name-based virtual hosting
## System requirements
Molly Brown is known to run on:
* FreeBSD
* GNU/Linux
* OpenBSD
* 9Front
Please let us know if you get it to work on some other platform!
Molly Brown only has a single dependency beyond the Go standard
library, which is [this TOML parsing
library](https://github.com/BurntSushi/toml).
The OpenBSD implementation also uses the [golang.org/x/sys/unix
package](https://godoc.org/golang.org/x/sys/unix) to provide the
[pledge(2)](https://man.openbsd.org/pledge.2) and
[unveil(2)](https://man.openbsd.org/unveil.2) system calls to provide
additional security features.
## Installation
@ -21,7 +81,7 @@ developer yourself in which case you surely already have this done)...
(you can in fact put your $GOPATH anywhere you like, but `~/go` is the
convention)
### Fetch and build MB
### Fetch and build Molly Brown
Run `go get tildegit.org/solderpunk/molly-brown`. If everything goes
well, the end result of this will be that you'll have the Molly Brown
@ -32,55 +92,369 @@ makes you happier or your life easier, you can copy that binary to
### Configuration
In the source directory mentioned above, you should find a file named
`example.conf`. Copy this to `/etc/molly.conf` and edit it to suit
your environment. The default values for all possible options are
specified in the file - just uncomment and change the ones which won't
work for you. All options are explained below in the Configuration
Options section.
Molly Brown can run without a configuration file, in which case it
will use compiled-in default settings. However, these settings are
oriented toward quick test runs with all files in the current
working directory. For regular use, you will want to override these
defaults with more suitable settings from a config file. An example
config file showing the syntax for all settings can be found in the
`~/go/src/tildegit.org/solderpunk/molly-brown/` directory with the
filename `example.conf`. You can copy this file to `/etc/molly.conf`
and edit it to suit your environment. All the options are explained
further below. If you put your configuration file somewhere other
than `/etc/molly.conf`, you will need to use Molly Brown's `-c`
command line option to tell Molly Brown where to find it.
### Daemonisation and launching
### Running
Currently Molly Brown just runs like an ordinary program, without
daemonising itself. You'll need to use another program, like the one
at `http://libslack.org/daemon/`, to handle daemonising.
The `molly-brown` executable recognises the following command line
switches:
Currently Molly Brown is not integrated with any kind of init system,
so you'll have to handle getting it to start on boot up yourself. If
you are using a sufficiently right-headed operating system, the
easiest way to do this is by putting your call to `daemon` (or
whatever else you use) in `/etc/rc.local`.
* `-c`: Used to specify a config file.
* `-C`: Used to specify a directory to chroot to (unix only).
* `-u`: Used to specify the name of an unprivileged user which
Molly Brown should switch to running as if started as
root or run as a setuid executable (unix only).
* `-v`: Print version number and exit.
If you write a working systemd unit file for Molly Brown, please feel
free to share it with me and I'll get it into the repo.
Molly Brown does not handle details like daemonising itself, changing
the user it runs as, etc. You will need to take care of these tasks
by, e.g. integrating Molly Brown with your operating system's init
system. Some limited instructions on how to do this for common
systems follows.
Note that Golang programs are unable to reliably change their UID once
run (a source of constant frustration to me!). So don't start it as
root, or it'll remain as root forever. Run it as `nobody`, or a
dedicated `molly` user. Make sure that user has read access to the
TLS keys and write access to the specified log file.
#### Manual management
You can always use a tool like [daemon](`http://libslack.org/daemon/`)
to take care of daemonising the Molly Brown process, changing the user
it runs as, chrooting it to a particular location, etc. You can call
`daemon` from `/etc/rc.local` (if your OS still supports it) to start
it on system boot.
#### Systemd
An example systemd unit file for Molly Brown, named
`molly-brown.service.example`, can be found in the `contrib/init`
directory of the Molly Brown source directory. After copying this
file to `/etc/systemd/system/molly-brown.service`
or `/usr/lib/systemd/system/molly-brown.service` (consult your
system's documentation for the appropriate choice) and making any
necessary changes for your environment, you can run the follow
commands as root to start Molly Brown and make sure it starts
automatically on system boot.
```sh
# systemctl daemon-reload
# systemctl enable molly-brown.service
# systemctl start molly-brown.service
```
#### OpenRC
An example OpenRC initscript for Molly Brown, named
`molly-brown.openrc.example`, can be found in the `contrib/init`
directory of the Molly Brown source directory.
More detailed instructions on OpenRC setup are welcome!
#### OpenBSD
An example OpenBSD initscript for Molly Brown, named
`molly-brown.openbsd.example`, can be found in the `contrib/init`
directory of the Molly Brown source directory. After copying this
file to `/etc/rc.d/mollybrownd`, you can add the `mollybrownd`
daemon to your system startup with `rcctl` or by manually adding
`mollybrownd` to your `/etc/rc.conf.local` configuration. The
following lines in `rc.conf.local` will autostart your
`mollybrownd` daemon as the user `username`:
```
mollybrownd_user=username
pkg_scripts=mollybrownd
```
Be sure that the user running your `mollybrownd` daemon has
read access to `/etc/molly.conf` and all of the files and
directories listed in `/etc/molly.conf`. That user will
also need write access to the configured log file locations.
You can start your `mollybrownd` daemon with `rcctl`:
```
rcctl start mollybrownd
```
#### FreeBSD
An example FreeBSD rc script is in
`contrib/init/molly-brown.freebsd.example`.
Copy rc script to `/etc/rc.d/molly`, and add `molly_enable="YES"`
to `/etc/rc.conf` to enable the service.
Make sure the `daemon` user has access to config locations in
`molly.conf` like `CertPath`, `KeyPath`, `DocBase`, etc.
Start `molly` with,
```
service molly start
```
## Configuration Options
The following options can be set in `/etc/molly.conf`:
The following sections detail all the options which can be set in
`/etc/molly.conf` or any other configuration file specified with the
`-c` option.
The format of the configuration file is
[TOML](https://github.com/toml-lang/toml), which bares some similarity
to the "INI" format. Remember that you can check `example.conf` for
examples of the appropriate syntax.
### Basic options
* `Port`: The TCP port to listen for connections on (default value
`1965`).
* `Hostname`: The hostname to respond to requests for (default value
`localhost`). Requests for URLs with other hosts will result in a
status 53 (PROXY REQUEST REFUSED) response.
* `CertPath`: Path to TLS certificate in .pem format (default value
* `CertPath`: Path to TLS certificate in PEM format (default value
`cert.pem`).
* `KeyPath`: Path to TLS private key in .pem format (default value
* `KeyPath`: Path to TLS private key in PEM format (default value
`key.pem`).
* `DocBase`: Base directory for Gemini content (default value
`/var/gemini/`).
`/var/gemini/`). Only world-readable files stored in or below this
directory will be served by Molly Brown.
* `HomeDocBase`: Requests for paths beginning with `~/username/` will
be looked up relative to `DocBase/HomeDocBase/username/` (default
value `users`). Note that Molly Brown does *not* look inside user's
actual home directories like you may expect based on experience with
other server software. Of course, you can symlink
`/var/gemini/users/gus/` to `/home/gus/public_gemini/` if you want.
* `LogPath`: Path to log file (default value `molly.log`). Note that
all intermediate directories must exist, Molly Brown won't create
them for you.
* `AccessLog`: Path to access log file (default value `access.log`,
i.e. in the current wrorking directory). Note that all intermediate
directories must exist, Molly Brown won't create them for you. Set
to `-` for logging to `stdout`, or to an empty string to disable
access logging.
* `ErrorLog`: Path to error log file. If set to an empty string (the
default), Molly Brown will log errors to stderr (where they are
easily captured by systemd or similar init systems). If set to a
file, note that all intermediate directories must exist, Molly Brown
won't create them for you.
* `GeminiExt`: Files with this extension will be served with a MIME
type of `text/gemini` (default value `gmi`).
* `MimeOverrides`: In this section of the config file, keys are path
regexs and values are MIME types. If the path of a file which is
about to be served matches one the regexs, the corresponding MIME type
will be used instead of one inferred from the filename extension.
* `DefaultLang`: If this option is set, it will be served as the
`lang` parameter of the MIME type for all `text/gemini` content.
* `DefaultEncoding`: If this option is set, it will be served as the
`charset` parameter of the MIME type for all `text/gemini` content.
### Directory listings
Molly Brown will automatically generate directory listings for
world-readable directories under `DocBase` which do not contain an
`index.gmi` file. Only world-readable files and directories will be
listed. If a world-readable file named `.mollyhead` is found in a
directory, it's contents will be inserted above the directory listing
instead of the default "Directory listing" title.
The following options allow users to configure various aspects of the
directory listing:
* `DirectoryListing` (boolean): if true, enable directory listing; if false,
return 51 Not found (default value true)
* `DirectorySort`: A string specifying how to sort files in
automatically generated directory listings. Must be one of "Name",
"Size" or "Time" (default value "Name").
* `DirectorySubdirsFirst` (boolean): if true, list subdirectories of
the directory being listed before files. Subdirs and files will be
sorted within their respective categories according to
`DirectorySort` (default value false).
* `DirectoryReverse` (boolean): if true, automatically generated
directory listings will list files in descending order of whatever
`DirectorySort` is set to (default value false).
* `DirectoryTitles` (boolean): if true, automatically generated
directory listings will use the first top-level heading (i.e. line
beginning with "# ") in files with an extension of `GeminiExt`
instead of the filename (default value false).
### Redirects
* `TempRedirects`: In this section of the config file, keys are
regular expressions which the server will attempt to match against
the path component if incoming request URLs. If a match is found,
Molly Brown will serve a redirect to a new URL derived by replacing
the path component with the value corresponding to the matched key.
Within the replacement values, $1, $2, etc. will be replaced by the
first, second, etc. submatch in the regular expression. Named
captures can also be used for more sophisticated redirect logic -
see the documentation for the Go standard library's `regexp` package
for full details.
* `PermRedirects`: As per `TempRedirects` above, but Molly Brown will
use the 31 status code instead of 30.
### Dynamic content
Molly Brown supports dynamically generated content using an adaptation
of the CGI standard, and also the SCGI standard.
The `stdout` of CGI processes will be sent verbatim as the response to
the client, and CGI applications are responsible for generating their
own response headers. CGI processes must terminate naturally within
10 seconds of being spawned to avoid being killed. Details about the
request are available to CGI applications through environment
variables, generally following RFC 3875. In particular, note that if
a request URL includes components after the path to an executable
(e.g. `cgi-bin/script.py/foo/bar/baz`) then the environment variable
`SCRIPT_PATH` will contain the part of the URL path mapping to the
executable (e.g. `/var/gemini/cgi-bin/scripty.py`) while the variable
`PATH_INFO` will contain the remainder (e.g. `foo/bar/baz`).
Molly Brown itself tries very hard to avoid being tricked into serving
content that isn't supposed to be served, but it is completely unable
to impose any control over what CGI processes can or can't go after
they are started! Where possible, Molly Brown will use the operating
system's security features to reduce risk, but it is your
responsibility to understand what it can and cannot do and weigh the
risks accordingly:
When compiled on GNU/Linux with Go version 1.16 or later, or on any
other unix operating system with any version of Go, Molly Brown will
use the setuid() system call as follows. When the compiled
`molly-brown` executable has its SETUID bit set, so that it starts
with the privileges of the user who owns the binary, it will change
the effective UID back to the real UID before it begins accepting
network connections. This way, config files, log files and TLS keys
can be set readable by the user who owns the binary, but not readable
by the user who runs the binary. CGI processes will then be unable to
read any of those sensitive files. If the binary is not SETUID but is
run by the superuser/root, then Molly will change its UID to that of
the `nobody` user (or any other user specified with the `-u` option)
before accepting network connections, so CGI processes will again not
be able to read sensitive files. Note that while these measures can
protect Molly's own sensitive files from CGI processes, CGI processes
may still be able to read other sensitive files anywhere else on the
system. Consider chroot()-ing Molly Brown into a small corner of the
filesystem (see discussion of the `-C` option at the start of the
Running section) to reduce this risk.
When compiled on GNU/Linux with Go versions 1.15 or earlier, Molly
Brown is completley unable to reliably change its UID due to the way
early implementations of goroutines interacted with the setuid()
system call. In this situation, Molly Brown will refuse to run as
superuser/root. It will run as any other user, but CGI processes will
necessary run as the same user as the server and so unavoidably will
have access to sensitive files. You should proceed with extreme
caution and only use carefully vetted CGI programs. Consider using
systemd's ability to chroot a non-privileged process at the moment of
startup to at least confine the risk to Molly Brown's sensitive files
and not the entire system's.
Molly Brown will compile on non-unix operating systems and is known to
run on Plan9, for example, but no special security measures are taken
on these non-unix platforms. It is your responsibility to understand
the risks. If you are aware of security measures for these systems
which can be implemented in Go, patches are extremely welcome.
SCGI applications must be started separately (i.e. Molly Brown expects
them to already be running and will not attempt to start them itself),
and as such they can run e.g. as their own user and/or chrooted into
their own filesystem, meaning that they are less of a security threat
than CGI applications (in addition to avoiding the overhead of process
startup, database connection etc. on each request).
* `CGIPaths`: A list of filesystem paths, within which
world-executable files will be run as CGI processes. The paths act
as prefixes, i.e. if `/var/gemini/cgi-bin` is listed then
`/var/gemini/cgi-bin/script.py` and
`/var/gemini/cgi-bin/subdir/subsubdir/script.py` will both be run.
The paths may include basic wildcard characters, where `?` matches a
single non-separator character and `*` matches a sequence of them -
if wildcards are used, the path should *not* end in a trailing slash
- this appears to be a peculiarity of the Go standard library's
`filepath.Glob` function. Any non-absolute paths will be resolved
relative to `DocBase`.
* `SCGIPaths`: In this section of the config file, keys are URL path
prefixes and values are filesystem paths to unix domain sockets.
Any request for a URL whose path begins with one of the specified
prefixes will cause an SCGI request to be sent to the corresponding
domain socket. Anything sent back from a program listening on the
other end of the socket will be sent as the response to the client.
SCGI applications are responsible for generating their own response
headers.
### TLS options
* `AllowTLS12` (boolean): if true, Molly Brown will accept connections
from clients using TLS version 1.2 or later (1.2 is the bare minimum
allowed by the Gemini spec). If set to false, Molly Brown will
instead require TLS version 1.3 or later - 1.2 to 1.3 was a big
change and drastic simplification of the TLS spec which discarded a
wide range of old and insecure configurations. (default value `true`)
#### Certificate zones
Molly Brown allows you to use client certificates to restrict access
to certain resources (which may be static or dynamic). The overall
workflow is highly reminiscent of OpenSSH's `authorized_keys`
facility.
* `CertificateZones`: In this section of the config file, keys are
path regexs and values are lists of hex-encoded SHA256 fingerprints
of client certificates. Any requests whose path matches one of the
regexs will only be served as normal if the request is made with a
client certificate whose fingerprint is in the corresponding list.
Requests made without a certificate will cause a response with a
status code of 60. Requests made with a certificate not in the list
will cause a response with a status code of 60.
## .molly files
In order to allow users of shared-hosting who do not have access to
the main Molly Brown configuration file to customise some aspects of
their Gemini site, Molly Brown features functionality much like
Apache's `.htaccess` files. If the main configuration file contains
the line `ReadMollyFiles = true`, then each directory in the path to a
resource will be checked for a file named `.molly`. These files
should be in exactly the same format as the main configuration file,
an their contents will override (some) settings from the main file.
Each `.molly` file will override settings specified in `.molly` files
from higher directories.
E.g. when handling a request which maps to
`/var/gemini/foo/bar/baz/file.gmi`, then:
* The settings in the file `/var/gemini/.molly`, if it exists, will
override those in `/etc/molly.conf`.
* The settings in the file `/var/gemini/foo/.molly`, if it exists,
will override those in `/var/gemini/.molly`.
* The settings in the file `/var/gemini/foo/bar/.molly`, if it exists,
will override those in `/var/gemini/foo/.molly`.
* The settings in the file `/var/gemini/foo/bar/baz/.molly`, if it
exists, will override those in `/var/gemini/foo/bar/.molly`.
Only the following settings can be overriden by `.molly` files. Any
other settings in `.molly` files will be ignored:
* `CertificateZones`
* `DefaultLang`
* `DefaultEncoding`
* `DirectorySort`
* `DirectorySubdirsFirst`
* `DirectoryReverse`
* `DirectoryTitles`
* `GeminiExt`
* `MimeOverrides`
* `PermRedirects`
* `TempRedirects`
## Trivia
Margaret Brown was an American philanthropist and socialite who
survived the sinking of the RMS Titanic, leading to a Broadway musical
and later a film about her life being titled "The Unsinkable Molly
Brown". The "unsinkable" moniker inspired NASA astronaut Gus Grissom
to name the Gemini 3 capsule he commanded "Molly Brown" - Grissom had
almost drowned a few years earlier when his Mercury 4 capsule "Liberty
Bell" sank after splashdown.

63
certificate.go Normal file
View File

@ -0,0 +1,63 @@
package main
import (
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"net"
"net/url"
"regexp"
"time"
)
func enforceCertificateValidity(clientCerts []*x509.Certificate, conn net.Conn, logEntry *LogEntry) {
// This will fail if any of multiple certs are invalid
// Maybe we should just require one valid?
now := time.Now()
for _, cert := range clientCerts {
if now.Before(cert.NotBefore) {
conn.Write([]byte("64 Client certificate not yet valid!\r\n"))
logEntry.Status = 64
return
} else if now.After(cert.NotAfter) {
conn.Write([]byte("65 Client certificate has expired!\r\n"))
logEntry.Status = 65
return
}
}
}
func handleCertificateZones(URL *url.URL, clientCerts []*x509.Certificate, config UserConfig, conn net.Conn, logEntry *LogEntry) {
authorised := true
for zone, allowedFingerprints := range config.CertificateZones {
matched, err := regexp.Match(zone, []byte(URL.Path))
if !matched || err != nil {
continue
}
authorised = false
for _, clientCert := range clientCerts {
for _, allowedFingerprint := range allowedFingerprints {
if getCertFingerprint(clientCert) == allowedFingerprint {
authorised = true
break
}
}
}
}
if !authorised {
if len(clientCerts) > 0 {
conn.Write([]byte("61 Provided certificate not authorised for this resource\r\n"))
logEntry.Status = 61
} else {
conn.Write([]byte("60 A pre-authorised certificate is required to access this resource\r\n"))
logEntry.Status = 60
}
return
}
}
func getCertFingerprint(cert *x509.Certificate) string {
hash := sha256.Sum256(cert.Raw)
fingerprint := hex.EncodeToString(hash[:])
return fingerprint
}

240
config.go
View File

@ -1,43 +1,243 @@
package main
import (
"errors"
"github.com/BurntSushi/toml"
"log"
"os"
"path/filepath"
"strings"
)
type Config struct {
Port int
Hostname string
CertPath string
KeyPath string
DocBase string
HomeDocBase string
LogPath string
CGIPath string
type SysConfig struct {
Port int
Hostname string
CertPath string
KeyPath string
AccessLog string
ErrorLog string
DocBase string
HomeDocBase string
CGIPaths []string
SCGIPaths map[string]string
ReadMollyFiles bool
AllowTLS12 bool
RateLimitEnable bool
RateLimitAverage int
RateLimitSoft int
RateLimitHard int
}
func getConfig(filename string) (Config, error) {
type UserConfig struct {
GeminiExt string
DefaultLang string
DefaultEncoding string
TempRedirects map[string]string
PermRedirects map[string]string
MimeOverrides map[string]string
CertificateZones map[string][]string
DirectoryListing bool
DirectorySort string
DirectorySubdirsFirst bool
DirectoryReverse bool
DirectoryTitles bool
}
var config Config
func getConfig(filename string) (SysConfig, UserConfig, error) {
var sysConfig SysConfig
var userConfig UserConfig
// Defaults
config.Port = 1965
config.Hostname = "localhost"
config.CertPath = "cert.pem"
config.KeyPath = "key.pem"
config.DocBase = "/var/gemini/"
config.HomeDocBase = "users"
config.LogPath = "molly.log"
config.CGIPath = "^/var/gemini/cgi-bin/"
sysConfig.Port = 1965
sysConfig.Hostname = "localhost"
sysConfig.CertPath = "cert.pem"
sysConfig.KeyPath = "key.pem"
sysConfig.AccessLog = "access.log"
sysConfig.ErrorLog = ""
sysConfig.DocBase = "/var/gemini/"
sysConfig.HomeDocBase = "users"
sysConfig.CGIPaths = make([]string, 0)
sysConfig.SCGIPaths = make(map[string]string)
sysConfig.ReadMollyFiles = false
sysConfig.AllowTLS12 = true
sysConfig.RateLimitEnable = false
sysConfig.RateLimitAverage = 1
sysConfig.RateLimitSoft = 10
sysConfig.RateLimitHard = 50
userConfig.GeminiExt = "gmi"
userConfig.DefaultLang = ""
userConfig.DefaultEncoding = ""
userConfig.TempRedirects = make(map[string]string)
userConfig.PermRedirects = make(map[string]string)
userConfig.DirectoryListing = true
userConfig.DirectorySort = "Name"
userConfig.DirectorySubdirsFirst = false
// Return defaults if no filename given
if filename == "" {
return config, nil
return sysConfig, userConfig, nil
}
// Attempt to overwrite defaults from file
sysConfig, err := readSysConfig(filename, sysConfig)
if err != nil {
return sysConfig, userConfig, err
}
userConfig, err = readUserConfig(filename, userConfig, true)
if err != nil {
return sysConfig, userConfig, err
}
return sysConfig, userConfig, nil
}
func readSysConfig(filename string, config SysConfig) (SysConfig, error) {
_, err := toml.DecodeFile(filename, &config)
if err != nil {
return config, err
}
// Force hostname to lowercase
config.Hostname = strings.ToLower(config.Hostname)
// Absolutise paths
config.DocBase, err = filepath.Abs(config.DocBase)
if err != nil {
return config, err
}
config.CertPath, err = filepath.Abs(config.CertPath)
if err != nil {
return config, err
}
config.KeyPath, err = filepath.Abs(config.KeyPath)
if err != nil {
return config, err
}
if config.AccessLog != "" && config.AccessLog != "-" {
config.AccessLog, err = filepath.Abs(config.AccessLog)
if err != nil {
return config, err
}
}
if config.ErrorLog != "" {
config.ErrorLog, err = filepath.Abs(config.ErrorLog)
if err != nil {
return config, err
}
}
// Absolutise CGI paths
for index, cgiPath := range config.CGIPaths {
if !filepath.IsAbs(cgiPath) {
config.CGIPaths[index] = filepath.Join(config.DocBase, cgiPath)
}
}
// Expand CGI paths
var cgiPaths []string
for _, cgiPath := range config.CGIPaths {
expandedPaths, err := filepath.Glob(cgiPath)
if err != nil {
return config, errors.New("Error expanding CGI path glob " + cgiPath + ": " + err.Error())
}
cgiPaths = append(cgiPaths, expandedPaths...)
}
config.CGIPaths = cgiPaths
// Absolutise SCGI paths
for index, scgiPath := range config.SCGIPaths {
config.SCGIPaths[index], err = filepath.Abs( scgiPath)
if err != nil {
return config, err
}
}
return config, nil
}
func readUserConfig(filename string, config UserConfig, requireValid bool) (UserConfig, error) {
_, err := toml.DecodeFile(filename, &config)
if err != nil {
return config, err
}
// Validate pseudo-enums
if requireValid {
switch config.DirectorySort {
case "Name", "Size", "Time":
default:
return config, errors.New("Invalid DirectorySort value.")
}
}
// Validate redirects
for key, value := range config.TempRedirects {
if strings.Contains(value, "://") && !strings.HasPrefix(value, "gemini://") {
if requireValid {
return config, errors.New("Invalid cross-protocol redirect to " + value)
} else {
log.Println("Ignoring cross-protocol redirect to " + value + " in .molly file " + filename)
delete(config.TempRedirects, key)
}
}
}
for key, value := range config.PermRedirects {
if strings.Contains(value, "://") && !strings.HasPrefix(value, "gemini://") {
if requireValid {
return config, errors.New("Invalid cross-protocol redirect to " + value)
} else {
log.Println("Ignoring cross-protocol redirect to " + value + " in .molly file " + filename)
delete(config.PermRedirects, key)
}
}
}
return config, nil
}
func parseMollyFiles(path string, docBase string, config UserConfig) UserConfig {
// Replace config variables which use pointers with new ones,
// so that changes made here aren't reflected everywhere.
config.TempRedirects = make(map[string]string)
config.PermRedirects = make(map[string]string)
config.MimeOverrides = make(map[string]string)
config.CertificateZones = make(map[string][]string)
// Build list of directories to check
var dirs []string
dirs = append(dirs, path)
for {
if path == filepath.Clean(docBase) {
break
}
subpath := filepath.Dir(path)
dirs = append(dirs, subpath)
path = subpath
}
// Parse files in reverse order
for i := len(dirs) - 1; i >= 0; i-- {
dir := dirs[i]
// Break out of the loop if a directory doesn't exist
_, err := os.Stat(dir)
if os.IsNotExist(err) {
break
}
// Construct path for a .molly file in this dir
mollyPath := filepath.Join(dir, ".molly")
_, err = os.Stat(mollyPath)
if err != nil {
continue
}
// If the file exists and we can read it, try to parse it
config, err = readUserConfig(mollyPath, config, false)
if err != nil {
log.Println("Error parsing .molly file " + mollyPath + ": " + err.Error())
continue
}
}
return config
}

View File

@ -0,0 +1,47 @@
#!/bin/sh
#
# $FreeBSD$
#
# PROVIDE: molly
# REQUIRE: networking
# KEYWORD: shutdown
. /etc/rc.subr
name="molly"
desc="Gemini Protocol daemon"
rcvar="molly_enable"
command="/usr/local/sbin/molly-brown"
command_args="-c /etc/molly.conf"
molly_brown_user="daemon"
pidfile="/var/run/${name}.pid"
required_files="/etc/molly.conf"
start_cmd="molly_start"
stop_cmd="molly_stop"
status_cmd="molly_status"
molly_start() {
/usr/sbin/daemon -P ${pidfile} -r -f -u $molly_brown_user $command
}
molly_stop() {
if [ -e "${pidfile}" ]; then
kill -s TERM `cat ${pidfile}`
else
echo "${name} is not running"
fi
}
molly_status() {
if [ -e "${pidfile}" ]; then
echo "${name} is running as pid `cat ${pidfile}`"
else
echo "${name} is not running"
fi
}
load_rc_config $name
run_rc_command "$1"

View File

@ -0,0 +1,13 @@
#!/bin/ksh
daemon='/usr/sbin/molly-brown -c /etc/molly.conf &'
. /etc/rc.d/rc.subr
rc_reload=NO
rc_stop() {
pkill molly-brown
}
rc_cmd $1

View File

@ -0,0 +1,12 @@
#!/sbin/openrc-run
command="/usr/sbin/molly-brown"
command_user="gemini"
command_background="yes"
pidfile="/run/$RC_SVCNAME/$RC_SVCNAME.pid"
start_pre() {
checkpath --directory --owner $command_user:$command_user --mode 0775 \
/run/$RC_SVCNAME /var/log/molly
}

View File

@ -0,0 +1,12 @@
[Unit]
Description=Molly Brown gemini server
After=network.target
[Service]
Type=simple
Restart=always
User=molly
ExecStart=/usr/local/bin/molly-brown -c /etc/molly.conf
[Install]
WantedBy=multi-user.target

132
dirlist.go Normal file
View File

@ -0,0 +1,132 @@
package main
import (
"bufio"
"fmt"
"io/ioutil"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
)
func generateDirectoryListing(URL *url.URL, path string, config UserConfig) (string, error) {
var listing string
files, err := ioutil.ReadDir(path)
if err != nil {
return listing, err
}
listing = "# Directory listing\n\n"
// Override with .mollyhead file
header_path := filepath.Join(path, ".mollyhead")
_, err = os.Stat(header_path)
if err == nil {
header, err := ioutil.ReadFile(header_path)
if err != nil {
return listing, err
}
listing = string(header)
}
// Do "up" link first
if URL.Path != "/" {
if strings.HasSuffix(URL.Path, "/") {
URL.Path = URL.Path[:len(URL.Path)-1]
}
up := filepath.Dir(URL.Path)
listing += fmt.Sprintf("=> %s %s\n", up, "..")
}
// Sort files by criteria first
sort.SliceStable(files, func(i, j int) bool {
if config.DirectoryReverse {
i, j = j, i
}
if config.DirectorySort == "Name" {
return files[i].Name() < files[j].Name()
} else if config.DirectorySort == "Size" {
return files[i].Size() < files[j].Size()
} else if config.DirectorySort == "Time" {
return files[i].ModTime().Before(files[j].ModTime())
}
return false // Should not happen
})
// Sort directories before file
if config.DirectorySubdirsFirst {
sort.SliceStable(files, func(i, j int) bool {
// If i is a dir and j is a file, i < j
if files[i].IsDir() && !files[j].IsDir() {
return true
} else {
return false
}
})
}
// Format lines
for _, file := range files {
// Skip dotfiles
if strings.HasPrefix(file.Name(), ".") {
continue
}
// Only list world readable files
if uint64(file.Mode().Perm())&0444 != 0444 {
continue
}
// Make sure links to directories have a trailing slash,
// to avoid needless redirects
relativeUrl := url.PathEscape(file.Name())
if file.IsDir() {
relativeUrl += "/"
}
listing += fmt.Sprintf("=> %s %s\n", relativeUrl, generatePrettyFileLabel(file, path, config))
}
return listing, nil
}
func generatePrettyFileLabel(info os.FileInfo, path string, config UserConfig) string {
var size string
if info.IsDir() {
size = " "
} else if info.Size() < 1024 {
size = fmt.Sprintf("%4d B", info.Size())
} else if info.Size() < (1024 << 10) {
size = fmt.Sprintf("%4d KiB", info.Size()>>10)
} else if info.Size() < 1024<<20 {
size = fmt.Sprintf("%4d MiB", info.Size()>>20)
} else if info.Size() < 1024<<30 {
size = fmt.Sprintf("%4d GiB", info.Size()>>30)
} else if info.Size() < 1024<<40 {
size = fmt.Sprintf("%4d TiB", info.Size()>>40)
} else {
size = "GIGANTIC"
}
name := info.Name()
if config.DirectoryTitles && filepath.Ext(name) == "."+config.GeminiExt {
name = readHeading(path, info)
}
if len(name) > 40 {
name = name[:36] + "..."
}
if info.IsDir() {
name += "/"
}
return fmt.Sprintf("%-40s %s %v", name, size, info.ModTime().Format("Jan _2 2006"))
}
func readHeading(path string, info os.FileInfo) string {
filePath := filepath.Join(path, info.Name())
file, err := os.Open(filePath)
if err != nil {
return info.Name()
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "# ") {
return strings.TrimSpace(line[1:])
}
}
return info.Name()
}

206
dynamic.go Normal file
View File

@ -0,0 +1,206 @@
package main
import (
"bufio"
"context"
"crypto/tls"
"io"
"log"
"net"
"net/url"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
func handleCGI(config SysConfig, path string, cgiPath string, URL *url.URL, logEntry *LogEntry, conn net.Conn) {
// Find the shortest leading part of path which maps to an executable file.
// Call this part scriptPath, and everything after it pathInfo.
components := strings.Split(path, "/")
scriptPath := ""
pathInfo := ""
matched := false
for i := 0; i <= len(components); i++ {
scriptPath = strings.Join(components[0:i], "/")
pathInfo = strings.Join(components[i:], "/")
if !strings.HasPrefix(scriptPath, cgiPath) {
continue
}
info, err := os.Stat(scriptPath)
if err != nil {
break
} else if info.IsDir() {
continue
} else if info.Mode().Perm()&0555 == 0555 {
matched = true
break
}
}
// If we didn't find a match, give up and let this request be handled as
// if it were a static file
if !matched {
return
}
// Prepare environment variables
vars := prepareCGIVariables(config, URL, conn, scriptPath, pathInfo)
// Spawn process
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, scriptPath)
cmd.Env = []string{}
for key, value := range vars {
cmd.Env = append(cmd.Env, key+"="+value)
}
response, err := cmd.Output()
if ctx.Err() == context.DeadlineExceeded {
log.Println("Terminating CGI process " + path + " due to exceeding 10 second runtime limit.")
conn.Write([]byte("42 CGI process timed out!\r\n"))
logEntry.Status = 42
return
}
if err != nil {
log.Println("Error running CGI program " + path + ": " + err.Error())
if err, ok := err.(*exec.ExitError); ok {
log.Println("↳ stderr output: " + string(err.Stderr))
}
conn.Write([]byte("42 CGI error!\r\n"))
logEntry.Status = 42
return
}
// Extract response header
responseString := string(response)
if len(responseString) == 0 {
log.Println("Received no response from CGI process " + path)
conn.Write([]byte("42 CGI error!\r\n"))
logEntry.Status = 42
return
}
header, _, _ := bufio.NewReader(strings.NewReader(string(response))).ReadLine()
status, err := strconv.Atoi(strings.Fields(string(header))[0])
if err != nil {
log.Println("Unable to parse first line of output from CGI process " + path + " as valid Gemini response header. Line was: " + string(header))
conn.Write([]byte("42 CGI error!\r\n"))
logEntry.Status = 42
return
}
logEntry.Status = status
// Write response
conn.Write(response)
}
func handleSCGI(URL *url.URL, scgiPath string, scgiSocket string, config SysConfig, logEntry *LogEntry, conn net.Conn) {
// Connect to socket
socket, err := net.Dial("unix", scgiSocket)
if err != nil {
log.Println("Error connecting to SCGI socket " + scgiSocket + ": " + err.Error())
conn.Write([]byte("42 Error connecting to SCGI service!\r\n"))
logEntry.Status = 42
return
}
defer socket.Close()
// Send variables
vars := prepareSCGIVariables(config, URL, scgiPath, conn)
length := 0
for key, value := range vars {
length += len(key)
length += len(value)
length += 2
}
socket.Write([]byte(strconv.Itoa(length) + ":"))
for key, value := range vars {
socket.Write([]byte(key + "\x00"))
socket.Write([]byte(value + "\x00"))
}
socket.Write([]byte(","))
// Read and relay response
buffer := make([]byte, 1027)
first := true
for {
n, err := socket.Read(buffer)
if err != nil {
if err == io.EOF {
break
} else if !first {
// Err
log.Println("Error reading from SCGI socket " + scgiSocket + ": " + err.Error())
conn.Write([]byte("42 Error reading from SCGI service!\r\n"))
logEntry.Status = 42
return
} else {
break
}
}
// Extract status code from first line
if first {
first = false
lines := strings.SplitN(string(buffer), "\r\n", 2)
status, err := strconv.Atoi(strings.Fields(lines[0])[0])
if err != nil {
conn.Write([]byte("42 CGI error!\r\n"))
logEntry.Status = 42
return
}
logEntry.Status = status
}
// Send to client
conn.Write(buffer[:n])
}
}
func prepareCGIVariables(config SysConfig, URL *url.URL, conn net.Conn, script_path string, path_info string) map[string]string {
vars := prepareGatewayVariables(config, URL, conn)
vars["GATEWAY_INTERFACE"] = "CGI/1.1"
vars["SCRIPT_PATH"] = script_path
vars["PATH_INFO"] = path_info
return vars
}
func prepareSCGIVariables(config SysConfig, URL *url.URL, scgiPath string, conn net.Conn) map[string]string {
vars := prepareGatewayVariables(config, URL, conn)
vars["SCGI"] = "1"
vars["CONTENT_LENGTH"] = "0"
vars["SCRIPT_PATH"] = scgiPath
vars["PATH_INFO"] = URL.Path[len(scgiPath):]
return vars
}
func prepareGatewayVariables(config SysConfig, URL *url.URL, conn net.Conn) map[string]string {
vars := make(map[string]string)
vars["QUERY_STRING"] = URL.RawQuery
vars["REQUEST_METHOD"] = ""
vars["SERVER_NAME"] = config.Hostname
vars["SERVER_PORT"] = strconv.Itoa(config.Port)
vars["SERVER_PROTOCOL"] = "GEMINI"
vars["SERVER_SOFTWARE"] = "MOLLY_BROWN"
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
vars["REMOTE_ADDR"] = host
// Add TLS variables
var tlsConn (*tls.Conn) = conn.(*tls.Conn)
connState := tlsConn.ConnectionState()
// vars["TLS_CIPHER"] = CipherSuiteName(connState.CipherSuite)
// Add client cert variables
clientCerts := connState.PeerCertificates
if len(clientCerts) > 0 {
cert := clientCerts[0]
vars["TLS_CLIENT_HASH"] = getCertFingerprint(cert)
vars["TLS_CLIENT_ISSUER"] = cert.Issuer.String()
vars["TLS_CLIENT_ISSUER_CN"] = cert.Issuer.CommonName
vars["TLS_CLIENT_SUBJECT"] = cert.Subject.String()
vars["TLS_CLIENT_SUBJECT_CN"] = cert.Subject.CommonName
// To make it easier to detect when a cert is present
vars["AUTH_TYPE"] = "Certificate"
}
return vars
}

View File

@ -1,8 +1,56 @@
## Basic settings
#
#Port = 1965
#Hostname = "localhost"
#CertPath = "cert.pem"
#KeyPath = "key.pem"
#DocBase = "/var/gemini/"
#HomeDocBase = "users"
#LogPath = "molly.log"
#CGIPath = "^/var/gemini/cgi-bin/"
#GeminiExt = "gmi"
#DefaultLang = "fi"
#AccessLog = "/var/log/molly/access.log"
#ErrorLog = "/var/log/molly/error.log"
#ReadMollyFiles = true
#
## Directory listing
#
#DirectoryListing = true
#DirectorySort = "Time"
#DirectorySubdirsFirst = false
#DirectoryReverse = true
#DirectoryTitles = true
#
## Dynamic content
#
#CGIPaths = [
# "/var/gemini/cgi-bin",
# "/var/gemini/users/*/cgi-bin/", # Unsafe!
#]
#
#[SCGIPaths]
#"/scgi-app-1/" = "/var/run/scgi1.sock"
#"/scgi-app-2/" = "/var/run/scgi2.sock"
#
## MIME type overrides
#
#[MimeOverrides]
#"atom.xml$" = "application/atom+xml"
#"rss.xml$" = "application/rss+xml"
#
## Redirects
#
#[TempRedirects]
#"/old/path/file.ext" = "/new/path/file.ext"
#[PermRedirects]
#"/old/path/file.ext" = "/new/path/file.ext"
#
## Certificate zones
#
#[CertificateZones]
#"^/secure-zone-1/" = [
# "d146953386694266175d10be3617427dfbeb751d1805d36b3c7aedd9de02d9af",
#]
#"^/secure-zone-2/" = [
# "d146953386694266175d10be3617427dfbeb751d1805d36b3c7aedd9de02d9af",
# "786257797c871bf617e0b60acf7a7dfaf195289d8b08d1df5ed0e316092f0c8d",
#]

8
go.mod Normal file
View File

@ -0,0 +1,8 @@
module tildegit.org/solderpunk/molly-brown
go 1.15
require (
github.com/BurntSushi/toml v1.2.1 // indirect
golang.org/x/sys v0.5.0 // indirect
)

4
go.sum Normal file
View File

@ -0,0 +1,4 @@
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@ -1,221 +1,430 @@
package main
import (
"bufio"
"context"
"fmt"
"io"
"io/ioutil"
"log"
"mime"
"net"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"bufio"
"crypto/tls"
"errors"
"fmt"
"io"
"log"
"mime"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
func handleGeminiRequest(conn net.Conn, config Config, logEntries chan LogEntry) {
// Utility function below borrowed from
// https://stackoverflow.com/questions/28024731/check-if-given-path-is-a-subdirectory-of-another-in-golang
func isSubdir(subdir, superdir string) (bool, error) {
up := ".." + string(os.PathSeparator)
// path-comparisons using filepath.Abs don't work reliably according to docs (no unique representation).
rel, err := filepath.Rel(superdir, subdir)
if err != nil {
return false, err
}
if !strings.HasPrefix(rel, up) && rel != ".." {
return true, nil
}
return false, nil
}
func handleGeminiRequest(conn net.Conn, sysConfig SysConfig, config UserConfig, accessLogEntries chan LogEntry, rl *RateLimiter, wg *sync.WaitGroup) {
defer conn.Close()
var log LogEntry
log.Time = time.Now()
log.RemoteAddr = conn.RemoteAddr()
log.RequestURL = "-"
log.Status = 0
defer func() { logEntries <- log }()
defer wg.Done()
var tlsConn (*tls.Conn) = conn.(*tls.Conn)
var logEntry LogEntry
logEntry.Time = time.Now()
logEntry.RemoteAddr = conn.RemoteAddr()
logEntry.RequestURL = "-"
logEntry.Status = 0
if accessLogEntries != nil {
defer func() { accessLogEntries <- logEntry }()
}
// Enforce rate limiting
if sysConfig.RateLimitEnable {
noPort := logEntry.RemoteAddr.String()
noPort = noPort[0:strings.LastIndex(noPort, ":")]
limited := rl.hardLimited(noPort)
if limited {
conn.Close()
return
}
delay, limited := rl.softLimited(noPort)
if limited {
conn.Write([]byte("44 " + strconv.Itoa(delay) + " second cool down, please!\r\n"))
logEntry.Status = 44
return
}
}
// Read request
reader := bufio.NewReaderSize(conn, 1024)
request, overflow, err := reader.ReadLine()
if overflow {
conn.Write([]byte("59 Request too long!r\n"))
log.Status = 59
return
} else if err != nil {
conn.Write([]byte("40 Unknown error reading request!r\n"))
log.Status = 40
return
}
// Parse request as URL
URL, err := url.Parse(string(request))
URL, err := readRequest(conn, &logEntry)
if err != nil {
conn.Write([]byte("59 Error parsing URL!r\n"))
log.Status = 59
return
}
log.RequestURL = URL.String()
// Set implicit scheme
if URL.Scheme == "" {
URL.Scheme = "gemini"
// Enforce client certificate validity
clientCerts := tlsConn.ConnectionState().PeerCertificates
enforceCertificateValidity(clientCerts, conn, &logEntry)
if logEntry.Status != 0 {
return
}
// Reject non-gemini schemes
if URL.Scheme != "gemini" {
conn.Write([]byte("53 No proxying to non-Gemini content!\r\n"))
log.Status = 53
logEntry.Status = 53
return
}
// Reject requests for content from other servers
requestHostname := strings.Split(URL.Host, ":")[0] // Shave off port
if requestHostname != config.Hostname {
conn.Write([]byte("53 No proxying to other hosts!\r\n"))
log.Status = 53
requestedHost := strings.ToLower(URL.Hostname())
// Trim trailing . from FQDNs
if strings.HasSuffix(requestedHost, ".") {
requestedHost = requestedHost[:len(requestedHost)-1]
}
if requestedHost != sysConfig.Hostname || (URL.Port() != "" && URL.Port() != strconv.Itoa(sysConfig.Port)) {
conn.Write([]byte("53 No proxying to other hosts or ports!\r\n"))
logEntry.Status = 53
return
}
// Fail if there are dots in the path
if strings.Contains(URL.Path, "..") {
conn.Write([]byte("50 Your directory traversal technique has been defeated!\r\n"))
log.Status = 50
logEntry.Status = 50
return
}
// Check whether this URL is in a certificate zone
handleCertificateZones(URL, clientCerts, config, conn, &logEntry)
if logEntry.Status != 0 {
return
}
// Check for redirects
handleRedirects(URL, config, conn, &logEntry)
if logEntry.Status != 0 {
return
}
// Resolve URI path to actual filesystem path
path := URL.Path
path := resolvePath(URL.Path, sysConfig)
// Read Molly files. Yes, even before checking if `path` exists!
// /foo/bar/baz.gmi may not exist on the disk but /foo/.molly may and it
// may inform us that /foo/bar/baz.gmi ought to redirect to somewhere which
// *does* exist on disk!
if sysConfig.ReadMollyFiles {
config = parseMollyFiles(path, sysConfig.DocBase, config)
// We may have picked up new cert zones and/or redirects above, so:
handleCertificateZones(URL, clientCerts, config, conn, &logEntry)
if logEntry.Status != 0 {
return
}
handleRedirects(URL, config, conn, &logEntry)
if logEntry.Status != 0 {
return
}
}
// Check whether this URL is in a configured CGI path
for _, cgiPath := range sysConfig.CGIPaths {
if strings.HasPrefix(path, cgiPath) {
handleCGI(sysConfig, path, cgiPath, URL, &logEntry, conn)
if logEntry.Status != 0 {
return
}
}
}
// Check whether this URL is mapped to an SCGI app
for scgiPath, scgiSocket := range sysConfig.SCGIPaths {
if strings.HasPrefix(URL.Path, scgiPath) {
handleSCGI(URL, scgiPath, scgiSocket, sysConfig, &logEntry, conn)
return
}
}
// Okay, at this point we really are committed to looking on disk for `path`.
// Make sure it exists, and is world readable, and if it's a symbolic link,
// follow it and check these things again!
rawPath := path
var info os.FileInfo
for {
info, err = os.Stat(path)
if os.IsNotExist(err) || os.IsPermission(err) {
conn.Write([]byte("51 Not found!\r\n"))
logEntry.Status = 51
return
} else if err != nil {
log.Println("Error getting info for file " + path + ": " + err.Error())
conn.Write([]byte("40 Temporary failure!\r\n"))
logEntry.Status = 40
return
} else if uint64(info.Mode().Perm())&0444 != 0444 {
conn.Write([]byte("51 Not found!\r\n"))
logEntry.Status = 51
return
}
newPath, err := filepath.EvalSymlinks(path)
if err!= nil {
log.Println("Error evaluating path " + path + " for symlinks: " + err.Error())
conn.Write([]byte("51 Not found!\r\n"))
logEntry.Status = 51
return
}
if newPath == path {
break
}
path = newPath
}
// If symbolic links have been used to escape the intended document directory,
// deny all knowledge
isSub, err := isSubdir(path, sysConfig.DocBase)
if err != nil {
log.Println("Error testing whether path " + path + " is below DocBase: " + err.Error())
}
if !isSub {
log.Println("Refusing to follow symlink from " + rawPath + " outside of DocBase!")
}
if err != nil || !isSub {
conn.Write([]byte("51 Not found!\r\n"))
logEntry.Status = 51
return
}
// Refuse to serve sensitive files even if they are inside DocBase and
// world-readable because if they are it's likely a mistake
if path == sysConfig.KeyPath || path == sysConfig.AccessLog || path == sysConfig.ErrorLog || filepath.Base(path) == ".molly" {
conn.Write([]byte("51 Not found!\r\n"))
logEntry.Status = 51
return
}
// Finally, serve a simple static file or directory
if info.IsDir() {
serveDirectory(URL, path, &logEntry, conn, config)
} else {
serveFile(path, info, &logEntry, conn, config)
}
}
func readRequest(conn net.Conn, logEntry *LogEntry) (*url.URL, error) {
err := conn.SetReadDeadline(time.Now().Add(30 * time.Second))
if err != nil {
log.Println("Error setting read deadline: " + err.Error())
return nil, err
}
reader := bufio.NewReaderSize(conn, 1024)
request, overflow, err := reader.ReadLine()
if overflow {
conn.Write([]byte("59 Request too long!\r\n"))
logEntry.Status = 59
return nil, errors.New("Request too long")
} else if err != nil {
if errors.Is(err, os.ErrDeadlineExceeded) {
conn.Write([]byte("40 Request timed out!\r\n"))
} else {
log.Println("Error reading request from " + conn.RemoteAddr().String() + ": " + err.Error())
conn.Write([]byte("40 Unknown error reading request!\r\n"))
}
logEntry.Status = 40
return nil, err
}
// Parse request as URL
URL, err := url.Parse(string(request))
if err != nil {
log.Println("Error parsing request URL " + string(request) + ": " + err.Error())
conn.Write([]byte("59 Error parsing URL!\r\n"))
logEntry.Status = 59
return nil, errors.New("Bad URL in request")
}
logEntry.RequestURL = URL.String()
// Set implicit scheme
if URL.Scheme == "" {
URL.Scheme = "gemini"
}
return URL, nil
}
func resolvePath(path string, config SysConfig) string {
// Handle tildes
if strings.HasPrefix(path, "/~") {
bits := strings.Split(path, "/")
bits := strings.Split(path, "/")
username := bits[1][1:]
new_prefix := filepath.Join(config.DocBase, config.HomeDocBase, username)
path = strings.Replace(path, bits[1], new_prefix, 1)
path = filepath.Clean(path)
} else {
path = filepath.Join(config.DocBase, URL.Path)
path = filepath.Join(config.DocBase, path)
}
return path
}
// Fail if file does not exist or we may not read it
info, err := os.Stat(path)
if os.IsNotExist(err) || os.IsPermission(err) {
conn.Write([]byte("51 Not found!\r\n"))
log.Status = 51
return
}
func handleRedirects(URL *url.URL, config UserConfig, conn net.Conn, logEntry *LogEntry) {
handleRedirectsInner(URL, config.TempRedirects, 30, conn, logEntry)
handleRedirectsInner(URL, config.PermRedirects, 31, conn, logEntry)
}
// Handle URLS which map to a directory
if info.IsDir() {
// Redirect to add trailing slash if missing
// (otherwise relative links don't work properly)
if !strings.HasSuffix(URL.Path, "/") {
conn.Write([]byte(fmt.Sprintf("31 %s\r\n", URL.String()+"/")))
log.Status = 31
return
func handleRedirectsInner(URL *url.URL, redirects map[string]string, status int, conn net.Conn, logEntry *LogEntry) {
strStatus := strconv.Itoa(status)
for src, dst := range redirects {
compiled, err := regexp.Compile(src)
if err != nil {
log.Println("Error compiling redirect regexp " + src + ": " + err.Error())
continue
}
// Add index.gmi to directory paths, if it exists
index_path := filepath.Join(path, "index.gmi")
index_info, err := os.Stat(index_path)
if err == nil {
path = index_path
info = index_info
} else if os.IsPermission(err) {
conn.Write([]byte("51 Not found!\r\n"))
log.Status = 51
if compiled.MatchString(URL.Path) {
new_target := compiled.ReplaceAllString(URL.Path, dst)
if !strings.HasPrefix(new_target, "gemini://") {
URL.Path = new_target
new_target = URL.String()
}
conn.Write([]byte(strStatus + " " + new_target + "\r\n"))
logEntry.Status = status
return
}
}
// Fail if file is not world readable
if uint64(info.Mode().Perm())&0444 != 0444 {
conn.Write([]byte("51 Not found!\r\n"))
log.Status = 51
}
func serveDirectory(URL *url.URL, path string, logEntry *LogEntry, conn net.Conn, config UserConfig) {
// Redirect to add trailing slash if missing
// (otherwise relative links don't work properly)
if !strings.HasSuffix(URL.Path, "/") {
URL.Path += "/"
conn.Write([]byte(fmt.Sprintf("31 %s\r\n", URL.String())))
logEntry.Status = 31
return
}
// If this is a directory, serve a generated listing
if info.IsDir() {
// Check for index.gmi if path is a directory
index_path := filepath.Join(path, "index."+config.GeminiExt)
index_info, err := os.Stat(index_path)
if err == nil && uint64(index_info.Mode().Perm())&0444 == 0444 {
serveFile(index_path, index_info, logEntry, conn, config)
// Serve a generated listing
} else if config.DirectoryListing {
listing, err := generateDirectoryListing(URL, path, config)
if err != nil {
log.Println("Error generating listing for directory " + path + ": " + err.Error())
conn.Write([]byte("40 Server error!\r\n"))
logEntry.Status = 40
return
}
conn.Write([]byte("20 text/gemini\r\n"))
log.Status = 20
conn.Write([]byte(generateDirectoryListing(path)))
logEntry.Status = 20
conn.Write([]byte(listing))
} else {
conn.Write([]byte("51 Not found!\r\n"))
logEntry.Status = 51
}
}
func serveFile(path string, info os.FileInfo, logEntry *LogEntry, conn net.Conn, config UserConfig) {
// Get MIME type of files
ext := filepath.Ext(path)
var mimeType string
if ext == "."+config.GeminiExt {
mimeType = "text/gemini"
} else {
mimeType = mime.TypeByExtension(ext)
}
// Override extension-based MIME type
for pathRegex, newType := range config.MimeOverrides {
overridden, err := regexp.Match(pathRegex, []byte(path))
if err == nil && overridden {
mimeType = newType
}
}
// Try to open the file
f, err := os.Open(path)
if err != nil {
log.Println("Error reading file " + path + ": " + err.Error())
conn.Write([]byte("50 Error!\r\n"))
logEntry.Status = 50
return
}
// If this file is executable, get dynamic content
inCGIPath, err := regexp.Match(config.CGIPath, []byte(path))
if inCGIPath && info.Mode().Perm() & 0111 == 0111 {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, path)
stdin, err := cmd.StdinPipe()
if err != nil {
conn.Write([]byte("42 CGI error!\r\n"))
log.Status = 42
return
}
defer stdin.Close()
io.WriteString(stdin, URL.String())
io.WriteString(stdin, "\r\n")
stdin.Close()
response, err := cmd.Output()
if ctx.Err() == context.DeadlineExceeded {
conn.Write([]byte("42 CGI process timed out!\r\n"))
log.Status = 42
return
}
if err != nil {
conn.Write([]byte("42 CGI error!\r\n"))
log.Status = 42
return
}
// Extract response header
header, _, err := bufio.NewReader(strings.NewReader(string(response))).ReadLine()
status, err2 := strconv.Atoi(strings.Fields(string(header))[0])
if err != nil || err2 != nil {
conn.Write([]byte("42 CGI error!\r\n"))
log.Status = 42
return
}
log.Status = status
// Write response
conn.Write(response)
defer f.Close()
// Otherwise, serve the file contents
} else {
// Get MIME type of files
ext := filepath.Ext(path)
var mimeType string
if ext == ".gmi" {
mimeType = "text/gemini"
} else {
mimeType = mime.TypeByExtension(ext)
// If the file extension wasn't recognised, or there's not one, use bytes
// from the now open file to sniff!
if mimeType == "" {
buffer := make([]byte, 512)
n, err := f.Read(buffer)
if err == nil {
_, err = f.Seek(0, 0)
}
fmt.Println(path, ext, mimeType)
contents, err := ioutil.ReadFile(path)
if err != nil {
log.Println("Error peeking into file " + path + ": " + err.Error())
conn.Write([]byte("50 Error!\r\n"))
log.Status = 50
} else {
conn.Write([]byte(fmt.Sprintf("20 %s\r\n", mimeType)))
log.Status = 20
conn.Write(contents)
logEntry.Status = 50
return
}
mimeType = http.DetectContentType(buffer[0:n])
}
return
// Generic response
conn.Write([]byte("20 text/gemini\r\n"))
body := fmt.Sprintf("Molly at %s says \"Hi!\" from %s.\n", URL.Host, URL.Path)
conn.Write([]byte(body))
log.Status = 20
}
// Add charset parameter
if strings.HasPrefix(mimeType, "text/gemini") && config.DefaultEncoding != "" {
mimeType += "; charset=" + config.DefaultEncoding
}
// Add lang parameter
if strings.HasPrefix(mimeType, "text/gemini") && config.DefaultLang != "" {
mimeType += "; lang=" + config.DefaultLang
}
func generateDirectoryListing(path string) string {
var listing string
files, err := ioutil.ReadDir(path)
// Derive a maximum allowed download time from the filesyize.
// Assume non-malicious clients can manage an average of 0.5 KB/s or better.
// But always allow at least 30 seconds
allowedTime := int(info.Size() / 512)
if allowedTime < 30 {
allowedTime = 30
}
err = conn.SetWriteDeadline(time.Now().Add(time.Duration(allowedTime) * time.Second))
if err != nil {
log.Fatal(err)
log.Println("Error setting write deadline: " + err.Error())
conn.Write([]byte("40 Error!\r\n"))
logEntry.Status = 40
return
}
listing = "# Directory listing\n\n"
for _, file := range files {
// Skip dotfiles
if strings.HasPrefix(file.Name(), ".") {
continue
// Send response
conn.Write([]byte(fmt.Sprintf("20 %s\r\n", mimeType)))
_, err = io.Copy(conn, f)
if err != nil {
// Prepare to close the connection *without* TLS Close Notify so the client
// knows something has gone wrong!
tlsConn, _ := conn.(*tls.Conn)
netConn := tlsConn.NetConn()
tcpConn := netConn.(*net.TCPConn)
remoteAddr := conn.RemoteAddr().String()
if errors.Is(err, os.ErrDeadlineExceeded) {
log.Println("Writing to " + remoteAddr + " timed out.")
// Make sure Close() below takes immediate effect in
// the case of a timeout as a defence against
// socket exhaustion attacks
tcpConn.SetLinger(0)
} else {
log.Println("Error writing response to " + remoteAddr + ": " + err.Error())
}
// Only list world readable files
if uint64(file.Mode().Perm())&0444 != 0444 {
continue
}
listing += fmt.Sprintf("=> %s %s\n", url.PathEscape(file.Name()), file.Name())
tcpConn.Close()
return
}
return listing
logEntry.Status = 20
}

185
launch.go Normal file
View File

@ -0,0 +1,185 @@
package main
import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"io/ioutil"
"log"
"os"
"os/signal"
"strconv"
"sync"
"syscall"
"time"
)
var VERSION = "0.0.0"
func launch(sysConfig SysConfig, userConfig UserConfig, privInfo userInfo) int {
var err error
// Open log files
if sysConfig.ErrorLog != "" {
errorLogFile, err := os.OpenFile(sysConfig.ErrorLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Println("Error opening error log file: " + err.Error())
return 1
}
defer errorLogFile.Close()
log.SetOutput(errorLogFile)
}
log.SetFlags(log.Ldate|log.Ltime)
var accessLogFile *os.File
if sysConfig.AccessLog == "-" {
accessLogFile = os.Stdout
} else if sysConfig.AccessLog != "" {
accessLogFile, err = os.OpenFile(sysConfig.AccessLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Println("Error opening access log file: " + err.Error())
return 1
}
defer accessLogFile.Close()
}
// Read TLS files, create TLS config
// Check key file permissions first
info, err := os.Stat(sysConfig.KeyPath)
if err != nil {
log.Println("Error opening TLS key file: " + err.Error())
return 1
}
if uint64(info.Mode().Perm())&0444 == 0444 {
log.Println("Refusing to use world-readable TLS key file " + sysConfig.KeyPath)
return 1
}
// Check certificate hostname matches server hostname
info, err = os.Stat(sysConfig.CertPath)
if err != nil {
log.Println("Error opening TLS certificate file: " + err.Error())
return 1
}
certFile, err := os.Open(sysConfig.CertPath)
if err != nil {
log.Println("Error opening TLS certificate file: " + err.Error())
return 1
}
certBytes, err := ioutil.ReadAll(certFile)
if err != nil {
log.Println("Error reading TLS certificate file: " + err.Error())
return 1
}
certDer, _ := pem.Decode(certBytes)
if certDer == nil {
log.Println("Error decoding TLS certificate file: " + err.Error())
return 1
}
certx509, err := x509.ParseCertificate(certDer.Bytes)
if err != nil {
log.Println("Error parsing TLS certificate: " + err.Error())
return 1
}
err = certx509.VerifyHostname(sysConfig.Hostname)
if err != nil {
log.Println("Invalid TLS certificate: " + err.Error())
return 1
}
// Warn if certificate is expired
now := time.Now()
if now.After(certx509.NotAfter) {
log.Println("Hey, your certificate expired on " + certx509.NotAfter.String() + "!!!")
}
// Load certificate and private key
cert, err := tls.LoadX509KeyPair(sysConfig.CertPath, sysConfig.KeyPath)
if err != nil {
log.Println("Error loading TLS keypair: " + err.Error())
return 1
}
var tlscfg tls.Config
tlscfg.Certificates = []tls.Certificate{cert}
tlscfg.ClientAuth = tls.RequestClientCert
if sysConfig.AllowTLS12 {
tlscfg.MinVersion = tls.VersionTLS12
} else {
tlscfg.MinVersion = tls.VersionTLS13
}
if len(userConfig.CertificateZones) > 0 || sysConfig.ReadMollyFiles {
tlscfg.ClientAuth = tls.RequestClientCert
}
// Try to chdir to /, so we don't block any mountpoints
// But if we can't for some reason it's no big deal
err = os.Chdir("/")
if err != nil {
log.Println("Could not change working directory to /: " + err.Error())
}
// Apply security restrictions
err = enableSecurityRestrictions(sysConfig, privInfo)
if err != nil {
log.Println("Exiting due to failure to apply security restrictions.")
return 1
}
// Create TLS listener
listener, err := tls.Listen("tcp", ":"+strconv.Itoa(sysConfig.Port), &tlscfg)
if err != nil {
log.Println("Error creating TLS listener: " + err.Error())
return 1
}
defer listener.Close()
// Start log handling routines
var accessLogEntries chan LogEntry
if sysConfig.AccessLog == "" {
accessLogEntries = nil
} else {
accessLogEntries = make(chan LogEntry, 10)
go func() {
for {
entry := <-accessLogEntries
if entry.Status != 0 {
writeLogEntry(accessLogFile, entry)
}
}
}()
}
// Start listening for signals
shutdown := make(chan struct{})
sigterm := make(chan os.Signal, 1)
signal.Notify(sigterm, syscall.SIGTERM)
go func() {
<-sigterm
log.Println("Caught SIGTERM. Waiting for handlers to finish...")
close(shutdown)
listener.Close()
}()
// Infinite serve loop (SIGTERM breaks out)
running := true
var wg sync.WaitGroup
rl := newRateLimiter(sysConfig.RateLimitAverage, sysConfig.RateLimitSoft, sysConfig.RateLimitHard)
for running {
conn, err := listener.Accept()
if err == nil {
wg.Add(1)
go handleGeminiRequest(conn, sysConfig, userConfig, accessLogEntries, &rl, &wg)
} else {
select {
case <-shutdown:
running = false
default:
log.Println("Error accepting connection: " + err.Error())
}
}
}
// Wait for still-running handler Go routines to finish
wg.Wait()
log.Println("Exiting.")
// Exit successfully
return 0
}

View File

@ -1,24 +1,28 @@
package main
import (
"net"
"os"
"strconv"
"time"
"net"
"os"
"strconv"
"strings"
"time"
)
type LogEntry struct {
Time time.Time
RemoteAddr net.Addr
RequestURL string
Status int
Time time.Time
RemoteAddr net.Addr
RequestURL string
Status int
}
func writeLogEntry(fp *os.File, entry LogEntry) {
var line string
line = entry.Time.Format(time.RFC3339)
// Trim port from remote address
addr := entry.RemoteAddr.String()
addr = addr[0:strings.LastIndex(addr, ":")]
line += "\t" + addr
line += "\t" + strconv.Itoa(entry.Status)
line += "\t" + entry.RemoteAddr.String()
line += "\t" + entry.RequestURL
line += "\n"
fp.WriteString(line)

69
main.go
View File

@ -1,7 +1,8 @@
// +build js nacl plan9 windows
package main
import (
"crypto/tls"
"flag"
"fmt"
"log"
@ -10,64 +11,26 @@ import (
func main() {
var conf_file string
var version bool
// Parse args and read config
flag.StringVar(&conf_file, "c", "", "Path to config file")
// Parse args
flag.StringVar(&conf_file, "c", "/etc/molly.conf", "Path to config file")
flag.BoolVar(&version, "v", false, "Print version and exit")
flag.Parse()
if conf_file == "" {
_, err := os.Stat("/etc/molly.conf")
if !os.IsNotExist(err) {
conf_file = "/etc/molly.conf"
}
}
config, err := getConfig(conf_file)
if err != nil {
fmt.Println("Error reading config file " + conf_file)
os.Exit(1)
// If requested, print version and exit
if version {
fmt.Println("Molly Brown version", VERSION)
os.Exit(0)
}
// Open logfile
logfile, err := os.OpenFile(config.LogPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
fmt.Println("Error opening log file " + config.LogPath + ".")
os.Exit(2)
}
defer logfile.Close()
// Read TLS files, create TLS config
cert, err := tls.LoadX509KeyPair(config.CertPath, config.KeyPath)
// Read config
sysConfig, userConfig, err := getConfig(conf_file)
if err != nil {
log.Fatal(err)
}
tlscfg := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
}
// Create TLS listener
listener, err := tls.Listen("tcp", ":1965", tlscfg)
if err != nil {
fmt.Println(err)
return
}
defer listener.Close()
// Start log handling routine
logEntries := make(chan LogEntry, 10)
go func () {
for {
entry := <- logEntries
writeLogEntry(logfile, entry)
}
}()
// Infinite serve loop
for {
conn, err := listener.Accept()
if err != nil {
log.Fatal(err)
}
go handleGeminiRequest(conn, config, logEntries)
}
// Run server and exit
var dummy userInfo
os.Exit(launch(sysConfig, userConfig, dummy))
}

55
main_unix.go Normal file
View File

@ -0,0 +1,55 @@
// +build aix darwin dragonfly freebsd illumos linux netbsd openbsd solaris
package main
import (
"flag"
"fmt"
"log"
"os"
"syscall"
)
func main() {
var conf_file string
var chroot string
var user string
var version bool
// Parse args
flag.StringVar(&conf_file, "c", "/etc/molly.conf", "Path to config file")
flag.StringVar(&chroot, "C", "", "Path to chroot into")
flag.StringVar(&user, "u", "nobody", "Unprivileged user")
flag.BoolVar(&version, "v", false, "Print version and exit")
flag.Parse()
// If requested, print version and exit
if version {
fmt.Println("Molly Brown version", VERSION)
os.Exit(0)
}
// Read config
sysConfig, userConfig, err := getConfig(conf_file)
if err != nil {
log.Fatal(err)
}
// Read user info
privInfo, err := getUserInfo(user)
// Chroot, if asked
if chroot != "" {
err := syscall.Chroot(chroot)
if err == nil {
err = os.Chdir("/")
}
if err != nil {
log.Println("Could not chroot to " + chroot + ": " + err.Error())
os.Exit(1)
}
}
// Run server and exit
os.Exit(launch(sysConfig, userConfig, privInfo))
}

87
ratelim.go Normal file
View File

@ -0,0 +1,87 @@
package main
import (
"log"
"sync"
"strconv"
"time"
)
type RateLimiter struct {
mu sync.Mutex
bucket map[string]int
bans map[string]time.Time
banCounts map[string]int
rate int
softLimit int
hardLimit int
}
func newRateLimiter(rate int, softLimit int, hardLimit int) RateLimiter {
var rl = new(RateLimiter)
rl.bucket = make(map[string]int)
rl.bans = make(map[string]time.Time)
rl.banCounts = make(map[string]int)
rl.rate = rate
rl.softLimit = softLimit
rl.hardLimit = hardLimit
// Leak periodically
go func () {
for(true) {
rl.mu.Lock()
// Leak the buckets
for addr, drips := range rl.bucket {
if drips <= rate {
delete(rl.bucket, addr)
} else {
rl.bucket[addr] = drips - rl.rate
}
}
// Expire bans
now := time.Now()
for addr, expiry := range rl.bans {
if now.After(expiry) {
delete(rl.bans, addr)
}
}
// Wait
rl.mu.Unlock()
time.Sleep(time.Second)
}
}()
return *rl
}
func (rl *RateLimiter) softLimited(addr string) (int, bool) {
rl.mu.Lock()
defer rl.mu.Unlock()
drips, present := rl.bucket[addr]
if !present {
rl.bucket[addr] = 1
return 1, false
}
drips += 1
rl.bucket[addr] = drips
if drips > rl.hardLimit {
banCount, present := rl.banCounts[addr]
if present {
banCount += 1
} else {
banCount = 1
}
rl.banCounts[addr] = banCount
banDuration := 1 << (banCount - 1)
now := time.Now()
expiry := now.Add(time.Duration(banDuration)*time.Hour)
rl.bans[addr] = expiry
log.Println("Banning " + addr + " for " + strconv.Itoa(banDuration) + " hours due to ignoring rate limiting.")
}
return drips, drips > rl.softLimit
}
func (rl *RateLimiter) hardLimited(addr string) bool {
_, present := rl.bans[addr]
return present
}

14
security.go Normal file
View File

@ -0,0 +1,14 @@
// +build js nacl plan9 windows
package main
type userInfo struct {
}
// Restrict access to the files specified in config in an OS-dependent way.
// This is intended to be called immediately prior to accepting client
// connections and may be used to establish a security "jail" for the molly
// brown executable.
func enableSecurityRestrictions(config SysConfig, ui userInfo) error {
return nil
}

119
security_dropprivs.go Normal file
View File

@ -0,0 +1,119 @@
// +build linux,go1.16 aix darwin dragonfly freebsd illumos netbsd openbsd solaris
package main
import (
"log"
"os"
"os/user"
"strconv"
"syscall"
)
type userInfo struct {
uid int
euid int
gid int
egid int
supp_groups []int
is_setuid bool
is_setgid bool
root_user bool
root_prim_group bool
root_supp_group bool
need_drop bool
unpriv_uid int
unpriv_gid int
}
func getUserInfo(unprivUser string) (userInfo, error) {
var ui userInfo
ui.uid = os.Getuid()
ui.euid = os.Geteuid()
ui.gid = os.Getgid()
ui.egid = os.Getegid()
supp_groups, err := os.Getgroups()
if err != nil {
log.Println("Could not get supplementary groups: ", err.Error())
return ui, err
}
ui.supp_groups = supp_groups
ui.unpriv_uid = -1
ui.unpriv_gid = -1
ui.is_setuid = ui.uid != ui.euid
ui.is_setgid = ui.gid != ui.egid
ui.root_user = ui.uid == 0 || ui.euid == 0
ui.root_prim_group = ui.gid == 0 || ui.egid == 0
for _, gid := range ui.supp_groups {
if gid == 0 {
ui.root_supp_group = true
break
}
}
ui.need_drop = ui.is_setuid || ui.is_setgid || ui.root_user || ui.root_prim_group || ui.root_supp_group
if ui.root_user || ui.root_prim_group {
nobody_user, err := user.Lookup(unprivUser)
if err != nil {
log.Println("Running as root but could not lookup UID for user " + unprivUser + ": " + err.Error())
return ui, err
}
ui.unpriv_uid, err = strconv.Atoi(nobody_user.Uid)
ui.unpriv_gid, err = strconv.Atoi(nobody_user.Gid)
if err != nil {
log.Println("Running as root but could not lookup UID for user " + unprivUser + ": " + err.Error())
return ui, err
}
}
return ui, nil
}
func DropPrivs(ui userInfo) error {
// If we're already unprivileged, all good
if !ui.need_drop {
return nil
}
// Drop supplementary groups
if ui.root_supp_group {
err := syscall.Setgroups([]int{})
if err != nil {
log.Println("Could not unset supplementary groups: " + err.Error())
return err
}
}
// Setgid()
if ui.root_prim_group || ui.is_setgid {
var target_gid int
if ui.root_prim_group {
target_gid = ui.unpriv_gid
} else {
target_gid = ui.gid
}
err := syscall.Setgid(target_gid)
if err != nil {
log.Println("Could not setgid to " + strconv.Itoa(target_gid) + ": " + err.Error())
return err
}
}
// Setuid()
if ui.root_user || ui.is_setuid {
var target_uid int
if ui.root_user {
target_uid = ui.unpriv_uid
} else {
target_uid = ui.uid
}
err := syscall.Setuid(target_uid)
if err != nil {
log.Println("Could not setuid to " + strconv.Itoa(target_uid) + ": " + err.Error())
return err
}
}
return nil
}

32
security_oldgolinux.go Normal file
View File

@ -0,0 +1,32 @@
// +build linux,!go1.16
package main
import (
"errors"
"log"
"os"
)
type userInfo struct {
}
func getUserInfo(unprivUser string) (userInfo, error) {
var dummy userInfo
return dummy, nil
}
func enableSecurityRestrictions(config SysConfig, ui userInfo) error {
// Prior to Go 1.6, setuid did not work reliably on Linux
// So, absolutely refuse to run as root
uid := os.Getuid()
euid := os.Geteuid()
if uid == 0 || euid == 0 {
setuid_err := "Refusing to run with root privileges when setuid() will not work!"
log.Println(setuid_err)
return errors.New(setuid_err)
}
return nil
}

77
security_openbsd.go Normal file
View File

@ -0,0 +1,77 @@
package main
import (
"golang.org/x/sys/unix"
"log"
"path/filepath"
)
// Restrict access to the files specified in config in an OS-dependent way.
// The OpenBSD implementation uses pledge(2) and unveil(2) to restrict the
// operations available to the molly brown executable. Please note that (S)CGI
// processes that molly brown spawns or communicates with are unrestricted
// and should pledge their own restrictions and unveil their own files.
func enableSecurityRestrictions(config SysConfig, ui userInfo) error {
// Setuid to an unprivileged user
err := DropPrivs(ui)
if err != nil {
return err
}
// Unveil the configured document base as readable.
log.Println("Unveiling \"" + config.DocBase + "\" as readable.")
err = unix.Unveil(config.DocBase, "r")
if err != nil {
log.Println("Could not unveil DocBase: " + err.Error())
return err
}
// Unveil cgi path globs as executable.
for _, cgiPath := range config.CGIPaths {
cgiGlobbedPaths, err := filepath.Glob(cgiPath)
for _, cgiGlobbedPath := range cgiGlobbedPaths {
log.Println("Unveiling \"" + cgiGlobbedPath + "\" as executable.")
err = unix.Unveil(cgiGlobbedPath, "rx")
if err != nil {
log.Println("Could not unveil CGIPaths: " + err.Error())
return err
}
}
}
// Unveil scgi socket paths as readable and writeable.
for _, scgiSocket := range config.SCGIPaths {
log.Println("Unveiling \"" + scgiSocket + "\" as read/write.")
err = unix.Unveil(scgiSocket, "rw")
if err != nil {
return err
}
}
// Finalize the unveil list.
// Any files not whitelisted above won't be accessible to molly brown.
err = unix.UnveilBlock()
if err != nil {
log.Println("Could not block unveil: " + err.Error())
return err
}
// Pledge to only use stdio, inet, and rpath syscalls.
promises := "stdio inet rpath"
if len(config.CGIPaths) > 0 {
// If CGI paths have been specified, also allow exec syscalls.
promises += " exec proc"
}
if len(config.SCGIPaths) > 0 {
// If SCGI paths have been specified, also allow unix sockets.
promises += " unix"
}
err = unix.PledgePromises(promises)
if err != nil {
log.Println("Could not pledge: " + err.Error())
return err
}
return nil
}

10
security_other_unix.go Normal file
View File

@ -0,0 +1,10 @@
// +build linux,go1.16 aix darwin dragonfly freebsd illumos netbsd solaris
package main
func enableSecurityRestrictions(config SysConfig, ui userInfo) error {
// Setuid to an unprivileged user
return DropPrivs(ui)
}