Compare commits

...

153 Commits

Author SHA1 Message Date
Solderpunk 88daabe091 Overhaul TOFU checking code.
Main motivation for this was to switch from keying the cache cert
database off hostname + address to hostname + port.  While making
the necessary changes I refactored to reduce code duplication and
make the overall flow of the TOFU checks more transparent.

The check of whether the "previous certificate" has expired has
been changed from using the most frequently seen previous cert to
the most recently seen, which makes a *lot* more sense and is
arguably a bug fix.

The address column of the DB is now used only for reporting, but
the column is not maintained well, or rather, the semantics are
currently "address cert was first received from", and we may want
something less static?
2024-01-17 20:58:59 +01:00
Solderpunk fc056ef680 Merge pull request 'Print <META> string before user input' (#44) from continue/AV-98:master into master
Reviewed-on: solderpunk/AV-98#44
2024-01-17 17:44:05 +00:00
Aleksey Ryndin 6d489e6ab3 Print <META> string before user input 2024-01-04 18:58:54 +03:00
Solderpunk 4b06b23e2a Merge pull request 'Fix: ERROR: local variable 'certdir' referenced before assignment' (#43) from continue/AV-98:master into master
Reviewed-on: solderpunk/AV-98#43
2023-12-09 19:06:16 +00:00
Aleksey Ryndin bf0db27d6f Merge branch 'master' of tildegit.org:continue/AV-98 2023-12-07 17:49:25 +03:00
Alexey Ryndin 3361c79c1e Fix: ERROR: local variable 'certdir' referenced before assignment 2023-12-06 13:57:56 +03:00
Alexey Ryndin 474787dab7 Merge branch 'master' of tildegit.org:solderpunk/AV-98 2023-12-06 11:58:46 +03:00
Aleksey Ryndin fae71f5746 Merge branch 'master' of tildegit.org:solderpunk/AV-98 2023-12-06 09:08:42 +03:00
Solderpunk 854369afad Add a 'user' command, analogous to 'root' but jumps to a pubnix user's capsule if the URL starts with ~username. 2023-11-26 12:36:49 +01:00
Solderpunk 0e9953882c Don't treat filename collisions as fatal. 2023-11-26 12:17:42 +01:00
Solderpunk e96d373eec Fix bug with default response to Y/N prompts. 2023-11-26 12:12:19 +01:00
Solderpunk 0e91b4f894 Rename the gus command to search, and the old search to filter. Add option to set search endpoint. 2023-11-25 16:16:54 +01:00
Solderpunk 0268cd426b Further splitting up and renaming of files.
Now that everything lives in src/av98/ as per the latest Python
fashion, it's a problem to have a file named av98.py.  So split
it out into main.py (which just implements the argument parsing)
and client.py.  The old clientcerts.py has become certmanager.py
so that tab completion for client.py is quick and easy.
2023-11-25 16:12:46 +01:00
Solderpunk 4b759aec70 Add --output command line option for use with --download. 2023-11-25 13:08:36 +01:00
Solderpunk 48c8fd9543 Get on board with modern Python packaging conventions.
Remember when we used to make fun of Java people for sticking
everything in a `src/` directory?
2023-11-22 11:20:05 +01:00
Solderpunk 188cacca1f Restore coloured debug output, like we had before the logging module was adopted. 2023-11-19 16:49:07 +01:00
Solderpunk b46b15905b Prepare to cut 1.1.0. 2023-11-19 16:26:48 +01:00
Solderpunk 3abf44d18c Make the new --download option play nicely with --tls_cert and --tls_key. 2023-11-19 16:23:36 +01:00
Solderpunk 203ffaea90 Replace VF-1's flight-themed sign off with AV-98 policing-themed sign off. 2023-11-19 15:25:29 +01:00
Solderpunk cbbc410976 Add curl/wget style --download option. 2023-11-19 15:24:26 +01:00
Solderpunk 305f7f9f2c Make do_quit() silent, move the farewell message to main(). 2023-11-19 14:40:26 +01:00
Solderpunk 4df88896a8 Ensure we drive a useable filename for saving items whose URL does not provide one. 2023-11-19 14:38:55 +01:00
Solderpunk c8c12cab86 Don't trigger a traceback in debug mode for 4x or 5x status codes. 2023-11-19 14:35:13 +01:00
Solderpunk a0eedac532 Include temporary buffer file name in debug output. 2023-11-19 14:34:29 +01:00
Solderpunk a459e49fa0 Extract titles from gemtext and use them as GeminiItem.name if that's missing. 2023-11-19 14:27:47 +01:00
Solderpunk 0a9846b342 Correctly report downloaded file size in debug messages.
Previously, the return value of fp.write() was used, however for
text files this counts the number of characters written, not the
number of bytes, and for non-ASCII content these differ.
2023-11-19 12:29:48 +01:00
Solderpunk 9970f21e47 Move a little more clutter from av98.py into util.py. 2023-11-19 10:41:08 +01:00
Solderpunk 73ce79310d Reuse a consistent function for getting Y/N type user input. 2023-11-19 10:20:54 +01:00
Solderpunk 03b90fcd5e Put config dir discovery/creation in own method, store computed filename of bookmarks. 2023-11-18 19:28:15 +01:00
Solderpunk 62972c0228 Restore cert prompt functionality. 2023-11-18 19:27:42 +01:00
Solderpunk 0393ae3ea3 Correctly track domains which a client cert has been sent to. 2023-11-18 17:15:17 +01:00
Solderpunk 2a70985176 General tidy-up of entire av98.py file.
Most a matter of rearranging the order of methods to flow
sensibly, as well writing or updating docstrings, getting rid of
old unused return values, and fixing a few very minor defects.
2023-11-18 15:59:36 +01:00
Solderpunk 247f01e3e7 Update bookmarks to reflect permanent redirects upon exiting. 2023-11-18 14:23:56 +01:00
Solderpunk de7e5dc254 Factor out client certificate management into its own class/file. 2023-11-18 14:22:32 +01:00
Solderpunk 607223c25a Get real persnickety about nice option/blackbox alignment. 2023-11-17 20:08:48 +01:00
Solderpunk a9b34278a8 Don't hardcode black box spacing values. 2023-11-17 19:59:12 +01:00
Solderpunk 4f354ab291 Nicely align setting names and values. 2023-11-17 19:56:47 +01:00
Solderpunk bd7c5c2110 Log network errors a little more carefully. 2023-11-17 19:44:06 +01:00
Solderpunk 71f8a3dc86 Gracefully handle requests for file:// URLs which point at a directory. 2023-11-16 19:18:30 +01:00
Solderpunk 0ce09f37a6 Gracefully handle requests for file:// URLs where the file does not exist. 2023-11-16 19:14:22 +01:00
Solderpunk 681b11b8a4 Include local file and cached resource handling inside nice try/except error printing. 2023-11-16 19:13:20 +01:00
Solderpunk 91ff51a0ef Fix subtle breakage on some certs, when ssl_dnsname_match throws a non-CertificateError exception. 2023-11-15 19:47:17 +01:00
Solderpunk 50c43c75b4 Provider progress animation when downloading files > 100 KiB. 2023-11-15 19:46:01 +01:00
Solderpunk f78b6ff780 Move handling of permanent redirects inside of _fetch_over_network(). 2023-11-15 19:45:36 +01:00
Solderpunk 9e2cce7ce0 Print full tracebacks from exceptions when debugging is enabled. 2023-11-15 19:38:45 +01:00
Solderpunk 23b0597b6d Update email address and copyright years. 2023-11-15 19:38:13 +01:00
Solderpunk 67729fb711 Count redirects in black box recorder. 2023-11-15 18:40:48 +01:00
Solderpunk 480f2cc15f Rejig how do_bookmark() handles an argument to simplify go_to_gi(). 2023-11-15 18:29:52 +01:00
Solderpunk d2fe381c3e Change Cache to use a TemporaryDirectory for its storage to
ensure and simplify thorough cleanups upon shutdown.

Thanks to Ghost for making me aware of this possibility!
2023-11-15 18:21:11 +01:00
Solderpunk 048b04bed2 Extensively refactor the way temporary files are used.
Instead of littering /tmp with one file per download plus one per
rendered gemtext file, just reuse the same two files over and
over.  If enabled, caching creates separate copies.

Pretty sure this solves Issue #35, but I'll test and close that
after also improving the use of cache files...
2023-11-15 18:06:33 +01:00
Solderpunk 713616d556 Refactor of networking logic.
1. Move client certificate handling stuff inside of _send_request().
2. Change _fetch_over_network() to not be recursive, by just looping
   through calls to _send_request().  This facilitates moving the
   redirect-tracking state inside _fetch_over_network(), instead of
   keeping it in GeminiClient.
3. Also allow _fetch_over_network() to save response to a provided
   filename, and use this to implement do_save(), rather than
   _go_to_gi().  This avoids the need for awkward gymnastics with
   the internal state.
2023-11-14 19:40:40 +01:00
Solderpunk 01da844141 Pull response parsing inside of _send_request(). 2023-11-14 08:56:39 +01:00
Solderpunk 7f1aa5cbf3 Remove unused imports. 2023-11-14 00:38:00 +01:00
Solderpunk 33e66b439b Test for saveability against a fetched GI, not a tempfile. 2023-11-14 00:36:43 +01:00
Solderpunk d689cbd04f Fix _get_active_tmpfile to handle local file GIs. 2023-11-14 00:35:47 +01:00
Solderpunk 3aedd549e5 Remove unecessary duplication of handling code between _handle_gemtext() and _go_to_gi(). 2023-11-14 00:33:48 +01:00
Solderpunk e4a44679dc Fix saving of local files. 2023-11-14 00:29:59 +01:00
Solderpunk 588d599cb4 Add actual support for reading local files, use it for bookmarks. 2023-11-14 00:15:32 +01:00
Solderpunk c6886d7eb9 Don't clutter go_to_gi() with error logging. 2023-11-13 23:58:12 +01:00
Solderpunk cdb2b0282c Don't expose Cache object's trim method. 2023-11-13 20:24:54 +01:00
Solderpunk 053dcb7254 Factor out certificate validation into its own class/file. 2023-11-12 15:36:54 +01:00
Solderpunk 79a6187eac Restore debugging output from cache using logging module. 2023-11-12 15:14:05 +01:00
Solderpunk e678bca089 Factor out caching functionality into own class/file. 2023-11-12 14:49:01 +01:00
Solderpunk 87473fee1b Acknowledge contributors of recently merged PRs. 2023-11-12 14:19:23 +01:00
Solderpunk 522047a209 Merge pull request 'Pass extra information to _handle_cert_request' (#29) from govynnus/AV-98:fix-cert-request into master
Reviewed-on: solderpunk/AV-98#29
2023-11-12 10:37:28 +00:00
Solderpunk b9d0633f38 Merge pull request 'Add support for touring a range where the start index is bigger than the end index' (#25) from rmgr/AV-98:reverse-range into master
Reviewed-on: solderpunk/AV-98#25
2023-11-12 10:33:10 +00:00
Solderpunk 24b02327b0 Merge pull request 'Add support for http/https -> gemini proxy' (#24) from rmgr/AV-98:http into master
Reviewed-on: solderpunk/AV-98#24
2023-11-12 10:28:52 +00:00
Solderpunk d5666c9c19 Merge pull request 'Swap GUS for geminispace.info' (#36) from sario528/AV-98:search-fix into master
Reviewed-on: solderpunk/AV-98#36
2023-11-12 10:22:39 +00:00
Solderpunk a1cb220113 Merge pull request 'improve compatibility with Python 3.10' (#40) from nic/AV-98:master into master
Reviewed-on: solderpunk/AV-98#40
2023-11-12 10:18:29 +00:00
Solderpunk cb1cbfec85 Merge pull request 'Fix: ValueError if MIME is empty string (like a #20)' (#42) from continue/AV-98:fix-empty-mime into master
Reviewed-on: solderpunk/AV-98#42
2023-11-12 10:12:37 +00:00
Aleksey Ryndin d84872d23f Revert "Fix: input() UnicodeDecodeError tolerance"
This reverts commit 8f1642c9a1.
2023-09-06 23:08:27 +03:00
Aleksey Ryndin 8f1642c9a1 Fix: input() UnicodeDecodeError tolerance 2023-09-06 13:15:29 +03:00
Aleksey Ryndin 22c7efce7c Fix: ValueError if MIME is empty string (like a #20) 2023-08-27 16:49:36 +03:00
Nic Waller 6ee2a0716d improve compatibility with Python 3.10 2022-06-29 20:57:34 -07:00
sario528 5dfe62fc63
Swap GUS for geminispace.info 2021-04-15 05:04:58 -05:00
Callum Brown 92da876795 Pass extra information to _handle_cert_request
This fixes a bug introduced in f45630 when handling a certificate request was
factored out of _fetch_over_network.
Also make the options consistent in terms of grammar.
2021-01-03 11:07:22 +00:00
Solderpunk 265a69a6ed Document RC files. Closes #27. 2020-12-24 13:27:18 +01:00
rmgr ab913ebf54 Add support for touring a range where the start index is bigger than the end index 2020-09-15 20:19:32 +09:30
rmgr 20395cb826 Add support for http/https -> gemini proxy 2020-09-15 20:02:29 +09:30
Solderpunk 129c56c1d4 Fix another hasty cache hack bug. 2020-09-03 21:21:04 +02:00
Solderpunk ba0f707669 Ignore the cache when reloading a page. 2020-09-01 23:27:59 +02:00
Solderpunk 67f9c662b3 Add option to disable caching. 2020-09-01 23:11:55 +02:00
Solderpunk 545d5f917d Count cache hits in black box output. 2020-09-01 21:14:17 +02:00
Solderpunk f45630450f Make sure early terminations of _fetch_over_network happen via an exception, not by returning None. Factor out certificate handling interface. 2020-08-31 21:18:15 +02:00
Solderpunk 4e8f3dcd05 Fix variable name bug introduced by hasty hacking of cache system. 2020-08-31 21:17:06 +02:00
Solderpunk 08c60e202b Turn some magic numbers into constants. 2020-08-30 23:17:21 +02:00
Solderpunk 0f328141b9 Initial implementation of short-term caching. 2020-08-30 20:21:15 +02:00
Solderpunk 4d652e0fef Remove more transient client certificate stuff. 2020-08-30 18:16:31 +02:00
Solderpunk da8b6cc7f3 Visually distinguish non-Gemini links from Gemini links. 2020-08-30 17:23:36 +02:00
Solderpunk 969d3c1b18 Permit use of ~ in key/cert files. 2020-08-30 16:52:06 +02:00
Solderpunk e20ac17107 Stop treating transient client certificates as a special case. 2020-08-18 21:41:51 +02:00
Solderpunk d39cddcc84 Make default MIME handlers more generic. 2020-08-18 21:14:04 +02:00
Solderpunk 03be5bfebf Use proper handler resolution logic for the text/gemini case (so that settings for text/* can apply). 2020-08-18 21:13:26 +02:00
Solderpunk 72754114f4 Error out if a URL attempts to redirect to itself. 2020-08-18 21:06:12 +02:00
Solderpunk 1509f895f1 Rename handle_index handle_gemtext, for clarity. It should have been called handle_menu in VF-1 in the first place, anyway. 2020-08-18 21:05:49 +02:00
Solderpunk 4e634a539b Merge pull request 'Fix some bugs in the 'cert' UI' (#22) from govynnus/AV-98:bugfix-cert into master
Reviewed-on: solderpunk/AV-98#22
2020-08-15 11:40:32 +00:00
govynnus 99e5ceec65 Fix some bugs in the 'cert' UI
- os.path.exists() allows directories so use os.path.isfile() instead
- os.path.isfile() does not interpret '~' as /home/<user> so add note to users
- Use right certificate directory in `mycert` example
- Display a message and abort if no previously generated certs
2020-08-14 22:13:21 +01:00
Solderpunk ce834dd231 Use correct handler for text/gemini content. 2020-08-11 22:01:47 +02:00
Solderpunk 96cf8e13fe ACTUALLY fix time conversion bug as attempted in 76d7d, grumble, grumble... 2020-06-14 12:28:34 +02:00
Solderpunk 097458754e Bump version for development. 2020-06-13 23:42:36 +02:00
Solderpunk b972ca7d5d Release 1.0.1. 2020-06-13 23:39:04 +02:00
Solderpunk 76d7d23a2a Fix time conversion bug in blackbox command. 2020-06-13 15:36:05 +02:00
solderpunk afa67f40ae Merge pull request 'Standardize abbrevs formatting' (#19) from vee/AV-98:vee/abbrevs-formatting into master 2020-06-13 09:16:09 -04:00
Vee 3cf447cc3a Standardize abbrevs formatting
The output format for `help` includes a trailing and leading blank line,
which were missing from `abbrevs` output. Additionally, `help` includes
a colon at the end of the header line, which this commit also adds to
`abbrevs` output.
2020-06-13 06:39:18 -04:00
Solderpunk dfa1dd7fd0 Don't choke on non gopher/gemini/http(s) links. Closes #18. 2020-06-09 22:13:42 +02:00
Solderpunk 44ee42ba8a Check that a file exists before trying to delete it. Rare errors can cause code paths leading to attempted double deletion. 2020-06-08 21:52:28 +02:00
Solderpunk 9526c384db Bump version for development. 2020-06-08 18:49:26 +02:00
Solderpunk 9a80987587 Cut 1.0.0! 2020-06-07 22:51:02 +02:00
Solderpunk d7082d23e5 Add setup.py 2020-06-07 22:48:44 +02:00
Solderpunk be20eb4a50 Add docstrings for client cert methods. 2020-06-07 20:42:19 +02:00
Solderpunk c09ae60167 Flesh out README. 2020-06-07 19:55:49 +02:00
Solderpunk b8fa8233bc Support new status code 11. 2020-06-07 19:13:00 +02:00
Solderpunk 94cf54df18 Recognise quote line type. 2020-06-07 19:09:53 +02:00
Solderpunk 5331d5254d Update recognition of list item lines to match recent spec update. 2020-06-07 19:07:30 +02:00
Solderpunk 6306e4ef58 Do not strip non-breaking spaces from advanced line types. 2020-06-07 19:06:39 +02:00
Solderpunk 98dc9a96b4 Fill out LICENSE template! Closes #17. 2020-06-04 20:29:03 +02:00
Solderpunk 1bc6a69bb9 Permit use of ECDSA. 2020-06-04 16:21:11 +02:00
Solderpunk 8d7715ee4b Add dancek to contributors, sort contributors alphabetically. 2020-06-02 22:57:48 +02:00
solderpunk fd8ee5bfb7 Merge pull request 'Add gemini:// support directly to urllib.parse' (#16) from dancek/AV-98:simplify-urljoin into master
Thanks a lot!  I've never bothered to poke inside `urljoin` before so didn't realise this was so easy to do.  I agree that this is far more readable and maintainable.
2020-06-02 14:45:48 -04:00
Hannu Hartikainen 9c82b63ff1 Add gemini:// support directly to urllib.parse 2020-06-01 10:22:49 +03:00
Solderpunk 2fd8fe919b Do not read more than the maximum number of bytes in a valid response header. 2020-05-31 18:33:32 +02:00
Solderpunk d5ed0c5d7a Don't crash when buggy servers send no header at all. 2020-05-31 18:33:08 +02:00
Solderpunk 08ce625575 Arglblargl *actually* fix redirects. 2020-05-31 14:24:23 +02:00
Solderpunk 34e97e4cf3 Fix redirect logic. 2020-05-31 14:23:30 +02:00
Solderpunk 5187e75566 Fix cross-domain redirect warning, and add cross-protocol redirect warning. 2020-05-31 14:06:23 +02:00
Solderpunk 088c415987 Make openssl binary calls compatible with LibreSSL. 2020-05-31 10:58:45 +02:00
Solderpunk 16dc7dc831 Cipher hardening. 2020-05-31 00:02:37 +02:00
Solderpunk fecd46378c Use current UTC time for comparison against certificate validity. Closes #14. Thanks, mozz! 2020-05-28 21:01:04 +02:00
solderpunk 6b17792546 Merge pull request 'Add `abbrevs` command' (#13) from vee/AV-98:vee/abbrevs into master 2020-05-27 14:46:54 -04:00
Vee e558c80740 Add `abbrevs` command
It lists all available AV-98 command abbreviations.
2020-05-27 09:16:22 -04:00
Solderpunk dbe08ee787 Another silly bug fix, closes #12. 2020-05-27 09:00:42 +02:00
Solderpunk 49531bfb25 Fix silly copy/paste bug. Closes #11. 2020-05-27 08:57:44 +02:00
Solderpunk a3fd543aa6 Correctly test individual names, not Common Name over and over. 2020-05-23 17:20:26 +02:00
Solderpunk 16cf9fecb6 Don't crash when cert has no Common Name. 2020-05-23 17:13:30 +02:00
Solderpunk 94e8abe934 Slightly better wording around certs. 2020-05-23 13:35:13 +02:00
Solderpunk 2c7e6502f8 Fix umask call. 2020-05-23 13:24:39 +02:00
Solderpunk c48c85b5e1 Notify upon creation of config directory. 2020-05-23 13:18:37 +02:00
Solderpunk 68d5f9b42e Set umask so that config directory is private. 2020-05-23 13:17:12 +02:00
Solderpunk 8945fa4f7e Don't follow cross-domain redirects automatically. 2020-05-23 12:53:20 +02:00
Solderpunk 7a3f1c77a5 Present expiration information about previous certificates in TOFU warning messages. 2020-05-23 12:53:02 +02:00
Solderpunk e455d2ec85 Actually create a missing config directory! 2020-05-22 23:24:49 +02:00
Solderpunk a68e092593 Add option to toggle between CA and TOFU certificate validation. 2020-05-19 23:14:09 +02:00
Solderpunk ec07491578 Check alternative subject names. 2020-05-17 22:36:10 +02:00
Solderpunk 094e3117c4 Better reporting of certificate errors. 2020-05-17 22:36:00 +02:00
Solderpunk 9ce8d2481a Use cryptography library to do better certificate checking, if it's available. 2020-05-17 20:38:06 +02:00
Solderpunk ca1a0a62e6 Cache certificates to disk in the expectation of more advanced cert wrangling in future. 2020-05-17 18:35:35 +02:00
Solderpunk 68e55d245a Add AV-98 contributors, in place of VF-1 contributors. 2020-05-17 17:57:34 +02:00
Solderpunk 991de05512 Immediately commit all changes to the TOFU DB, so it gets unlocked and multiple clients can access it at once. 2020-05-17 14:02:36 +02:00
Solderpunk 13f885c226 Make transient clients expire after 1 day, not 365! 2020-05-17 12:18:09 +02:00
Solderpunk d1412377da Initial implementation of TOFU security model. 2020-05-16 18:58:53 +02:00
solderpunk cbd1ff48e9 Merge pull request 'Limit server header response length' (#9) from jprjr/AV-98:header-limit into master 2020-05-16 12:54:00 -04:00
jprjr 78e0134c8a spec states meta max length is 1024 2020-05-16 13:59:05 +00:00
jprjr 0b79cd174f enforce a maximum header line length 2020-05-16 13:58:33 +00:00
11 changed files with 2204 additions and 1360 deletions

View File

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

View File

@ -1,3 +1,62 @@
# AV-98
Experimental VF-1-derived client for the "Gemini" protocol
AV-98 is an experimental client for the
[Gemini protocol](https://gemini.circumlunar.space). It is derived from the
[gopher client VF-1](https://github.com/solderpunk/VF-1) by the same author.
AV-98 is "experimental" in the sense that it may occasionally extend or deviate
from the official Gemini specification for the purposes of, well,
experimentation. Despite this, it is expected to be stable enough for regular
daily use at the same time.
## Dependencies
AV-98 has no "strict dependencies", i.e. it will run and work without anything
else beyond the Python standard library. However, it will "opportunistically
import" a few other libraries if they are available to offer an improved
experience.
* The [ansiwrap library](https://pypi.org/project/ansiwrap/) may result in
neater display of text which makes use of ANSI escape codes to control colour.
* The [cryptography library](https://pypi.org/project/cryptography/) will
provide a better and slightly more secure experience when using the default
TOFU certificate validation mode and is highly recommended.
## Features
* TOFU or CA server certificate validation
* Extensive client certificate support if an `openssl` binary is available
* Ability to specify external handler programs for different MIME types
* Gopher proxy support (e.g. for use with
[Agena](https://tildegit.org/solderpunk/agena))
* Advanced navigation tools like `tour` and `mark` (as per VF-1)
* Bookmarks
* IPv6 support
* Supports any character encoding recognised by Python
## Lightning introduction
You use the `go` command to visit a URL, e.g. `go gemini.circumlunar.space`.
Links in Gemini documents are assigned numerical indices. Just type an index to
follow that link.
If a Gemini document is too long to fit on your screen, use the `less` command
to pipe it to the `less` pager.
Use the `help` command to learn about additional commands.
## RC files
You can use an RC file to automatically run any sequence of valid AV-98
commands upon start up. This can be used to make settings controlled with the
`set` or `handler` commanders persistent. You can also put a `go` command in
your RC file to visit a "homepage" automatically on startup, or to pre-prepare
a `tour` of your favourite Gemini sites.
The RC file should be called `av98rc`. AV-98 will look for it first in
`~/.av98/` and second in `~/.config/av98/`. Note that either directory might
already exist even if you haven't created it manually, as AV-98 will, if
necessary, create the directory itself the first time you save a bookmark (the
bookmark file is saved in the same location). AV-98 will create
`~/.config/av98` only if `~/.config/` already exists on your system, otherwise
it will create `~/.av98/`.

1358
av98.py

File diff suppressed because it is too large Load Diff

26
pyproject.toml Executable file
View File

@ -0,0 +1,26 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "AV-98"
dynamic = ["version"]
description = "Command line Gemini client"
authors = [{name="Solderpunk", email="solderpunk@posteo.net"}]
classifiers = [
"License :: OSI Approved :: BSD License",
"Programming Language :: Python :: 3 :: Only",
"Topic :: Communications",
"Intended Audience :: End Users/Desktop",
"Environment :: Console",
"Development Status :: 5 - Production/Stable",
]
[project.urls]
Homepage = "https://tildegit.org/solderpunk/AV-98/"
Issues = "https://tildegit.org/solderpunk/AV-98/issues"
[project.scripts]
av98 = "av98.main:main"
[project.optional-dependencies]
tofu = ["cryptography"]
colour = ["ansiwrap"]
[tool.setuptools.dynamic]
version = {attr = "av98.__version__"}

1
src/av98/__init__.py Normal file
View File

@ -0,0 +1 @@
__version__ = "1.1.0dev"

76
src/av98/cache.py Normal file
View File

@ -0,0 +1,76 @@
_MAX_CACHE_SIZE = 10
_MAX_CACHE_AGE_SECS = 180
import logging
import os
import os.path
import shutil
import tempfile
import time
ui_out = logging.getLogger("av98_logger")
class Cache:
def __init__(self):
self.cache = {}
self.cache_timestamps = {}
self.tempdir = tempfile.TemporaryDirectory()
def check(self, url):
if url not in self.cache:
return False
now = time.time()
cached = self.cache_timestamps[url]
if now - cached > _MAX_CACHE_AGE_SECS:
ui_out.debug("Expiring old cached copy of resource.")
self._remove(url)
return False
ui_out.debug("Found cached copy of resource.")
return True
def _remove(self, url):
self.cache_timestamps.pop(url)
mime, filename = self.cache.pop(url)
os.unlink(filename)
self.validatecache()
def add(self, url, mime, filename):
# Copy client's buffer file to new cache file
tmpf = tempfile.NamedTemporaryFile(dir=self.tempdir.name, delete=False)
tmpf.close()
shutil.copyfile(filename, tmpf.name)
# Remember details
self.cache_timestamps[url] = time.time()
self.cache[url] = (mime, tmpf.name)
if len(self.cache) > _MAX_CACHE_SIZE:
self._trim()
self.validatecache()
def _trim(self):
# Order cache entries by age
lru = [(t, u) for (u, t) in self.cache_timestamps.items()]
lru.sort()
# Drop the oldest entry no matter what
_, url = lru[0]
ui_out.debug("Dropping cached copy of {} from full cache.".format(url))
self._remove(url)
# Drop other entries if they are older than the limit
now = time.time()
for cached, url in lru[1:]:
if now - cached > _MAX_CACHE_AGE_SECS:
ui_out.debug("Dropping cached copy of {} from full cache.".format(url))
self._remove(url)
else:
break
self.validatecache()
def get(self, url):
return self.cache[url]
def validatecache(self):
assert self.cache.keys() == self.cache_timestamps.keys()
for _, filename in self.cache.values():
assert os.path.isfile(filename)

233
src/av98/certmanager.py Normal file
View File

@ -0,0 +1,233 @@
import glob
import logging
import os
import os.path
import uuid
import av98.util as util
ui_out = logging.getLogger("av98_logger")
class ClientCertificateManager:
def __init__(self, config_dir):
self.config_dir = config_dir
self.client_certs = {
"active": None
}
self.active_cert_domains = []
self.active_is_transient = False
self.transient_certs_created = []
def cleanup(self):
for cert in self.transient_certs_created:
for ext in (".crt", ".key"):
certfile = os.path.join(self.config_dir, "transient_certs", cert+ext)
if os.path.exists(certfile):
os.remove(certfile)
def manage(self):
if self.client_certs["active"]:
print("Active certificate: {}".format(self.client_certs["active"][0]))
print("1. Deactivate client certificate.")
print("2. Generate new certificate.")
print("3. Load previously generated certificate.")
print("4. Load externally created client certificate from file.")
print("Enter blank line to exit certificate manager.")
choice = input("> ").strip()
if choice == "1":
print("Deactivating client certificate.")
self._deactivate_client_cert()
elif choice == "2":
self._generate_persistent_client_cert()
elif choice == "3":
self._choose_client_cert()
elif choice == "4":
self._load_client_cert()
else:
print("Aborting.")
def associate_client_cert(self, context, gi):
# Be careful with client certificates!
# Are we crossing a domain boundary?
if self.client_certs["active"] and gi.host not in self.active_cert_domains:
if self.active_is_transient:
if util.ask_yes_no("Permanently delete currently active transient certificate?"):
print("Destroying certificate.")
self._deactivate_client_cert()
else:
print("Staying here.")
return False
else:
if util.ask_yes_no("PRIVACY ALERT: Deactivate client cert before connecting to a new domain?"):
print("Deactivating certificate.")
self._deactivate_client_cert()
else:
print("Keeping certificate active for {}".format(gi.host))
self.active_cert_domains.append(gi.host)
self.client_certs[gi.host] = self.client_certs["active"]
# Suggest reactivating previous certs
if not self.client_certs["active"] and gi.host in self.client_certs:
if util.ask_yes_no("PRIVACY ALERT: Reactivate previously used client cert for {}?".format(gi.host)):
self._activate_client_cert(*self.client_certs[gi.host])
self.active_cert_domains.append(gi.host)
else:
print("Remaining unidentified.")
self.client_certs.pop(gi.host)
# Associate certs to context based on above
if self.client_certs["active"]:
certfile, keyfile = self.client_certs["active"]
context.load_cert_chain(certfile, keyfile)
return True
def is_cert_active(self):
return self.client_certs["active"] != None
def handle_cert_request(self, meta, status, host):
# Don't do client cert stuff in restricted mode, as in principle
# it could be used to fill up the disk by creating a whole lot of
# certificates
print("SERVER SAYS: ", meta)
# Present different messages for different 6x statuses, but
# handle them the same.
if status in ("64", "65"):
print("The server rejected your certificate because it is either expired or not yet valid.")
elif status == "63":
print("The server did not accept your certificate.")
print("You may need to e.g. coordinate with the admin to get your certificate fingerprint whitelisted.")
else:
print("The site {} is requesting a client certificate.".format(host))
print("This will allow the site to recognise you across requests.")
# Give the user choices
print("What do you want to do?")
print("1. Give up.")
print("2. Generate a new transient certificate.")
print("3. Generate a new persistent certificate.")
print("4. Load a previously generated certificate.")
print("5. Load a certificate from an external file.")
choice = input("> ").strip()
if choice == "2":
self._generate_transient_cert_cert()
elif choice == "3":
self._generate_persistent_client_cert()
elif choice == "4":
self._choose_client_cert()
elif choice == "5":
self._load_client_cert()
else:
print("Giving up.")
return False
if self.client_certs["active"]:
self.active_cert_domains.append(host)
self.client_certs[host] = self.client_certs["active"]
return True
def _load_client_cert(self):
"""
Interactively load a TLS client certificate from the filesystem in PEM
format.
"""
print("Loading client certificate file, in PEM format (blank line to cancel)")
certfile = input("Certfile path: ").strip()
if not certfile:
print("Aborting.")
return
certfile = os.path.expanduser(certfile)
if not os.path.isfile(certfile):
print("Certificate file {} does not exist.".format(certfile))
return
print("Loading private key file, in PEM format (blank line to cancel)")
keyfile = input("Keyfile path: ").strip()
if not keyfile:
print("Aborting.")
return
keyfile = os.path.expanduser(keyfile)
if not os.path.isfile(keyfile):
print("Private key file {} does not exist.".format(keyfile))
return
self._activate_client_cert(certfile, keyfile)
def _generate_transient_cert_cert(self):
"""
Use `openssl` command to generate a new transient client certificate
with 24 hours of validity.
"""
certdir = os.path.join(self.config_dir, "transient_certs")
name = str(uuid.uuid4())
self._generate_client_cert(certdir, name, transient=True)
self.active_is_transient = True
self.transient_certs_created.append(name)
def _generate_persistent_client_cert(self):
"""
Interactively use `openssl` command to generate a new persistent client
certificate with one year of validity.
"""
certdir = os.path.join(self.config_dir, "client_certs")
print("What do you want to name this new certificate?")
print("Answering `mycert` will create `{0}/mycert.crt` and `{0}/mycert.key`".format(certdir))
name = input("> ")
if not name.strip():
print("Aborting.")
return
self._generate_client_cert(certdir, name)
def _generate_client_cert(self, certdir, basename, transient=False):
"""
Use `openssl` binary to generate a client certificate (which may be
transient or persistent) and save the certificate and private key to the
specified directory with the specified basename.
"""
if not os.path.exists(certdir):
os.makedirs(certdir)
certfile = os.path.join(certdir, basename+".crt")
keyfile = os.path.join(certdir, basename+".key")
cmd = "openssl req -x509 -newkey rsa:2048 -days {} -nodes -keyout {} -out {}".format(1 if transient else 365, keyfile, certfile)
if transient:
cmd += " -subj '/CN={}'".format(basename)
os.system(cmd)
self._activate_client_cert(certfile, keyfile)
def _choose_client_cert(self):
"""
Interactively select a previously generated client certificate and
activate it.
"""
certdir = os.path.join(self.config_dir, "client_certs")
certs = glob.glob(os.path.join(certdir, "*.crt"))
if len(certs) == 0:
print("There are no previously generated certificates.")
return
certdir = {}
for n, cert in enumerate(certs):
certdir[str(n+1)] = (cert, os.path.splitext(cert)[0] + ".key")
print("{}. {}".format(n+1, os.path.splitext(os.path.basename(cert))[0]))
choice = input("> ").strip()
if choice in certdir:
certfile, keyfile = certdir[choice]
self._activate_client_cert(certfile, keyfile)
else:
print("What?")
def _activate_client_cert(self, certfile, keyfile):
self.client_certs["active"] = (certfile, keyfile)
self.active_cert_domains = []
ui_out.debug("Using ID {} / {}.".format(*self.client_certs["active"]))
def _deactivate_client_cert(self):
if self.active_is_transient:
for filename in self.client_certs["active"]:
os.remove(filename)
for domain in self.active_cert_domains:
self.client_certs.pop(domain)
self.client_certs["active"] = None
self.active_cert_domains = []
self.active_is_transient = False

1416
src/av98/client.py Executable file

File diff suppressed because it is too large Load Diff

130
src/av98/main.py Executable file
View File

@ -0,0 +1,130 @@
#!/usr/bin/env python3
# AV-98 Gemini client
# Dervied from VF-1 (https://github.com/solderpunk/VF-1),
# (C) 2019, 2020, 2023 Solderpunk <solderpunk@posteo.net>
# With contributions from:
# - danceka <hannu.hartikainen@gmail.com>
# - <jprjr@tilde.club>
# - <vee@vnsf.xyz>
# - Klaus Alexander Seistrup <klaus@seistrup.dk>
# - govynnus <govynnus@sdf.org>
# - Nik <nic@tilde.team>
# - <sario528@ctrl-c.club>
# - rmgr
# - Aleksey Ryndin
import argparse
import os.path
import shutil
import sys
from av98 import __version__
from av98.client import GeminiClient
def main():
# Parse args
parser = argparse.ArgumentParser(description='A command line gemini client.')
parser.add_argument('--bookmarks', action='store_true',
help='start with your list of bookmarks')
parser.add_argument('--dl', '--download', action='store_true',
help='download a single URL and quit')
parser.add_argument('-o', '--output', metavar='FILE',
help='filename to save --dl URL to')
parser.add_argument('--tls-cert', metavar='FILE', help='TLS client certificate file')
parser.add_argument('--tls-key', metavar='FILE', help='TLS client certificate private key file')
parser.add_argument('--restricted', action="store_true", help='Disallow shell, add, and save commands')
parser.add_argument('--version', action='store_true',
help='display version information and quit')
parser.add_argument('url', metavar='URL', nargs='*',
help='start with this URL')
args = parser.parse_args()
# Handle --version
if args.version:
print("AV-98 " + __version__)
sys.exit()
# Instantiate client
gc = GeminiClient(args.restricted)
# Activate client certs now in case they are needed for --download below
if args.tls_cert and args.tls_key:
gc.client_cert_manager._activate_client_cert(args.tls_cert, args.tls_key)
for url in args.url:
gi = GeminiItem(url)
gc.client_cert_manager.active_cert_domains.append(gi.host)
# Handle --download
if args.dl:
gc.onecmd("set debug True")
# Download
gi = GeminiItem(args.url[0])
gi, mime = gc._fetch_over_network(gi)
# Decide on a filename
if args.output:
filename = args.output
else:
if mime == "text/gemini":
# Parse gemtext in the hopes of getting a gi.name for the filename
gc.active_raw_file = gc.raw_file_buffer
gc._handle_gemtext(gi)
filename = gi.derive_filename(mime)
# Copy from temp file to pwd with a nice name
shutil.copyfile(gc.raw_file_buffer, filename)
size = os.path.getsize(filename)
# Notify user where the file ended up
print("Wrote %d byte %s response to %s." % (size, mime, filename))
gc.do_quit()
sys.exit()
# Process config file
rcfile = os.path.join(gc.config_dir, "av98rc")
if os.path.exists(rcfile):
print("Using config %s" % rcfile)
with open(rcfile, "r") as fp:
for line in fp:
line = line.strip()
if ((args.bookmarks or args.url) and
any((line.startswith(x) for x in ("go", "g", "tour", "t")))
):
if args.bookmarks:
print("Skipping rc command \"%s\" due to --bookmarks option." % line)
else:
print("Skipping rc command \"%s\" due to provided URLs." % line)
continue
gc.cmdqueue.append(line)
# Say hi
print("Welcome to AV-98!")
if args.restricted:
print("Restricted mode engaged!")
print("Enjoy your patrol through Geminispace...")
# Add commands to the queue based on command line arguments
if args.bookmarks:
gc.cmdqueue.append("bookmarks")
elif args.url:
if len(args.url) == 1:
gc.cmdqueue.append("go %s" % args.url[0])
else:
for url in args.url:
if not url.startswith("gemini://"):
url = "gemini://" + url
gc.cmdqueue.append("tour %s" % url)
gc.cmdqueue.append("tour")
# Endless interpret loop until user quits
while True:
try:
gc.cmdloop()
break
except KeyboardInterrupt:
print("")
# Say goodbye
print()
print("Thank you for patrolling with AV-98!")
sys.exit()
if __name__ == '__main__':
main()

210
src/av98/tofu.py Normal file
View File

@ -0,0 +1,210 @@
import datetime
import hashlib
import logging
import os
import os.path
import sqlite3
import ssl
import time
try:
from cryptography import x509
from cryptography.hazmat.backends import default_backend
_HAS_CRYPTOGRAPHY = True
_BACKEND = default_backend()
except ModuleNotFoundError:
_HAS_CRYPTOGRAPHY = False
import av98.util as util
ui_out = logging.getLogger("av98_logger")
class TofuStore:
def __init__(self, config_dir):
self.config_dir = config_dir
self.certdir = os.path.join(config_dir, "cert_cache")
if not os.path.exists(self.certdir):
os.makedirs(self.certdir)
db_path = os.path.join(self.config_dir, "tofu.db")
self.db_conn = sqlite3.connect(db_path)
self.db_cur = self.db_conn.cursor()
self.create_db()
self.update_db()
def create_db(self):
self.db_cur.execute("""CREATE TABLE IF NOT EXISTS cert_cache
(hostname text, port integer, address text, fingerprint text,
first_seen date, last_seen date, count integer)""")
def update_db(self):
# Update 1 - check for port column
try:
self.db_cur.execute("""SELECT port FROM cert_cache where 1=0""")
has_port = True
except sqlite3.OperationalError:
has_port = False
if not has_port:
self.db_cur.execute("""ALTER TABLE cert_cache ADD COLUMN port integer""")
self.db_cur.execute("""UPDATE cert_cache SET port= 1965 WHERE count > 0""")
def close(self):
self.db_conn.commit()
self.db_conn.close()
def validate_cert(self, address, port, host, cert):
"""
Validate a TLS certificate in TOFU mode.
If the cryptography module is installed:
- Check the certificate Common Name or SAN matches `host`
- Check the certificate's not valid before date is in the past
- Check the certificate's not valid after date is in the future
Whether the cryptography module is installed or not, check the
certificate's fingerprint against the TOFU database to see if we've
previously encountered a different certificate for this hostname and
port
"""
now = datetime.datetime.utcnow()
# Do 'advanced' checks if Cryptography library is installed
if _HAS_CRYPTOGRAPHY:
self.check_cert_expiry_and_names(cert, host, now)
# Compute SHA256 fingerprint
sha = hashlib.sha256()
sha.update(cert)
fingerprint = sha.hexdigest()
# Have we been here before?
self.db_cur.execute("""SELECT fingerprint, address, first_seen, last_seen, count
FROM cert_cache WHERE hostname=? AND port=?""", (host, port))
cached_certs = self.db_cur.fetchall()
# If not, cache this first cert and we're done
if not cached_certs:
ui_out.debug("TOFU: Blindly trusting first ever certificate for this host!")
self.cache_new_cert(cert, host, port, address, fingerprint, now)
return
# If we have, check the received cert against the cache
if self.find_cert_in_cache(host, port, fingerprint, cached_certs, now):
return
# Handle an unrecognised cert
ui_out.debug("TOFU: Unrecognised certificate {}! Raising the alarm...".format(fingerprint))
## Find the most recently seen previous cert for reporting
most_recent = None
for cached_fingerprint, cached_address, first, last, count in cached_certs:
if not most_recent or last > most_recent:
most_recent = last
most_recent_cert = cached_fingerprint
most_recent_address = cached_address
most_recent_count = count
## Report the situation
print("****************************************")
print("[SECURITY WARNING] Unrecognised certificate!")
print("The certificate presented for {}:{} ({}) has never been seen before.".format(host, port, address))
print("This MIGHT be a Man-in-the-Middle attack.")
print("A different certificate has previously been seen {} times.".format(most_recent_count))
if _HAS_CRYPTOGRAPHY:
previous_ttl = self.get_cached_cert_expiry(most_recent_cert) - now
if previous_ttl < datetime.timedelta():
print("That certificate has expired, which reduces suspicion somewhat.")
else:
print("That certificate is still valid for: {}".format(previous_ttl))
if most_recent_address == address:
print("The new certificate is being served from the same IP address as the previous one.")
else:
print("The new certificate is being served from a DIFFERNET IP address as the previous one.")
print("****************************************")
print("Attempt to verify the new certificate fingerprint out-of-band:")
print(fingerprint)
## Ask the question
if util.ask_yes_no("Accept this new certificate?"):
self.cache_new_cert(cert, host, port, address, fingerprint, now)
else:
raise Exception("TOFU Failure!")
def cache_new_cert(self, cert, host, port, address, fingerprint, now):
"""
Accept a new certificate for a given host/port combo.
"""
# Save cert to disk
with open(os.path.join(self.certdir, fingerprint+".crt"), "wb") as fp:
fp.write(cert)
# Record in DB
self.db_cur.execute("""INSERT INTO cert_cache
(hostname, port, address, fingerprint, first_seen, last_seen, count)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(host, port, address, fingerprint, now, now, 1))
self.db_conn.commit()
def check_cert_expiry_and_names(self, cert, host, now):
"""
- Check the certificate Common Name or SAN matches `host`
- Check the certificate's not valid before date is in the past
- Check the certificate's not valid after date is in the future
"""
c = x509.load_der_x509_certificate(cert, _BACKEND)
# Check certificate validity dates
if c.not_valid_before >= now:
raise ssl.CertificateError("Certificate not valid until: {}!".format(c.not_valid_before))
elif c.not_valid_after <= now:
raise ssl.CertificateError("Certificate expired as of: {})!".format(c.not_valid_after))
# Check certificate hostnames
names = []
common_name = c.subject.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME)
if common_name:
names.append(common_name[0].value)
try:
names.extend([alt.value for alt in c.extensions.get_extension_for_oid(x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value])
except x509.ExtensionNotFound:
pass
names = set(names)
for name in names:
try:
ssl._dnsname_match(name, host)
break
except Exception:
continue
else:
# If we didn't break out, none of the names were valid
raise ssl.CertificateError("Hostname does not match certificate common name or any alternative names.")
def find_cert_in_cache(self, host, port, fingerprint, cached_certs, now):
"""
Try to find a cached certificate for the given host:port matching the
given fingerprint. If one is found, update the "last seen" DB value.
"""
for cached_fingerprint, cached_address, first, last, count in cached_certs:
if fingerprint == cached_fingerprint:
# Matched!
ui_out.debug("TOFU: Accepting previously seen ({} times) certificate {}".format(count, fingerprint))
self.db_cur.execute("""UPDATE cert_cache
SET last_seen=?, count=?
WHERE hostname=? AND port=? AND fingerprint=?""",
(now, count+1, host, port, fingerprint))
self.db_conn.commit()
return True
return False
def get_cached_cert_expiry(self, fingerprint):
"""
Parse the stored certificate with a given fingerprint and return its
expiry date.
"""
with open(os.path.join(self.certdir, fingerprint+".crt"), "rb") as fp:
previous_cert = fp.read()
previous_cert = x509.load_der_x509_certificate(previous_cert, _BACKEND)
return previous_cert.not_valid_after

50
src/av98/util.py Normal file
View File

@ -0,0 +1,50 @@
import os.path
# Cheap and cheerful URL detector
def looks_like_url(word):
return "." in word and word.startswith("gemini://")
def handle_filename_collisions(filename):
while os.path.exists(filename):
print("File %s already exists!" % filename)
filename = input("Choose a new one, or leave blank to abort: ")
return filename
def ask_yes_no(prompt, default=None):
print(prompt)
if default == True:
prompt = "(Y)/N: "
elif default == False:
prompt = "Y/(N): "
else:
prompt = "Y/N: "
while True:
resp = input(prompt)
if not resp.strip() and default != None:
return default
elif resp.strip().lower() in ("y", "yes"):
return True
elif resp.strip().lower() in ("n","no"):
return False
def fix_ipv6_url(url):
if not url.count(":") > 2: # Best way to detect them?
return url
# If there's a pair of []s in there, it's probably fine as is.
if "[" in url and "]" in url:
return url
# Easiest case is a raw address, no schema, no path.
# Just wrap it in square brackets and whack a slash on the end
if "/" not in url:
return "[" + url + "]/"
# Now the trickier cases...
if "://" in url:
schema, schemaless = url.split("://")
else:
schema, schemaless = None, url
if "/" in schemaless:
netloc, rest = schemaless.split("/",1)
schemaless = "[" + netloc + "]" + "/" + rest
if schema:
return schema + "://" + schemaless
return schemaless