diff options
author | Thomas Touhey <thomas@touhey.fr> | 2021-09-04 13:35:47 +0200 |
---|---|---|
committer | Thomas Touhey <thomas@touhey.fr> | 2021-09-04 13:35:47 +0200 |
commit | 5ee802842794f112977b7b9273e35f45f2bf8a88 (patch) | |
tree | 5fa3948e850706f7a56a8a94f5290bad19b4cd00 | |
parent | 2ee7db103f5e391e1131c6e5bbb6d836f024120d (diff) |
Reorganized the Python module a tad, gonna rework the docs as well
-rw-r--r-- | .python-version | 2 | ||||
-rwxr-xr-x | Makefile | 14 | ||||
-rw-r--r-- | Pipfile | 6 | ||||
-rw-r--r-- | Pipfile.lock | 244 | ||||
-rw-r--r-- | docs/.python-version | 1 | ||||
-rw-r--r-- | docs/Makefile | 2 | ||||
-rw-r--r-- | docs/Pipfile | 3 | ||||
-rw-r--r-- | docs/Pipfile.lock | 291 | ||||
-rw-r--r-- | docs/api/config.rst (renamed from docs/configure.rst) | 0 | ||||
-rw-r--r-- | docs/conf.py | 2 | ||||
-rw-r--r-- | docs/explain.rst | 6 | ||||
-rw-r--r-- | docs/howto.rst | 7 | ||||
-rw-r--r-- | docs/index.rst | 6 | ||||
-rw-r--r-- | docs/onboarding.rst (renamed from docs/run.rst) | 8 | ||||
-rwxr-xr-x | fingerd/__init__.py | 82 | ||||
-rwxr-xr-x | fingerd/__main__.py | 6 | ||||
-rwxr-xr-x | fingerd/_binds.py | 238 | ||||
-rwxr-xr-x | fingerd/_exceptions.py | 26 | ||||
-rwxr-xr-x | fingerd/_server.py | 342 | ||||
-rwxr-xr-x | fingerd/_util.py | 509 | ||||
-rw-r--r-- | fingerd/cli.py | 63 | ||||
-rwxr-xr-x | fingerd/errors.py | 52 | ||||
-rwxr-xr-x | fingerd/fiction.py (renamed from fingerd/_fiction.py) | 212 | ||||
-rwxr-xr-x | fingerd/server.py | 1064 | ||||
-rw-r--r-- | fingerd/version.py | 10 | ||||
-rw-r--r-- | sample-scenarios/actions.toml | 72 | ||||
-rw-r--r-- | setup.cfg | 3 |
27 files changed, 1628 insertions, 1643 deletions
diff --git a/.python-version b/.python-version index a76ccff..11aaa06 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.7.1 +3.9.5 @@ -1,7 +1,7 @@ #!/usr/bin/make -f PE := pipenv run ST := $(PE) ./setup.py - DNAME := dist/$(shell $(ST) --name)-$(shell $(ST) --version).tar.gz + DNAME := dist/$(shell PIPENV_DONT_LOAD_ENV=1 $(ST) --name)-$(shell PIPENV_DONT_LOAD_ENV=1 $(ST) --version).tar.gz test tests: $(ST) test @@ -17,9 +17,14 @@ checkdocs: @$(ST) checkdocs run: - @pipenv run python -m fingerd + @pipenv run python -m fingerd --scenario sample-scenarios/actions.toml +redirect-localhost-ports: + iptables -t nat -A OUTPUT -p tcp -s 127.0.0.1 -d 127.0.0.1 \ + --dport 79 -j DNAT --to 127.0.0.1:3999 + ip6tables -t nat -A OUTPUT -p tcp -s ::1 -d ::1 \ + --dport 79 -j DNAT --to '[::1]:3999' -dist: $(DNAMe) +dist: $(DNAME) $(DNAME): $(ST) sdist upload: $(DNAME) @@ -31,5 +36,6 @@ install-user: $(ST) install --user .PHONY: test tests prepare update docs checkdocs -.PHONY: run dist upload install install-user +.PHONY: run redirect-localhost-ports +.PHONY: dist upload install install-user # End of file. @@ -4,14 +4,12 @@ verify_ssl = true name = 'pypi' [requires] -python_version = '3.7' +python_version = '3.9' [packages] python-dotenv = '*' nanomsg = '*' toml = '*' +click = '*' [dev-packages] -sphinx = '*' -sphinx_rtd_theme = '*' -"collective.checkdocs" = '*' diff --git a/Pipfile.lock b/Pipfile.lock index 41004d3..5f96c40 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,11 +1,11 @@ { "_meta": { "hash": { - "sha256": "9e0f4516a6616f2d7a636e6380ff0a2b074eb58e224d1474c169f794c529e155" + "sha256": "f5ee09d66def8c5ee2107e42585d2b58120e45fdcbf0e654c7a3a34acb9979bf" }, "pipfile-spec": 6, "requires": { - "python_version": "3.7" + "python_version": "3.9" }, "sources": [ { @@ -16,6 +16,14 @@ ] }, "default": { + "click": { + "hashes": [ + "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a", + "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6" + ], + "index": "pypi", + "version": "==8.0.1" + }, "nanomsg": { "hashes": [ "sha256:843be41258219d9d319cf434a68cac7669834ab9c993ea4bab5b3d87f62a7a13" @@ -25,238 +33,20 @@ }, "python-dotenv": { "hashes": [ - "sha256:debd928b49dbc2bf68040566f55cdb3252458036464806f4094487244e2a4093", - "sha256:f157d71d5fec9d4bd5f51c82746b6344dffa680ee85217c123f4a0c8117c4544" + "sha256:00aa34e92d992e9f8383730816359647f358f4a3be1ba45e5a5cefd27ee91544", + "sha256:b1ae5e9643d5ed987fc57cc2583021e38db531946518130777734f9589b3141f" ], "index": "pypi", - "version": "==0.10.3" + "version": "==0.17.1" }, "toml": { "hashes": [ - "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", - "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e" + "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", + "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], "index": "pypi", - "version": "==0.10.0" + "version": "==0.10.2" } }, - "develop": { - "alabaster": { - "hashes": [ - "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", - "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02" - ], - "version": "==0.7.12" - }, - "attrs": { - "hashes": [ - "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", - "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" - ], - "version": "==19.1.0" - }, - "babel": { - "hashes": [ - "sha256:af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab", - "sha256:e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28" - ], - "version": "==2.7.0" - }, - "certifi": { - "hashes": [ - "sha256:046832c04d4e752f37383b628bc601a7ea7211496b4638f6514d0e5b9acc4939", - "sha256:945e3ba63a0b9f577b1395204e13c3a231f9bc0223888be653286534e5873695" - ], - "version": "==2019.6.16" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "collective.checkdocs": { - "hashes": [ - "sha256:3a5328257c5224bc72753820c182910d7fb336bc1dba5e09113d48566655e46e" - ], - "index": "pypi", - "version": "==0.2" - }, - "docutils": { - "hashes": [ - "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0", - "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827", - "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99" - ], - "version": "==0.15.2" - }, - "idna": { - "hashes": [ - "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", - "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" - ], - "version": "==2.8" - }, - "imagesize": { - "hashes": [ - "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8", - "sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5" - ], - "version": "==1.1.0" - }, - "jinja2": { - "hashes": [ - "sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", - "sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b" - ], - "version": "==2.10.1" - }, - "markupsafe": { - "hashes": [ - "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", - "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", - "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", - "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", - "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", - "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", - "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", - "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", - "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", - "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", - "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", - "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", - "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", - "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", - "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", - "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", - "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", - "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", - "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", - "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", - "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", - "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", - "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", - "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", - "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", - "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", - "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", - "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7" - ], - "version": "==1.1.1" - }, - "packaging": { - "hashes": [ - "sha256:a7ac867b97fdc07ee80a8058fe4435ccd274ecc3b0ed61d852d7d53055528cf9", - "sha256:c491ca87294da7cc01902edbe30a5bc6c4c28172b5138ab4e4aa1b9d7bfaeafe" - ], - "version": "==19.1" - }, - "pygments": { - "hashes": [ - "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", - "sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297" - ], - "version": "==2.4.2" - }, - "pyparsing": { - "hashes": [ - "sha256:6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80", - "sha256:d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4" - ], - "version": "==2.4.2" - }, - "pytz": { - "hashes": [ - "sha256:26c0b32e437e54a18161324a2fca3c4b9846b74a8dccddd843113109e1116b32", - "sha256:c894d57500a4cd2d5c71114aaab77dbab5eabd9022308ce5ac9bb93a60a6f0c7" - ], - "version": "==2019.2" - }, - "requests": { - "hashes": [ - "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", - "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" - ], - "version": "==2.22.0" - }, - "six": { - "hashes": [ - "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", - "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" - ], - "version": "==1.12.0" - }, - "snowballstemmer": { - "hashes": [ - "sha256:713e53b79cbcf97bc5245a06080a33d54a77e7cce2f789c835a143bcdb5c033e" - ], - "version": "==1.9.1" - }, - "sphinx": { - "hashes": [ - "sha256:0d586b0f8c2fc3cc6559c5e8fd6124628110514fda0e5d7c82e682d749d2e845", - "sha256:839a3ed6f6b092bb60f492024489cc9e6991360fb9f52ed6361acd510d261069" - ], - "index": "pypi", - "version": "==2.2.0" - }, - "sphinx-rtd-theme": { - "hashes": [ - "sha256:00cf895504a7895ee433807c62094cf1e95f065843bf3acd17037c3e9a2becd4", - "sha256:728607e34d60456d736cc7991fd236afb828b21b82f956c5ea75f94c8414040a" - ], - "index": "pypi", - "version": "==0.4.3" - }, - "sphinxcontrib-applehelp": { - "hashes": [ - "sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897", - "sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d" - ], - "version": "==1.0.1" - }, - "sphinxcontrib-devhelp": { - "hashes": [ - "sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34", - "sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981" - ], - "version": "==1.0.1" - }, - "sphinxcontrib-htmlhelp": { - "hashes": [ - "sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422", - "sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7" - ], - "version": "==1.0.2" - }, - "sphinxcontrib-jsmath": { - "hashes": [ - "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", - "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" - ], - "version": "==1.0.1" - }, - "sphinxcontrib-qthelp": { - "hashes": [ - "sha256:513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20", - "sha256:79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f" - ], - "version": "==1.0.2" - }, - "sphinxcontrib-serializinghtml": { - "hashes": [ - "sha256:c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227", - "sha256:db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768" - ], - "version": "==1.1.3" - }, - "urllib3": { - "hashes": [ - "sha256:b246607a25ac80bedac05c6f282e3cdaf3afb65420fd024ac94435cabe6e18d1", - "sha256:dbe59173209418ae49d485b87d1681aefa36252ee85884c31346debd19463232" - ], - "version": "==1.25.3" - } - } + "develop": {} } diff --git a/docs/.python-version b/docs/.python-version deleted file mode 100644 index 0b2eb36..0000000 --- a/docs/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.7.2 diff --git a/docs/Makefile b/docs/Makefile index ebf70c7..972c107 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -10,7 +10,7 @@ SPHINXWATCH = $(PE) sphinx-autobuild SPHINXPROJ = textoutpc SOURCEDIR = . BUILDDIR = _build -WEBROOT = fingerd.touhey.pro:fingerd_doc +WEBROOT = hercule:fingerd/docs # Put it first so that "make" without argument is like "make help". help: diff --git a/docs/Pipfile b/docs/Pipfile index 57a81a3..bca103b 100644 --- a/docs/Pipfile +++ b/docs/Pipfile @@ -8,6 +8,7 @@ verify_ssl = true [packages] sphinx = "*" sphinx-rtd-theme = "*" +sphinx-autobuild = "*" [requires] -python_version = "3.7" +python_version = "3.9" diff --git a/docs/Pipfile.lock b/docs/Pipfile.lock index 22db0bb..9003dd3 100644 --- a/docs/Pipfile.lock +++ b/docs/Pipfile.lock @@ -1,11 +1,11 @@ { "_meta": { "hash": { - "sha256": "ad49cdffc75c09b6abfb00f80a96f9c4967858dfc7153f34febb993592308482" + "sha256": "94b3326dd69ba0924a8e8ba1a51f1972bceebacbd4daa2f0c7200a78f86e7f35" }, "pipfile-spec": 6, "requires": { - "python_version": "3.7" + "python_version": "3.9" }, "sources": [ { @@ -25,200 +25,293 @@ }, "babel": { "hashes": [ - "sha256:af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab", - "sha256:e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28" + "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9", + "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0" ], - "version": "==2.7.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.9.1" }, "certifi": { "hashes": [ - "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5", - "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae" + "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", + "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" ], - "version": "==2019.3.9" + "version": "==2021.5.30" }, "chardet": { "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", + "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" ], - "version": "==3.0.4" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==4.0.0" + }, + "colorama": { + "hashes": [ + "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", + "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==0.4.4" }, "docutils": { "hashes": [ - "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", - "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274", - "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6" + "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", + "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" ], - "version": "==0.14" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==0.16" }, "idna": { "hashes": [ - "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", - "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" + "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", + "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], - "version": "==2.8" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.10" }, "imagesize": { "hashes": [ - "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8", - "sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5" + "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", + "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" ], - "version": "==1.1.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.2.0" }, "jinja2": { "hashes": [ - "sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", - "sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b" + "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4", + "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4" + ], + "markers": "python_version >= '3.6'", + "version": "==3.0.1" + }, + "livereload": { + "hashes": [ + "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869" ], - "version": "==2.10.1" + "version": "==2.6.3" }, "markupsafe": { "hashes": [ - "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", - "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", - "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", - "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", - "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", - "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", - "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", - "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", - "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", - "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", - "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", - "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", - "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", - "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", - "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", - "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", - "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", - "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", - "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", - "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", - "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", - "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", - "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", - "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", - "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", - "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", - "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", - "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7" - ], - "version": "==1.1.1" + "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", + "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", + "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", + "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", + "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", + "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", + "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", + "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", + "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", + "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", + "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", + "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", + "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", + "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", + "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", + "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", + "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", + "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", + "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", + "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", + "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", + "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", + "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", + "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", + "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", + "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", + "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", + "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", + "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", + "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", + "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", + "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", + "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", + "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" + ], + "markers": "python_version >= '3.6'", + "version": "==2.0.1" }, "packaging": { "hashes": [ - "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af", - "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3" + "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5", + "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a" ], - "version": "==19.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==20.9" }, "pygments": { "hashes": [ - "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", - "sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297" + "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f", + "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e" ], - "version": "==2.4.2" + "markers": "python_version >= '3.5'", + "version": "==2.9.0" }, "pyparsing": { "hashes": [ - "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a", - "sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03" + "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", + "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "version": "==2.4.0" + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.4.7" }, "pytz": { "hashes": [ - "sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", - "sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141" + "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da", + "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798" ], - "version": "==2019.1" + "version": "==2021.1" }, "requests": { "hashes": [ - "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", - "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31" + "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", + "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" ], - "version": "==2.22.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==2.25.1" }, "six": { "hashes": [ - "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", - "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], - "version": "==1.12.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" }, "snowballstemmer": { "hashes": [ - "sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128", - "sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89" + "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2", + "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914" ], - "version": "==1.2.1" + "version": "==2.1.0" }, "sphinx": { "hashes": [ - "sha256:423280646fb37944dd3c85c58fb92a20d745793a9f6c511f59da82fa97cd404b", - "sha256:de930f42600a4fef993587633984cc5027dedba2464bcf00ddace26b40f8d9ce" + "sha256:b5c2ae4120bf00c799ba9b3699bc895816d272d120080fbc967292f29b52b48c", + "sha256:d1cb10bee9c4231f1700ec2e24a91be3f3a3aba066ea4ca9f3bbe47e59d5a1d4" ], "index": "pypi", - "version": "==2.0.1" + "version": "==4.0.2" + }, + "sphinx-autobuild": { + "hashes": [ + "sha256:8fe8cbfdb75db04475232f05187c776f46f6e9e04cacf1e49ce81bdac649ccac", + "sha256:de1ca3b66e271d2b5b5140c35034c89e47f263f2cd5db302c9217065f7443f05" + ], + "index": "pypi", + "version": "==2021.3.14" }, "sphinx-rtd-theme": { "hashes": [ - "sha256:00cf895504a7895ee433807c62094cf1e95f065843bf3acd17037c3e9a2becd4", - "sha256:728607e34d60456d736cc7991fd236afb828b21b82f956c5ea75f94c8414040a" + "sha256:32bd3b5d13dc8186d7a42fc816a23d32e83a4827d7d9882948e7b837c232da5a", + "sha256:4a05bdbe8b1446d77a01e20a23ebc6777c74f43237035e76be89699308987d6f" ], "index": "pypi", - "version": "==0.4.3" + "version": "==0.5.2" }, "sphinxcontrib-applehelp": { "hashes": [ - "sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897", - "sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d" + "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", + "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" ], - "version": "==1.0.1" + "markers": "python_version >= '3.5'", + "version": "==1.0.2" }, "sphinxcontrib-devhelp": { "hashes": [ - "sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34", - "sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981" + "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", + "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" ], - "version": "==1.0.1" + "markers": "python_version >= '3.5'", + "version": "==1.0.2" }, "sphinxcontrib-htmlhelp": { "hashes": [ - "sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422", - "sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7" + "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07", + "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2" ], - "version": "==1.0.2" + "markers": "python_version >= '3.6'", + "version": "==2.0.0" }, "sphinxcontrib-jsmath": { "hashes": [ "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" ], + "markers": "python_version >= '3.5'", "version": "==1.0.1" }, "sphinxcontrib-qthelp": { "hashes": [ - "sha256:513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20", - "sha256:79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f" + "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", + "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" ], - "version": "==1.0.2" + "markers": "python_version >= '3.5'", + "version": "==1.0.3" }, "sphinxcontrib-serializinghtml": { "hashes": [ - "sha256:c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227", - "sha256:db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768" + "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd", + "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952" + ], + "markers": "python_version >= '3.5'", + "version": "==1.1.5" + }, + "tornado": { + "hashes": [ + "sha256:0a00ff4561e2929a2c37ce706cb8233b7907e0cdc22eab98888aca5dd3775feb", + "sha256:0d321a39c36e5f2c4ff12b4ed58d41390460f798422c4504e09eb5678e09998c", + "sha256:1e8225a1070cd8eec59a996c43229fe8f95689cb16e552d130b9793cb570a288", + "sha256:20241b3cb4f425e971cb0a8e4ffc9b0a861530ae3c52f2b0434e6c1b57e9fd95", + "sha256:25ad220258349a12ae87ede08a7b04aca51237721f63b1808d39bdb4b2164558", + "sha256:33892118b165401f291070100d6d09359ca74addda679b60390b09f8ef325ffe", + "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791", + "sha256:3447475585bae2e77ecb832fc0300c3695516a47d46cefa0528181a34c5b9d3d", + "sha256:34ca2dac9e4d7afb0bed4677512e36a52f09caa6fded70b4e3e1c89dbd92c326", + "sha256:3e63498f680547ed24d2c71e6497f24bca791aca2fe116dbc2bd0ac7f191691b", + "sha256:548430be2740e327b3fe0201abe471f314741efcb0067ec4f2d7dcfb4825f3e4", + "sha256:6196a5c39286cc37c024cd78834fb9345e464525d8991c21e908cc046d1cc02c", + "sha256:61b32d06ae8a036a6607805e6720ef00a3c98207038444ba7fd3d169cd998910", + "sha256:6286efab1ed6e74b7028327365cf7346b1d777d63ab30e21a0f4d5b275fc17d5", + "sha256:65d98939f1a2e74b58839f8c4dab3b6b3c1ce84972ae712be02845e65391ac7c", + "sha256:66324e4e1beede9ac79e60f88de548da58b1f8ab4b2f1354d8375774f997e6c0", + "sha256:6c77c9937962577a6a76917845d06af6ab9197702a42e1346d8ae2e76b5e3675", + "sha256:70dec29e8ac485dbf57481baee40781c63e381bebea080991893cd297742b8fd", + "sha256:7250a3fa399f08ec9cb3f7b1b987955d17e044f1ade821b32e5f435130250d7f", + "sha256:748290bf9112b581c525e6e6d3820621ff020ed95af6f17fedef416b27ed564c", + "sha256:7da13da6f985aab7f6f28debab00c67ff9cbacd588e8477034c0652ac141feea", + "sha256:8f959b26f2634a091bb42241c3ed8d3cedb506e7c27b8dd5c7b9f745318ddbb6", + "sha256:9de9e5188a782be6b1ce866e8a51bc76a0fbaa0e16613823fc38e4fc2556ad05", + "sha256:a48900ecea1cbb71b8c71c620dee15b62f85f7c14189bdeee54966fbd9a0c5bd", + "sha256:b87936fd2c317b6ee08a5741ea06b9d11a6074ef4cc42e031bc6403f82a32575", + "sha256:c77da1263aa361938476f04c4b6c8916001b90b2c2fdd92d8d535e1af48fba5a", + "sha256:cb5ec8eead331e3bb4ce8066cf06d2dfef1bfb1b2a73082dfe8a161301b76e37", + "sha256:cc0ee35043162abbf717b7df924597ade8e5395e7b66d18270116f8745ceb795", + "sha256:d14d30e7f46a0476efb0deb5b61343b1526f73ebb5ed84f23dc794bdb88f9d9f", + "sha256:d371e811d6b156d82aa5f9a4e08b58debf97c302a35714f6f45e35139c332e32", + "sha256:d3d20ea5782ba63ed13bc2b8c291a053c8d807a8fa927d941bd718468f7b950c", + "sha256:d3f7594930c423fd9f5d1a76bee85a2c36fd8b4b16921cae7e965f22575e9c01", + "sha256:dcef026f608f678c118779cd6591c8af6e9b4155c44e0d1bc0c87c036fb8c8c4", + "sha256:e0791ac58d91ac58f694d8d2957884df8e4e2f6687cdf367ef7eb7497f79eaa2", + "sha256:e385b637ac3acaae8022e7e47dfa7b83d3620e432e3ecb9a3f7f58f150e50921", + "sha256:e519d64089b0876c7b467274468709dadf11e41d65f63bba207e04217f47c085", + "sha256:e7229e60ac41a1202444497ddde70a48d33909e484f96eb0da9baf8dc68541df", + "sha256:ed3ad863b1b40cd1d4bd21e7498329ccaece75db5a5bf58cd3c9f130843e7102", + "sha256:f0ba29bafd8e7e22920567ce0d232c26d4d47c8b5cf4ed7b562b5db39fa199c5", + "sha256:fa2ba70284fa42c2a5ecb35e322e68823288a4251f9ba9cc77be04ae15eada68", + "sha256:fba85b6cd9c39be262fcd23865652920832b61583de2a2ca907dbd8e8a8c81e5" ], - "version": "==1.1.3" + "markers": "python_version >= '3.5'", + "version": "==6.1" }, "urllib3": { "hashes": [ - "sha256:b246607a25ac80bedac05c6f282e3cdaf3afb65420fd024ac94435cabe6e18d1", - "sha256:dbe59173209418ae49d485b87d1681aefa36252ee85884c31346debd19463232" + "sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c", + "sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098" ], - "version": "==1.25.3" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==1.26.5" } }, "develop": {} diff --git a/docs/configure.rst b/docs/api/config.rst index 978d848..978d848 100644 --- a/docs/configure.rst +++ b/docs/api/config.rst diff --git a/docs/conf.py b/docs/conf.py index 5b882e0..fcbd235 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ # -- Project information ----------------------------------------------------- project = 'fingerd' -copyright = '2019, Thomas Touhey' +copyright = '2019-2021, Thomas Touhey' author = 'Thomas Touhey' # The full version, including alpha/beta/rc tags diff --git a/docs/explain.rst b/docs/explain.rst new file mode 100644 index 0000000..c173121 --- /dev/null +++ b/docs/explain.rst @@ -0,0 +1,6 @@ +Discussion topics +================= + +.. todo:: + + Discuss finger concepts, and stuff. diff --git a/docs/howto.rst b/docs/howto.rst new file mode 100644 index 0000000..5ecb176 --- /dev/null +++ b/docs/howto.rst @@ -0,0 +1,7 @@ +Howtos +====== + +In this section, you will be able to find tutorials for solving specific +problems using fingerd. + +.. toctree:: diff --git a/docs/index.rst b/docs/index.rst index 0ec0706..87b7ef1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,8 +11,10 @@ project homepage at `fingerd.touhey.pro <https://fingerd.touhey.pro/>`_! .. toctree:: :maxdepth: 2 - run - configure + onboarding + howto + explain + api .. _RFC 742: https://tools.ietf.org/html/rfc742 .. _RFC 1288: https://tools.ietf.org/html/rfc1288 diff --git a/docs/run.rst b/docs/onboarding.rst index 3204d16..767dc47 100644 --- a/docs/run.rst +++ b/docs/onboarding.rst @@ -1,5 +1,5 @@ -Running fingerd -=============== +Onboarding +========== ``fingerd`` can be run directly and without configuration (while taking its default option values) with the following command: @@ -11,3 +11,7 @@ default option values) with the following command: The server will run while giving native information on TCP port 79 on both IPv4 and IPv6 (if available). However, you can tweak this behaviour by configuring it. + +.. todo:: + + Make a little tutorial about how to run a finger server. diff --git a/fingerd/__init__.py b/fingerd/__init__.py index aa779a9..a43600c 100755 --- a/fingerd/__init__.py +++ b/fingerd/__init__.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 #************************************************************************** -# Copyright (C) 2017-2019 Thomas Touhey <thomas@touhey.fr> +# Copyright (C) 2017-2021 Thomas Touhey <thomas@touhey.fr> # This file is part of the fingerd project, which is MIT-licensed. #************************************************************************** """ finger is both a protocol and a utility to get the information and @@ -15,22 +15,11 @@ import os.path as _path from os import environ as _environ from sys import stderr as _stderr -from argparse import ArgumentParser as _ArgumentParser -from ._util import (ConfigurationError, BindError, - FingerUser, FingerSession, FingerFormatter, - FingerInterface, FingerLogger) -from ._fiction import (FingerFiction, FingerLiveInterface, - FingerFictionInterface) -from ._server import FingerServer - -__all__ = ["run", "version", - "FingerUser", "FingerSession", "FingerFormatter", - "FingerInterface", "FingerLogger", "FingerServer", "FingerFiction", - "FingerNativeInterface", "FingerFictionInterface", - "ConfigurationError", "BindError"] - -version = "0.1" +from .version import version +from .errors import * +from .server import * +from .fiction import * # --- # Native interface definition. @@ -50,65 +39,4 @@ except (ImportError, ModuleNotFoundError): FingerNativeInterface = _FingerNoNativeFoundInterface -# --- -# Main function. -# --- - -def _get_server(): - """ Get the server through the configuration stored in the - environment. """ - - # Load the .env file if present. - - try: - import dotenv as _dotenv - _dotenv.load_dotenv() - except ImportError: - if _path.exists('.env'): - print("Warning: a `.env` file was found but `python-dotenv`", - file = _stderr) - print("isn't installed; consider installing it.", - file = _stderr) - - # Load the environment variables. - - bind = _environ.get('BIND', 'localhost:79') - host = _environ.get('FINGER_HOST', 'LOCALHOST') - iface = _environ.get('FINGER_TYPE', 'NATIVE').casefold() - scpt = _environ.get('FINGER_ACTIONS', 'actions.toml') - src = _environ.get('FINGER_INCOMING', 'ipc:///var/run/fingerd.sock') - - if iface == 'native': - iface = FingerNativeInterface() - elif iface in ('actions', 'scenario'): - fic = FingerFiction() - fic.load(scpt) - - iface = FingerFictionInterface(fic) - elif iface == 'live': - iface = FingerLiveInterface(src) - else: - if iface != 'dummy': - print("warning: unknown interface type, falling back on dummy", - file = _stderr) - iface = FingerInterface() - - return FingerServer(bind = bind, host = host, interface = iface) - -def run(): - """ Main function for the module. """ - - # Non-interactive command-line interface. - - ap = _ArgumentParser(prog = 'fingerd', - description = 'Finger (RFC 1288) server-side daemon.') - args = ap.parse_args() - - # Get the server, run it. - - server = _get_server() - - server.serve_forever() - server.shutdown() - # End of file. diff --git a/fingerd/__main__.py b/fingerd/__main__.py index 57714f5..5e39ae9 100755 --- a/fingerd/__main__.py +++ b/fingerd/__main__.py @@ -1,16 +1,16 @@ #!/usr/bin/env python3 #************************************************************************** -# Copyright (C) 2017-2019 Thomas Touhey <thomas@touhey.fr> +# Copyright (C) 2017-2021 Thomas Touhey <thomas@touhey.fr> # This file is part of the fingerd project, which is MIT-licensed. #************************************************************************** """ Main script of the module. Runs the server depending on the configuration content. """ -from . import run as _run +from .cli import cli as _cli __all__ = [] if __name__ == '__main__': - _run() + _cli() # End of file. diff --git a/fingerd/_binds.py b/fingerd/_binds.py deleted file mode 100755 index ac391f3..0000000 --- a/fingerd/_binds.py +++ /dev/null @@ -1,238 +0,0 @@ -#!/usr/bin/env python3 -#************************************************************************** -# Copyright (C) 2017-2019 Thomas Touhey <thomas@touhey.fr> -# This file is part of the fingerd project, which is MIT-licensed. -#************************************************************************** -""" This file serves for managing binds. """ - -from enum import Enum as _Enum - -import socket as _socket - -from ._exceptions import \ - InvalidBindAddressError as _InvalidBindAddressError - -__all__ = ["BindAddressType", "BindsDecoder"] - -# --- -# Bind adresses and address types. -# --- - -class BindAddressType(_Enum): - """ Bind address type. """ - - """ TCP on IPv4 bind. """ - TCP_IPv4 = 1 - - """ TCP on IPv6 bind. """ - TCP_IPv6 = 2 - - """ IPC (Unix socket) bind. """ - IPC = 3 - -class _BindAddress: - """ Bind address for fingerd. """ - - def __init__(self, family): - self.__family = BindAddressType(family) - - def __repr__(self): - return f"{self.__class__.__name__}(family = {self.__family})" - - @property - def family(self): - """ Family as one of the `BindAddressType` enumeration values. """ - - return self.__family - -class _TCP4Address(_BindAddress): - """ IPv4 TCP Address. """ - - def __init__(self, address, port): - # XXX: normalize the address to the binary representation in a - # better way. - - try: - self.__addr = _socket.inet_pton(_socket.AF_INET, address) - except: - self.__addr = address - - self.__port = port - - @property - def runserver_params(self): - """ Return the data as `_runserver` parameters. """ - - return (_socket.AF_INET, _socket.inet_ntop(_socket.AF_INET, - self.__addr), self.__port) - -class _TCP6Address(_BindAddress): - """ IPv6 TCP Address. """ - - def __init__(self, address, port): - # XXX: normalize the address to the binary representation in a - # better way. - - try: - self.__addr = _socket.inet_pton(_socket.AF_INET6, address) - except: - self.__addr = address - - self.__port = port - - @property - def runserver_params(self): - """ Return the data as `_runserver` parameters. """ - - return (_socket.AF_INET6, _socket.inet_ntop(_socket.AF_INET6, - self.__addr), self.__port) - -# --- -# Decoding from strings. -# --- - -def _decode_tcp_host(x): - """ Decode suitable hosts for a TCP bind. """ - - addrs = () - addr = x - - # TODO: manage the '*' case. - - # Get the host part first, we'll decode it later. - - if x[0] == '[': - # The host part is an IPv6, look for the closing ']' and - # decode it later. - - to = x.find(']') - if to < 0: - raise _InvalidBindAddressError(addr, "expected " \ - "closing ']'") - - host = x[1:to] - x = x[to + 1:] - - is_ipv6 = True - else: - # The host part is either an IPv4 or a host name, look for - # the ':' and decode it later. - - host, *x = x.split(':') - x = ':' + ':'.join(x) - - is_ipv6 = False - - # Decode the port part. - - if x == '': - port = 79 - elif x[0] == ':': - try: - port = int(x[1:]) - except: - try: - assert x[1:] != '' - port = _socket.getservbyname(x[1:]) - except: - raise _InvalidBindAddressError(addr, - "expected a valid port number or name " \ - f"(got {repr(x[1:])})") from None - else: - raise _InvalidBindAddressError(addr, - "garbage found after the host") - - # Decode the host part and get the addresses. - - addrs = () - if is_ipv6: - # Decode the IPv6 address (validate it using `_socket.inet_pton`). - - ip6 = host - _socket.inet_pton(_socket.AF_INET6, host) - addrs += (_TCP6Address(ip6, port),) - else: - # Decode the host (try IPv4, otherwise, resolve domain). - - try: - ip = host.split('.') - assert 2 <= len(ip) <= 4 - - ip = list(map(int, ip)) - assert all(lambda x: 0 <= x < 256, ip) - - if len(ip) == 2: - ip = [ip[0], 0, 0, ip[1]] - elif len(ip) == 3: - ip = [ip[0], 0, ip[1], ip[2]] - - addrs += (_TCP4Address(ip, port),) - except: - entries = _socket.getaddrinfo(host, port, - proto = _socket.IPPROTO_TCP, - type = _socket.SOCK_STREAM) - - for ent in entries: - if ent[0] not in (_socket.AF_INET, _socket.AF_INET6) \ - or ent[1] not in (_socket.SOCK_STREAM,): - continue - - if ent[0] == _socket.AF_INET: - ip = ent[4][0] - _socket.inet_pton(_socket.AF_INET, ent[4][0]) - addrs += (_TCP4Address(ip, port),) - else: - ip6 = ent[4][0] - _socket.inet_pton(_socket.AF_INET6, ent[4][0]) - addrs += (_TCP6Address(ip6, port),) - - return addrs - -class BindsDecoder: - """ Binds decoder for fingerd. - Takes a raw string and the protocol name, either 'finger' (the base - protocol managed by the class) or 'fingerd-control' (the protocol - used for controlling the live fingerd interface). """ - - def __init__(self, raw, proto = 'finger'): - proto = proto.casefold() - if proto not in ('finger', 'fingerd-control'): - raise NotImplementedError(f"unsupported protocol {proto}") - - self.__binds = set() - - for x in map(lambda x: x.strip(), raw.split(',')): - addr = x - - # Try to find a scheme. - - scheme, *rest = x.split(':/') - if not rest: - # No scheme found, let's just guess the scheme based on - # the situation. - - x = scheme - scheme = {'finger': 'tcp', 'fingerd-control': 'ipc'}[proto] - else: - # just don't add the ':' of ':/' again - x = '/' + ':/'.join(rest) - - if (proto == 'finger' and scheme != 'tcp') \ - or scheme not in ('tcp', 'ipc'): - raise _InvalidBindError("unsupported scheme " - f"{repr(scheme)} for protocol {repr(proto)}") - - # Decode the address data. - - if scheme == "tcp": - self.__binds.update(_decode_tcp_host(x)) - - self.__binds = tuple(self.__binds) - - def __iter__(self): - return iter(self.__binds) - - def __repr__(self): - return f"{self.__class__.__name__}(binds = {self.__binds})" - -# End of file. diff --git a/fingerd/_exceptions.py b/fingerd/_exceptions.py deleted file mode 100755 index a2ecdd0..0000000 --- a/fingerd/_exceptions.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python3 -#************************************************************************** -# Copyright (C) 2017-2019 Thomas Touhey <thomas@touhey.fr> -# This file is part of the fingerd project, which is MIT-licensed. -#************************************************************************** -""" This file defines the exceptions used throughout the module. """ - -class ConfigurationError(Exception): - """ Generic exception for when an invalid configuration - exception occurs. """ - - pass - -class BindError(ConfigurationError): - """ Exception raised when there were errors in the binds. """ - - def __init__(self): - super().__init__("invalid bind") - -class InvalidBindAddressError(ConfigurationError): - """ Exception raised when a bind could not be understood. """ - - def __init__(self, addr, text): - super().__init__(f"{text} for address {addr}") - -# End of file. diff --git a/fingerd/_server.py b/fingerd/_server.py deleted file mode 100755 index 01cb4e1..0000000 --- a/fingerd/_server.py +++ /dev/null @@ -1,342 +0,0 @@ -#!/usr/bin/env python3 -#************************************************************************** -# Copyright (C) 2017-2019 Thomas Touhey <thomas@touhey.fr> -# This file is part of the fingerd project, which is MIT-licensed. -#************************************************************************** -""" The main server class for the server, with the related utilities that - will not be presented to the user, is defined in this file. """ - -import string as _string, socket as _socket, signal as _signal, \ - multiprocessing as _multip, socketserver as _socketserver - -from io import TextIOWrapper as _TextIOWrapper, StringIO as _StringIO - -from ._util import (FingerInterface as _FingerInterface, - FingerFormatter as _FingerFormatter, FingerLogger as _FingerLogger, - ConfigurationError as _InvalidConfError, - BindError as _InvalidBindError, BindAddressType as _BindAddressType, - BindsDecoder as _BindsDecoder) - -__all__ = ["FingerServer"] - -# --- -# Finger/TCP query decoding. -# --- - -class _FingerQuery: - """ A finger query. Requests information about connected or specific - users on a remote server. - - There are three types of requests recognized by RFC 1288: - - {C} is a request for a list of all online users; - - {Q1} is a request for a local user; - - {Q2} is a request for a distant user (with hostname). - - /W means the RUIP (program answering the query) should be more - verbose (this token can be ignored). """ - - # "By default, this program SHOULD filter any unprintable data, - # leaving only printable 7-bit characters (ASCII 32 through - # ASCII 126), tabs (ASCII 9) and CRLFs." - - allowed_chars = ("\t !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" - + _string.ascii_letters + _string.digits) - - def __init__(self, raw): - """ Initialize the query object by decoding the data. """ - - # Get a character string out of the query. - - raw = ''.join(c for c in raw if c in self.allowed_chars) - self.line = raw - - # Get elements. - - self.host = None - self.username = None - self.verbose = False - for element in raw.split(): - if element[0] == '/': - if 'W' in element[1:]: - self.verbose = True - continue - elif self.username is not None: - raise Exception - self.username = element - - if self.username is not None and '@' in self.username: - self.host, *self.username = self.username.split('@')[::-1] - self.username = '@'.join(self.username[::-1]) - -# --- -# TCP request handler. -# --- - -class _FingerTCPHandler(_socketserver.StreamRequestHandler): - """ The TCP request handler for our finger daemon. - Instantiated once per connection to the server (request). """ - - def handle(self): - """ Request handling. """ - - inp = _TextIOWrapper(self.rfile, encoding = 'ascii', - errors = 'ignore', newline = '\r\n') - outp = _TextIOWrapper(self.wfile, encoding = 'ascii') - - # Gather the request line. - - try: - line = inp.readline() - except ConnectionResetError: - self.logger.no_query(self.src) - return - - # Decode the request. - - ans = _StringIO() - - try: - query = None - query = _FingerQuery(line) - except: - self.logger.bad_query(self.src) - ans.write(self.fmt.format_query_error(self.host)) - return - - if query is not None: - self.answer(query, ans) - - # Write the output. - - try: - outp.write(ans.getvalue()) - except ConnectionResetError: - self.logger.could_not_answer(self.src) - - @property - def fmt(self): - """ The answer formatter. """ - - return self.server.params['formatter'] - - @property - def iface(self): - """ The interface formatter. """ - - return self.server.params['interface'] - - @property - def host(self): - """ The host. """ - - return self.server.params['host'] - - @property - def logger(self): - """ The logger. """ - - return self.server.params['logger'] - - @property - def src(self): - """ The source IP address. """ - - return self.client_address[0] - - def answer(self, query, outp): - """ Answer the query. - `query` is the `_FingerQuery` instance. - `outp` is the output stream. - - This method is thought to be overriden someday. """ - - ans = self.fmt.format_header(self.host, query.line) - - # Gather the content. - - if query.host is not None: - if query.username: - self.logger.transmit(self.src, query.username, query.host) - else: - self.logger.transmit_list(self.src, query.host) - - cont = self.iface.transmit_query(query.host, query.username, - query.verbose) - ans += cont - else: - if query.username: - self.logger.search_users(self.src, query.username) - check = lambda x: query.username in x.login - else: - self.logger.list(self.src) - check = lambda x: x.sessions - - users = self.iface.search_users(check) - - if query.username or query.verbose: - ans += self.fmt.format_long(users) - else: - ans += self.fmt.format_short(users) - - # Send the answer. - - ans += self.fmt.format_footer() - outp.write(ans) - -# --- -# TCP servers. -# --- - -class _IPv4TCPServer(_socketserver.ThreadingMixIn, - _socketserver.TCPServer): - """ The IPv4 TCP Server to be used """ - - address_family = _socket.AF_INET - allow_reuse_address = True - - def __init__(self, *args, params = {}, **kwargs): - self.params = params - super().__init__(*args, **kwargs) - -class _IPv6TCPServer(_socketserver.ThreadingMixIn, - _socketserver.TCPServer): - """ The IPv6 TCP Server to be used """ - - address_family = _socket.AF_INET6 - allow_reuse_address = True - - def __init__(self, *args, params = {}, **kwargs): - self.params = params - super().__init__(*args, **kwargs) - -# --- -# Public server class. -# --- - -def _runserver(family, addr, port, params): - """ Run the server (will be run in another thread). """ - - logger = params['logger'] - - # Make the server corresponding to the entry. - - if family == _socket.AF_INET: - server = _IPv4TCPServer((addr, port), _FingerTCPHandler, - params = params) - elif family == _socket.AF_INET6: - server = _IPv6TCPServer((addr, port), _FingerTCPHandler, - params = params) - - # Run it. - - logger.start(addr, port) - try: server.serve_forever() - except KeyboardInterrupt: - pass - logger.stop(addr, port) - -_ldh = _string.ascii_letters + _string.digits + '.-' - -class FingerServer: - """ The Finger Server class. """ - - def __init__(self, bind = 'localhost:79', host = 'LOCALHOST', - interface = _FingerInterface(), formatter = _FingerFormatter(), - logger = _FingerLogger()): - - # Check the host. - - try: - host = host.upper() - assert all(c in _ldh for c in host) - except: - raise _InvalidHostError(host) - - # Check the interface and formatter classes. - - if not isinstance(interface, _FingerInterface): - raise InvalidConfigurationError("please base your interface " - "on the base class provided by the fingerd module") - if not isinstance(formatter, _FingerFormatter): - raise InvalidConfigurationError("please base your formatter " - "on the base class provided by the fingerd module") - if not isinstance(logger, _FingerLogger): - raise InvalidConfigurationError("please base your logger " - "on the base class provided by the fingerd module") - - # Keep the parameters. - - self._host = host - self._logger = logger - self._interface = interface - self._formatter = formatter - - # Make what is to send to the handlers. - - self._params = {'host': host, 'interface': interface, - 'formatter': formatter, 'logger': logger} - - # Check the addresses. - - if not self._makebinds(bind): - raise _InvalidBindError() - - def _makebinds(self, addresses): - """ Create the servers data using the bind values. """ - - self._servers = [] - - for bind in _BindsDecoder(addresses): - self._servers.append([bind.runserver_params, None]) - - return bool(self._servers) - - def start(self): - """ Bind and start the underlying servers. """ - - for entry in self._servers: - # Check if the thread already exists and runs. - - if entry[1] != None and not entry[1].is_alive(): - continue - - # Start the thread. - - entry[1] = _multip.Process(target = _runserver, - args = entry[0] + (self._params,)) - entry[1].start() - - def stop(self): - """ Stop the underlying servers. """ - - for entry in self._servers: - # Check if the thread is still here. - - if entry[1] == None: - continue - if not entry[1].is_alive(): - entry[1] = None - continue - - # Stop it. - - entry[1].join() - entry[1] = None - - def serve_forever(self): - """ Serve forever. """ - - self.start() - try: - while True: - _signal.pause() - except KeyboardInterrupt: - pass - self.stop() - - def shutdown(self): - """ Shutdown the server, alias to `.stop()`. """ - - self.stop() - -# End of file. diff --git a/fingerd/_util.py b/fingerd/_util.py deleted file mode 100755 index b51ed34..0000000 --- a/fingerd/_util.py +++ /dev/null @@ -1,509 +0,0 @@ -#!/usr/bin/env python3 -#************************************************************************** -# Copyright (C) 2017-2019 Thomas Touhey <thomas@touhey.fr> -# This file is part of the fingerd project, which is MIT-licensed. -#************************************************************************** -""" This file defines the base classes for what is used by the - finger server, directly or indirectly. """ - -import sys as _sys, copy as _copy -import multiprocessing as _multip - -from datetime import datetime as _dt, timedelta as _td - -from ._exceptions import ConfigurationError, BindError -from ._binds import BindAddressType, BindsDecoder - -__all__ = ["ConfigurationError", "BindError", - "FingerUser", "FingerSession", - "FingerFormatter", "FingerInterface", "FingerLogger" - "BindAddressType", "BindsDecoder"] - -# --- -# User-related classes. -# --- - -class FingerUser: - """ User description for the finger interface. """ - - def __init__(self, *_, login = None, name = None, - home = None, shell = None): - self.__login = None - self.__name = '' - self.__home = None - self.__shell = None - self.__office = None - self.__plan = None - self.__last_login = None - self.__sessions = _FingerSessionManager() - - self.login = login - self.name = name - self.home = home - self.shell = shell - - def __repr__(self): - p = ('login', 'name', 'home', 'shell', 'office', - 'last_login', 'sessions') - p = (f"{x} = {repr(getattr(self, x))}" for x in p \ - if getattr(self, x) is not None) - return f"{self.__class__.__name__}({', '.join(p)})" - - @property - def login(self): - """ Login name, e.g. 'cake'. """ - - return self.__login - - @login.setter - def login(self, value): - self.__login = value - - @property - def name(self): - """ Full user name, e.g. 'Jean Dupont'. """ - - return self.__name - - @name.setter - def name(self, value): - self.__name = value - - @property - def last_login(self): - """ Last login date. """ - - return self.__last_login - - @last_login.setter - def last_login(self, value): - self.__last_login = None if value is None else \ - value if isinstance(value, _dt) else _dt(value) - - @property - def home(self): - """ The user's home. """ - - return self.__home - - @home.setter - def home(self, value): - self.__home = None if value is None else str(value) - - @property - def shell(self): - """ The user's shell. """ - - return self.__shell - - @shell.setter - def shell(self, value): - self.__shell = None if value is None else str(value) - - @property - def office(self): - """ The user's office. """ - - return self.__office - - @office.setter - def office(self, value): - self.__office = None if value is None else str(value) - - @property - def plan(self): - """ The user's plan. """ - - return self.__plan - - @plan.setter - def plan(self, value): - if value is None: - self.__plan = None - else: - value = str(value) - self.__plan = '\n'.join(value.splitlines()) - - @property - def sessions(self): - """ Current sessions. """ - - return self.__sessions - -class _FingerSessionManager: - """ Session manager. """ - - def __init__(self): - self.__sessions = [] - - def __repr__(self): - return repr(self.__sessions) - - def __bool__(self): - return bool(self.__sessions) - - def __iter__(self): - return iter(self.__sessions[::-1]) - - def __delitem__(self, key): - if key is None: - self.__sessions.pop(0) - else: - for i in [i for i, x in enumerate(self.__sessions) \ - if key == x.name][::-1]: - self.__sessions.pop(i) - - def __getitem__(self, key): - if key is None: - try: - return self.__sessions[0] - except IndexError: - raise KeyError("could not get latest session") from None - - if type(key) is int: - try: - return self.__sessions[key] - except IndexError: - msg = f"could not get session #{repr(key)}" - raise IndexError(msg) from None - - try: - return next(x for x in self.__sessions if key == x.name) - except StopIteration: - raise KeyError(f"could not get session {repr(key)}") from None - - def __setitem__(self, key, value): - if not isinstance(value, FingerSession): - raise TypeError("can only add sessions into a session manager") - value = _copy.deepcopy(value) - - if key is None: - # Check if except the first session, the key of the session, - # if any, does not override another key. - - if value.name is not None: - try: - next(i for i, x in self.__sessions[1:] \ - if value.name == x.name) - except StopIteration: - pass - else: - msg = "value.name overrides another session's key" - raise ValueError(msg) from None - - try: - self.__sessions[0] = value - return - except IndexError: - raise KeyError("could not set latest session") from None - - if type(key) is int: - # Check if except the key-th session, the key of the session, - # if any, does not override another key. - - if value.name is not None: - try: - next(i for i, x in self.__sessions \ - if i != key and value.name == x.name) - except StopIteration: - pass - else: - msg = "value.name overrides another session's key" - raise ValueError(msg) from None - - try: - self.__sessions[key] = value - return - except IndexError: - msg = f"could not set session #{repr(key)}" - raise IndexError(msg) from None - - value.name = key - - try: - i = next(i for i, x in enumerate(self.__sessions) \ - if key == x.name) - except StopIteration: - raise KeyError(f"could not set session {repr(key)}") from None - - self.__sessions[i] = value - - def add(self, session): - """ Add a session. """ - - if not isinstance(session, FingerSession): - raise TypeError("can only insert sessions into a session " - "manager") - if session.name is not None: - try: - next(i for i, x in self.__sessions \ - if session.name == x.name) - except StopIteration: - pass - else: - msg = "session.name overrides another session's key" - raise ValueError(msg) from None - - self.__sessions.insert(0, session) - -class FingerSession: - """ Session for a user. """ - - def __init__(self, *_, time = _dt.now()): - self.__start = time if isinstance(time, _dt) else _dt(time) - self.__line = None - self.__host = None - self.__idle = self.__start - - def __repr__(self): - p = ('start', 'line', 'orig', 'idle') - p = (f"{x} = {repr(getattr(self, x))}" for x in p \ - if getattr(self, x) is not None) - return f"{self.__class__.__name__}({', '.join(p)})" - - @property - def start(self): - """ The session start time. """ - - return self.__start - - @start.setter - def start(self, value): - self.__start = value if isinstance(value, _dt) else _dt(value) - - @property - def line(self): - """ The line on which the user is. """ - - return self.__line - - @line.setter - def line(self, value): - self.__line = None if value is None else str(value) - - @property - def host(self): - """ The host from which the user is connected. """ - - return self.__host - - @host.setter - def host(self, value): - self.__host = None if value is None else str(value) - -# --- -# Formatter base class. -# --- - -def _format_idle(idle): - def _iter_idle(idle): - days = int(idle.days) - hours = int(idle.seconds / 3600) - mins = int(idle.seconds % 3600 / 60) - secs = int(idle.seconds % 60) - - if days: - yield f"{days} day{('', 's')[days > 1]}" - if hours: - yield f"{hours} hour{('', 's')[hours > 1]}" - if mins: - yield f"{mins} minute{('', 's')[mins > 1]}" - if secs: - yield f"{secs} second{('', 's')[secs > 1]}" - - return f"{' '.join(_iter_idle(idle))} idle" - -def _format_time(d): - if d < _td(): - return "" - - days = int(d.days) - hours = int(d.seconds / 3600) - mins = int(d.seconds % 3600 / 60) - - if days: - return f"{days}d" - elif hours or mins: - return f"{hours:02}:{mins:02}" - - return "" - -def _format_when(d): - return d.strftime("%a %H:%M") - -class FingerFormatter: - """ Answer formatter. - Formats the answer following RFC 1288. """ - - def format_query_error(self, name): - """ There is an error in your query! """ - - return "Site: {}\r\n" \ - "You have made a mistake in your query!\r\n".format(name) - - def format_header(self, name, request): - """ Formats the header of each request. """ - - if request: - request = ' ' + request - - return f"Site: {name}\r\n" f"Command line:{request}\r\n" "\r\n" - - def format_footer(self): - """ Formats the footer of each request (in case the developer - wants to add something at the end, anything). """ - - return "" - - def format_short(self, users): - """ Format a user list in a short fashion. """ - - if not users: - return "No user list available.\r\n" - - lst = [] - for user in users: - if not user.sessions: - lst.append((user, None)) - for session in user.sessions: - lst.append((user, session)) - - now = _dt.now() - - _login = lambda u, s: u.login - _name = lambda u, s: u.name - _line = lambda u, s: s.line if s and s.line else '' - _idle = lambda u, s: _format_time(now - s.idle) if s else '' - _logt = lambda u, s: _format_when(s.start) if s else '' - _offic = lambda u, s: u.office if u.office else '' - - columns = ( - ('Login',) + tuple(_login(u, s) for u, s in lst), - ('Name',) + tuple( _name(u, s) for u, s in lst), - ('TTY',) + tuple( _line(u, s) for u, s in lst), - ('Idle',) + tuple( _idle(u, s) for u, s in lst), - ('When',) + tuple( _logt(u, s) for u, s in lst), - ('Office',) + tuple(_offic(u, s) for u, s in lst)) - - sizes = tuple(max(map(len, c)) + 1 for i, c in enumerate(columns)) - align = ('<', '<', '<', '^', '^', '<') - - lines = [] - for line in range(len(columns[0])): - lines.append(' '.join(\ - f"{columns[i][line][:sizes[i]]:{align[i]}{sizes[i]}}" \ - for i in range(len(columns)))) - - return '\r\n'.join(lines) + '\r\n' - - def format_long(self, users): - """ Format a user list in a long fashion. """ - - if not users: - return "No user list available.\r\n" - - res = '' - - for user in users: - res += ("" - f"Login name: {user.login[:27]:<27} Name: {user.name}\r\n" - f"Directory: {user.home[:28]:<28} Shell: {user.shell}\r\n" - f"Office: {user.office if user.office else ''}\r\n") - - if user.sessions: - # List current sessions. - - for se in user.sessions: - since = se.start.strftime("%a %b %e %R") - tz = str(_dt.utcnow().astimezone().tzinfo) - res += f"On since {since} ({tz}) on {se.line}" - if se.host is not None: - res += f" from {se.host}" - res += "\r\n" - - idle = _dt.now() - se.idle - if idle >= _td(seconds = 4): - res += f" {_format_idle(idle)}\r\n" - elif user.last_login is not None: - # Show last login. - - date = user.last_login.strftime("%a %b %e %R") - tz = str(_dt.utcnow().astimezone().tzinfo) - res += f"Last login {date} ({tz}) on console\r\n" - else: - res += "Never logged in.\r\n" - - if user.plan is None: - res += "No plan.\r\n" - else: - res += "Plan:\r\n" - res += "\r\n".join(user.plan.splitlines()) - res += "\r\n" - - res += "\r\n" - - return res - -# --- -# Interface (dummy) base class. -# --- - -class FingerInterface: - """ Finger interface to the users. - Expandable (the current class defines the dummy interface). """ - - def transmit_query(self, user, host, verbose): - return "This server won't transmit finger queries.\r\n" - - def search_users(self, check): - return [] - -# --- -# Server logger base class. -# --- - -class FingerLogger: - """ Finger logger for the server. - Expandable, but the base class should be enough for most needs. """ - - def __init__(self, stream = _sys.stderr): - self._stream = stream - self._lock = _multip.Lock() - - def _log(self, fmt, *args): - self._lock.acquire() - print('\r' + fmt.format(*args), file=self._stream) - self._lock.release() - - def _logr(self, src, fmt, *args): - self._log('[{}] ' + fmt, src, *args) - - def start(self, host, port): - self._log("Starting fingerd on [{}]:{}.", host, port) - - def stop(self, host, port): - self._log("Stopping fingerd on [{}]:{}.", host, port) - - def no_query(self, source): - self._logr(source, "no query. (possible scan)") - - def bad_query(self, source): - self._logr(source, "bad request.") - - def could_not_answer(self, source): - self._logr(source, "could not write the answer.") - - def transmit_list(self, source, host): - self._logr(source, "transmit user list query to `{}`.", host) - - def transmit(self, source, username, host): - self._logr(source, "transmit user query for `{}` to `{}`.", - username, host) - - def search_users(self, source, username): - self._logr(source, "look for user `{}`.", username) - - def list(self, source): - self._logr(source, "list connected users.") - -# End of file. diff --git a/fingerd/cli.py b/fingerd/cli.py new file mode 100644 index 0000000..be665a4 --- /dev/null +++ b/fingerd/cli.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +#************************************************************************** +# Copyright (C) 2021 Thomas Touhey <thomas@touhey.fr> +# This file is part of the fingerd project, which is MIT-licensed. +#************************************************************************** +""" fingerd CLI interface. """ + +import click as _click + +from platform import (python_implementation as _python_impl, + python_version as _python_version) +from .version import version as _version +from . import (FingerNativeInterface as _FingerNativeInterface, + FingerFiction as _FingerFiction, + FingerFictionInterface as _FingerFictionInterface, + FingerLiveInterface as _FingerLiveInterface, + FingerInterface as _FingerInterface, + FingerServer as _FingerServer) + +__all__ = ['cli'] + +@_click.command(context_settings = {'help_option_names': ['-h', '--help']}) +@_click.version_option(version = _version, + message = f"fingerd version {_version}, " + f"on {_python_impl()} {_python_version()}") +@_click.option('-b', '--binds', default = 'localhost:79', + envvar = ('BIND', 'BINDS')) +@_click.option('-H', '--hostname', default = 'localhost', + envvar = ('FINGER_HOST',)) +@_click.option('-t', '--type', default = 'native', + envvar = ('FINGER_TYPE',)) +@_click.option('-s', '--scenario', default = 'actions.toml', + envvar = ('FINGER_ACTIONS',)) +@_click.option('-i', '--incoming', default = 'ipc:///var/run/fingerd.sock', + envvar = ('FINGER_INCOMING',)) +def cli(binds, hostname, type, scenario, incoming): + """ fingerd is a modern finger (RFC 1288) server. + Find out more at <https://fingerd.touhey.pro/>. """ + + hostname = hostname.upper() + type = type.casefold() + + if type == 'native': + iface = _FingerNativeInterface() + elif type in ('actions', 'scenario'): + fic = _FingerFiction() + fic.load(scenario) + + iface = _FingerFictionInterface(fic) + elif type == 'live': + iface = _FingerLiveInterface(src) + else: + if type != 'dummy': + print("warning: unknown interface type, falling back on dummy", + file = _stderr) + + iface = _FingerInterface() + + server = _FingerServer(binds = binds, + hostname = hostname, interface = iface) + server.serve_forever() + +# End of file. diff --git a/fingerd/errors.py b/fingerd/errors.py new file mode 100755 index 0000000..98110e8 --- /dev/null +++ b/fingerd/errors.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +#************************************************************************** +# Copyright (C) 2017-2021 Thomas Touhey <thomas@touhey.fr> +# This file is part of the fingerd project, which is MIT-licensed. +#************************************************************************** +""" This file defines the exceptions used throughout the module. """ + +__all__ = [ + 'ConfigurationError', + 'BindError', 'NoBindsError', 'InvalidBindError', + 'HostnameError' +] + +# --- +# Configuration-related errors. +# --- + +class ConfigurationError(Exception): + """ Generic exception for when an invalid configuration + exception occurs. """ + + pass + +class BindError(ConfigurationError): + """ Exception raised when an error has occurred with the provided + binds. """ + + def __init__(self, msg): + super().__init__("an error has occurred with the provided binds: " + + msg) + +class NoBindsError(BindError): + """ Exception raised when no binds were provided. """ + + def __init__(self): + super().__init__("no valid bind") + +class InvalidBindError(BindError): + """ Exception raised when one of the provided binds came out + erroneous. """ + + def __init__(self, bind, msg = None): + super().__init__("one of the provided bind ({}) ".format(repr(bind)) + + "was invalid{}".format(': ' + msg if msg is not None else '')) + +class HostnameError(ConfigurationError): + """ Exception raised when a host name is invalid. """ + + def __init__(self, hostname): + super().__init__("invalid host name {}.".format(repr(hostname))) + +# End of file. diff --git a/fingerd/_fiction.py b/fingerd/fiction.py index b2bec47..6d63416 100755 --- a/fingerd/_fiction.py +++ b/fingerd/fiction.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 #************************************************************************** -# Copyright (C) 2017-2019 Thomas Touhey <thomas@touhey.fr> +# Copyright (C) 2017-2021 Thomas Touhey <thomas@touhey.fr> # This file is part of the fingerd project, which is MIT-licensed. #************************************************************************** """ Definitions for the finger server fiction interface. @@ -11,10 +11,14 @@ import copy as _copy, re as _re, math as _math import itertools as _itertools from datetime import datetime as _dt, timedelta as _td -from ._util import FingerInterface as _FingerInterface, \ - FingerUser as _FingerUser, FingerSession as _FingerSession +from .server import ( + FingerInterface as _FingerInterface, + FingerUser as _FingerUser, + FingerSession as _FingerSession) -__all__ = ["FingerFictionInterface", "FingerLiveInterface", +__all__ = [ + "FingerFictionInterface", + "FingerLiveInterface", "FingerFiction"] _toml = None @@ -34,36 +38,36 @@ class _FictionalSession(_FingerSession): def __init__(self, *args, name = None, is_idle = False, **kwargs): super().__init__(*args, **kwargs) - self.__name = None if name is None else str(name) - self.__is_idle = is_idle - self.__idle_last = self.start + self._name = None if name is None else str(name) + self._is_idle = is_idle + self._idle_last = self.start def __repr__(self): p = ('name', 'start', 'line', 'host', 'idle_since', 'active_since') p = (f"{x} = {repr(getattr(self, x))}" for x in p \ if getattr(self, x) is not None) - return f"{self.__class__.__name__}({', '.join(p)})" + return f"{self._class__.__name__}({', '.join(p)})" @property def name(self): """ Session name (for identifying). """ - return self.__name + return self._name @name.setter def name(self, value): - self.__name = None if value is None else str(value) + self._name = None if value is None else str(value) @property def idle(self): """ Idle time (simulated). """ - if self.__is_idle: - return self.__idle_last + if self._is_idle: + return self._idle_last # Generate a number of seconds and return it. - x = (_dt.now() - self.__idle_last).seconds + x = (_dt.now() - self._idle_last).seconds s = lambda x: _math.sin(x * (_math.pi / 2)) randsecs = int(abs(s(x) + s(x / 4))) @@ -79,27 +83,27 @@ class _FictionalSession(_FingerSession): def idle_since(self): """ Idle since the given time. """ - if not self.__is_idle: + if not self._is_idle: return None - return self.__idle_last + return self._idle_last @idle_since.setter def idle_since(self, value): - self.__is_idle = True - self.__idle_last = value + self._is_idle = True + self._idle_last = value @property def active_since(self): """ Active since the given time. """ - if self.__is_idle: + if self._is_idle: return None - return self.__idle_last + return self._idle_last @active_since.setter def active_since(self, value): - self.__is_idle = False - self.__idle_last = value + self._is_idle = False + self._idle_last = value # --- # Parse a delta string. @@ -151,168 +155,168 @@ class _UserCreationAction(_Action): def __init__(self, user): super().__init__() - self.__user = user + self._user = user def __repr__(self): p = (f"{x} = {repr(getattr(self, x))}" for x in ('user')) - return f"{self.__class__.__name__}({', '.join(p)})" + return f"{self._class__.__name__}({', '.join(p)})" @property def user(self): """ The user. """ - return _copy.deepcopy(self.__user) + return _copy.deepcopy(self._user) class _UserEditionAction(_Action): """ A user status has been changed. """ def __init__(self, login, name, home, shell, office, plan): super().__init__() - self.__login = login - self.__name = name - self.__home = home - self.__shell = shell - self.__office = office - self.__plan = plan + self._login = login + self._name = name + self._home = home + self._shell = shell + self._office = office + self._plan = plan def __repr__(self): p = (f"{x} = {repr(getattr(self, x))}" for x in ('login', 'name', 'home', 'shell')) - return f"{self.__class__.__name__}({', '.join(p)})" + return f"{self._class__.__name__}({', '.join(p)})" @property def login(self): """ The user's login. """ - return self.__login + return self._login @property def name(self): """ The user's name. """ - return self.__name + return self._name @property def home(self): """ The user's home. """ - return self.__home + return self._home @property def shell(self): """ The user's shell. """ - return self.__shell + return self._shell @property def office(self): """ The user's office. """ - return self.__office + return self._office @property def plan(self): """ The user's plan. """ - return self.__plan + return self._plan class _UserLoginAction(_Action): """ A user has logged in. """ def __init__(self, login, session): super().__init__() - self.__login = login - self.__session = session + self._login = login + self._session = session def __repr__(self): p = (f"{x} = {repr(getattr(self, x))}" for x in ('login', 'session')) - return f"{self.__class__.__name__}({', '.join(p)})" + return f"{self._class__.__name__}({', '.join(p)})" @property def login(self): """ The user's login. """ - return self.__login + return self._login @property def session(self): """ The session to create. """ - return self.__session + return self._session class _UserLogoutAction(_Action): """ A user has logged out. """ def __init__(self, login, name): super().__init__() - self.__login = login - self.__name = name + self._login = login + self._name = name def __repr__(self): p = (f"{x} = {repr(getattr(self, x))}" for x in ('login', 'name')) - return f"{self.__class__.__name__}({', '.join(p)})" + return f"{self._class__.__name__}({', '.join(p)})" @property def login(self): """ The user's login. """ - return self.__login + return self._login @property def name(self): """ The name of the session to destroy. """ - return self.__name + return self._name class _UserIdleToggleAction(_Action): """ A user has changed its idle status. """ def __init__(self, login, name, idle): super().__init__() - self.__login = login - self.__name = name - self.__idle = bool(idle) + self._login = login + self._name = name + self._idle = bool(idle) def __repr__(self): p = (f"{x} = {repr(getattr(self, x))}" for x in ('login', 'name', 'idle')) - return f"{self.__class__.__name__}({', '.join(p)})" + return f"{self._class__.__name__}({', '.join(p)})" @property def login(self): """ The user's login. """ - return self.__login + return self._login @property def name(self): """ The name of the session to create. """ - return self.__name + return self._name @property def idle(self): """ The idle status. """ - return self.__idle + return self._idle class _UserDeletionAction(_Action): """ A user has been deleted. """ def __init__(self, login): super().__init__() - self.__login = login + self._login = login def __repr__(self): p = (f"{x} = {repr(getattr(self, x))}" for x in ('login')) - return f"{self.__class__.__name__}({', '.join(p)})" + return f"{self._class__.__name__}({', '.join(p)})" @property def login(self): """ The user's login. """ - return self.__login + return self._login # --- # Finger interface to actions. @@ -339,18 +343,18 @@ class FingerFiction: # Initialize the properties. - self.__type = 'interrupt' - self.__start = _td() - self.__duration = None + self._type = 'interrupt' + self._start = _td() + self._duration = None - self.__end_type = None - self.__end_time = None - self.__actions = [] + self._end_type = None + self._end_time = None + self._actions = [] def ready(self): """ Check if the fiction is ready. """ - return self.__end_time is not None + return self._end_time is not None def load(self, path): """ Load from an `actions.toml` file. """ @@ -493,23 +497,23 @@ class FingerFiction: # FIXME: check that incompatible actions, such as double creation # for a user, doesn't occur. - self.__type = end_type - self.__start = actions[0][0] if actions else _td() - self.__duration = end_time - self.__actions = actions + self._type = end_type + self._start = actions[0][0] if actions else _td() + self._duration = end_time + self._actions = actions def get(self, to = None, since = None): """ Get the events. """ if to is None: - to = self.__duration + to = self._duration if since is None: - since = self.__start - _td(seconds = 1) + since = self._start - _td(seconds = 1) if since > to: raise ValueError(f"`since` ({since}) should be " \ f"before `to` ({to}).") - for time, action in self.__actions: + for time, action in self._actions: if since < time <= to: yield time, action @@ -517,13 +521,13 @@ class FingerFiction: def type(self): """ Type of action flow, either 'interrupt' or 'stop'. """ - return self.__type + return self._type @property def duration(self): """ Maximum offset. """ - return self.__duration + return self._duration class _FingerFictionalInterface(_FingerInterface): """ Fiction interface, to get a scene as received from inter-process @@ -536,26 +540,26 @@ class _FingerFictionalInterface(_FingerInterface): # - `users`: the users. # - `start`: the start time. - self.__users = {} - self.__start = start + self._users = {} + self._start = start @property def start(self): """ Get the fiction starting point. """ - return self.__start + return self._start def search_users(self, check): """ Look for users according to a check. """ self.update() - return [_copy.deepcopy(u) for u in self.__users.values() \ + return [_copy.deepcopy(u) for u in self._users.values() \ if check(u)] def reset(self): """ Reset the interface (revert all actions). """ - self.__users = {} + self._users = {} def apply(self, action, time = _dt.now()): """ Apply an action. """ @@ -566,20 +570,20 @@ class _FingerFictionalInterface(_FingerInterface): user = _copy.deepcopy(action.user) if user.login is None: raise ValueError("missing login") - if user.login in self.__users: + if user.login in self._users: raise ValueError("already got a user with that login") - self.__users[user.login] = user + self._users[user.login] = user elif isinstance(action, _UserEditionAction): # Edit user `action.user` with the given modifications. if action.login is None: raise ValueError("missing login") - if not action.login in self.__users: + if not action.login in self._users: raise ValueError("got no user with login " f"{repr(action.login)}") - user = self.__users[action.login] + user = self._users[action.login] if action.name is not False: user.name = action.name if action.shell is not False: @@ -595,22 +599,22 @@ class _FingerFictionalInterface(_FingerInterface): if action.login is None: raise ValueError("missing login") - if not action.login in self.__users: + if not action.login in self._users: raise ValueError("got no user with login " f"{repr(action.login)}") - del self.__users[action.login] + del self._users[action.login] elif isinstance(action, _UserLoginAction): # Login as user `action.login` with session `action.session`. session = _copy.deepcopy(action.session) - session.start = self.__start + time + session.start = self._start + time if action.login is None: raise ValueError("missing login") try: - user = self.__users[action.login] + user = self._users[action.login] except KeyError: raise ValueError("got no user with login " f"{repr(action.login)}") from None @@ -624,7 +628,7 @@ class _FingerFictionalInterface(_FingerInterface): raise ValueError("missing login") try: - user = self.__users[action.login] + user = self._users[action.login] except KeyError: raise ValueError("got no user with login " \ f"{repr(action.login)}") from None @@ -641,7 +645,7 @@ class _FingerFictionalInterface(_FingerInterface): raise ValueError("missing login") try: - user = self.__users[action.login] + user = self._users[action.login] except KeyError: raise ValueError("got no user with login " \ f"{repr(action.login)}") from None @@ -652,7 +656,7 @@ class _FingerFictionalInterface(_FingerInterface): raise ValueError(f"got no session {repr(action.name)} " f"for user {repr(action.login)}") from None - since = self.__start + time + since = self._start + time if action.idle: session.idle_since = since else: @@ -682,7 +686,7 @@ class FingerFictionInterface(_FingerFictionalInterface): # Initialize the object properties. if isinstance(fiction, FingerFiction): - self.__as = _copy.copy(fiction) + self._as = _copy.copy(fiction) elif fiction is not None: raise TypeError("fiction should be a FingerFiction or None, " f"is {fiction.__class__.__name__}.") @@ -691,24 +695,24 @@ class FingerFictionInterface(_FingerFictionalInterface): # - `fiction`: the script to follow. # - `current`: the last update time. - self.__fiction = fiction - self.__current = None + self._fiction = fiction + self._current = None - def __get_time(self): + def _get_time(self): """ Get the current timedelta using the origin and duration. """ now = _dt.now() - if self.__fiction is not None: + if self._fiction is not None: # Either 'stop' or 'interrupt'. # These cases only are distinguishable after the # end of the fiction. - if now > self.start + self.__fiction.duration: - if self.__fiction.type == 'stop': + if now > self.start + self._fiction.duration: + if self._fiction.type == 'stop': exit(0) - return self.__fiction.duration + return self._fiction.duration # We're within the duration of the fiction, so we just return the # offset from the start. @@ -718,22 +722,22 @@ class FingerFictionInterface(_FingerFictionalInterface): def update(self): """ Update the state according to the script. """ - time = self.__get_time() + time = self._get_time() - if self.__fiction is not None: + if self._fiction is not None: # Check if we need to reset (no users left and apply the # actions from before the start). - if self.__current is None or self.__current > time: + if self._current is None or self._current > time: self.reset() - self.__current = None + self._current = None # Then apply the actions up to the current time. - for time, action in self.__fiction.get(to = time, - since = self.__current): + for time, action in self._fiction.get(to = time, + since = self._current): self.apply(action, time) - self.__current = time + self._current = time # End of file. diff --git a/fingerd/server.py b/fingerd/server.py new file mode 100755 index 0000000..395a37a --- /dev/null +++ b/fingerd/server.py @@ -0,0 +1,1064 @@ +#!/usr/bin/env python3 +#************************************************************************** +# Copyright (C) 2017-2021 Thomas Touhey <thomas@touhey.fr> +# This file is part of the fingerd project, which is MIT-licensed. +#************************************************************************** +""" The main server class for the server, with the related utilities that + will not be presented to the user, is defined in this file. """ + +import string as _string +import socket as _socket +import signal as _signal +import sys as _sys +import copy as _copy +import multiprocessing as _multip +import socketserver as _socketserver + +from enum import Enum as _Enum +from io import TextIOWrapper as _TextIOWrapper, StringIO as _StringIO +from datetime import datetime as _dt, timedelta as _td + +from .errors import ( + NoBindsError as _NoBindsError, + InvalidBindError as _InvalidBindError, + HostnameError as _HostnameError) + +__all__ = [ + 'FingerServer', + 'FingerInterface', 'FingerFormatter', 'FingerLogger', + 'FingerUser', 'FingerSession' +] + +# --- +# Basic representations. +# --- + +class FingerUser: + """ User description for the finger interface. """ + + def __init__(self, *_, login = None, name = None, + home = None, shell = None): + self._login = None + self._name = '' + self._home = None + self._shell = None + self._office = None + self._plan = None + self._last_login = None + self._sessions = _FingerSessionManager() + + self.login = login + self.name = name + self.home = home + self.shell = shell + + def __repr__(self): + p = ('login', 'name', 'home', 'shell', 'office', + 'last_login', 'sessions') + p = (f"{x} = {repr(getattr(self, x))}" for x in p \ + if getattr(self, x) is not None) + return f"{self._class__.__name__}({', '.join(p)})" + + @property + def login(self): + """ Login name, e.g. 'cake'. """ + + return self._login + + @login.setter + def login(self, value): + self._login = value + + @property + def name(self): + """ Full user name, e.g. 'Jean Dupont'. """ + + return self._name + + @name.setter + def name(self, value): + self._name = value + + @property + def last_login(self): + """ Last login date. """ + + return self._last_login + + @last_login.setter + def last_login(self, value): + self._last_login = None if value is None else \ + value if isinstance(value, _dt) else _dt(value) + + @property + def home(self): + """ The user's home. """ + + return self._home + + @home.setter + def home(self, value): + self._home = None if value is None else str(value) + + @property + def shell(self): + """ The user's shell. """ + + return self._shell + + @shell.setter + def shell(self, value): + self._shell = None if value is None else str(value) + + @property + def office(self): + """ The user's office. """ + + return self._office + + @office.setter + def office(self, value): + self._office = None if value is None else str(value) + + @property + def plan(self): + """ The user's plan. """ + + return self._plan + + @plan.setter + def plan(self, value): + if value is None: + self._plan = None + else: + value = str(value) + self._plan = '\n'.join(value.splitlines()) + + @property + def sessions(self): + """ Current sessions. """ + + return self._sessions + + +class _FingerSessionManager: + """ Session manager. """ + + def __init__(self): + self._sessions = [] + + def __repr__(self): + return repr(self._sessions) + + def __bool__(self): + return bool(self._sessions) + + def __iter__(self): + return iter(self._sessions[::-1]) + + def __delitem__(self, key): + if key is None: + self._sessions.pop(0) + else: + for i in [i for i, x in enumerate(self._sessions) \ + if key == x.name][::-1]: + self._sessions.pop(i) + + def __getitem__(self, key): + if key is None: + try: + return self._sessions[0] + except IndexError: + raise KeyError("could not get latest session") from None + + if type(key) is int: + try: + return self._sessions[key] + except IndexError: + msg = f"could not get session #{repr(key)}" + raise IndexError(msg) from None + + try: + return next(x for x in self._sessions if key == x.name) + except StopIteration: + raise KeyError(f"could not get session {repr(key)}") from None + + def __setitem__(self, key, value): + if not isinstance(value, FingerSession): + raise TypeError("can only add sessions into a session manager") + value = _copy.deepcopy(value) + + if key is None: + # Check if except the first session, the key of the session, + # if any, does not override another key. + + if value.name is not None: + try: + next(i for i, x in self._sessions[1:] \ + if value.name == x.name) + except StopIteration: + pass + else: + msg = "value.name overrides another session's key" + raise ValueError(msg) from None + + try: + self._sessions[0] = value + return + except IndexError: + raise KeyError("could not set latest session") from None + + if type(key) is int: + # Check if except the key-th session, the key of the session, + # if any, does not override another key. + + if value.name is not None: + try: + next(i for i, x in self._sessions \ + if i != key and value.name == x.name) + except StopIteration: + pass + else: + msg = "value.name overrides another session's key" + raise ValueError(msg) from None + + try: + self._sessions[key] = value + return + except IndexError: + msg = f"could not set session #{repr(key)}" + raise IndexError(msg) from None + + value.name = key + + try: + i = next(i for i, x in enumerate(self._sessions) \ + if key == x.name) + except StopIteration: + raise KeyError(f"could not set session {repr(key)}") from None + + self._sessions[i] = value + + def add(self, session): + """ Add a session. """ + + if not isinstance(session, FingerSession): + raise TypeError("can only insert sessions into a session " + "manager") + if session.name is not None: + try: + next(i for i, x in self._sessions \ + if session.name == x.name) + except StopIteration: + pass + else: + msg = "session.name overrides another session's key" + raise ValueError(msg) from None + + self._sessions.insert(0, session) + + +class FingerSession: + """ Session for a user. """ + + def __init__(self, *_, time = _dt.now()): + self._start = time if isinstance(time, _dt) else _dt(time) + self._line = None + self._host = None + self._idle = self._start + + def __repr__(self): + p = ('start', 'line', 'orig', 'idle') + p = (f"{x} = {repr(getattr(self, x))}" for x in p \ + if getattr(self, x) is not None) + return f"{self._class__.__name__}({', '.join(p)})" + + @property + def start(self): + """ The session start time. """ + + return self._start + + @start.setter + def start(self, value): + self._start = value if isinstance(value, _dt) else _dt(value) + + @property + def line(self): + """ The line on which the user is. """ + + return self._line + + @line.setter + def line(self, value): + self._line = None if value is None else str(value) + + @property + def host(self): + """ The host from which the user is connected. """ + + return self._host + + @host.setter + def host(self, value): + self._host = None if value is None else str(value) + +# --- +# Formatter base class. +# --- + +class FingerFormatter: + """ Answer formatter. + Formats the answer following RFC 1288. """ + + # --- + # Internal formatting utilities. + # --- + + def _format_idle(self, idle): + """ Format an idle time delta. """ + + def _iter_idle(idle): + days = int(idle.days) + hours = int(idle.seconds / 3600) + mins = int(idle.seconds % 3600 / 60) + secs = int(idle.seconds % 60) + + if days: + yield f"{days} day{('', 's')[days > 1]}" + if hours: + yield f"{hours} hour{('', 's')[hours > 1]}" + if mins: + yield f"{mins} minute{('', 's')[mins > 1]}" + if secs: + yield f"{secs} second{('', 's')[secs > 1]}" + + return f"{' '.join(_iter_idle(idle))} idle" + + def _format_time(self, d): + """ Format a date and time. """ + + if d < _td(): + return "" + + days = int(d.days) + hours = int(d.seconds / 3600) + mins = int(d.seconds % 3600 / 60) + + if days: + return f"{days}d" + elif hours or mins: + return f"{hours:02}:{mins:02}" + + return "" + + def _format_when(self, d): + """ Format a date and time for 'when'. """ + + return d.strftime("%a %H:%M") + + # --- + # Used formatting functions. + # --- + + def format_query_error(self, name): + """ There is an error in your query! """ + + return "Site: {}\r\n" \ + "You have made a mistake in your query!\r\n".format(name) + + def format_header(self, name, request): + """ Formats the header of each request. """ + + if request: + request = ' ' + request + + return f"Site: {name}\r\n" f"Command line:{request}\r\n" "\r\n" + + def format_footer(self): + """ Formats the footer of each request (in case the developer + wants to add something at the end, anything). """ + + return "" + + def format_short(self, users): + """ Format a user list in a short fashion. """ + + if not users: + return "No user list available.\r\n" + + lst = [] + for user in users: + if not user.sessions: + lst.append((user, None)) + for session in user.sessions: + lst.append((user, session)) + + now = _dt.now() + + _login = lambda u, s: u.login + _name = lambda u, s: u.name + _line = lambda u, s: s.line if s and s.line else '' + _idle = lambda u, s: self._format_time(now - s.idle) if s else '' + _logt = lambda u, s: self._format_when(s.start) if s else '' + _offic = lambda u, s: u.office if u.office else '' + + columns = ( + ('Login',) + tuple(_login(u, s) for u, s in lst), + ('Name',) + tuple( _name(u, s) for u, s in lst), + ('TTY',) + tuple( _line(u, s) for u, s in lst), + ('Idle',) + tuple( _idle(u, s) for u, s in lst), + ('When',) + tuple( _logt(u, s) for u, s in lst), + ('Office',) + tuple(_offic(u, s) for u, s in lst)) + + sizes = tuple(max(map(len, c)) + 1 for i, c in enumerate(columns)) + align = ('<', '<', '<', '^', '^', '<') + + lines = [] + for line in range(len(columns[0])): + lines.append(' '.join(\ + f"{columns[i][line][:sizes[i]]:{align[i]}{sizes[i]}}" \ + for i in range(len(columns)))) + + return '\r\n'.join(lines) + '\r\n' + + def format_long(self, users): + """ Format a user list in a long fashion. """ + + if not users: + return "No user list available.\r\n" + + res = '' + + for user in users: + res += ("" + f"Login name: {user.login[:27]:<27} Name: {user.name}\r\n" + f"Directory: {user.home[:28]:<28} Shell: {user.shell}\r\n" + f"Office: {user.office if user.office else ''}\r\n") + + if user.sessions: + # List current sessions. + + for se in user.sessions: + since = se.start.strftime("%a %b %e %R") + tz = str(_dt.utcnow().astimezone().tzinfo) + res += f"On since {since} ({tz}) on {se.line}" + if se.host is not None: + res += f" from {se.host}" + res += "\r\n" + + idle = _dt.now() - se.idle + if idle >= _td(seconds = 4): + res += f" {self._format_idle(idle)}\r\n" + elif user.last_login is not None: + # Show last login. + + date = user.last_login.strftime("%a %b %e %R") + tz = str(_dt.utcnow().astimezone().tzinfo) + res += f"Last login {date} ({tz}) on console\r\n" + else: + res += "Never logged in.\r\n" + + if user.plan is None: + res += "No plan.\r\n" + else: + res += "Plan:\r\n" + res += "\r\n".join(user.plan.splitlines()) + res += "\r\n" + + res += "\r\n" + + return res + +# --- +# Interface (dummy) base class. +# --- + +class FingerInterface: + """ Finger interface to the users. + Expandable (the current class defines the dummy interface). """ + + def transmit_query(self, user, host, verbose): + return "This server won't transmit finger queries.\r\n" + + def search_users(self, check): + return [] + +# --- +# Server logger base class. +# --- + +class FingerLogger: + """ Finger logger for the server. + Expandable, but the base class should be enough for most needs. """ + + def __init__(self, stream = _sys.stderr): + self._stream = stream + self._lock = _multip.Lock() + + def _log(self, fmt, *args): + self._lock.acquire() + print('\r' + fmt.format(*args), file=self._stream) + self._lock.release() + + def _logr(self, src, fmt, *args): + self._log('[{}] ' + fmt, src, *args) + + def start(self, host, port): + self._log("Starting fingerd on [{}]:{}.", host, port) + + def stop(self, host, port): + self._log("Stopping fingerd on [{}]:{}.", host, port) + + def no_query(self, source): + self._logr(source, "no query. (possible scan)") + + def bad_query(self, source): + self._logr(source, "bad request.") + + def could_not_answer(self, source): + self._logr(source, "could not write the answer.") + + def transmit_list(self, source, host): + self._logr(source, "transmit user list query to `{}`.", host) + + def transmit(self, source, username, host): + self._logr(source, "transmit user query for `{}` to `{}`.", + username, host) + + def search_users(self, source, username): + self._logr(source, "look for user `{}`.", username) + + def list(self, source): + self._logr(source, "list connected users.") + +# --- +# Bind-related configuration. +# --- + +class _BindAddress: + """ Bind address for fingerd. """ + + class Type(_Enum): + """ Bind address type. """ + + """ TCP on IPv4 bind. """ + TCP_IPv4 = 1 + + """ TCP on IPv6 bind. """ + TCP_IPv6 = 2 + + """ IPC (Unix socket) bind. """ + IPC = 3 + + def __init__(self, family): + self._family = self.Type(family) + + def __repr__(self): + return f"{self._class__.__name__}(family = {self._family})" + + @property + def family(self): + """ Family as one of the `BindAddress.Type` enumeration values. """ + + return self._family + + +class _TCP4Address(_BindAddress): + """ IPv4 TCP Address. """ + + def __init__(self, address, port): + super().__init__(_BindAddress.Type.TCP_IPv4) + + try: + self._addr = _socket.inet_pton(_socket.AF_INET, address) + except: + self._addr = address + + self._port = port + + @property + def runserver_params(self): + """ Return the data as `_runserver` parameters. """ + + return (_socket.AF_INET, _socket.inet_ntop(_socket.AF_INET, + self._addr), self._port) + +class _TCP6Address(_BindAddress): + """ IPv6 TCP Address. """ + + def __init__(self, address, port): + super().__init__(_BindAddress.Type.TCP_IPv6) + + try: + self._addr = _socket.inet_pton(_socket.AF_INET6, address) + except: + self._addr = address + + self._port = port + + @property + def runserver_params(self): + """ Return the data as `_runserver` parameters. """ + + return (_socket.AF_INET6, _socket.inet_ntop(_socket.AF_INET6, + self._addr), self._port) + + +class _BindsDecoder: + """ Binds decoder for fingerd. + Takes a raw string and the protocol name, either 'finger' (the base + protocol managed by the class) or 'fingerd-control' (the protocol + used for controlling the live fingerd interface). """ + + def __init__(self, raw, proto = 'finger'): + proto = proto.casefold() + if proto not in ('finger', 'fingerd-control'): + raise ValueError(f"unsupported protocol {proto}") + + self._binds = set() + + for x in map(lambda x: x.strip(), raw.split(',')): + addr = x + + # Try to find a scheme. + + scheme, *rest = x.split(':/') + if not rest: + # No scheme found, let's just guess the scheme based on + # the situation. + + x = scheme + scheme = {'finger': 'tcp', 'fingerd-control': 'ipc'}[proto] + else: + # just don't add the ':' of ':/' again + x = '/' + ':/'.join(rest) + + if (proto == 'finger' and scheme != 'tcp') \ + or scheme not in ('tcp', 'ipc'): + raise _InvalidBindError(addr, "unsupported scheme " + f"{repr(scheme)} for protocol {repr(proto)}") + + # Decode the address data. + + if scheme == "tcp": + self._binds.update(self._decode_tcp_host(x)) + + self._binds = tuple(self._binds) + + def __iter__(self): + return iter(self._binds) + + def __repr__(self): + return f"{self._class__.__name__}(binds = {self._binds})" + + def _decode_tcp_host(self, x): + """ Decode suitable hosts for a TCP bind. """ + + addrs = () + addr = x + + # TODO: manage the '*' case. + + # Get the host part first, we'll decode it later. + + if x[0] == '[': + # The host part is an IPv6, look for the closing ']' and + # decode it later. + + to = x.find(']') + if to < 0: + raise _InvalidBindAddressError(addr, "expected " \ + "closing ']'") + + host = x[1:to] + x = x[to + 1:] + + is_ipv6 = True + else: + # The host part is either an IPv4 or a host name, look for + # the ':' and decode it later. + + host, *x = x.split(':') + x = ':' + ':'.join(x) + + is_ipv6 = False + + # Decode the port part. + + if x == '': + port = 79 + elif x[0] == ':': + try: + port = int(x[1:]) + except: + try: + assert x[1:] != '' + port = _socket.getservbyname(x[1:]) + except: + raise _InvalidBindError(addr, + "expected a valid port number or name " \ + f"(got {repr(x[1:])})") from None + else: + raise _InvalidBindAddressError(addr, + "garbage found after the host") + + # Decode the host part and get the addresses. + + addrs = () + if is_ipv6: + # Decode the IPv6 address (validate it using `_socket.inet_pton`). + + ip6 = host + _socket.inet_pton(_socket.AF_INET6, host) + addrs += (_TCP6Address(ip6, port),) + else: + # Decode the host (try IPv4, otherwise, resolve domain). + + try: + ip = host.split('.') + assert 2 <= len(ip) <= 4 + + ip = list(map(int, ip)) + assert all(lambda x: 0 <= x < 256, ip) + + if len(ip) == 2: + ip = [ip[0], 0, 0, ip[1]] + elif len(ip) == 3: + ip = [ip[0], 0, ip[1], ip[2]] + + addrs += (_TCP4Address(ip, port),) + except: + entries = _socket.getaddrinfo(host, port, + proto = _socket.IPPROTO_TCP, + type = _socket.SOCK_STREAM) + + for ent in entries: + if ent[0] not in (_socket.AF_INET, _socket.AF_INET6) \ + or ent[1] not in (_socket.SOCK_STREAM,): + continue + + if ent[0] == _socket.AF_INET: + ip = ent[4][0] + _socket.inet_pton(_socket.AF_INET, ent[4][0]) + addrs += (_TCP4Address(ip, port),) + else: + ip6 = ent[4][0] + _socket.inet_pton(_socket.AF_INET6, ent[4][0]) + addrs += (_TCP6Address(ip6, port),) + + return addrs + +# --- +# Finger/TCP server implementation. +# --- + +class _FingerQuery: + """ A finger query. Requests information about connected or specific + users on a remote server. + + There are three types of requests recognized by RFC 1288: + + * {C} is a request for a list of all online users. + * {Q1} is a request for a local user. + * {Q2} is a request for a distant user (with hostname). + + /W means the RUIP (program answering the query) should be more + verbose (this token can be ignored). """ + + # "By default, this program SHOULD filter any unprintable data, + # leaving only printable 7-bit characters (ASCII 32 through + # ASCII 126), tabs (ASCII 9) and CRLFs." + + allowed_chars = ("\t !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + + _string.ascii_letters + _string.digits) + + def __init__(self, raw): + """ Initialize the query object by decoding the data. """ + + # Get a character string out of the query. + + raw = ''.join(c for c in raw if c in self.allowed_chars) + self.line = raw + + # Get elements. + + self.host = None + self.username = None + self.verbose = False + for element in raw.split(): + if element[0] == '/': + if 'W' in element[1:]: + self.verbose = True + continue + elif self.username is not None: + raise Exception + self.username = element + + if self.username is not None and '@' in self.username: + self.host, *self.username = self.username.split('@')[::-1] + self.username = '@'.join(self.username[::-1]) + + +class _FingerTCPHandler(_socketserver.StreamRequestHandler): + """ The TCP request handler for our finger daemon. + Instantiated once per connection to the server (request). """ + + def handle(self): + """ Request handling. """ + + inp = _TextIOWrapper(self.rfile, encoding = 'ascii', + errors = 'ignore', newline = '\r\n') + outp = _TextIOWrapper(self.wfile, encoding = 'ascii') + + # Gather the request line. + + try: + line = inp.readline() + except ConnectionResetError: + self.logger.no_query(self.src) + return + + # Decode the request. + + ans = _StringIO() + + try: + query = None + query = _FingerQuery(line) + except: + self.logger.bad_query(self.src) + ans.write(self.fmt.format_query_error(self.host)) + return + + if query is not None: + self.answer(query, ans) + + # Write the output. + + try: + outp.write(ans.getvalue()) + except ConnectionResetError: + self.logger.could_not_answer(self.src) + + @property + def fmt(self): + """ The answer formatter. """ + + return self.server.params['formatter'] + + @property + def iface(self): + """ The interface formatter. """ + + return self.server.params['interface'] + + @property + def host(self): + """ The host. """ + + return self.server.params['host'] + + @property + def logger(self): + """ The logger. """ + + return self.server.params['logger'] + + @property + def src(self): + """ The source IP address. """ + + return self.client_address[0] + + def answer(self, query, outp): + """ Answer the query. + `query` is the `_FingerQuery` instance. + `outp` is the output stream. + + This method is thought to be overriden someday. """ + + ans = self.fmt.format_header(self.host, query.line) + + # Gather the content. + + if query.host is not None: + if query.username: + self.logger.transmit(self.src, query.username, query.host) + else: + self.logger.transmit_list(self.src, query.host) + + cont = self.iface.transmit_query(query.host, query.username, + query.verbose) + ans += cont + else: + if query.username: + self.logger.search_users(self.src, query.username) + check = lambda x: query.username in x.login + else: + self.logger.list(self.src) + check = lambda x: x.sessions + + users = self.iface.search_users(check) + + if query.username or query.verbose: + ans += self.fmt.format_long(users) + else: + ans += self.fmt.format_short(users) + + # Send the answer. + + ans += self.fmt.format_footer() + outp.write(ans) + + +class _IPv4TCPServer(_socketserver.ThreadingMixIn, + _socketserver.TCPServer): + """ The IPv4 TCP Server to be used """ + + address_family = _socket.AF_INET + allow_reuse_address = True + + def __init__(self, *args, params = {}, **kwargs): + self.params = params + super().__init__(*args, **kwargs) + + +class _IPv6TCPServer(_socketserver.ThreadingMixIn, + _socketserver.TCPServer): + """ The IPv6 TCP Server to be used """ + + address_family = _socket.AF_INET6 + allow_reuse_address = True + + def __init__(self, *args, params = {}, **kwargs): + self.params = params + super().__init__(*args, **kwargs) + + +class FingerServer: + """ The Finger Server class. """ + + def __init__(self, binds = 'localhost:79', hostname = 'LOCALHOST', + interface = FingerInterface(), formatter = FingerFormatter(), + logger = FingerLogger()): + # Check the host name. + + try: + hostname = hostname.upper() + assert all(c in _string.ascii_letters + _string.digits + '.-' + for c in hostname) + except: + raise _HostnameError(hostname) + + # Check the interface and formatter classes. + + if not isinstance(interface, FingerInterface): + raise TypeError("please base your interface " + "on the base class provided by the fingerd module") + + if not isinstance(formatter, FingerFormatter): + raise TypeError("please base your formatter " + "on the base class provided by the fingerd module") + + if not isinstance(logger, FingerLogger): + raise TypeError("please base your logger " + "on the base class provided by the fingerd module") + + # Keep the parameters. + + self._host = hostname + self._logger = logger + self._interface = interface + self._formatter = formatter + + # Check the addresses. + + self._servers = [] + + for bind in _BindsDecoder(binds): + self._servers.append([bind.runserver_params, None]) + + if not self._servers: + raise _NoBindsError() + + def start(self): + """ Bind and start the underlying servers. """ + + def run_server(family, addr, port): + """ Run the server (will be run in another thread). """ + + logger = self._logger + + # Make the server corresponding to the entry. + + params = { + 'host': self._host, + 'interface': self._interface, + 'formatter': self._formatter, + 'logger': self._logger + } + + if family == _socket.AF_INET: + server = _IPv4TCPServer((addr, port), _FingerTCPHandler, + params = params) + elif family == _socket.AF_INET6: + server = _IPv6TCPServer((addr, port), _FingerTCPHandler, + params = params) + + # Run it. + + logger.start(addr, port) + try: server.serve_forever() + except KeyboardInterrupt: + pass + logger.stop(addr, port) + + for entry in self._servers: + # Check if the thread already exists and runs. + + if entry[1] != None and not entry[1].is_alive(): + continue + + # Start the thread. + + entry[1] = _multip.Process(target = run_server, + args = entry[0]) + entry[1].start() + + def stop(self): + """ Stop the underlying servers. """ + + for entry in self._servers: + # Check if the thread is still here. + + if entry[1] == None: + continue + if not entry[1].is_alive(): + entry[1] = None + continue + + # Stop it. + + entry[1].join() + entry[1] = None + + def serve_forever(self): + """ Serve forever. """ + + self.start() + + try: + while True: + _signal.pause() + except KeyboardInterrupt: + pass + + self.stop() + + def shutdown(self): + """ Shutdown the server, alias to `.stop()`. """ + + self.stop() + +# End of file. diff --git a/fingerd/version.py b/fingerd/version.py new file mode 100644 index 0000000..4c7f345 --- /dev/null +++ b/fingerd/version.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +#************************************************************************** +# Copyright (C) 2021 Thomas Touhey <thomas@touhey.fr> +# This file is part of the fingerd project, which is MIT-licensed. +#************************************************************************** +""" fingerd version definition. """ + +version = "0.1" + +# End of file. diff --git a/sample-scenarios/actions.toml b/sample-scenarios/actions.toml new file mode 100644 index 0000000..30f851b --- /dev/null +++ b/sample-scenarios/actions.toml @@ -0,0 +1,72 @@ +[[-120j]] +type = "create" +login = "rinehart" +name = "Mark J. Rinehart" +shell = "/bin/bash" +home = "/home/rinehart" +office = "019 Hill" + +[[-24j]] +type = "create" +login = "cake" +name = "Thomas Touhey" +shell = "/bin/zsh" +home = "/home/cake" +office = "none, +33621612972" +plan = "../.plan" + +[[-22j]] +type = "update" +login = "cake" +name = "Thomas Adrian Touhey" +office = false +plan = false + +[[-6j]] +type = "create" +login = "blebneh" +name = "Bleb K. Neh" +shell = "/bin/sh" +home = "/home/bleb" +office = "019 Hill 908-932-" + +[[-2j6h20m]] +type = "login" +login = "cake" +line = "pts/0" +host = "192.168.1.77" + +[[-2j5h20m]] +type = "idle" +login = "cake" + +[[-2h38s]] +type = "login" +login = "cake" +line = "pts/1" +host = "192.168.1.77" + +[[-10s]] +type = "login" +login = "rinehart" +line = "ttyS3" + +[[10s]] +type = "delete" +login = "blebneh" + +[[30s]] +type = "logout" +login = "rinehart" + +[[40s]] +type = "logout" +login = "cake" + +[[45s]] +type = "active" +login = "cake" + +[[1m30s]] +type = "idle" +login = "cake" @@ -1,6 +1,6 @@ [metadata] name = fingerd -version = attr: fingerd.version +version = attr: fingerd.version.version url = https://fingerd.touhey.pro/ author = Thomas Touhey author_email = thomas@touhey.fr @@ -26,6 +26,7 @@ scripts = scripts/fingerd-control install_requires = toml + click [options.package_data] * = *.txt, *.rst |