Compare commits

...

257 Commits

Author SHA1 Message Date
Max Erenberg cf42e49ae6 fix home links
continuous-integration/drone/push Build is passing Details
2024-03-24 18:11:48 -04:00
Max Erenberg bf2afd1195 Remove FCGI support
continuous-integration/drone/push Build is passing Details
Apache's ProxyPass directive doesn't seem to be passing the URI for
FCGI. There's probably a way to configure this, but it's easier to just
use HTTP instead.
2024-03-24 17:54:13 -04:00
Max Erenberg 5f8de94393 restrict app.sock access to www-data
continuous-integration/drone/push Build is passing Details
2024-03-23 23:00:37 -04:00
Max Erenberg 2164ceddf0 fix compilation error in api.go
continuous-integration/drone/push Build is passing Details
2024-03-23 19:32:23 -04:00
Max Erenberg 7716f7bd10 Add web UI for password resets (#123)
continuous-integration/drone/push Build is passing Details
Reviewed-on: public/pyceo#123
2024-03-23 19:26:30 -04:00
Nathan Chung 32cb22665a
packaging: system update commands
continuous-integration/drone/push Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-21 02:24:41 -05:00
Nathan Chung 56a59186e0
extra packaging instructions and notes
continuous-integration/drone/push Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-21 02:20:58 -05:00
Nathan Chung 5ecec2b54c
1.0.31: changelog
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-21 02:20:42 -05:00
Nathan Chung f584a89cec
update pgp packaging info
continuous-integration/drone/push Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-21 01:53:10 -05:00
Nathan Chung 7d9ec99f8f
release 1.0.31
continuous-integration/drone/push Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-21 01:40:22 -05:00
Nathan Chung 28adf6e13d
Merge branch '1.0.30-update-packaging'
continuous-integration/drone/push Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-21 01:39:50 -05:00
Max Erenberg 9c51ad3a01 Allow offsck to add members to the office group (#126)
continuous-integration/drone/push Build is passing Details
Closes #62.

Reviewed-on: public/pyceo#126
2024-02-17 19:31:03 -05:00
Nathan Chung bf7f1c7724
update debian packaging instructions
continuous-integration/drone/push Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-14 14:16:40 -05:00
Leon Zhang 194b5ec4a6 See #125 (#127)
continuous-integration/drone/push Build is passing Details
I couldn't figure out how to reopen a PR after merge, so I'm opening another one.

Co-authored-by: Leon <lzhang219@gmail.com>
Reviewed-on: public/pyceo#127
Reviewed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-by: Nathan Chung <n4chung@csclub.uwaterloo.ca>
2024-02-11 16:33:39 -05:00
Nathan Chung c83bbe2563 update packaging version and instructions (#122)
continuous-integration/drone/push Build is passing Details
- push version to release version `1.0.30`
- add additional instructions for packaging eg. pgp, additional build dependencies
- git ignore gpg private keys

Reviewed-on: public/pyceo#122
Reviewed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-authored-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
Co-committed-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-11 12:20:30 -05:00
Max Erenberg 32709ad401 Revert "Fix for regression in issue #124 (#125)"
continuous-integration/drone/push Build is passing Details
This reverts commit 3780662ba4.
2024-02-10 15:37:06 -05:00
Leon Zhang 3780662ba4 Fix for regression in issue #124 (#125)
continuous-integration/drone/push Build is passing Details
Co-authored-by: Leon <lzhang219@gmail.com>
Reviewed-on: public/pyceo#125
Reviewed-by: Nathan Chung <n4chung@csclub.uwaterloo.ca>
2024-02-10 15:32:52 -05:00
Nathan Chung b1dac8ce07
1.0.30: update changelog and package uploaders
continuous-integration/drone/pr Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-04 14:47:10 -05:00
Nathan Chung c5edf5ea48
update packaging version and instructions
continuous-integration/drone/pr Build is passing Details
Signed-off-by: Nathan13888 <29968201+Nathan13888@users.noreply.github.com>
2024-02-04 14:24:22 -05:00
Max Erenberg a4a4ef089c Query Active Directory LDAP for alumni (#120)
continuous-integration/drone/push Build is passing Details
Closes #116.

UWLDAP has program information for current students, so we should continue using it by default.
If the sn attribute (last name) is missing from the entry, then we query ADLDAP instead.

Reviewed-on: public/pyceo#120
2024-02-01 23:57:53 -05:00
Max Erenberg bd1da799c6 Allow ceod/* principals for all requests (#121)
continuous-integration/drone/push Build is passing Details
Allow the ceod/\* principals (which should only be used by the ceod daemons) to make requests to all API endpoints.

Reviewed-on: public/pyceo#121
2024-01-28 21:37:34 -05:00
Leon Zhang 25994af312 Update available positions in configuration files, pertaining to issue #63 (#117)
continuous-integration/drone/push Build is passing Details
Updated available positions adding new positions and removing 2 unused positions in ceo. Passed Drone CI.

Co-authored-by: Leon <lzhang219@gmail.com>
Reviewed-on: public/pyceo#117
Reviewed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2024-01-22 22:00:30 -05:00
Ohm Patel de23296413 Validate usernames across tui & for create_user on cli/api (#115)
continuous-integration/drone/push Build is passing Details
Current changes should address issues raised by @merenber in #114 excluding public/pyceo#114 (comment) (both CLI and TUI validation)

* Unit test for invalid name was added but needs to be modified as regex should be changed to disallow underscores eventually.

Reviewed-on: public/pyceo#115
Reviewed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-authored-by: o32patel <ohm.patel@uwaterloo.ca>
Co-committed-by: o32patel <ohm.patel@uwaterloo.ca>
2024-01-22 13:15:40 -05:00
Ohm Patel f06ccdc3f9 Add username verification (#114)
continuous-integration/drone/push Build is passing Details
Move and add validation to Controller.get_username_from_view(). This addresses #101
+ Add tests for username validator

Reviewed-on: public/pyceo#114
Reviewed-by: Nathan Chung <n4chung@csclub.uwaterloo.ca>
Co-authored-by: o32patel <ohm.patel@uwaterloo.ca>
Co-committed-by: o32patel <ohm.patel@uwaterloo.ca>
2024-01-15 20:01:44 -05:00
Max Erenberg 5332259731 Use persistent Docker images for development (#113)
continuous-integration/drone/push Build is passing Details
This PR adds support for building Docker images during development which can be re-used multiple times. This allows us to easily run `docker-compose up` and `docker-compose down` many times without having to reinstall packages from scratch every time.

Reviewed-on: public/pyceo#113
2023-12-03 23:29:11 -05:00
Max Erenberg 392ec153d0 release 1.0.29
continuous-integration/drone/push Build is passing Details
2023-07-31 21:29:56 -04:00
Max Erenberg 36bf340385 remove ceod.postinst 2023-07-31 20:06:32 -04:00
Max Erenberg 7e851daa8f update deps 2023-07-31 19:27:45 -04:00
Max Erenberg e0ed4fa23a check that forwarding_addresses is a list 2023-07-31 18:26:06 -04:00
Max Erenberg 6786c8e44e shorten tests for group search API 2023-07-31 18:24:34 -04:00
Max Erenberg 337c05c511 release 1.0.27
continuous-integration/drone/push Build is passing Details
2023-06-09 02:51:35 -04:00
Max Erenberg 65688c72da make forwarding_addresses mandatory when creating member (#97)
continuous-integration/drone/push Build is passing Details
Closes #96.

Reviewed-on: public/pyceo#97
2023-06-09 02:39:50 -04:00
Justin Chung 968f0815c7 Add linting pre-commit hook and hook install script (#86)
continuous-integration/drone/push Build is passing Details
Co-authored-by: Justin Chung <20733699+justin13888@users.noreply.github.com>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#86
Reviewed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-authored-by: Justin Chung <j24chung@csclub.uwaterloo.ca>
Co-committed-by: Justin Chung <j24chung@csclub.uwaterloo.ca>
2023-05-29 00:14:05 -04:00
Daniel Sun 010937ea17 Add group lookup functionality (#88)
continuous-integration/drone/push Build is passing Details
note: **I am unaware of best practices** but I tried my best to keep changes consistent with the codebase

feedback would be much appreciated

notable changes:
**new api endpoint**: `/groups/search` -- I moved searching into the api so it could be used in tui and cli, also seemed like a good idea to keep the json response as small as possible
**tui searching** -- at first I wanted to make this realtime interactable, but the work required seemed inappropriate to a feature I am assuming will only be used sparingly

Co-authored-by: Daniel Sun <dandancool@github.com>
Co-authored-by: Daniel Sun <d6sun@uwaterloo.ca>
Reviewed-on: public/pyceo#88
Reviewed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-authored-by: Daniel Sun <d6sun@csclub.uwaterloo.ca>
Co-committed-by: Daniel Sun <d6sun@csclub.uwaterloo.ca>
2023-03-04 01:21:04 -05:00
Max Erenberg 234ab62f27 add accounttype=0 to CloudStack listAccounts query
continuous-integration/drone/push Build is passing Details
2023-02-18 11:16:36 -05:00
Max Erenberg f9bda2f724 release 1.0.26
continuous-integration/drone/push Build is passing Details
2023-02-13 17:43:41 -05:00
Max Erenberg 239b992107 reduce UWLDAP batch size to 10
continuous-integration/drone/push Build is passing Details
2023-02-13 17:34:49 -05:00
Max Erenberg 754731ba5f upgrade Debian dependencies
continuous-integration/drone/push Build is passing Details
2023-02-06 02:16:17 -05:00
Max Erenberg b33339817f fix logging messages for renewing a member
continuous-integration/drone/push Build is passing Details
2023-02-06 00:29:45 -05:00
Max Erenberg 6dccd8b659 release 1.0.25 2023-02-06 00:12:22 -05:00
Max Erenberg 3f58d1aff5 print warning message when cloud account is created
continuous-integration/drone/push Build is passing Details
2023-02-05 23:48:38 -05:00
Justin Chung 5e8f1b5ba5 Implement TUI support for multiple users in each position (#80)
continuous-integration/drone/push Build is passing Details
Co-authored-by: Justin Chung <20733699+justin13888@users.noreply.github.com>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#80
Co-authored-by: Justin Chung <j24chung@csclub.uwaterloo.ca>
Co-committed-by: Justin Chung <j24chung@csclub.uwaterloo.ca>
2023-01-23 02:26:13 -05:00
Max Erenberg f84965c8e1 reload all NGINX servers after adding a vhost (#90)
continuous-integration/drone/push Build is passing Details
Currently, only the NGINX server on biloba is reloaded after adding a new vhost or renewing an SSL certificate. The NGINX server on chamomile should also be reloaded, since chamomile is a warm standby for biloba.

This PR adds a new config option in ceod.ini to specify the shell command to reload the web servers.

Reviewed-on: public/pyceo#90
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2023-01-22 17:20:55 -05:00
Max Erenberg 4394c4e277 use bullseye for base container (#91)
continuous-integration/drone/push Build is passing Details
All of the machines running ceod are on bullseye, so we don't need to support buster anymore.

Reviewed-on: public/pyceo#91
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2023-01-22 17:15:40 -05:00
Jonathan Leung b507c56136 Show groups in member for API, CLI and TUI (#82)
continuous-integration/drone/push Build is passing Details
Closes #69.

Tests are failing locally with many `assert os.geteuid() == 0` errors even on the master branch. I will add tests after I figure this out.

Reviewed-on: public/pyceo#82
Co-authored-by: Jonathan Leung <j23leung@csclub.uwaterloo.ca>
Co-committed-by: Jonathan Leung <j23leung@csclub.uwaterloo.ca>
2022-11-26 20:09:05 -05:00
Max Erenberg c0c9736593 Use the admin creds in the HTTPClient when necessary (#85)
continuous-integration/drone/push Build is passing Details
Currently, ceod uses the Kerberos credentials of the client when making requests to other services. This requires the client to send delegated credentials. Unfortunately the NPM krb5 package appears to be unable to perform delegation. So we will use the admin credentials instead (when appropriate).

Reviewed-on: public/pyceo#85
Reviewed-by: Raymond Li <raymo@csclub.uwaterloo.ca>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2022-11-06 15:23:27 -05:00
Max Erenberg 1e452d10ce Assume program is Alumni if UWLDAP is missing data (#84)
continuous-integration/drone/push Build is passing Details
This PR sets 'program=Alumni' for members who either do not have an 'ou' attribute in UWLDAP, or who do not have a UWLDAP entry at all.

Reviewed-on: public/pyceo#84
Reviewed-by: Raymond Li <raymo@csclub.uwaterloo.ca>
2022-11-01 21:02:05 -04:00
Max Erenberg 6a1fa81b82 merenber signs the packages
continuous-integration/drone/push Build is passing Details
Something went wrong when e42zhang tried to upload the packages to the
mirror. reprepro kept on complaining that no distribution would accept
the new package. I modified the changelog, re-signed and re-uploaded
the packages, and that worked, so I'm still not sure what the problem
was.
2022-10-23 22:04:32 -04:00
Max Erenberg 6df1f4d459 Revert "Simplify packaging"
This reverts commit b4a1373559.
2022-10-23 22:00:48 -04:00
Edwin 2cf9e25b59 More fixes
continuous-integration/drone/push Build is passing Details
2022-10-23 21:16:58 -04:00
Edwin 9ff3d850c9 Release 1.0.24 2022-10-23 19:50:38 -04:00
Edwin b4a1373559 Simplify packaging 2022-10-23 19:50:06 -04:00
Raymond Li dceb5d6572
Revert "#63: Add positions to CEO (#79)"
continuous-integration/drone/push Build is passing Details
This reverts commit 3b7c89c925.
2022-10-13 18:12:36 -04:00
Jonathan Leung c30ca54752 Sort group member listing by WatIAM ID (#78)
continuous-integration/drone/push Build is failing Details
Closes #74.

Co-authored-by: Jono <jowonowo@gmail.com>
Reviewed-on: public/pyceo#78
Reviewed-by: Raymond Li <raymo@csclub.uwaterloo.ca>
Co-authored-by: Jonathan Leung <j23leung@csclub.uwaterloo.ca>
Co-committed-by: Jonathan Leung <j23leung@csclub.uwaterloo.ca>
2022-10-13 14:58:50 -04:00
Nathan Chung 3b7c89c925 #63: Add positions to CEO (#79)
continuous-integration/drone/push Build is failing Details
public/pyceo#63

Added the following positions:
* ext affairs lead
* marketing lead
* design lead
* events lead
* reps lead
* mods lead
* photography lead
* other

Signed-off-by: n4chung <n4chung@csclub.uwaterloo.ca>

Co-authored-by: n4chung <n4chung@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#79
Reviewed-by: Raymond Li <raymo@csclub.uwaterloo.ca>
Co-authored-by: Nathan Chung <n4chung@csclub.uwaterloo.ca>
Co-committed-by: Nathan Chung <n4chung@csclub.uwaterloo.ca>
2022-10-13 14:58:34 -04:00
Max Erenberg a2324090f3 update README instructions for curl
continuous-integration/drone/push Build is passing Details
2022-10-12 01:56:54 -04:00
Max Erenberg f66f7c8f5a remove override_dh_systemd_start
continuous-integration/drone/push Build is passing Details
2022-10-09 17:52:37 -04:00
Max Erenberg 3e5b829085 check if mail_local_addresses exists in UWLDAP entry
continuous-integration/drone/push Build is passing Details
2022-10-07 08:13:59 -04:00
Rio Liu 57ba72ef26 Add support for using number in member terms renwewal API (#77)
continuous-integration/drone/push Build is passing Details
Closed #75

Co-authored-by: Rio6 <rio.liu@r26.me>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#77
Co-authored-by: Rio Liu <r345liu@csclub.uwaterloo.ca>
Co-committed-by: Rio Liu <r345liu@csclub.uwaterloo.ca>
2022-10-07 07:58:23 -04:00
Max Erenberg 779e35a08e fix shadowExpire deserialization
continuous-integration/drone/push Build is passing Details
2022-09-10 16:01:40 -04:00
Raymond Li 3cc9b011c3 Use HTTPS in sample
continuous-integration/drone/push Build is passing Details
2022-09-10 14:18:17 -04:00
Max Erenberg 2739c45aff use LDAP instead of NSS for authz (#73)
continuous-integration/drone/push Build is passing Details
Closes #71.

Reviewed-on: public/pyceo#73
2022-09-09 17:26:54 -04:00
Max Erenberg 651f4fb702 add more logging (#72)
continuous-integration/drone/push Build is passing Details
Closes #70.

Reviewed-on: public/pyceo#72
2022-09-09 14:42:43 -04:00
Max Erenberg 953bee549e fix tests
continuous-integration/drone/push Build is passing Details
2022-09-05 17:34:28 -04:00
Max Erenberg 0334e7e667 fix email formatting bug in ClubWebHostingService
continuous-integration/drone/push Build is failing Details
2022-09-05 15:37:35 -04:00
Max Erenberg 8decd3bc30 packaging for bullseye
continuous-integration/drone/push Build is passing Details
2022-09-05 00:36:50 -04:00
Max Erenberg 8ad8271db1 Fix some bugs in ClubWebHostingService
continuous-integration/drone/push Build is passing Details
* Don't read the value of an Apache directive unless we are sure
  it can only accept one argument
* Handle the case where a club's www directory is not readable
2022-09-04 23:22:28 -04:00
Raymond Li 4ebb9bb0a8
Release v1.0.22
continuous-integration/drone/push Build is passing Details
2022-08-06 02:20:46 +00:00
Max Erenberg cfb5f77711 Disable inactive club sites (#68)
continuous-integration/drone/push Build is passing Details
Closes #51.

An API argument called `remove_inactive_club_reps` was added so that we can dynamically control whether we want to remove inactive club reps or not. The default action is only to disable club websites without changing group membership.

Reviewed-on: public/pyceo#68
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2022-07-22 23:51:59 -04:00
Max Erenberg 32b2dbb307 Allow addmember and removemember to accept multiple usernames (#67)
continuous-integration/drone/push Build is passing Details
Closes #57.

Reviewed-on: public/pyceo#67
2022-07-02 11:26:38 -04:00
Max Erenberg dc412ef5cb implement renewal reminders (#61)
continuous-integration/drone/push Build is passing Details
Closes #55.

Once this is merged and deployed, a cron job will be used to automatically run `ceo members remindexpire` at the beginning of every term.

Reviewed-on: public/pyceo#61
2022-06-30 20:02:06 -04:00
Max Erenberg dbb5bf1c8d packaging for bullseye
continuous-integration/drone/push Build is passing Details
2022-06-09 10:10:31 -04:00
Max Erenberg 00c7d562ad fix URL bug in ContainerRegistryService
continuous-integration/drone/push Build is passing Details
2022-06-09 09:07:12 -04:00
Max Erenberg 6fae2e4115 use quote_plus for signing CloudStack API requests
continuous-integration/drone/push Build is passing Details
2022-06-09 01:23:04 -04:00
Max Erenberg 1fc432bb0f add hint to GetGroupResponseView
continuous-integration/drone/push Build is passing Details
2022-06-05 10:20:22 -04:00
Max Erenberg 2678bdf16e packaging for bullseye and focal
continuous-integration/drone/push Build is passing Details
2022-06-04 22:47:05 -04:00
Rio Liu 55c4b2151d Unsubscribe/resubscribe members when they're expired and renewed (#53)
continuous-integration/drone/push Build is passing Details
Co-authored-by: Rio Liu <rio.liu@r26.me>
Co-authored-by: Rio6 <rio.liu@r26.me>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#53
Co-authored-by: Rio <r345liu@localhost>
Co-committed-by: Rio <r345liu@localhost>
2022-06-02 02:06:49 -04:00
Max Erenberg 87470e1f3b don't reset password for local MySQL users
continuous-integration/drone/push Build is passing Details
2022-05-23 23:01:09 -04:00
Max Erenberg b543f0eb0c Rewrite TUI (#52)
continuous-integration/drone/push Build is passing Details
Closes #44.
Closes #47.
Closes #49.
Closes #50.

The TUI has been rewritten using urwid instead of asciimatics. The MVC pattern was also used to help increase organization and readability.

The mouse has been disabled, which allows users to easily copy text from the terminal.

Terms are now sorted when displayed, for both the CLI and the TUI.

Reviewed-on: public/pyceo#52
2022-05-22 14:09:46 -04:00
Max Erenberg 19496b4568 use mailman for mailman_host
continuous-integration/drone/push Build is failing Details
2022-05-01 03:39:17 -04:00
Raymond Li 8da700472f Update 'ceod/model/templates/welcome_message.j2'
continuous-integration/drone/push Build is passing Details
2022-03-18 21:35:32 -04:00
Max Erenberg 5197228d68 packaging for bullseye
continuous-integration/drone/push Build is passing Details
2022-03-13 08:03:23 -04:00
Max Erenberg 9b8425f30e bump version 2022-03-12 21:26:55 -05:00
Max Erenberg f3c542208a send cloud warning emails to root
continuous-integration/drone/push Build is passing Details
2022-03-12 16:09:19 -05:00
Max Erenberg 2487ab3668 update security section of docs 2022-03-12 15:50:42 -05:00
Max Erenberg 539de01c4d use admin GSSAPI creds for some API endpoints (#45)
continuous-integration/drone/push Build is passing Details
Office staff currently can't sign up new members because ceod uses their GSSAPI credentials to authenticate to LDAP, and those credentials are insufficient.

This PR uses the ceod/admin credentials instead for signing up new members and for renewing existing memberships.

Reviewed-on: public/pyceo#45
2022-03-12 15:19:14 -05:00
Max Erenberg af4e342f3c Add ':z' to Docker volume mounts
continuous-integration/drone/push Build is failing Details
On systems with SELinux enabled (e.g. Fedora), Docker/Podman cannot
access directories on the host by default.
2022-03-12 14:31:12 -05:00
Max Erenberg 00ced22950 add script to extend a term
continuous-integration/drone/push Build is passing Details
2022-01-15 23:44:23 -05:00
Max Erenberg 5200259cfa allow loginShell to be optional
continuous-integration/drone/push Build is passing Details
2022-01-10 01:32:26 -05:00
Max Erenberg 7d3e03e7fd increase retries for Postfix
continuous-integration/drone/push Build is passing Details
2022-01-07 23:06:58 -05:00
Max Erenberg 71e6b474a4 packaging for bullseye
continuous-integration/drone/push Build is passing Details
2022-01-05 01:32:58 -05:00
Max Erenberg 5351cf8aee Revert "don't subscribe club reps to csc-general"
This reverts commit fa05c4ad4a.
2022-01-05 01:28:05 -05:00
Max Erenberg 2ee9511337 set HOME environment variable in /etc/default/ceod
continuous-integration/drone/push Build is passing Details
2022-01-05 00:54:40 -05:00
Max Erenberg feb16ee625 packaging for bullseye
continuous-integration/drone/push Build is passing Details
2022-01-05 00:26:41 -05:00
Max Erenberg 0c166f93ad bump version
continuous-integration/drone/push Build is passing Details
2022-01-05 00:17:51 -05:00
Max Erenberg ef45344724 Revert "use Kubernetes runner"
This reverts commit 28b5000e89.
2022-01-05 00:17:04 -05:00
Max Erenberg 28b5000e89 use Kubernetes runner
continuous-integration/drone/push Build was killed Details
2022-01-04 23:54:51 -05:00
Max Erenberg 7908d49840 include ACME challenge location snippet in NGINX template 2022-01-04 23:53:44 -05:00
Max Erenberg 41d293ee3b add retry mechanism when sending email to new user
continuous-integration/drone/push Build is passing Details
2022-01-04 23:45:04 -05:00
Max Erenberg fa05c4ad4a don't subscribe club reps to csc-general
continuous-integration/drone/push Build is passing Details
2022-01-03 20:43:58 -05:00
Max Erenberg 02598fa3bc allow ignored Harbor projects to be configurable
continuous-integration/drone/push Build is passing Details
2022-01-02 22:43:22 -05:00
Max Erenberg 7ec17b2b4d chmod 600 the kubeconfig 2022-01-02 18:49:11 -05:00
Raymond Li 5f93b0e912 Update 'README.md'
continuous-integration/drone/push Build is passing Details
2022-01-02 16:06:37 -05:00
Raymond Li 7cb07547fa Update 'README.md'
continuous-integration/drone/push Build is passing Details
2022-01-02 16:06:03 -05:00
Raymond Li f45efefaca Autofill set positions (#41)
continuous-integration/drone/push Build is passing Details
Implements #40

Co-authored-by: Raymond Li <hi@raymond.li>
Reviewed-on: public/pyceo#41
Co-authored-by: Raymond Li <raymo@csclub.uwaterloo.ca>
Co-committed-by: Raymond Li <raymo@csclub.uwaterloo.ca>
2022-01-02 02:41:28 -05:00
Raymond Li d7b6ac2307 Update 'PACKAGING.md'
continuous-integration/drone/push Build is passing Details
2022-01-01 22:36:43 -05:00
Raymond Li 70d27c5817 Update 'PACKAGING.md'
continuous-integration/drone/push Build is passing Details
2022-01-01 22:34:39 -05:00
Max Erenberg 88b40b79cc Don't expire syscom members (#43)
continuous-integration/drone/push Build is passing Details
Closes #37.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#43
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2022-01-01 12:15:32 -05:00
Max Erenberg 1e94132e97 Add container registry API (#42)
continuous-integration/drone/push Build is passing Details
Add an API for members to create a project on Harbor.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#42
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2022-01-01 00:49:05 -05:00
Max Erenberg d200d3d6cf add packaging for bullseye and move Packaging documentation
continuous-integration/drone/push Build is passing Details
2021-12-25 12:43:05 -05:00
Max Erenberg 5e03ff932f bump version 2021-12-25 11:52:44 -05:00
Max Erenberg 0422e4487b fix flaky test for vhost rate limiting
continuous-integration/drone/push Build is passing Details
2021-12-25 11:31:54 -05:00
Max Erenberg 6e96e409be add (objectClass=member) filter for expired members 2021-12-25 11:23:06 -05:00
d278liu 250d24ae37 use binary search when finding new uid (#39)
continuous-integration/drone/push Build is passing Details
closes #36

Co-authored-by: Daniel Liu <mr.picklepinosaur@gmail.com>
Reviewed-on: public/pyceo#39
Co-authored-by: d278liu <d278liu@localhost>
Co-committed-by: d278liu <d278liu@localhost>
2021-12-23 17:00:27 -05:00
Max Erenberg 0640337564 Add ROOT environment variable to /etc/default/ceod
continuous-integration/drone/push Build is passing Details
kubectl was failing because it couldn't find the kubeconfig in
/root/.kube/config
2021-12-18 17:45:20 -05:00
Max Erenberg afb63f44dc add packaging for buster and bullseye 2021-12-18 16:59:13 -05:00
Max Erenberg 19c860b4ed bump version 2021-12-18 16:42:26 -05:00
Max Erenberg f08f4872cf Add Kubernetes API endpoint (#38)
continuous-integration/drone/push Build is passing Details
Add an API for members to create their own Kubernetes namespace.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#38
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-12-18 16:35:05 -05:00
Rio Liu b4110d887d Expire member cli and api (#33)
continuous-integration/drone/push Build is passing Details
continuous-integration/drone Build is passing Details
Closes #23

Co-authored-by: Rio Liu <rio.liu@r26.me>
Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#33
Co-authored-by: Rio <r345liu@localhost>
Co-committed-by: Rio <r345liu@localhost>
2021-12-11 16:30:18 -05:00
Max Erenberg f1c0ce3dd6 packaging for buster and bullseye 2021-11-28 23:22:16 -05:00
Max Erenberg 1338825c5d use NGINX with acme.sh 2021-11-28 22:35:46 -05:00
Max Erenberg 3a30f45672 add packaging for buster and bullseye 2021-11-28 15:42:59 -05:00
Max Erenberg bd50f4142f use Caddy instead of NGINX for vhosts 2021-11-28 15:21:48 -05:00
Max Erenberg 0d55f01bfc packaging for buster 2021-11-27 18:23:32 -05:00
Max Erenberg e71d9b7d30 packaging for bullseye 2021-11-27 18:10:52 -05:00
Max Erenberg aa2efcb26a use master branch in CI badge 2021-11-27 18:01:18 -05:00
Max Erenberg a7c5098b67 Add cloud vhost API (#35)
continuous-integration/drone/push Build is passing Details
Add an API for members to create their own virtual hosts.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#35
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-11-27 17:59:21 -05:00
Max Erenberg 0798419e34 packaging for buster
continuous-integration/drone/push Build is passing Details
2021-11-21 12:31:11 -05:00
Max Erenberg 7306241a78 packaging for bullseye 2021-11-21 12:06:07 -05:00
Max Erenberg eda5ca576a add cloud API to docs 2021-11-21 11:53:25 -05:00
Max Erenberg ac98aaf38d Add API to manage cloud accounts (#34)
continuous-integration/drone/push Build is passing Details
This PR adds API endpoints and a CLI to create cloud accounts and to purge accounts of expired members.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#34
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-11-21 11:11:20 -05:00
Max Erenberg 798510511f fix first/last name script
continuous-integration/drone/push Build is passing Details
2021-11-07 01:12:49 -04:00
Max Erenberg ed9893604f fix lint error
continuous-integration/drone/push Build is passing Details
2021-11-03 21:23:16 -04:00
Max Erenberg 52db130ef8 update docs for packaging in a container
continuous-integration/drone/push Build is failing Details
2021-11-02 20:34:44 -04:00
Max Erenberg 99819ce4fe go back to symlinks 2021-11-02 20:30:56 -04:00
Max Erenberg 89febf0400 use python binaries in virtualenv when packaging 2021-11-02 20:11:42 -04:00
Max Erenberg 620ef8ef8e fix UWLDAP multiple-UID bug 2021-11-02 19:02:07 -04:00
Max Erenberg ae48bcd98a fix bug in email sender address
continuous-integration/drone/push Build is passing Details
2021-11-02 03:05:46 -04:00
Max Erenberg 1f107b0614 use port 465 for smtps
continuous-integration/drone/push Build is passing Details
2021-11-02 02:46:05 -04:00
Max Erenberg f9f5d70ad3 use pip install . instead of python setup.py install 2021-11-02 02:07:15 -04:00
Max Erenberg bdc2f9b31b include Jinja templates in MANIFEST.in 2021-11-02 01:53:53 -04:00
Max Erenberg 729f443e72 move term check to LDAPService instead of User constructor 2021-11-02 01:30:01 -04:00
Max Erenberg dbbc533111 add cryptography package to requirements.txt 2021-11-02 00:35:26 -04:00
Max Erenberg 57787c170a remove v1 from drone.yml
continuous-integration/drone/push Build is passing Details
2021-11-01 22:11:16 -04:00
Max Erenberg beec46a951 re-run CI
continuous-integration/drone/push Build is passing Details
2021-10-29 00:18:16 -04:00
Max Erenberg 3218938909 add suggestions from ehashman
continuous-integration/drone/push Build is failing Details
2021-10-28 22:27:03 -04:00
Max Erenberg 2493bb1a6b modify CSC schema instead of using inetOrgPerson
continuous-integration/drone/push Build is passing Details
2021-10-28 21:33:32 -04:00
Max Erenberg 02aff43e7f Add debian packaging (#32)
continuous-integration/drone/push Build is failing Details
Closes #31.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#32
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-10-28 20:52:19 -04:00
Max Erenberg 2970736105 place CI fix into common.sh
continuous-integration/drone/push Build is passing Details
2021-10-25 08:18:25 -04:00
Max Erenberg a0cc29738b change perms on /tmp in CI container
continuous-integration/drone/push Build is passing Details
2021-10-25 08:09:07 -04:00
Max Erenberg 3e1af74f0c re-run CI
continuous-integration/drone/push Build is failing Details
2021-10-25 07:42:04 -04:00
Max Erenberg a5bab379e2 Merge branch 'v1' of csclub.uwaterloo.ca:public/pyceo into v1 2021-10-25 07:41:52 -04:00
Max Erenberg ac573039da add mailLocalAddress to each record (#30)
continuous-integration/drone/push Build is failing Details
Closes #26.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#30
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-10-25 07:36:25 -04:00
Max Erenberg 1374ff95aa Merge branch 'v1' of csclub.uwaterloo.ca:public/pyceo into v1 2021-10-24 07:20:10 -04:00
Max Erenberg 23f40c74f9 Use inetOrgPerson instead of account (#29)
continuous-integration/drone/push Build is passing Details
Closes #25.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#29
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-10-23 23:21:09 -04:00
Max Erenberg ac7f41801b Merge branch 'v1' of csclub.uwaterloo.ca:public/pyceo into v1 2021-10-23 10:24:44 -04:00
Max Erenberg e3c50d867a Add isClubRep attribute (#27)
continuous-integration/drone/push Build is passing Details
Closes #24.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#27
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-10-23 10:23:43 -04:00
Max Erenberg 1fbc068f3b update README 2021-10-05 00:10:15 -04:00
Max Erenberg 1fcc49ef12 add documentation (#22)
continuous-integration/drone/push Build is passing Details
Add OpenAPI spec and man pages

Co-authored-by: Max Erenberg <>
Co-authored-by: Rio Liu <rio.liu@r26.me>
Co-authored-by: Andrew Wang <a268wang@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#22
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-10-05 00:07:10 -04:00
Max Erenberg 3e1131c4e4 no-resizing (#21)
continuous-integration/drone/push Build is passing Details
This PR disables resizing the TUI.
Unfortunately this is a regression from the old ceo. But trying to preserve state when destroying and creating new views in asciimatics proved to be very difficult.

Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#21
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-10-04 20:04:05 -04:00
Rio Liu 7edc01e42b Positions TUI (#20)
continuous-integration/drone/push Build is passing Details
Closes #17

Co-authored-by: Rio Liu <rio.liu@r26.me>
Co-authored-by: Max Erenberg <>
Reviewed-on: public/pyceo#20
Co-authored-by: Rio <r345liu@localhost>
Co-committed-by: Rio <r345liu@localhost>
2021-09-26 15:23:47 -04:00
Max Erenberg 2a5d903eba add mailman CLI command
continuous-integration/drone/push Build is passing Details
2021-09-25 15:16:22 -04:00
Max Erenberg 3cba9680f5 test that email is sent when user is created 2021-09-25 13:56:23 -04:00
Max Erenberg 749ca41080 use custom TransactionView for AddUser
continuous-integration/drone/push Build is passing Details
2021-09-17 23:46:12 -04:00
Max Erenberg de18a9f293 add option to use Docker instead of VM (#16)
continuous-integration/drone/push Build is passing Details
Co-authored-by: Max Erenberg <>
Co-authored-by: Rio <r345liu@localhost>
Reviewed-on: public/pyceo#16
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-committed-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
2021-09-17 22:39:27 -04:00
Max Erenberg 652620a7c5 fix lint errors
continuous-integration/drone/push Build is passing Details
2021-09-12 09:36:54 -04:00
Max Erenberg 155c96c500 implement Database views in TUI
continuous-integration/drone/push Build is failing Details
2021-09-12 02:08:15 -04:00
Max Erenberg ad38588141 use single ListBox in WelcomeView
continuous-integration/drone/push Build is passing Details
2021-09-11 17:24:23 -04:00
Andrew Wang 33323fd112 Add database CLI (#15)
continuous-integration/drone/push Build is passing Details
Closes #12

Co-authored-by: Andrew Wang <a268wang@csclub.uwaterloo.ca>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#15
Co-authored-by: Andrew Wang <a268wang@localhost>
Co-committed-by: Andrew Wang <a268wang@localhost>
2021-09-11 13:33:43 -04:00
Max Erenberg cb6243c3e2 remove unused handler
continuous-integration/drone/push Build is passing Details
2021-09-08 22:29:36 +00:00
Neil Parikh 6e2b9dee24 Update discord link (#13)
continuous-integration/drone/push Build is passing Details
Co-authored-by: n3parikh <n3parikh@localhost>
Reviewed-on: public/pyceo#13
Co-authored-by: n3parikh <n3parikh@csclub.uwaterloo.ca>
Co-committed-by: n3parikh <n3parikh@csclub.uwaterloo.ca>
2021-09-08 18:23:29 -04:00
Rio Liu 651988bb08 Positions CLI (#11)
continuous-integration/drone/push Build is passing Details
Closes #9

Co-authored-by: Rio6 <rio.liu@r26.me>
Co-authored-by: Rio Liu <rio.liu@r26.me>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#11
Co-authored-by: Rio <r345liu@localhost>
Co-committed-by: Rio <r345liu@localhost>
2021-09-08 09:32:34 -04:00
Max Erenberg 82b7b2c015 fix lint errors
continuous-integration/drone/push Build is passing Details
2021-09-08 04:11:03 +00:00
Max Erenberg 0bf24230a0 add global quit button
continuous-integration/drone/push Build is failing Details
2021-09-08 04:10:21 +00:00
Max Erenberg 4aaf10b687 add Databases and Positions menus 2021-09-08 03:38:12 +00:00
Max Erenberg df7148940a implement RemoveMemberFromGroupView
continuous-integration/drone/push Build is passing Details
2021-09-08 03:20:51 +00:00
Max Erenberg 21173d1b8c implement AddMemberToGroupView 2021-09-08 02:59:56 +00:00
Max Erenberg beb16b1740 implement GetGroupView 2021-09-07 05:22:20 +00:00
Max Erenberg 6b3ad28e89 implement AddGroupView
continuous-integration/drone/push Build is passing Details
2021-09-07 05:02:34 +00:00
Max Erenberg ebaeeaaf13 implement ChanageLoginShellView and SetForwardingAddressesView
continuous-integration/drone/push Build is failing Details
2021-09-07 04:16:29 +00:00
Max Erenberg a08fca4c60 fix lint errors
continuous-integration/drone/push Build is passing Details
2021-09-07 03:05:13 +00:00
Max Erenberg 1406899ea2 implement ResetPasswordView
continuous-integration/drone/push Build is failing Details
2021-09-07 03:03:30 +00:00
Max Erenberg d3c98e418a implement GetUser in TUI 2021-09-07 02:29:53 +00:00
Max Erenberg af73dd713d add flash_message 2021-09-06 20:16:45 +00:00
Max Erenberg 39158676ae use CeoFrame as parent class for TransactionView
continuous-integration/drone/push Build is passing Details
2021-09-06 16:48:07 +00:00
Max Erenberg 40ee927b91 Merge branch 'v1' of csclub.uwaterloo.ca:public/pyceo into v1 2021-09-06 16:42:13 +00:00
Max Erenberg ee21873ad7 implement membership renewals in TUI 2021-09-06 16:40:05 +00:00
Max Erenberg cce920d6ba save view state in model 2021-09-05 22:48:20 +00:00
Andrew Wang c6c01d8720 allow mysql connections from unix socket (#14)
continuous-integration/drone/push Build is passing Details
Co-authored-by: Andrew Wang <someone.zip@gmail.com>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#14
Co-authored-by: Andrew Wang <a268wang@localhost>
Co-committed-by: Andrew Wang <a268wang@localhost>
2021-09-04 22:25:37 -04:00
Max Erenberg 6f1851fc19 Merge branch 'v1' into tui
continuous-integration/drone/push Build is failing Details
2021-09-04 23:57:50 +00:00
Max Erenberg bb56870652 add skeleton for TUI 2021-09-04 23:05:19 +00:00
Andrew Wang eb5d632606 db-api (#10)
continuous-integration/drone/push Build is passing Details
Implement DB endpoints

Co-authored-by: Andrew Wang <someone.zip@gmail.com>
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Reviewed-on: public/pyceo#10
Co-authored-by: Andrew Wang <a268wang@localhost>
Co-committed-by: Andrew Wang <a268wang@localhost>
2021-08-29 13:08:35 -04:00
Max Erenberg 7d23fd690f store GSSAPI token in flask.g
continuous-integration/drone/push Build is passing Details
2021-08-28 05:51:48 +00:00
Max Erenberg d8e5b1f1d4 update README
continuous-integration/drone/push Build is passing Details
2021-08-26 02:26:56 +00:00
Max Erenberg 46881f7a1f update .drone.yml
continuous-integration/drone/push Build is passing Details
2021-08-26 02:20:24 +00:00
Max Erenberg e011e98026 use GSSAPI delegation
continuous-integration/drone/push Build was killed Details
2021-08-26 02:19:18 +00:00
Max Erenberg 95e167578f remove libsasl2-dev dependency 2021-08-24 20:50:34 +00:00
Max Erenberg 51737585bd add updateprograms CLI
continuous-integration/drone/push Build is passing Details
2021-08-24 19:37:05 +00:00
Max Erenberg 831ebf17aa add groups CLI
continuous-integration/drone/push Build is passing Details
2021-08-24 05:48:55 +00:00
Max Erenberg 45192d75bf update social media links in welcome message
continuous-integration/drone/push Build is passing Details
2021-08-23 23:40:52 +00:00
Max Erenberg e851c77e74 include password in welcome email 2021-08-23 23:36:49 +00:00
Max Erenberg 08a3faaefc add unit tests for members CLI
continuous-integration/drone/push Build is passing Details
2021-08-23 23:01:24 +00:00
Max Erenberg 7a8751fd8f Merge branch 'v1' of csclub.uwaterloo.ca:public/pyceo into v1 2021-08-23 13:59:24 +00:00
Max Erenberg 6917247fdd add members CLI 2021-08-23 13:59:01 +00:00
Rio Liu ad937eebeb Positions API (#7)
continuous-integration/drone/push Build is passing Details
Co-authored-by: Max Erenberg <merenber@csclub.uwaterloo.ca>
Co-authored-by: Rio Liu <r345liu@csclub.uwaterloo.ca>
Co-authored-by: Rio6 <rio.liu@r26.me>
Reviewed-on: public/pyceo#7
Co-authored-by: Rio <r345liu@localhost>
Co-committed-by: Rio <r345liu@localhost>
2021-08-22 17:57:36 -04:00
Max Erenberg 0974a7471b ignore UserAlreadySubscribedError
continuous-integration/drone/push Build is passing Details
2021-08-22 06:06:11 +00:00
Max Erenberg 0783588323 announce new user to ceo mailing list
continuous-integration/drone/push Build is passing Details
2021-08-22 05:44:41 +00:00
Max Erenberg 7142659a8c force delete Kerberos test principals
continuous-integration/drone/push Build is passing Details
2021-08-22 04:36:19 +00:00
Max Erenberg 862dfc01b2 add trigger branches to drone.yml
continuous-integration/drone/push Build is passing Details
2021-08-21 07:20:40 +00:00
Max Erenberg bb82945b41 remove hostname from /etc/hosts in auth1
continuous-integration/drone/push Build is passing Details
2021-08-21 07:13:36 +00:00
Max Erenberg 38f354c106 add sasl-host to slapd.conf
continuous-integration/drone/push Build is failing Details
2021-08-21 06:54:59 +00:00
Max Erenberg 95d083fca1 use our own SPNEGO implementation
continuous-integration/drone/push Build is failing Details
2021-08-21 06:27:33 +00:00
Max Erenberg 89e6c541ab add hostname check
continuous-integration/drone/push Build is failing Details
2021-08-20 18:46:36 +00:00
Max Erenberg c39eff6ca7 let service container sleep
continuous-integration/drone/push Build is failing Details
2021-08-20 18:39:31 +00:00
Max Erenberg e4970bf008 remove search option from resolv.conf
continuous-integration/drone/push Build is failing Details
2021-08-20 18:34:29 +00:00
Max Erenberg d11c6af2ec add tests to drone.yml
continuous-integration/drone/push Build is failing Details
2021-08-20 18:17:00 +00:00
Max Erenberg 4783621d22 update CI badge
continuous-integration/drone/push Build is passing Details
2021-08-20 02:29:25 +00:00
Max Erenberg 14273dcbe6 add drone.yml
continuous-integration/drone/push Build is passing Details
2021-08-20 02:24:55 +00:00
Max Erenberg 14c058eb67 use socket.gethostname() in krb5 test 2021-08-20 01:57:53 +00:00
Max Erenberg dc09210d23 add documentation about architecture 2021-08-20 01:41:50 +00:00
Max Erenberg 583fcded9b add test for API request without KRB-CRED 2021-08-19 23:53:13 +00:00
Max Erenberg 46fd926acc add test for RemoteMailmanService 2021-08-19 22:08:48 +00:00
Max Erenberg 490abb302c add simple authz tests 2021-08-19 20:33:44 +00:00
Max Erenberg 26fd8f6f68 remove duplicate function definition 2021-08-19 17:22:34 +00:00
Max Erenberg 2a286579cb Merge branch 'v1' into uwldap_tests 2021-08-19 17:20:47 +00:00
Max Erenberg ecf089c261 Implement Groups API (#6)
This PR implements the /api/groups endpoints.

Closes public/pyceo#2.

Reviewed-on: public/pyceo#6
Co-authored-by: Max Erenberg <merenber@localhost>
Co-committed-by: Max Erenberg <merenber@localhost>
2021-08-19 12:58:59 -04:00
Max Erenberg cc0bc4a638 add tests for Mailman API 2021-08-19 16:14:41 +00:00
Max Erenberg 2273ffa241 add test for krb5 2021-08-19 06:21:30 +00:00
Max Erenberg 12a83ce4c0 remove create_sync_response 2021-08-19 05:11:22 +00:00
Max Erenberg 28c55b2fed add tests for UWLDAP API 2021-08-19 04:56:25 +00:00
Max Erenberg 448692018a add test for group.to_dict() with one member 2021-08-19 00:23:55 +00:00
Max Erenberg 6bf4d75a60 log error message instead of traceback 2021-08-19 00:19:57 +00:00
Max Erenberg 5bda74eaf9 fix test_group_to_dict 2021-08-19 00:05:44 +00:00
Max Erenberg df5d9e5f14 Merge branch 'v1' into groups_api 2021-08-19 00:02:09 +00:00
Max Erenberg 57ab275634 implement /api/groups endpoints 2021-08-18 23:48:17 +00:00
Max Erenberg e370035b25 add cffi as dev dependency 2021-08-18 19:53:30 +00:00
Max Erenberg d78d31eec0 add Kerberos delegation (#5)
This PR adds unconstrained Kerberos delegation to the API.

The client obtains a forwarded TGT and sends it, base64-encoded, in an HTTP header named 'X-KRB5-CRED'. The server reads this credential, creates a new credentials cache for the user, and stores the credential into the new cache. The server can now authenticate to other services (e.g. LDAP) over GSSAPI using the forwarded client's credentials.

Reviewed-on: public/pyceo#5
Co-authored-by: Max Erenberg <merenber@localhost>
Co-committed-by: Max Erenberg <merenber@localhost>
2021-08-18 15:39:14 -04:00
Max Erenberg dd59bea918 add Kerberos delegation 2021-08-18 01:59:24 +00:00
Max Erenberg d82b5a763b use ldap3 instead of python-ldap 2021-08-15 05:04:49 +00:00
Max Erenberg 6cdb41d47b move all tests to top-level folder 2021-08-14 00:11:56 +00:00
Max Erenberg cbf4aa43f8 add tests for uwldap 2021-08-04 20:59:36 +00:00
Max Erenberg 9e4d564a33 move INI file locations 2021-08-04 17:15:06 +00:00
Max Erenberg 3ecf43731f add tests for Group class 2021-08-04 06:33:50 +00:00
Max Erenberg e7bfe36c0b add tests for User class 2021-08-04 05:54:21 +00:00
Max Erenberg 87298e18b3 cast string values in Config 2021-08-04 03:30:19 +00:00
Max Erenberg baeb83b1e2 use ConfigParser 2021-08-03 23:19:33 +00:00
Max Erenberg 4a312378b7 remove mailman transactions 2021-08-03 20:11:13 +00:00
Max Erenberg 96cb2bc808 add updateprograms 2021-08-03 14:09:07 +00:00
Max Erenberg 7c67a07200 use create_sync_response 2021-08-03 03:20:11 +00:00
Max Erenberg c32e565f68 implement renewals and password resets 2021-08-02 08:01:13 +00:00
Max Erenberg da14764687 Merge branch 'v1' of https://git.csclub.uwaterloo.ca/public/pyceo into v1 2021-08-02 07:21:20 +00:00
Max Erenberg ff2ac95d5e add PATCH /api/members/:username endpoint 2021-08-02 07:19:29 +00:00
Max Erenberg 9227552b29 re-send EHLO after STARTTLS 2021-07-31 08:34:06 -04:00
Max Erenberg 7b749701f0 add README 2021-07-24 21:35:09 +00:00
Max Erenberg e966e3f307 add app factory 2021-07-24 21:09:10 +00:00
Max Erenberg 3b78b7ffb4 add MailService and MailmanService 2021-07-24 00:08:22 +00:00
Max Erenberg de0f473881 add base classes for users and groups 2021-07-19 05:47:39 +00:00
446 changed files with 23401 additions and 8491 deletions

40
.drone.yml Normal file
View File

@ -0,0 +1,40 @@
kind: pipeline
type: docker
name: default
steps:
# use the step name to mock out the gethostname() call in our tests
- name: phosphoric-acid
image: python:3.9-slim-bullseye
# unfortunately we have to do everything in one step because there's no
# way to share system packages between steps
commands:
# install dependencies
- apt update
- apt install --no-install-recommends -y gcc libkrb5-dev libaugeas0
- python3 -m venv venv
- . venv/bin/activate
- venv/bin/pip install -r dev-requirements.txt -r requirements.txt
# lint
- flake8
# unit + integration tests
- bash -c ". .drone/phosphoric-acid-setup.sh && IMAGE__setup && CONTAINER__setup"
- pytest -v
services:
- name: auth1
image: debian:bullseye-slim
commands:
- bash -c ". .drone/auth1-setup.sh && IMAGE__setup && CONTAINER__setup"
- sleep infinity
- name: coffee
image: debian:bullseye-slim
commands:
- bash -c ". .drone/coffee-setup.sh && IMAGE__setup && CONTAINER__setup"
- sleep infinity
trigger:
branch:
- master

17
.drone/adldap_data.ldif Normal file
View File

@ -0,0 +1,17 @@
dn: ou=ADLDAP,dc=csclub,dc=internal
objectClass: organizationalUnit
ou: ADLDAP
dn: cn=alumni1,ou=ADLDAP,dc=csclub,dc=internal
description: One, Alumni
givenName: Alumni
sn: *One
cn: alumni1
sAMAccountName: alumni1
displayName: alumni1
mail: alumni1@alumni.uwaterloo.internal
objectClass: mockADUser
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top

131
.drone/auth1-setup.sh Executable file
View File

@ -0,0 +1,131 @@
#!/bin/bash
set -ex
. .drone/common.sh
# If we don't do this then OpenLDAP uses a lot of RAM
ulimit -n 1024
CONTAINER__fix_hosts() {
add_fqdn_to_hosts $(get_ip_addr $(hostname)) auth1
if [ -n "$CI" ]; then
# I'm not sure why, but we also need to remove the hosts entry for the
# container's real hostname, otherwise slapd only looks for the principal
# ldap/<container hostname> (this is with the sasl-host option)
sed -E "/\\b$(hostname)\\b/d" /etc/hosts > /tmp/hosts
cat /tmp/hosts > /etc/hosts
rm /tmp/hosts
fi
}
IMAGE__setup_ldap() {
# In the "slim" Docker images, /usr/share/doc/* is excluded by default
echo 'path-include /usr/share/doc/sudo-ldap/schema.OpenLDAP' > /etc/dpkg/dpkg.cfg.d/zz-ceo
apt install -y --no-install-recommends slapd ldap-utils libnss-ldapd sudo-ldap
# `service slapd stop` doesn't seem to work
killall slapd || true
service nslcd stop || true
rm -rf /etc/ldap/slapd.d
rm /var/lib/ldap/*
cp .drone/slapd.conf /etc/ldap/slapd.conf
cp .drone/ldap.conf /etc/ldap/ldap.conf
cp /usr/share/doc/sudo-ldap/schema.OpenLDAP /etc/ldap/schema/sudo.schema
cp .drone/{rfc2307bis,csc,mock_ad}.schema /etc/ldap/schema/
chown -R openldap:openldap /etc/ldap/schema/ /var/lib/ldap/ /etc/ldap/
sleep 0.5 && service slapd start
ldapadd -c -f .drone/data.ldif -Y EXTERNAL -H ldapi:///
if [ -z "$CI" ]; then
ldapadd -c -f .drone/uwldap_data.ldif -Y EXTERNAL -H ldapi:/// || true
ldapadd -c -f .drone/adldap_data.ldif -Y EXTERNAL -H ldapi:/// || true
# setup ldapvi for convenience
apt install -y --no-install-recommends vim ldapvi
grep -q 'export EDITOR' /root/.bashrc || \
echo 'export EDITOR=vim' >> /root/.bashrc
grep -q 'alias ldapvi' /root/.bashrc || \
echo 'alias ldapvi="ldapvi -Y EXTERNAL -h ldapi:///"' >> /root/.bashrc
fi
}
IMAGE__setup_krb5() {
apt install -y krb5-admin-server krb5-user libpam-krb5 libsasl2-modules-gssapi-mit sasl2-bin
service krb5-admin-server stop || true
service krb5-kdc stop || true
service saslauthd stop || true
cp .drone/krb5.conf /etc/krb5.conf
cp .drone/kdc.conf /etc/krb5kdc.conf
echo '*/admin *' > /etc/krb5kdc/kadm5.acl
rm -f /var/lib/krb5kdc/*
echo -e 'krb5\nkrb5' | krb5_newrealm
service krb5-kdc start
service krb5-admin-server start
rm -f /etc/krb5.keytab
cat <<EOF | kadmin.local
addpol -minlength 4 default
addprinc -pw krb5 sysadmin/admin
addprinc -randkey ceod/admin
addprinc -randkey host/auth1.csclub.internal
addprinc -randkey ldap/auth1.csclub.internal
ktadd host/auth1.csclub.internal
ktadd ldap/auth1.csclub.internal
EOF
# Add all of the people defined in data.ldif
for princ in ctdalek exec1 regular1 office1; do
echo "addprinc -pw krb5 $princ" | kadmin.local
done
groupadd keytab || true
chgrp keytab /etc/krb5.keytab
chmod 640 /etc/krb5.keytab
usermod -a -G keytab openldap
usermod -a -G sasl openldap
cat <<EOF > /usr/lib/sasl2/slapd.conf
mech_list: plain login gssapi external
pwcheck_method: saslauthd
EOF
sed -E -i 's/^START=.*$/START=yes/' /etc/default/saslauthd
sed -E -i 's/^MECHANISMS=.*$/MECHANISMS="kerberos5"/' /etc/default/saslauthd
}
IMAGE__setup() {
# slapd needs /etc/hosts to be setup properly
CONTAINER__fix_resolv_conf
CONTAINER__fix_hosts
apt update
# for the 'killall' command
apt install -y psmisc
IMAGE__setup_ldap
IMAGE__setup_krb5
IMAGE__common_setup
service slapd stop || true
killall slapd || true
service krb5-admin-server stop || true
service krb5-kdc stop || true
service saslauthd stop || true
}
CONTAINER__setup() {
CONTAINER__fix_resolv_conf
CONTAINER__fix_hosts
local started_slapd=false
for i in {1..5}; do
if service slapd start; then
started_slapd=true
break
fi
sleep 1
done
if [ $started_slapd != "true" ]; then
echo "Failed to start slapd" >&2
return 1
fi
service krb5-admin-server start
service krb5-kdc start
service saslauthd start
service nslcd start
# Let other containers know that we're ready
nc -l -k 0.0.0.0 9000 &
}

67
.drone/coffee-setup.sh Executable file
View File

@ -0,0 +1,67 @@
#!/bin/bash
set -ex
. .drone/common.sh
CONTAINER__fix_hosts() {
add_fqdn_to_hosts $(get_ip_addr $(hostname)) coffee
add_fqdn_to_hosts $(get_ip_addr auth1) auth1
}
IMAGE__setup() {
IMAGE__ceod_setup
apt install --no-install-recommends -y default-mysql-server postgresql
# MYSQL
service mariadb stop
sed -E -i 's/^(bind-address[[:space:]]+= 127\.0\.0\.1)$/#\1/' /etc/mysql/mariadb.conf.d/50-server.cnf
service mariadb start
cat <<EOF | mysql
CREATE USER IF NOT EXISTS 'mysql' IDENTIFIED BY 'mysql';
GRANT ALL PRIVILEGES ON *.* TO 'mysql' WITH GRANT OPTION;
EOF
# POSTGRESQL
service postgresql stop
local POSTGRES_DIR=/etc/postgresql/*/main
cat <<EOF > $POSTGRES_DIR/pg_hba.conf
# TYPE DATABASE USER ADDRESS METHOD
local all postgres peer
host all postgres localhost md5
host all postgres 0.0.0.0/0 md5
host all postgres ::/0 md5
local all all peer
host all all localhost md5
local sameuser all peer
host sameuser all 0.0.0.0/0 md5
host sameuser all ::/0 md5
EOF
grep -Eq "^listen_addresses = '*'$" $POSTGRES_DIR/postgresql.conf || \
echo "listen_addresses = '*'" >> $POSTGRES_DIR/postgresql.conf
service postgresql start
su -c "
cat <<EOF | psql
ALTER USER postgres WITH PASSWORD 'postgres';
REVOKE ALL ON SCHEMA public FROM public;
GRANT ALL ON SCHEMA public TO postgres;
EOF" postgres
service mariadb stop || true
service postgresql stop || true
}
CONTAINER__setup() {
CONTAINER__fix_resolv_conf
CONTAINER__fix_hosts
CONTAINER__ceod_setup
if [ -z "$CI" ]; then
CONTAINER__auth_setup coffee
fi
service mariadb start
service postgresql start
# sync with phosphoric-acid
nc -l -k 0.0.0.0 9000 &
}

116
.drone/common.sh Normal file
View File

@ -0,0 +1,116 @@
export DEBIAN_FRONTEND=noninteractive
# The IMAGE__ functions should be called when building the image.
# The CONTAINER__ functions should be called when running an instance of the
# image in a container.
IMAGE__auth_setup() {
# LDAP
apt install -y --no-install-recommends libnss-ldapd
service nslcd stop || true
mkdir -p /etc/ldap
cp .drone/ldap.conf /etc/ldap/ldap.conf
grep -Eq '^map group member uniqueMember$' /etc/nslcd.conf || \
echo 'map group member uniqueMember' >> /etc/nslcd.conf
sed -E -i 's/^uri .*$/uri ldap:\/\/auth1.csclub.internal/' /etc/nslcd.conf
sed -E -i 's/^base .*$/base dc=csclub,dc=internal/' /etc/nslcd.conf
cp .drone/nsswitch.conf /etc/nsswitch.conf
# KERBEROS
apt install -y krb5-user libpam-krb5 libsasl2-modules-gssapi-mit
cp .drone/krb5.conf /etc/krb5.conf
}
IMAGE__common_setup() {
apt update
# netcat is used for synchronization between the containers
apt install -y netcat-openbsd
IMAGE__auth_setup
}
IMAGE__ceod_setup() {
IMAGE__common_setup
# ceod uses Augeas, which is not installed by default in the Python
# Docker container
apt install -y libaugeas0
}
CONTAINER__fix_resolv_conf() {
# don't resolve container names to *real* CSC machines
sed -E 's/([[:alnum:]-]+\.)*uwaterloo\.ca//g' /etc/resolv.conf > /tmp/resolv.conf
# remove empty 'search' lines, if we created them
sed -E -i '/^search[[:space:]]*$/d' /tmp/resolv.conf
# also remove the 'rotate' option, since this can cause the Docker DNS server
# to be circumvented
sed -E -i '/^options.*\brotate/d' /tmp/resolv.conf
# we can't replace /etc/resolv.conf using 'mv' because it's mounted into the container
cp /tmp/resolv.conf /etc/resolv.conf
rm /tmp/resolv.conf
}
CONTAINER__auth_setup() {
local hostname=$1
sync_with auth1
service nslcd start
rm -f /etc/krb5.keytab
cat <<EOF | kadmin -p sysadmin/admin -w krb5
addprinc -randkey host/$hostname.csclub.internal
addprinc -randkey ceod/$hostname.csclub.internal
ktadd host/$hostname.csclub.internal
ktadd ceod/$hostname.csclub.internal
EOF
}
CONTAINER__ceod_setup() {
# normally systemd creates /run/ceod for us
mkdir -p /run/ceod
# mock out systemctl
ln -sf /bin/true /usr/local/bin/systemctl
# mock out acme.sh
mkdir -p /root/.acme.sh
ln -sf /bin/true /root/.acme.sh/acme.sh
# mock out kubectl
cp .drone/mock_kubectl /usr/local/bin/kubectl
chmod +x /usr/local/bin/kubectl
# add k8s authority certificate
mkdir -p /etc/csc
cp .drone/k8s-authority.crt /etc/csc/k8s-authority.crt
# openssl is actually already present in the python Docker image,
# so we don't need to mock it out
}
# Common utility functions
get_ip_addr() {
# There appears to be a bug in newer versions of Podman where using both
# --name and --hostname causes a container to have two identical DNS
# entries, which causes `getent hosts` to print two lines.
# So we use `head -n 1` to select just the first line.
getent hosts $1 | head -n 1 | cut -d' ' -f1
}
add_fqdn_to_hosts() {
local ip_addr=$1
local hostname=$2
sed -E "/${ip_addr}.*\\b${hostname}\\b/d" /etc/hosts > /tmp/hosts
# we can't replace /etc/hosts using 'mv' because it's mounted into the container
cp /tmp/hosts /etc/hosts
rm /tmp/hosts
echo "$ip_addr $hostname.csclub.internal $hostname" >> /etc/hosts
}
sync_with() {
local host=$1
local port=9000
local synced=false
# give it 20 minutes (can be slow if you're using e.g. NFS or Ceph)
for i in {1..240}; do
if nc -vz $host $port ; then
synced=true
break
fi
sleep 5
done
test $synced = true
}

View File

@ -20,10 +20,15 @@ attributetype ( 1.3.6.1.4.1.27934.1.1.5 NAME 'nonMemberTerm'
EQUALITY caseIgnoreIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{5} )
attributetype ( 1.3.6.1.4.1.27934.1.1.6 NAME 'isClubRep'
EQUALITY booleanMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 )
objectclass ( 1.3.6.1.4.1.27934.1.2.1 NAME 'member'
SUP top AUXILIARY
MUST ( cn $ uid )
MAY ( studentid $ program $ term $ nonMemberTerm $ description $ position ) )
MAY ( studentid $ program $ term $ nonMemberTerm $ description $ position $
isClubRep $ sn $ givenName ) )
objectclass ( 1.3.6.1.4.1.27934.1.2.2 NAME 'club'
SUP top AUXILIARY

213
.drone/data.ldif Normal file
View File

@ -0,0 +1,213 @@
dn: dc=csclub,dc=internal
objectClass: top
objectClass: dcObject
objectClass: organization
dc: csclub
o: Computer Science Club
dn: ou=People,dc=csclub,dc=internal
objectClass: organizationalUnit
ou: People
dn: ou=Group,dc=csclub,dc=internal
objectClass: organizationalUnit
ou: Group
dn: ou=SUDOers,dc=csclub,dc=internal
objectClass: top
objectClass: organizationalUnit
ou: SUDOers
dn: cn=defaults,ou=SUDOers,dc=csclub,dc=internal
objectClass: top
objectClass: sudoRole
cn: defaults
sudoOption: !insults
sudoOption: !lecture
sudoOption: env_reset
sudoOption: listpw=never
sudoOption: shell_noargs
sudoOption: !mail_badpass
dn: cn=syscom,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
cn: syscom
gidNumber: 10001
uniqueMember: uid=ctdalek,ou=People,dc=csclub,dc=internal
dn: cn=%syscom,ou=SUDOers,dc=csclub,dc=internal
objectClass: top
objectClass: sudoRole
cn: %syscom
sudoUser: %syscom
sudoHost: ALL
sudoCommand: ALL
sudoRunAsUser: ALL
dn: cn=adm,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
gidNumber: 4
cn: adm
uniqueMember: uid=ctdalek,ou=People,dc=csclub,dc=internal
dn: cn=office,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
gidNumber: 10003
cn: office
uniqueMember: uid=ctdalek,ou=People,dc=csclub,dc=internal
uniqueMember: uid=office1,ou=People,dc=csclub,dc=internal
dn: cn=src,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
gidNumber: 40
cn: src
uniqueMember: uid=ctdalek,ou=People,dc=csclub,dc=internal
dn: cn=staff,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
gidNumber: 50
cn: staff
uniqueMember: uid=ctdalek,ou=People,dc=csclub,dc=internal
dn: uid=ctdalek,ou=People,dc=csclub,dc=internal
cn: Calum Dalek
givenName: Calum
sn: Dalek
userPassword: {SASL}ctdalek@CSCLUB.INTERNAL
loginShell: /bin/bash
homeDirectory: /users/ctdalek
uid: ctdalek
uidNumber: 20001
gidNumber: 20001
objectClass: top
objectClass: account
objectClass: posixAccount
objectClass: shadowAccount
objectClass: member
program: MAT/Mathematics Computer Science
term: f2021
dn: cn=ctdalek,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
cn: ctdalek
gidNumber: 20001
dn: uid=regular1,ou=People,dc=csclub,dc=internal
cn: Regular One
givenName: Regular
sn: One
userPassword: {SASL}regular1@CSCLUB.INTERNAL
loginShell: /bin/bash
homeDirectory: /users/regular1
uid: regular1
uidNumber: 20002
gidNumber: 20002
objectClass: top
objectClass: account
objectClass: posixAccount
objectClass: shadowAccount
objectClass: member
program: MAT/Mathematics Computer Science
term: f2021
dn: cn=regular1,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
cn: regular1
gidNumber: 20002
dn: uid=exec1,ou=People,dc=csclub,dc=internal
cn: Exec One
givenName: Exec
sn: One
userPassword: {SASL}exec1@CSCLUB.INTERNAL
loginShell: /bin/bash
homeDirectory: /users/exec1
uid: exec1
uidNumber: 20003
gidNumber: 20003
objectClass: top
objectClass: account
objectClass: posixAccount
objectClass: shadowAccount
objectClass: member
program: MAT/Mathematics Computer Science
term: f2021
dn: cn=exec1,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
cn: exec1
gidNumber: 20003
dn: cn=exec,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
cn: exec
gidNumber: 10013
uniqueMember: uid=exec1,ou=People,dc=csclub,dc=internal
dn: uid=office1,ou=People,dc=csclub,dc=internal
cn: Office One
givenName: Office
sn: One
userPassword: {SASL}office1@CSCLUB.INTERNAL
loginShell: /bin/bash
homeDirectory: /users/office1
uid: office1
uidNumber: 20004
gidNumber: 20004
objectClass: top
objectClass: account
objectClass: posixAccount
objectClass: shadowAccount
objectClass: member
program: MAT/Mathematics Computer Science
term: f2021
dn: cn=office1,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
cn: office1
gidNumber: 20004
dn: uid=alumni1,ou=People,dc=csclub,dc=internal
cn: Alumni One
givenName: Alumni
sn: One
userPassword: {SASL}alumni1@CSCLUB.INTERNAL
loginShell: /bin/bash
homeDirectory: /users/alumni1
uid: alumni1
uidNumber: 20005
gidNumber: 20005
objectClass: top
objectClass: account
objectClass: posixAccount
objectClass: shadowAccount
objectClass: member
program: Alumni
term: w2024
dn: cn=alumni1,ou=Group,dc=csclub,dc=internal
objectClass: top
objectClass: group
objectClass: posixGroup
cn: alumni1
gidNumber: 20005

19
.drone/k8s-authority.crt Normal file
View File

@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIC/jCCAeagAwIBAgIBADANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDEwprdWJl
cm5ldGVzMB4XDTIxMTIwNDIxNDcxOVoXDTMxMTIwMjIxNDcxOVowFTETMBEGA1UE
AxMKa3ViZXJuZXRlczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN50
H4RcrV5ZDDqT5XMfN1ml8MalyMDAG8mE+lNT1rsUGBUp2jhNfG0OpFUm55yGarI9
2BrNGXLyFGm3yy6MWJorSUqaSBzt9+JHtBDVQwCgTX9PYSX1X/kFNQFLZkNrMtO4
417WELlkl9miCWWmTPOZAMYZWbnRKrndd3MsrhOcuDwqT5rX+LLl6VktWx5+qmuc
49sd3fWJ1MxLZ+Q6/Eo5jPuPVOPl8wLcwf9MD0rgRMVU+XycwDKr/3vmBbs22hiw
PcWIPHugAy4PRbiWfHOymO+c4WSCCS7nre3mIAyXuT0EEPDnEnrkbYoSuwIJ0tLp
N8/6vaLbBfO5ckAU2VUCAwEAAaNZMFcwDgYDVR0PAQH/BAQDAgKkMA8GA1UdEwEB
/wQFMAMBAf8wHQYDVR0OBBYEFNqlikMIHwY+A1/PHzwPB0CtSLX+MBUGA1UdEQQO
MAyCCmt1YmVybmV0ZXMwDQYJKoZIhvcNAQELBQADggEBAJ2j87US8VTVTFoayNSk
mzip60VzgKxawi/lP1F0/JqCHtdcaA/JmlN8FggzaSxS6AA/gxNTriLNLedhqgNF
f5F5Lq0bQAebzbijsEMr+wGE6zYBgg2L0u55jqSSU1Quhay83eCD0b0O3XHGdzg0
29jC+r8pOYWuwCBaIU8NN8EouHbQ25jqJAPLCIjuqPSEPfxjZla9f2ZO7Zpx+Yud
jDYHz9ZwBYmeR7Z74/oStJ+eIFfwlJKIQL0QFzKgw2KUHmmzHVxpx60rajiGNAb8
7FNPWTjIYX11Hy56jZAUirfwCak1IxfI8O0/X1LzVPCs7uaE1SG8TCsJgjrD2Nwm
2w4=
-----END CERTIFICATE-----

19
.drone/kdc.conf Normal file
View File

@ -0,0 +1,19 @@
[kdcdefaults]
kdc_ports = 88
[realms]
CSCLUB.INTERNAL = {
database_name = /var/lib/krb5kdc/principal
admin_keytab = FILE:/etc/krb5kdc/kadm5.keytab
acl_file = /etc/krb5kdc/kadm5.acl
key_stash_file = /etc/krb5kdc/stash
kdc_ports = 88
max_life = 10h 0m 0s
max_renewable_life = 7d 0h 0m 0s
master_key_type = des3-hmac-sha1
supported_enctypes = aes256-cts:normal arcfour-hmac:normal des3-hmac-sha1:normal des3-cbc-sha1:normal des-cbc-crc:normal des:normal des:v4 des:norealm des:onlyrealm des:afs3
default_principal_flags = +preauth
iprop_enable = true
iprop_slave_poll = 2m
iprop_port = 750
}

27
.drone/krb5.conf Normal file
View File

@ -0,0 +1,27 @@
[libdefaults]
default_realm = CSCLUB.INTERNAL
kdc_timesync = 1
ccache_type = 4
forwardable = true
proxiable = true
dns_lookup_kdc = false
dns_lookup_realm = false
allow_weak_crypto = true
[realms]
CSCLUB.INTERNAL = {
kdc = auth1.csclub.internal
admin_server = auth1.csclub.internal
}
[domain_realm]
.csclub.internal = CSCLUB.INTERNAL
csclub.internal = CSCLUB.INTERNAL
[logging]
kdc = SYSLOG:INFO:AUTH
admin_server = SYSLOG:INFO:AUTH
default = SYSLOG:INFO:AUTH

3
.drone/ldap.conf Normal file
View File

@ -0,0 +1,3 @@
BASE dc=csclub,dc=internal
URI ldap://auth1.csclub.internal
SUDOERS_BASE ou=SUDOers,dc=csclub,dc=internal

30
.drone/mail-setup.sh Executable file
View File

@ -0,0 +1,30 @@
#!/bin/bash
set -ex
. .drone/common.sh
CONTAINER__fix_hosts() {
add_fqdn_to_hosts $(get_ip_addr $(hostname)) mail
add_fqdn_to_hosts $(get_ip_addr auth1) auth1
}
IMAGE__setup() {
IMAGE__ceod_setup
}
CONTAINER__setup() {
CONTAINER__fix_resolv_conf
CONTAINER__fix_hosts
CONTAINER__ceod_setup
CONTAINER__auth_setup mail
# for the VHostManager
mkdir -p /run/ceod/member-vhosts
# mock services
venv/bin/python -m tests.MockMailmanServer &
venv/bin/python -m tests.MockSMTPServer &
venv/bin/python -m tests.MockCloudStackServer &
venv/bin/python -m tests.MockHarborServer &
# sync with phosphoric-acid
nc -l -k 0.0.0.0 9000 &
}

10
.drone/mock_ad.schema Normal file
View File

@ -0,0 +1,10 @@
# Mock Active Directory Schema
attributetype ( 1.3.6.1.4.1.70000.1.1.1 NAME 'sAMAccountName'
EQUALITY caseIgnoreMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )
objectclass ( 1.3.6.1.4.1.70000.1.2.1 NAME 'mockADUser'
SUP top AUXILIARY
MUST ( sAMAccountName ) )

19
.drone/mock_kubectl Normal file
View File

@ -0,0 +1,19 @@
#!/bin/bash
if [ "$1" = apply ]; then
exit
elif [ "$1" = delete ]; then
exit
elif [ "$1" = certificate ]; then
exit
elif [ "$1" = get ]; then
if [ "$2" = csr -a "$4" = "-o" -a "$5" = 'jsonpath={.status.certificate}' ]; then
echo -n 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUMxekNDQWI4Q0ZHeVk0ZVpVMnAvTjMzU0pCTlptMm1vSlE5TXFNQTBHQ1NxR1NJYjNEUUVCQ3dVQU1DZ3gKRURBT0JnTlZCQU1NQjJOMFpHRnNaV3N4RkRBU0JnTlZCQW9NQzJOell5MXRaVzFpWlhKek1CNFhEVEl4TVRJeApNekEwTWpJek4xb1hEVEl5TURFeE1qQTBNakl6TjFvd0tERVFNQTRHQTFVRUF3d0hZM1JrWVd4bGF6RVVNQklHCkExVUVDZ3dMWTNOakxXMWxiV0psY25Nd2dnRWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUJEd0F3Z2dFS0FvSUIKQVFEV09vaTd6ejE0c3VBZ0V2QkgrSHFHSzlCUUlQTm5QQ0llVkxXenlFRTNxUWZRV2YvcWNzeGNST2pSKzVCTgpKSXBaQlNZdjRmNE52WFZqaHlQendoWUd0bXJRYksyT3RCTDlqMDJMWjhMVHp2TnE0MW9CYVdXUFhhaVdIVys2CjkzQnlBdXFPMmdnSEt0elNkV09TcTZpeFBXMVNGUzJRMkFWaXdZUEg3b1pQYnZacUZvMzdhbVdwd1pWUHVuVi8KV2tFRUttNUVqV05DSVUzVWpPdS9HeEJOT1g0WEpqWld4bFcwQUVROVp3K2ZSazBkdU5ScVVyUDQxbDZvcG4rKwpLRkE5NFg2NUlzcUMvMlJ4OWgrNkZFRHhIcjJPcjhOcGFuMXRjZEZHQlFyMGMxV1JxRkNHTytIM0VTeUNya1BjCmdnRDlVN3c0TmdGYkQyaVU0QXc3ZkhwakFnTUJBQUV3RFFZSktvWklodmNOQVFFTEJRQURnZ0VCQUY3VWUwc3YKcFhSUzN1TFl1Y0k3UkRNRGpOZnFpZ0R3NnorbzZxVmxTdGpZTGpDNjFXRyswZ0g4TDJIbm5jZVYyelhjNDkrQQp6TjFna0lWT3JlRUQvRitKbGRPUGgySUpOY1pGYTBsckFFV0dNNWRRR3pDSUM0cEtmSGxOMTZ0c0w2bGdqWTYzCmUvZlhMTFdLdktDR2lRMUlBUTh4KzYyaTVvSmU3aDBlQ1Q0aEEyM0JTRnRNelo2aEdGUURNNGxxaWhHQjEyT2UKZE5yYStsNVdLemNFR21aVFBYTXNudEZVVndPejhaNld2eGo0UW1zL1dQUElKWDdLM2NiRUo4L1RQWG1tUzJrQwowNUtueUxVQzltYnR2TGZoYldhbFZVVlJVUkYwT1RaVk5mNkt6MDJWYlRqQjRJQXdyWGZKZC9lMkMvNFpGWlJTCjVWMnlJSnBJeVJGWTdQST0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo='
exit
elif [ "$2" = namespaces ]; then
echo '{"items":[{"metadata":{"name":"default"}}]}'
exit
fi
fi
echo 'Unrecognized command'
exit 1

20
.drone/nsswitch.conf Normal file
View File

@ -0,0 +1,20 @@
# /etc/nsswitch.conf
#
# Example configuration of GNU Name Service Switch functionality.
# If you have the `glibc-doc-reference' and `info' packages installed, try:
# `info libc "Name Service Switch"' for information about this file.
passwd: files ldap
group: files ldap
shadow: files ldap
hosts: files dns
networks: files
protocols: db files
services: db files
ethers: db files
rpc: db files
netgroup: nis
sudoers: files ldap

47
.drone/phosphoric-acid-setup.sh Executable file
View File

@ -0,0 +1,47 @@
#!/bin/bash
set -ex
. .drone/common.sh
CONTAINER__fix_hosts() {
add_fqdn_to_hosts "$(get_ip_addr $(hostname))" phosphoric-acid
add_fqdn_to_hosts "$(get_ip_addr auth1)" auth1
add_fqdn_to_hosts "$(get_ip_addr coffee)" coffee
# mail container doesn't run in CI
if [ -z "$CI" ]; then
add_fqdn_to_hosts $(get_ip_addr mail) mail
fi
}
CONTAINER__setup_userdirs() {
# initialize the skel directory
shopt -s dotglob
mkdir -p /users/skel
cp /etc/skel/* /users/skel/
# create directories for users
for user in ctdalek regular1 exec1; do
mkdir -p /users/$user
chown $user:$user /users/$user
done
}
IMAGE__setup() {
IMAGE__ceod_setup
# git is required by the ClubWebHostingService
apt install --no-install-recommends -y git
}
CONTAINER__setup() {
CONTAINER__fix_resolv_conf
CONTAINER__fix_hosts
CONTAINER__ceod_setup
CONTAINER__auth_setup phosphoric-acid
CONTAINER__setup_userdirs
echo "ktadd ceod/admin" | kadmin -p sysadmin/admin -w krb5
sync_with coffee
if [ -z "$CI" ]; then
sync_with mail
fi
}

287
.drone/rfc2307bis.schema Normal file
View File

@ -0,0 +1,287 @@
# builtin
#attributetype ( 1.3.6.1.1.1.1.0 NAME 'uidNumber'
# DESC 'An integer uniquely identifying a user in an administrative domain'
# EQUALITY integerMatch
# SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
# SINGLE-VALUE )
#
# builtin
#attributetype ( 1.3.6.1.1.1.1.1 NAME 'gidNumber'
# DESC 'An integer uniquely identifying a group in an
# administrative domain'
# EQUALITY integerMatch
# SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
# SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.2 NAME 'gecos'
DESC 'The GECOS field; the common name'
EQUALITY caseIgnoreIA5Match
SUBSTR caseIgnoreIA5SubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.3 NAME 'homeDirectory'
DESC 'The absolute path to the home directory'
EQUALITY caseExactIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.4 NAME 'loginShell'
DESC 'The path to the login shell'
EQUALITY caseExactIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.5 NAME 'shadowLastChange'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.6 NAME 'shadowMin'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.7 NAME 'shadowMax'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.8 NAME 'shadowWarning'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.9 NAME 'shadowInactive'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.10 NAME 'shadowExpire'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.11 NAME 'shadowFlag'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.12 NAME 'memberUid'
EQUALITY caseExactIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
attributetype ( 1.3.6.1.1.1.1.13 NAME 'memberNisNetgroup'
EQUALITY caseExactIA5Match
SUBSTR caseExactIA5SubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
attributetype ( 1.3.6.1.1.1.1.14 NAME 'nisNetgroupTriple'
DESC 'Netgroup triple'
EQUALITY caseIgnoreIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
attributetype ( 1.3.6.1.1.1.1.15 NAME 'ipServicePort'
DESC 'Service port number'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.16 NAME 'ipServiceProtocol'
DESC 'Service protocol name'
SUP name )
attributetype ( 1.3.6.1.1.1.1.17 NAME 'ipProtocolNumber'
DESC 'IP protocol number'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.18 NAME 'oncRpcNumber'
DESC 'ONC RPC number'
EQUALITY integerMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.27
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.19 NAME 'ipHostNumber'
DESC 'IPv4 addresses as a dotted decimal omitting leading
zeros or IPv6 addresses as defined in RFC2373'
SUP name )
attributetype ( 1.3.6.1.1.1.1.20 NAME 'ipNetworkNumber'
DESC 'IP network as a dotted decimal, eg. 192.168,
omitting leading zeros'
SUP name
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.21 NAME 'ipNetmaskNumber'
DESC 'IP netmask as a dotted decimal, eg. 255.255.255.0,
omitting leading zeros'
EQUALITY caseIgnoreIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.22 NAME 'macAddress'
DESC 'MAC address in maximal, colon separated hex
notation, eg. 00:00:92:90:ee:e2'
EQUALITY caseIgnoreIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
attributetype ( 1.3.6.1.1.1.1.23 NAME 'bootParameter'
DESC 'rpc.bootparamd parameter'
EQUALITY caseExactIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
attributetype ( 1.3.6.1.1.1.1.24 NAME 'bootFile'
DESC 'Boot image name'
EQUALITY caseExactIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
attributetype ( 1.3.6.1.1.1.1.26 NAME 'nisMapName'
DESC 'Name of a A generic NIS map'
SUP name )
attributetype ( 1.3.6.1.1.1.1.27 NAME 'nisMapEntry'
DESC 'A generic NIS entry'
EQUALITY caseExactIA5Match
SUBSTR caseExactIA5SubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26
SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.28 NAME 'nisPublicKey'
DESC 'NIS public key'
EQUALITY octetStringMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.29 NAME 'nisSecretKey'
DESC 'NIS secret key'
EQUALITY octetStringMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.30 NAME 'nisDomain'
DESC 'NIS domain'
EQUALITY caseIgnoreIA5Match
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26)
attributetype ( 1.3.6.1.1.1.1.31 NAME 'automountMapName'
DESC 'automount Map Name'
EQUALITY caseExactIA5Match
SUBSTR caseExactIA5SubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.32 NAME 'automountKey'
DESC 'Automount Key value'
EQUALITY caseExactIA5Match
SUBSTR caseExactIA5SubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE )
attributetype ( 1.3.6.1.1.1.1.33 NAME 'automountInformation'
DESC 'Automount information'
EQUALITY caseExactIA5Match
SUBSTR caseExactIA5SubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE )
objectclass ( 1.3.6.1.1.1.2.0 NAME 'posixAccount' SUP top AUXILIARY
DESC 'Abstraction of an account with POSIX attributes'
MUST ( cn $ uid $ uidNumber $ gidNumber $ homeDirectory )
MAY ( userPassword $ loginShell $ gecos $
description ) )
objectclass ( 1.3.6.1.1.1.2.1 NAME 'shadowAccount' SUP top AUXILIARY
DESC 'Additional attributes for shadow passwords'
MUST uid
MAY ( userPassword $ description $
shadowLastChange $ shadowMin $ shadowMax $
shadowWarning $ shadowInactive $
shadowExpire $ shadowFlag ) )
objectclass ( 1.3.6.1.1.1.2.2 NAME 'posixGroup' SUP top AUXILIARY
DESC 'Abstraction of a group of accounts'
MUST gidNumber
MAY ( userPassword $ memberUid $
description ) )
objectclass ( 1.3.6.1.1.1.2.3 NAME 'ipService' SUP top STRUCTURAL
DESC 'Abstraction an Internet Protocol service.
Maps an IP port and protocol (such as tcp or udp)
to one or more names; the distinguished value of
the cn attribute denotes the services canonical
name'
MUST ( cn $ ipServicePort $ ipServiceProtocol )
MAY description )
objectclass ( 1.3.6.1.1.1.2.4 NAME 'ipProtocol' SUP top STRUCTURAL
DESC 'Abstraction of an IP protocol. Maps a protocol number
to one or more names. The distinguished value of the cn
attribute denotes the protocols canonical name'
MUST ( cn $ ipProtocolNumber )
MAY description )
objectclass ( 1.3.6.1.1.1.2.5 NAME 'oncRpc' SUP top STRUCTURAL
DESC 'Abstraction of an Open Network Computing (ONC)
[RFC1057] Remote Procedure Call (RPC) binding.
This class maps an ONC RPC number to a name.
The distinguished value of the cn attribute denotes
the RPC services canonical name'
MUST ( cn $ oncRpcNumber )
MAY description )
objectclass ( 1.3.6.1.1.1.2.6 NAME 'ipHost' SUP top AUXILIARY
DESC 'Abstraction of a host, an IP device. The distinguished
value of the cn attribute denotes the hosts canonical
name. Device SHOULD be used as a structural class'
MUST ( cn $ ipHostNumber )
MAY ( userPassword $ l $ description $ manager ) )
objectclass ( 1.3.6.1.1.1.2.7 NAME 'ipNetwork' SUP top STRUCTURAL
DESC 'Abstraction of a network. The distinguished value of
the cn attribute denotes the networks canonical name'
MUST ipNetworkNumber
MAY ( cn $ ipNetmaskNumber $ l $ description $ manager ) )
objectclass ( 1.3.6.1.1.1.2.8 NAME 'nisNetgroup' SUP top STRUCTURAL
DESC 'Abstraction of a netgroup. May refer to other netgroups'
MUST cn
MAY ( nisNetgroupTriple $ memberNisNetgroup $ description ) )
objectclass ( 1.3.6.1.1.1.2.9 NAME 'nisMap' SUP top STRUCTURAL
DESC 'A generic abstraction of a NIS map'
MUST nisMapName
MAY description )
objectclass ( 1.3.6.1.1.1.2.10 NAME 'nisObject' SUP top STRUCTURAL
DESC 'An entry in a NIS map'
MUST ( cn $ nisMapEntry $ nisMapName )
MAY description )
objectclass ( 1.3.6.1.1.1.2.11 NAME 'ieee802Device' SUP top AUXILIARY
DESC 'A device with a MAC address; device SHOULD be
used as a structural class'
MAY macAddress )
objectclass ( 1.3.6.1.1.1.2.12 NAME 'bootableDevice' SUP top AUXILIARY
DESC 'A device with boot parameters; device SHOULD be
used as a structural class'
MAY ( bootFile $ bootParameter ) )
objectclass ( 1.3.6.1.1.1.2.14 NAME 'nisKeyObject' SUP top AUXILIARY
DESC 'An object with a public and secret key'
MUST ( cn $ nisPublicKey $ nisSecretKey )
MAY ( uidNumber $ description ) )
objectclass ( 1.3.6.1.1.1.2.15 NAME 'nisDomainObject' SUP top AUXILIARY
DESC 'Associates a NIS domain with a naming context'
MUST nisDomain )
objectclass ( 1.3.6.1.1.1.2.16 NAME 'automountMap' SUP top STRUCTURAL
MUST ( automountMapName )
MAY description )
objectclass ( 1.3.6.1.1.1.2.17 NAME 'automount' SUP top STRUCTURAL
DESC 'Automount information'
MUST ( automountKey $ automountInformation )
MAY description )
## namedObject is needed for groups without members
objectclass ( 1.3.6.1.4.1.5322.13.1.1 NAME 'namedObject' SUP top
STRUCTURAL MAY cn )

114
.drone/slapd.conf Normal file
View File

@ -0,0 +1,114 @@
# This is the main slapd configuration file. See slapd.conf(5) for more
# info on the configuration options.
include /etc/ldap/schema/core.schema
include /etc/ldap/schema/cosine.schema
include /etc/ldap/schema/rfc2307bis.schema
include /etc/ldap/schema/inetorgperson.schema
include /etc/ldap/schema/sudo.schema
include /etc/ldap/schema/csc.schema
include /etc/ldap/schema/mock_ad.schema
include /etc/ldap/schema/misc.schema
pidfile /var/run/slapd/slapd.pid
argsfile /var/run/slapd/slapd.args
#Warning: "stats" is *lots* of logging
loglevel sync
#loglevel stats config sync acl
modulepath /usr/lib/ldap
moduleload back_hdb
moduleload syncprov
moduleload auditlog
moduleload unique
sizelimit unlimited
timelimit unlimited
# consider local connections encrypted
localssf 128
# map kerberos users to ldap users
sasl-realm CSCLUB.INTERNAL
sasl-host auth1.csclub.internal
authz-regexp "uid=([^/=]*),cn=CSCLUB.INTERNAL,cn=GSSAPI,cn=auth"
"uid=$1,ou=people,dc=csclub,dc=internal"
authz-regexp "uid=ceod/admin,cn=CSCLUB.INTERNAL,cn=GSSAPI,cn=auth"
"cn=ceod,dc=csclub,dc=internal"
access to *
by dn="gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth" manage
by * break
# hide most attributes for alumni in mock UWLDAP
access to attrs=cn,sn,givenName,displayName,ou,mail
dn.regex="^uid=alumni[^,]+,ou=(Test)?UWLDAP,dc=csclub,dc=internal$"
by * none
# systems committee get full access
access to *
by dn="cn=ceod,dc=csclub,dc=internal" write
by group/group/uniqueMember="cn=syscom,ou=Group,dc=csclub,dc=internal" write
by * break
# allow office staff to add terms
# the renewal program may do the same
access to attrs=term
by group/group/uniqueMember="cn=office,ou=Group,dc=csclub,dc=internal" add
by dn="cn=renewal,dc=csclub,dc=internal" add
by * read
access to attrs=nonMemberTerm
by group/group/uniqueMember="cn=office,ou=Group,dc=csclub,dc=internal" add
by dn="cn=renewal,dc=csclub,dc=internal" add
by * read
# allow users to change their shells
access to attrs=loginShell
by self write
by * read
# allow simple authentication
access to attrs=userPassword
by anonymous auth
by * none
# allow access to attributes of top; they would otherwise be denied below
access to attrs=@top
by * read
# default permit
access to *
by * read
# main database options
# note: the mdb backend has a horrible bug in 2.4.31
# that causes indexing to destroy the database
database hdb
suffix "dc=csclub,dc=internal"
directory "/var/lib/ldap"
rootdn cn=root,dc=csclub,dc=internal
index default eq
index objectClass
index entryCSN,entryUUID
index uid,uidNumber
index cn,gidNumber
index uniqueMember,memberUid
index sudoUser,sudoHost pres,sub,eq
index term,nonMemberTerm
index mailLocalAddress
index modifyTimestamp,createTimestamp
# log all changes to the directory
overlay auditlog
auditlog /var/log/ldap/audit.log
# enforce uniqueness of usernames etc.
overlay unique
unique_uri ldap:///ou=People,dc=csclub,dc=internal?uid,uidNumber?sub
unique_uri ldap:///ou=Group,dc=csclub,dc=internal?cn,gidNumber?sub
# this is the master server
overlay syncprov
syncprov-checkpoint 100 10
syncprov-sessionlog 100

17
.drone/supervise.sh Executable file
View File

@ -0,0 +1,17 @@
#!/bin/sh
# A script that supervises a program. The program is restarted TIMEOUT second after it exits.
# SIGHUP restarts the program
# SIGTERM and SIGINT stops the program
TIMEOUT=1
running=1
trap 'kill -TERM $! 2>/dev/null' HUP
trap 'running=0; kill -TERM $! 2>/dev/null' TERM INT
trap 'running=0; kill -KILL $! 2>/dev/null' EXIT
while [ "$running" = 1 ]; do
"$@" &
wait
sleep "$TIMEOUT"
done

123
.drone/uwldap_data.ldif Normal file
View File

@ -0,0 +1,123 @@
dn: ou=UWLDAP,dc=csclub,dc=internal
objectClass: organizationalUnit
ou: UWLDAP
dn: uid=ctdalek,ou=UWLDAP,dc=csclub,dc=internal
displayName: Calum Dalek
givenName: Calum
sn: Dalek
cn: Calum Dalek
ou: MAT/Mathematics Computer Science
mailLocalAddress: ctdalek@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: ctdalek
mail: ctdalek@uwaterloo.internal
dn: uid=regular1,ou=UWLDAP,dc=csclub,dc=internal
displayName: Regular One
givenName: Regular
sn: One
cn: Regular One
ou: MAT/Mathematics Computer Science
mailLocalAddress: regular1@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: regular1
mail: regular1@uwaterloo.internal
dn: uid=regular2,ou=UWLDAP,dc=csclub,dc=internal
displayName: Regular Two
givenName: Regular
sn: Two
cn: Regular Two
ou: MAT/Mathematics Computer Science
mailLocalAddress: regular2@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: regular2
mail: regular2@uwaterloo.internal
dn: uid=regular3,ou=UWLDAP,dc=csclub,dc=internal
displayName: Regular Three
givenName: Regular
sn: Three
cn: Regular Three
ou: MAT/Mathematics Computer Science
mailLocalAddress: regular3@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: regular3
mail: regular3@uwaterloo.internal
dn: uid=exec1,ou=UWLDAP,dc=csclub,dc=internal
displayName: Exec One
givenName: Exec
sn: One
cn: Exec One
ou: MAT/Mathematics Computer Science
mailLocalAddress: exec1@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: exec1
mail: exec1@uwaterloo.internal
dn: uid=exec2,ou=UWLDAP,dc=csclub,dc=internal
displayName: Exec Two
givenName: Exec
sn: Two
cn: Exec Two
ou: MAT/Mathematics Computer Science
mailLocalAddress: exec2@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: exec2
mail: exec2@uwaterloo.internal
dn: uid=exec3,ou=UWLDAP,dc=csclub,dc=internal
displayName: Exec Three
givenName: Exec
sn: Three
cn: Exec Three
ou: MAT/Mathematics Computer Science
mailLocalAddress: exec3@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: exec3
mail: exec3@uwaterloo.internal
dn: uid=alumni1,ou=UWLDAP,dc=csclub,dc=internal
displayName: Alumni One
givenName: Alumni
sn: One
cn: Alumni One
ou: MAT/Mathematics Computer Science
mailLocalAddress: alumni1@uwaterloo.internal
objectClass: inetLocalMailRecipient
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
uid: alumni1
mail: alumni1@uwaterloo.internal

View File

@ -1,4 +0,0 @@
[DEFAULT]
sign-tags = True
posttag = git push /users/git/public/pyceo.git --tags
debian-tag=v%(version)s

5
.githooks/pre-commit Executable file
View File

@ -0,0 +1,5 @@
#!/bin/bash
docker run --rm -v "$PWD:$PWD:z" -w "$PWD" python:3.9-bullseye scripts/lint-docker.sh
exit $?

32
.gitignore vendored
View File

@ -1,5 +1,27 @@
/build-stamp
/build
*.pyc
/build-ceo
/build-ceod
# If you update this file, please also update the extend-diff-ignore option
# in debian/source/options.
*.key
*.gpg
*.pgp
__pycache__/
/venv/
/dist/
/build/
/*.egg-info/
.vscode/
*.o
*.so
*.swp
.idea/
/docs/*.1
/docs/*.5
/debian/ceo/
/debian/ceod/
/debian/ceo-common/
/debian/tmp/
/debian/ceo.substvars
/debian/files
/debian/.debhelper/
/debian/debhelper-build-stamp

1
MANIFEST.in Normal file
View File

@ -0,0 +1 @@
include ceod/model/templates/*.j2

34
Makefile Normal file
View File

@ -0,0 +1,34 @@
#!/usr/bin/make -f
SCDFILES = $(wildcard docs/*.scd)
MANPAGES = $(patsubst docs/%.scd,docs/%,${SCDFILES})
CEO_HOME = /var/lib/ceo
build: docs venv
venv:
python3 -m venv venv && \
. venv/bin/activate && \
pip install --upgrade pip && \
pip install setuptools wheel && \
pip install -r requirements.txt && \
pip install .
install:
@# Prepare the virtualenv to be moved (dangerous!)
@# Make sure you don't have '|' in your paths
grep -IRl $(CURDIR)/venv venv/bin | \
xargs perl -pe 's|\Q$(CURDIR)/venv\E|$(CEO_HOME)/venv|g' -i
mkdir -p $(DESTDIR)$(CEO_HOME)
mv venv $(DESTDIR)$(CEO_HOME)
docs:
for file in ${SCDFILES} ; do \
scdoc < $$file > `echo $$file | grep -oP '.*(?=\.scd$$)'` ; \
done
clean:
rm -f ${MANPAGES}
rm -rf venv
rm -rf debian/{ceo,ceod,ceo-common,tmp}
.PHONY: build docs clean venv install

122
PACKAGING.md Normal file
View File

@ -0,0 +1,122 @@
# Packaging
This is a guide for creating Debian packages for ceo. The instructions below
probably do not follow best practices, but at least I am confident that they
work.
## Prerequisites
Make sure your GPG key is in /srv/debian/gpg on potassium-benzoate. See
[here](https://wiki.csclub.uwaterloo.ca/Debian_Repository#Step_1:_Add_to_Uploaders)
for instructions.
Make sure you are in the `csc-mirror` group too.
## Creating the package
Use Docker/Podman to avoid screwing up your main system.
For example, to create a package for bullseye (replace `podman` with `docker` in all instances below if you're using Docker):
```sh
podman run -it --name pyceo-packaging -v "$PWD":"$PWD":z -w "$PWD" --security-opt="label=disable" debian:bookworm bash
# if disconnected from shell, reconnect with:
podman start pyceo-packaging
podman exec -it pyceo-packaging bash
```
**Important**: Make sure to use a container image for the same distribution which you're packaging.
For example, if you're creating a package for bullseye, you should be using the debian:bullseye
Docker image (this is because the virtualenv symlinks python to the OS' version of python).
Here are some of the prerequisites you'll need to build the deb files
(run this inside the container):
```sh
apt update
apt install -y devscripts debhelper git-buildpackage vim
apt install -y python3-dev python3-venv libkrb5-dev libpq-dev libaugeas0 scdoc # dependencies for building ceo
```
Make sure to also install all of the packages in the 'Build-Depends' section in debian/control.
Update VERSION.txt to the next version, and do a git commit (or `dpkg-source --commit`).
Now run `dch -i` and edit the changelog (update version, add your uploader name/email, add changes).
Now you will build a signed package. Place your key ID after the `-k` argument, e.g.
```sh
# (pre-requisite) if container doesn't have your gpg key
## step 1: export from host/another computer with your keyring
gpg --armor --output private.key --export-secret-key <your pgp key's id email>
## step 2: import into build container
gpg --import private.key
## step 3: find your key's public key
gpg --list-secret-keys # get key id
## step 4: trust ids (before building)
gpg --edit <pub key id>
gpg> trust # run when gpg editing prompt appears
> 5 # "ultimate" trust
gpg> save # gpg will report no changes were made, but trust of ids should be changed
# alternatively, sign with `debsign` after creating unsigned package
# build (signed) package
gbp buildpackage --git-upstream-branch=master -k8E5568ABB0CF96BC367806ED127923BE10DA48DC --lintian-opts --no-lintian
```
This will create a bunch of files (deb, dsc, tar.gz, etc.) in the parent directory.
Now do another git commit (since you edited the changelog file).
To clean the packages (run this after uploading, ie. **do NOT run this if you just finished building**):
```sh
rm ../*.{xz,gz,dsc,build,buildinfo,changes,deb}
```
## Uploading the package
Inside the container, go up one directory, and create a tarball with all the package files:
```
cd .. # within the container, generated files are in the parent directory of your git repo
tar zcvf pyceo.tar.gz *.{xz,gz,dsc,build,buildinfo,changes,deb}
```
Outside of the container (i.e. on your personal machine), copy the tarball out of the
container into your current directory, e.g.
```
podman cp pyceo-packaging:/home/max/repos/pyceo.tar.gz .
# or generally, if you're in the pyceo repo:
podman cp pyceo-packaging:$(cd ../ && pwd)/pyceo.tar.gz .
```
(Replace `/home/max/repos` by the directory in the container with the tarball.)
Now upload the tarball to a CSC machine, e.g.
```
# on "HOST" machine
scp pyceo.tar.gz mannitol:~
```
SSH into that machine and extract the tarball into a separate directory:
```
ssh mannitol
mkdir pyceo-parent && mv pyceo.tar.gz pyceo-parent/ && cd pyceo-parent
rm -iv *.{xz,gz,dsc,build,buildinfo,changes,deb}
tar zxvf pyceo.tar.gz
```
At this point, you will need a dupload.conf file. Ask someone on syscom for a copy. Place the dupload config at `~/.dupload.conf` (as per manpage).
Now upload the package to potassium-benzoate:
```
kinit
dupload *.changes
```
Now SSH into potassium-benzoate and run the following:
```
# note: this is AUTOMATICALLY done (within 10-20 minutes by a cron job)
sudo /srv/debian/bin/rrr-incoming
```
To check if mirror has accepted the new package, visit: http://debian.csclub.uwaterloo.ca/dists/bookworm/
<<<<<<< Updated upstream
=======
To update CEO:
```
# repeat this for all systems, starting from ceod servers
sudo apt update
# NOTE: be careful of changing configs!!
sudo apt install --only-upgrade ceod
````
>>>>>>> Stashed changes
There, that wasn't so bad...right? :')

249
README.md Normal file
View File

@ -0,0 +1,249 @@
# pyceo
[![Build Status](https://ci.csclub.uwaterloo.ca/api/badges/public/pyceo/status.svg)](https://ci.csclub.uwaterloo.ca/public/pyceo)
![Main TUI view](https://wiki.csclub.uwaterloo.ca/images/b/bb/Pyceo.png)
CEO (**C**SC **E**lectronic **O**ffice) is the tool used by CSC to manage
club accounts and memberships. See [docs/architecture.md](docs/architecture.md) for an
overview of its architecture.
The API documentation is available as a plain HTML file in [docs/redoc-static.html](docs/redoc-static.html).
## Development
### Podman
If you are not modifying code related to email or Mailman, then you may use
Podman containers instead, which are much easier to work with than the VM.
If you are using Podman, make sure to set the `DOCKER_HOST` environment variable
if you have not done so already:
```bash
# Add the following to e.g. your ~/.bashrc
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock
```
The Podman socket also needs to be running:
```bash
# Enabled by default on Debian, but not on Fedora
systemctl --user enable --now podman.socket
```
First, create the container images:
```sh
scripts/build-all-images.sh
```
Then bring up the containers:
```sh
docker-compose up -d
```
This will create some containers with the bare minimum necessary for ceod to
run, and start ceod on each of phosphoric-acid, mail, and coffee container.
You can check the containers status using:
```sh
docker-compose logs -f
```
To use ceo, run the following:
```sh
docker-compose exec phosphoric-acid bash
su ctdalek
. venv/bin/activate
python -m ceo # the password is krb5
```
This should bring up the TUI.
Normally, ceod should autoamtically restart when the source files are changed.
To manually restart the service, run:
```sh
docker-compose kill -s SIGHUP phosphoric-acid
```
To stop the containers, run:
```sh
docker-compose down
```
Alternatively, if you started docker-compose in the foreground, just press Ctrl-C.
### VM
If you need the full environment running in VM, follow the guide on
[syscom dev environment](https://git.uwaterloo.ca/csc/syscom-dev-environment).
This will setup all of the services needed for ceo to work. You should clone
this repo in the phosphoric-acid container under ctdalek's home directory; you
will then be able to access it from any container thanks to NFS.
Once you have the dev environment setup, there are a few more steps you'll
need to do for ceo.
#### Kerberos principals
First, you'll need `ceod/<hostname>` principals for each of phosphoric-acid,
coffee and mail. (coffee is taking over the role of caffeine for the DB
endpoints). For example, in the phosphoric-acid container:
```sh
kadmin -p sysadmin/admin
<password is krb5>
addprinc -randkey ceod/phosphoric-acid.csclub.internal
ktadd ceod/phosphoric-acid.csclub.internal
```
Do this for coffee and mail as well. You need to actually be in the
appropriate container when running these commands, since the credentials
are being added to the local keytab.
On phosphoric-acid, you will additionally need to create a principal
called `ceod/admin` (remember to addprinc **and** ktadd).
#### Database
**Note**: The instructions below apply to the dev environment only; in
production, the DB superusers should be restricted to the host where
the DB is running.
Attach to the coffee container, run `mysql`, and run the following:
```
CREATE USER 'mysql' IDENTIFIED BY 'mysql';
GRANT ALL PRIVILEGES ON *.* TO 'mysql' WITH GRANT OPTION;
```
(In prod, the superuser should have '@localhost' appended to its name.)
Now open /etc/mysql/mariadb.conf.d/50-server.cnf and comment out the following line:
```
bind-address = 127.0.0.1
```
Then restart MariaDB:
```
systemctl restart mariadb
```
Install PostgreSQL in the container:
```
apt install -y postgresql
```
Modify the superuser `postgres` for password authentication and restrict new users:
```
su postgres
psql
ALTER USER postgres WITH PASSWORD 'postgres';
REVOKE ALL ON SCHEMA public FROM public;
GRANT ALL ON SCHEMA public TO postgres;
```
Create a new `pg_hba.conf`:
```
cd /etc/postgresql/<version>/<branch>/
mv pg_hba.conf pg_hba.conf.old
```
```
# new pg_hba.conf
# TYPE DATABASE USER ADDRESS METHOD
local all postgres peer
host all postgres 0.0.0.0/0 md5
local all all peer
host all all localhost md5
local sameuser all peer
host sameuser all 0.0.0.0/0 md5
```
**Warning**: in prod, the postgres user should only be allowed to connect locally,
so the relevant snippet in pg_hba.conf should look something like
```
local all postgres md5
host all postgres localhost md5
host all postgres 0.0.0.0/0 reject
host all postgres ::/0 reject
```
Add the following to postgresql.conf:
```
listen_addresses = '*'
```
Now restart PostgreSQL:
```
systemctl restart postgresql
```
**In prod**, users can login remotely but superusers (`postgres` and `mysql`) are only
allowed to login from the database host.
#### Mailman
You should create the following mailing lists from the mail container:
```sh
/opt/mailman3/bin/mailman create syscom@csclub.internal
/opt/mailman3/bin/mailman create syscom-alerts@csclub.internal
/opt/mailman3/bin/mailman create exec@csclub.internal
/opt/mailman3/bin/mailman create ceo@csclub.internal
```
See https://git.uwaterloo.ca/csc/syscom-dev-environment/-/tree/master/mail
for instructions on how to access the Mailman UI from your browser.
If you want to actually see the archived messages, you'll
need to tweak the settings for each list from the UI so that non-member
messages get accepted (by default they get held).
#### Dependencies
Next, install and activate a virtualenv:
```sh
sudo apt install libkrb5-dev libpq-dev python3-dev
python3 -m venv venv
. venv/bin/activate
pip install -r requirements.txt
pip install -r dev-requirements.txt
```
#### Running the application
ceod is a distributed application, with instances on different hosts offering
different services.
Therefore, you will need to run ceod on multiple hosts. Currently, those are
phosphoric-acid, mail and caffeine (in the dev environment, caffeine is
replaced by coffee).
To run ceod on a single host (as root, since the app needs to read the keytab):
```sh
export FLASK_APP=ceod.api
export FLASK_DEBUG=true
flask run -h 0.0.0.0 -p 9987
```
Sometimes changes you make in the source code don't show up while Flask
is running. Stop the flask app (Ctrl-C), run `scripts/clear_cache.sh`, then
restart the app.
## Interacting with the application
To use the TUI:
```
python -m ceo
```
To use the CLI:
```
python -m ceo --help
```
Alternatively, you may use curl to send HTTP requests.
ceod uses [SPNEGO](https://en.wikipedia.org/wiki/SPNEGO) for authentication,
and TLS for confidentiality and integrity. In development mode, TLS can be
disabled.
First, make sure that your version of curl has been compiled with SPNEGO
support:
```sh
curl -V
```
Your should see 'SPNEGO' in the 'Features' section.
Here's an example of making a request to add a user (in the Docker container):
```sh
# If you're root, switch to the ctdalek user first
su ctdalek
# Get a Kerberos TGT (password is krb5)
kinit
# Make the request
curl --negotiate -u : --service-name ceod --delegation always \
-d '{"uid":"test_1","cn":"Test One","given_name":"Test","sn":"One","program":"Math","terms":["s2021"]}' \
-X POST http://phosphoric-acid:9987/api/members
# To delete the user:
curl --negotiate -u : --service-name ceod --delegation always \
-X DELETE http://phosphoric-acid:9987/api/members/test_1
# In prod, use the following base URL instead:
# https://phosphoric-acid.csclub.uwaterloo.ca:9987
```
## Packaging
See [PACKAGING.md](./PACKAGING.md).

1
VERSION.txt Normal file
View File

@ -0,0 +1 @@
1.0.31

40
bin/ceo
View File

@ -1,40 +0,0 @@
#!/usr/bin/python
import sys, ldap
from getpass import getpass
import ceo.urwid.main
import ceo.console.main
from ceo import ldapi, members
def start():
try:
if len(sys.argv) == 1:
print "Reading config file...",
members.configure()
print "Connecting to LDAP..."
members.connect(AuthCallback())
ceo.urwid.main.start()
else:
members.configure()
members.connect(AuthCallback())
ceo.console.main.start()
except ldap.LOCAL_ERROR, e:
print ldapi.format_ldaperror(e)
except ldap.INSUFFICIENT_ACCESS, e:
print ldapi.format_ldaperror(e)
print "You probably aren't permitted to do whatever you just tried."
print "Admittedly, ceo probably shouldn't have crashed either."
class AuthCallback:
def callback(self, error):
try:
print "Password: ",
return getpass("")
except KeyboardInterrupt:
print ""
sys.exit(1)
if __name__ == '__main__':
start()

View File

@ -1,5 +0,0 @@
if test -e .git; then
git-buildpackage --git-ignore-new -us -uc
else
debuild -us -uc
fi

1
ceo/.gitignore vendored
View File

@ -1 +0,0 @@
/ceo_pb2.py

View File

@ -0,0 +1,43 @@
from abc import ABC, abstractmethod
from typing import Union
import requests
class StreamResponseHandler(ABC):
"""
An abstract class to handle stream responses from the server.
The CLI and TUI should implement a child class.
"""
@abstractmethod
def handle_non_200(self, resp: requests.Response):
"""Handle a non-200 response."""
@abstractmethod
def begin(self):
"""Begin the transaction."""
@abstractmethod
def handle_aborted(self, err_msg: str):
"""Handle an aborted transaction."""
@abstractmethod
def handle_completed(self):
"""Handle a completed transaction."""
@abstractmethod
def handle_successful_operation(self):
"""Handle a successful operation."""
@abstractmethod
def handle_failed_operation(self, err_msg: Union[str, None]):
"""Handle a failed operation."""
@abstractmethod
def handle_skipped_operation(self):
"""Handle a skipped operation."""
@abstractmethod
def handle_unrecognized_operation(self, operation: str):
"""Handle an unrecognized operation."""

View File

@ -1 +0,0 @@
"""CSC Electronic Office"""

44
ceo/__main__.py Normal file
View File

@ -0,0 +1,44 @@
import os
import sys
from zope import component
from .cli import cli
from .krb_check import krb_check
from .tui.start import main as tui_main
from ceo_common.interfaces import IConfig, IHTTPClient
from ceo_common.model import Config, HTTPClient
from ceo_common.utils import is_in_development
def register_services():
# Using base component directly so events get triggered
baseComponent = component.getGlobalSiteManager()
# Config
if 'CEO_CONFIG' in os.environ:
config_file = os.environ['CEO_CONFIG']
else:
if is_in_development():
config_file = './tests/ceo_dev.ini'
else:
config_file = '/etc/csc/ceo.ini'
cfg = Config(config_file)
baseComponent.registerUtility(cfg, IConfig)
# HTTPService
http_client = HTTPClient()
baseComponent.registerUtility(http_client, IHTTPClient)
def main():
krb_check()
register_services()
if len(sys.argv) > 1:
cli(obj={})
else:
tui_main()
if __name__ == '__main__':
main()

View File

@ -0,0 +1,70 @@
import sys
from typing import List, Union
import click
import requests
from ..StreamResponseHandler import StreamResponseHandler
from ..operation_strings import descriptions as op_desc
class Abort(click.ClickException):
"""Abort silently."""
def __init__(self, exit_code=1):
super().__init__('')
self.exit_code = exit_code
def show(self):
pass
class CLIStreamResponseHandler(StreamResponseHandler):
def __init__(self, operations: List[str]):
super().__init__()
self.operations = operations
self.idx = 0
def handle_non_200(self, resp: requests.Response):
click.echo('An error occurred:')
click.echo(resp.text.rstrip())
raise Abort()
def begin(self):
click.echo(op_desc[self.operations[0]] + '... ', nl=False)
def handle_aborted(self, err_msg: str):
click.echo(click.style('ABORTED', fg='red'))
click.echo('The transaction was rolled back.')
click.echo('The error was: ' + err_msg)
click.echo('Please check the ceod logs.')
sys.exit(1)
def handle_completed(self):
click.echo('Transaction successfully completed.')
def _go_to_next_op(self):
"""
Increment the operation index and print the next operation, if
there is one.
"""
self.idx += 1
if self.idx < len(self.operations):
click.echo(op_desc[self.operations[self.idx]] + '... ', nl=False)
def handle_successful_operation(self):
click.echo(click.style('Done', fg='green'))
self._go_to_next_op()
def handle_failed_operation(self, err_msg: Union[str, None]):
click.echo(click.style('Failed', fg='red'))
if err_msg is not None:
click.echo(' Error message: ' + err_msg)
self._go_to_next_op()
def handle_skipped_operation(self):
click.echo('Skipped')
self._go_to_next_op()
def handle_unrecognized_operation(self, operation: str):
click.echo('Unrecognized operation: ' + operation)

1
ceo/cli/__init__.py Normal file
View File

@ -0,0 +1 @@
from .entrypoint import cli

91
ceo/cli/cloud.py Normal file
View File

@ -0,0 +1,91 @@
import click
from zope import component
from ceo_common.interfaces import IConfig
from ..utils import http_post, http_put, http_get, http_delete
from .utils import Abort, handle_sync_response, print_colon_kv
@click.group(short_help='Perform operations on the CSC cloud')
def cloud():
pass
@cloud.group(short_help='Manage your cloud account')
def account():
pass
@account.command(short_help='Activate your cloud account')
def activate():
cfg = component.getUtility(IConfig)
base_domain = cfg.get('base_domain')
resp = http_post('/api/cloud/accounts/create')
handle_sync_response(resp)
lines = [
'Congratulations! Your cloud account has been activated.',
f'You may now login into https://cloud.{base_domain} with your CSC credentials.',
"Make sure to enter 'Members' for the domain (no quotes).",
'',
'Please note that your cloud account will be PERMANENTLY DELETED when',
'your CSC membership expires, so make sure to purchase enough membership',
'terms in advance. You will receive a warning email one week before your',
'cloud account is deleted, so please make sure to check your Junk folder.',
]
for line in lines:
click.echo(line)
@cloud.group(short_help='Manage cloud accounts')
def accounts():
pass
@accounts.command(short_help='Purge expired cloud accounts')
def purge():
resp = http_post('/api/cloud/accounts/purge')
result = handle_sync_response(resp)
click.echo('Accounts to be deleted: ' + ','.join(result['accounts_to_be_deleted']))
click.echo('Accounts which were deleted: ' + ','.join(result['accounts_deleted']))
@cloud.group(short_help='Manage your virtual hosts')
def vhosts():
pass
@vhosts.command(name='add', short_help='Add a virtual host')
@click.argument('domain')
@click.argument('ip_address')
def add_vhost(domain, ip_address):
body = {'ip_address': ip_address}
if '/' in domain:
raise Abort('invalid domain name')
click.echo('Please wait, this may take a while...')
resp = http_put('/api/cloud/vhosts/' + domain, json=body)
handle_sync_response(resp)
click.echo('Done.')
@vhosts.command(name='delete', short_help='Delete a virtual host')
@click.argument('domain')
def delete_vhost(domain):
if '/' in domain:
raise Abort('invalid domain name')
resp = http_delete('/api/cloud/vhosts/' + domain)
handle_sync_response(resp)
click.echo('Done.')
@vhosts.command(name='list', short_help='List virtual hosts')
def list_vhosts():
resp = http_get('/api/cloud/vhosts')
result = handle_sync_response(resp)
vhosts = result['vhosts']
if not vhosts:
click.echo('No vhosts found.')
return
pairs = [(d['domain'], d['ip_address']) for d in vhosts]
print_colon_kv(pairs)

70
ceo/cli/database.py Normal file
View File

@ -0,0 +1,70 @@
import os
from typing import Dict
import click
from zope import component
from ..utils import http_post, http_get, http_delete, write_db_creds
from .utils import handle_sync_response, check_if_in_development
from ceo_common.interfaces import IConfig
def db_cli_response(filename: str, user_dict: Dict, password: str, db_type: str, op: str):
cfg_srv = component.getUtility(IConfig)
db_host = cfg_srv.get(f'{db_type}_host')
if db_type == 'mysql':
db_type_name = 'MySQL'
else:
db_type_name = 'PostgreSQL'
wrote_to_file = write_db_creds(filename, user_dict, password, db_type, db_host)
if op == 'create':
click.echo(f'{db_type_name} database created.')
username = user_dict['uid']
click.echo(f'''Connection Information:
Database: {username}
Username: {username}
Password: {password}
Host: {db_host}''')
if wrote_to_file:
click.echo(f"\nThese settings have been written to {filename}.")
else:
click.echo(f"\nWe were unable to write these settings to {filename}.")
def create(username: str, db_type: str):
db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL'
resp = http_get(f'/api/members/{username}')
user_dict = handle_sync_response(resp)
click.confirm(f'Are you sure you want to create a {db_type_name} database for {username}?', abort=True)
info_file_path = os.path.join(user_dict['home_directory'], f"ceo-{db_type}-info")
resp = http_post(f'/api/db/{db_type}/{username}')
result = handle_sync_response(resp)
password = result['password']
db_cli_response(info_file_path, user_dict, password, db_type, 'create')
def pwreset(username: str, db_type: str):
db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL'
resp = http_get(f'/api/members/{username}')
user_dict = handle_sync_response(resp)
click.confirm(f'Are you sure you want reset the {db_type_name} password for {username}?', abort=True)
info_file_path = os.path.join(user_dict['home_directory'], f"ceo-{db_type}-info")
resp = http_post(f'/api/db/{db_type}/{username}/pwreset')
result = handle_sync_response(resp)
password = result['password']
db_cli_response(info_file_path, user_dict, password, db_type, 'pwreset')
def delete(username: str, db_type: str):
check_if_in_development()
db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL'
click.confirm(f"Are you sure you want to delete the {db_type_name} database for {username}?", abort=True)
resp = http_delete(f'/api/db/{db_type}/{username}')
handle_sync_response(resp)

31
ceo/cli/entrypoint.py Normal file
View File

@ -0,0 +1,31 @@
import click
from .members import members
from .groups import groups
from .positions import positions
from .updateprograms import updateprograms
from .mysql import mysql
from .postgresql import postgresql
from .mailman import mailman
from .cloud import cloud
from .k8s import k8s
from .registry import registry
from .webhosting import webhosting
@click.group()
def cli():
pass
cli.add_command(members)
cli.add_command(groups)
cli.add_command(positions)
cli.add_command(updateprograms)
cli.add_command(mysql)
cli.add_command(postgresql)
cli.add_command(mailman)
cli.add_command(cloud)
cli.add_command(k8s)
cli.add_command(registry)
cli.add_command(webhosting)

163
ceo/cli/groups.py Normal file
View File

@ -0,0 +1,163 @@
from typing import Dict
import click
from zope import component
from ..utils import http_post, http_get, http_delete
from .utils import handle_stream_response, handle_sync_response, print_colon_kv, \
check_if_in_development
from ceo_common.interfaces import IConfig
from ceod.transactions.groups import (
AddGroupTransaction,
AddMemberToGroupTransaction,
RemoveMemberFromGroupTransaction,
DeleteGroupTransaction,
)
@click.group(short_help='Perform operations on CSC groups/clubs')
def groups():
pass
@groups.command(short_help='Add a new group')
@click.argument('group_name')
@click.option('-d', '--description', help='Group description', prompt=True)
def add(group_name, description):
click.echo('The following group will be created:')
lines = [
('cn', group_name),
('description', description),
]
print_colon_kv(lines)
click.confirm('Do you want to continue?', abort=True)
body = {
'cn': group_name,
'description': description,
}
operations = AddGroupTransaction.operations
resp = http_post('/api/groups', json=body)
data = handle_stream_response(resp, operations)
result = data[-1]['result']
print_group_lines(result)
def print_group_lines(result: Dict):
"""Pretty-print a group JSON response."""
lines = [
('cn', result['cn']),
('description', result.get('description', 'Unknown')),
('gid_number', str(result['gid_number'])),
]
for i, member in enumerate(result['members']):
if i == 0:
prefix = 'members'
else:
prefix = ''
lines.append((prefix, member['cn'] + ' (' + member['uid'] + ')'))
print_colon_kv(lines)
@groups.command(short_help='Get info about a group')
@click.argument('group_name')
def get(group_name):
resp = http_get('/api/groups/' + group_name)
result = handle_sync_response(resp)
print_group_lines(result)
@groups.command(short_help='Add one or more members to a group')
@click.argument('group_name')
@click.argument('username')
@click.argument('usernames', nargs=-1)
@click.option('--no-subscribe', is_flag=True, default=False,
help='Do not subscribe the member(s) to any auxiliary mailing lists.')
def addmember(group_name, username, usernames, no_subscribe):
usernames = [username, *usernames]
if len(usernames) == 1:
click.confirm(f'Are you sure you want to add {username} to {group_name}?',
abort=True)
else:
click.echo(f'The following users will be added to {group_name}:')
click.echo(', '.join(usernames))
click.confirm('Do you want to continue?', abort=True)
base_domain = component.getUtility(IConfig).get('base_domain')
operations = AddMemberToGroupTransaction.operations
if no_subscribe:
operations.remove('subscribe_user_to_auxiliary_mailing_lists')
for username in usernames:
url = f'/api/groups/{group_name}/members/{username}'
if no_subscribe:
url += '?subscribe_to_lists=false'
resp = http_post(url)
data = handle_stream_response(resp, operations)
result = data[-1]['result']
click.echo(f'Added {username} to ' + ', '.join(result['added_to_groups']))
if result.get('subscribed_to_lists'):
mailing_lists = [
mailing_list + '@' + base_domain
if '@' not in mailing_list
else mailing_list
for mailing_list in result['subscribed_to_lists']
]
click.echo(f'Subscribed {username} to ' + ', '.join(mailing_lists))
@groups.command(short_help='Remove one or more members from a group')
@click.argument('group_name')
@click.argument('username')
@click.argument('usernames', nargs=-1)
@click.option('--no-unsubscribe', is_flag=True, default=False,
help='Do not unsubscribe the member(s) from any auxiliary mailing lists.')
def removemember(group_name, username, usernames, no_unsubscribe):
usernames = [username, *usernames]
if len(usernames) == 1:
click.confirm(f'Are you sure you want to remove {username} from {group_name}?',
abort=True)
else:
click.echo(f'The following users will be removed from {group_name}:')
click.echo(', '.join(usernames))
click.confirm('Do you want to continue?', abort=True)
base_domain = component.getUtility(IConfig).get('base_domain')
operations = RemoveMemberFromGroupTransaction.operations
if no_unsubscribe:
operations.remove('unsubscribe_user_from_auxiliary_mailing_lists')
for username in usernames:
url = f'/api/groups/{group_name}/members/{username}'
if no_unsubscribe:
url += '?unsubscribe_from_lists=false'
resp = http_delete(url)
data = handle_stream_response(resp, operations)
result = data[-1]['result']
click.echo(f'Removed {username} from ' + ', '.join(result['removed_from_groups']))
if result.get('unsubscribed_from_lists'):
mailing_lists = [
mailing_list + '@' + base_domain
if '@' not in mailing_list
else mailing_list
for mailing_list in result['unsubscribed_from_lists']
]
click.echo(f'Unsubscribed {username} from ' + ', '.join(mailing_lists))
@groups.command(short_help='Delete a group')
@click.argument('group_name')
def delete(group_name):
check_if_in_development()
click.confirm(f"Are you sure you want to delete {group_name}?", abort=True)
resp = http_delete(f'/api/groups/{group_name}')
handle_stream_response(resp, DeleteGroupTransaction.operations)
@groups.command(short_help='Search for groups')
@click.argument('query')
@click.option('--count', default=10, help='number of results to show')
def search(query, count):
check_if_in_development()
resp = http_get(f'/api/groups/search/{query}/{count}')
result = handle_sync_response(resp)
for cn in result:
if cn != "":
click.echo(cn)

43
ceo/cli/k8s.py Normal file
View File

@ -0,0 +1,43 @@
import os
import traceback
import click
from ..utils import http_post
from .utils import handle_sync_response
@click.group(short_help='Manage your CSC Kubernetes resources')
def k8s():
pass
@k8s.group(short_help='Manage your CSC Kubernetes account')
def account():
pass
@account.command(short_help='Obtain a kubeconfig')
def activate():
kubedir = os.path.join(os.environ['HOME'], '.kube')
if not os.path.isdir(kubedir):
os.mkdir(kubedir)
kubeconfig = os.path.join(kubedir, 'config')
resp = http_post('/api/cloud/k8s/accounts/create')
result = handle_sync_response(resp)
try:
if os.path.isfile(kubeconfig):
kubeconfig_bak = os.path.join(kubedir, 'config.bak')
os.rename(kubeconfig, kubeconfig_bak)
with open(kubeconfig, 'w') as fo:
fo.write(result['kubeconfig'])
os.chmod(kubeconfig, 0o600)
except Exception:
click.echo(traceback.format_exc())
click.echo("We weren't able to write the kubeconfig file, so here it is.")
click.echo("Make sure to paste this into your ~/.kube/config.")
click.echo()
click.echo(result['kubeconfig'])
return
click.echo("Congratulations! You have a new kubeconfig in ~/.kube/config.")
click.echo("Run `kubectl cluster-info` to make sure everything is working.")

29
ceo/cli/mailman.py Normal file
View File

@ -0,0 +1,29 @@
import click
from ..utils import http_post, http_delete
from .utils import handle_sync_response
@click.group(short_help='Manage mailing list subscriptions')
def mailman():
pass
@mailman.command(short_help='Subscribe a member to a mailing list')
@click.argument('username')
@click.argument('mailing_list')
def subscribe(username, mailing_list):
click.confirm(f'Are you sure you want to subscribe {username} to {mailing_list}?', abort=True)
resp = http_post(f'/api/mailman/{mailing_list}/{username}')
handle_sync_response(resp)
click.echo('Done.')
@mailman.command(short_help='Unsubscribe a member from a mailing list')
@click.argument('username')
@click.argument('mailing_list')
def unsubscribe(username, mailing_list):
click.confirm(f'Are you sure you want to unsubscribe {username} from {mailing_list}?', abort=True)
resp = http_delete(f'/api/mailman/{mailing_list}/{username}')
handle_sync_response(resp)
click.echo('Done.')

232
ceo/cli/members.py Normal file
View File

@ -0,0 +1,232 @@
import sys
from typing import Dict
import click
from zope import component
from ceo_common.utils import validate_username
from ..term_utils import get_terms_for_renewal_for_user
from ..utils import http_post, http_get, http_patch, http_delete, \
get_failed_operations, user_dict_lines, get_adduser_operations
from .utils import handle_stream_response, handle_sync_response, print_lines, \
check_if_in_development
from ceo_common.interfaces import IConfig
from ceo_common.model.Term import get_terms_for_new_user
from ceod.transactions.members import DeleteMemberTransaction
@click.group(short_help='Perform operations on CSC members and club reps')
def members():
pass
@members.command(short_help='Add a new member or club rep')
@click.argument('username')
@click.option('--cn', help='Full name', required=False)
@click.option('--given-name', help='First name', required=False)
@click.option('--sn', help='Last name', required=False)
@click.option('--program', required=False, help='Academic program')
@click.option('--terms', 'num_terms', type=click.IntRange(1, 100),
help='Number of terms to add', default=1)
@click.option('--clubrep', is_flag=True, default=False,
help='Add non-member terms instead of member terms')
@click.option('--forwarding-address', required=False,
help=('Forwarding address to set in ~/.forward. '
'Default is UW address. '
'Set to the empty string to disable forwarding.'))
def add(username, cn, given_name, sn, program, num_terms, clubrep, forwarding_address):
cfg = component.getUtility(IConfig)
uw_domain = cfg.get('uw_domain')
# Verify that the username is valid before requesting data from UWLDAP
username_validator = validate_username(username)
if not username_validator.is_valid:
return click.echo("The provided username is invalid")
# Try to get info from UWLDAP
resp = http_get('/api/uwldap/' + username)
if resp.ok:
result = handle_sync_response(resp)
if cn is None and result.get('cn'):
cn = result['cn']
if given_name is None and result.get('given_name'):
given_name = result['given_name']
if sn is None and result.get('sn'):
sn = result['sn']
if program is None and result.get('program'):
program = result['program']
if forwarding_address is None and result.get('mail_local_addresses'):
forwarding_address = result['mail_local_addresses'][0]
if cn is None:
cn = click.prompt('Full name')
if given_name is None:
given_name = click.prompt('First name')
if sn is None:
sn = click.prompt('Last name')
if forwarding_address is None:
forwarding_address = username + '@' + uw_domain
terms = get_terms_for_new_user(num_terms)
body = {
'uid': username,
'cn': cn,
'given_name': given_name,
'sn': sn,
}
if program is not None:
body['program'] = program
if clubrep:
body['non_member_terms'] = terms
else:
body['terms'] = terms
if forwarding_address != '':
body['forwarding_addresses'] = [forwarding_address]
else:
body['forwarding_addresses'] = []
click.echo("The following user will be created:")
print_user_lines(body)
click.confirm('Do you want to continue?', abort=True)
operations = get_adduser_operations(body)
resp = http_post('/api/members', json=body)
data = handle_stream_response(resp, operations)
result = data[-1]['result']
print_user_lines(result)
failed_operations = get_failed_operations(data)
if 'send_welcome_message' in failed_operations:
click.echo(click.style(
'Warning: welcome message was not sent. You now need to manually '
'send the user their password.', fg='yellow'))
def print_user_lines(d: Dict):
"""Pretty-print a serialized User."""
print_lines(user_dict_lines(d))
@members.command(short_help='Get info about a user')
@click.argument('username')
def get(username):
resp = http_get('/api/members/' + username)
result = handle_sync_response(resp)
print_user_lines(result)
@members.command(short_help="Replace a user's login shell or forwarding addresses")
@click.argument('username')
@click.option('--login-shell', required=False, help='Login shell')
@click.option('--forwarding-addresses', required=False,
help=(
'Comma-separated list of forwarding addresses. '
'Set to the empty string to disable forwarding.'
))
def modify(username, login_shell, forwarding_addresses):
if login_shell is None and forwarding_addresses is None:
click.echo('Nothing to do.')
sys.exit()
operations = []
body = {}
if login_shell is not None:
body['login_shell'] = login_shell
operations.append('replace_login_shell')
click.echo('Login shell will be set to: ' + login_shell)
if forwarding_addresses is not None:
if forwarding_addresses == '':
forwarding_addresses = []
else:
forwarding_addresses = forwarding_addresses.split(',')
body['forwarding_addresses'] = forwarding_addresses
operations.append('replace_forwarding_addresses')
prefix = '~/.forward will be set to: '
if len(forwarding_addresses) > 0:
click.echo(prefix + forwarding_addresses[0])
for address in forwarding_addresses[1:]:
click.echo((' ' * len(prefix)) + address)
else:
click.echo(prefix)
click.confirm('Do you want to continue?', abort=True)
resp = http_patch('/api/members/' + username, json=body)
handle_stream_response(resp, operations)
@members.command(short_help="Renew a member or club rep's membership")
@click.argument('username')
@click.option('--terms', 'num_terms', type=click.IntRange(1, 100),
help='Number of terms to add', prompt='Number of terms')
@click.option('--clubrep', is_flag=True, default=False,
help='Add non-member terms instead of member terms')
def renew(username, num_terms, clubrep):
terms = get_terms_for_renewal_for_user(username, num_terms, clubrep)
if clubrep:
body = {'non_member_terms': terms}
click.echo('The following non-member terms will be added: ' + ','.join(terms))
else:
body = {'terms': terms}
click.echo('The following member terms will be added: ' + ','.join(terms))
click.confirm('Do you want to continue?', abort=True)
resp = http_post(f'/api/members/{username}/renew', json=body)
handle_sync_response(resp)
click.echo('Done.')
@members.command(short_help="Reset a user's password")
@click.argument('username')
def pwreset(username):
click.confirm(f"Are you sure you want to reset {username}'s password?", abort=True)
resp = http_post(f'/api/members/{username}/pwreset')
result = handle_sync_response(resp)
click.echo('New password: ' + result['password'])
@members.command(short_help="Delete a user")
@click.argument('username')
def delete(username):
check_if_in_development()
click.confirm(f"Are you sure you want to delete {username}?", abort=True)
resp = http_delete(f'/api/members/{username}')
handle_stream_response(resp, DeleteMemberTransaction.operations)
@members.command(short_help="Check for and mark expired members")
@click.option('--dry-run', is_flag=True, default=False)
def expire(dry_run):
resp = http_post(f'/api/members/expire?dry_run={dry_run and "yes" or "no"}')
result = handle_sync_response(resp)
if len(result) > 0:
if dry_run:
click.echo("The following members will be marked as expired:")
else:
click.echo("The following members has been marked as expired:")
for username in result:
click.echo(username)
@members.command(short_help="Send renewal reminder emails to expiring members")
@click.option('--dry-run', is_flag=True, default=False)
def remindexpire(dry_run):
url = '/api/members/remindexpire'
if dry_run:
url += '?dry_run=true'
resp = http_post(url)
result = handle_sync_response(resp)
if len(result) > 0:
if dry_run:
click.echo("The following members will be sent membership renewal reminders:")
else:
click.echo("The following members were sent membership renewal reminders:")
for username in result:
click.echo(username)
else:
click.echo("No members are pending expiration.")

26
ceo/cli/mysql.py Normal file
View File

@ -0,0 +1,26 @@
import click
from .database import create as db_create, pwreset as db_pwreset, delete as db_delete
@click.group(short_help='Perform operations on MySQL')
def mysql():
pass
@mysql.command(short_help='Create a MySQL database for a user')
@click.argument('username')
def create(username):
db_create(username, 'mysql')
@mysql.command(short_help='Reset the password of a MySQL user')
@click.argument('username')
def pwreset(username):
db_pwreset(username, 'mysql')
@mysql.command(short_help="Delete the database of a MySQL user")
@click.argument('username')
def delete(username):
db_delete(username, 'mysql')

53
ceo/cli/positions.py Normal file
View File

@ -0,0 +1,53 @@
import click
from zope import component
from ..utils import http_get, http_post
from .utils import handle_sync_response, handle_stream_response, print_colon_kv
from ceo_common.interfaces import IConfig
from ceod.transactions.members import UpdateMemberPositionsTransaction
@click.group(short_help='List or change exec positions')
def positions():
update_commands()
@positions.command(short_help='Get current positions')
def get():
resp = http_get('/api/positions')
result = handle_sync_response(resp)
print_colon_kv([
(position, ', '.join(usernames))
for position, usernames in result.items()
])
@positions.command(short_help='Update positions')
def set(**kwargs):
body = {
k.replace('_', '-'): v.replace(' ', '').split(',') if v else None
for k, v in kwargs.items()
}
print_body = {
k: ', '.join(v) if v else ''
for k, v in body.items()
}
click.echo('The positions will be updated:')
print_colon_kv(print_body.items())
click.confirm('Do you want to continue?', abort=True)
resp = http_post('/api/positions', json=body)
handle_stream_response(resp, UpdateMemberPositionsTransaction.operations)
# Provides dynamic parameters for `set' command using config file
def update_commands():
global set
cfg = component.getUtility(IConfig)
avail = cfg.get('positions_available')
required = cfg.get('positions_required')
for pos in avail:
r = pos in required
set = click.option(f'--{pos}', metavar='USERNAME', required=r, prompt=r)(set)

26
ceo/cli/postgresql.py Normal file
View File

@ -0,0 +1,26 @@
import click
from .database import create as db_create, pwreset as db_pwreset, delete as db_delete
@click.group(short_help='Perform operations on PostgreSQL')
def postgresql():
pass
@postgresql.command(short_help='Create a PostgreSQL database for a user')
@click.argument('username')
def create(username):
db_create(username, 'postgresql')
@postgresql.command(short_help='Reset the password of a PostgreSQL user')
@click.argument('username')
def pwreset(username):
db_pwreset(username, 'postgresql')
@postgresql.command(short_help="Delete the database of a PostgreSQL user")
@click.argument('username')
def delete(username):
db_delete(username, 'postgresql')

21
ceo/cli/registry.py Normal file
View File

@ -0,0 +1,21 @@
import click
from ..utils import http_post
from .utils import handle_sync_response
@click.group(short_help='Manage your container registry account')
def registry():
pass
@registry.group(short_help='Manage your container registry project')
def project():
pass
@project.command(short_help='Create a registry project')
def create():
resp = http_post('/api/cloud/registry/projects')
handle_sync_response(resp)
click.echo('Congratulations! Your registry project was successfully created.')

35
ceo/cli/updateprograms.py Normal file
View File

@ -0,0 +1,35 @@
import click
from ..utils import http_post
from .utils import handle_sync_response, print_colon_kv
@click.command(short_help="Sync the 'program' attribute with UWLDAP")
@click.option('--dry-run', is_flag=True, default=False)
@click.option('--members', required=False)
def updateprograms(dry_run, members):
body = {}
if dry_run:
body['dry_run'] = True
if members is not None:
body['members'] = ','.split(members)
if not dry_run:
click.confirm('Are you sure that you want to sync programs with UWLDAP?', abort=True)
resp = http_post('/api/uwldap/updateprograms', json=body)
result = handle_sync_response(resp)
if len(result) == 0:
click.echo('All programs are up-to-date.')
return
if dry_run:
click.echo('Members whose program would be changed:')
else:
click.echo('Members whose program was changed:')
lines = []
for uid, csc_program, uw_program in result:
csc_program = csc_program or 'Unknown'
csc_program = click.style(csc_program, fg='yellow')
uw_program = click.style(uw_program, fg='green')
lines.append((uid, csc_program + ' -> ' + uw_program))
print_colon_kv(lines)

57
ceo/cli/utils.py Normal file
View File

@ -0,0 +1,57 @@
from typing import List, Tuple, Dict
import click
import requests
from ceo_common.utils import is_in_development
from ..utils import space_colon_kv, generic_handle_stream_response
from .CLIStreamResponseHandler import CLIStreamResponseHandler
class Abort(click.ClickException):
"""Abort silently."""
def __init__(self, exit_code=1):
super().__init__('')
self.exit_code = exit_code
def show(self):
pass
def print_lines(lines: List[str]):
"""Print multiple lines to stdout."""
for line in lines:
click.echo(line)
def print_colon_kv(pairs: List[Tuple[str, str]]):
"""
Pretty-print a list of key-value pairs.
"""
for line in space_colon_kv(pairs):
click.echo(line)
def handle_stream_response(resp: requests.Response, operations: List[str]) -> List[Dict]:
handler = CLIStreamResponseHandler(operations)
return generic_handle_stream_response(resp, operations, handler)
def handle_sync_response(resp: requests.Response):
"""
Exit the program if the request was not successful.
Returns the parsed JSON response.
"""
if resp.status_code != 200:
click.echo('An error occurred:')
click.echo(resp.text.rstrip())
raise Abort()
return resp.json()
def check_if_in_development() -> bool:
"""Aborts if we are not currently in the dev environment."""
if not is_in_development():
click.echo('This command may only be called during development.')
raise Abort()

37
ceo/cli/webhosting.py Normal file
View File

@ -0,0 +1,37 @@
import click
from ..utils import http_post
from .utils import handle_sync_response
@click.group(short_help='Manage websites hosted by the main CSC web server')
def webhosting():
pass
@webhosting.command(short_help='Disable club sites with no active club reps')
@click.option('--dry-run', is_flag=True, default=False)
@click.option('--remove-inactive-club-reps', is_flag=True, default=False)
def disableclubsites(dry_run, remove_inactive_club_reps):
params = {}
if dry_run:
params['dry_run'] = 'true'
if remove_inactive_club_reps:
params['remove_inactive_club_reps'] = 'true'
if not dry_run:
click.confirm('Are you sure you want to disable the websites of clubs with no active club reps?', abort=True)
resp = http_post('/api/webhosting/disableclubsites', params=params)
disabled_club_names = handle_sync_response(resp)
if len(disabled_club_names) == 0:
if dry_run:
click.echo('No websites would have been disabled.')
else:
click.echo('No websites were disabled.')
else:
if dry_run:
click.echo('The following club websites would have been disabled:')
else:
click.echo('The following club websites were disabled:')
for club_name in disabled_club_names:
click.echo(club_name)

View File

@ -1,162 +0,0 @@
"""
Configuration Utility Module
This module contains functions to load and verify very simple configuration
files. Python supports ".ini" files, which suck, so this module is used
instead.
Example Configuration File:
include /path/to/other.cf
# these values are the same:
name_protected = "Michael Spang"
name_unprotected = Michael Spang
# these values are not the same:
yes_no = " yes"
no_yes = yes
# this value is an integer
arbitrary_number=2
# this value is not an integer
arbitrary_string="2"
# this is a key with no value
csclub
# this key contains whitespace
white space = sure, why not
# these two lines are treated as one
long line = first line \\
second line
Resultant Dictionary:
{
'name_protected': 'Michael Spang',
'name_unprotected:' 'Michael Spang',
'yes_no': ' yes',
'no_yes': 'yes',
'arbirary_number': 2,
'arbitrary_string': '2',
'csclub': None,
'white space': 'sure, why not'
'long line': 'first line \\n second line'
... (data from other.cf) ...
}
"""
from curses.ascii import isspace
class ConfigurationException(Exception):
"""Exception class for incomplete and incorrect configurations."""
def read(filename, included=None):
"""
Function to read a configuration file into a dictionary.
Parmaeters:
filename - the file to read
included - files previously read (internal)
Exceptions:
IOError - when the configuration file cannot be read
"""
if not included:
included = []
if filename in included:
return {}
included.append(filename)
conffile = open(filename)
options = {}
while True:
line = conffile.readline()
if line == '':
break
# remove comments
if '#' in line:
line = line[:line.find('#')]
# combine lines when the newline is escaped with \
while len(line) > 1 and line[-2] == '\\':
line = line[:-2] + line[-1]
next = conffile.readline()
line += next
if next == '':
break
line = line.strip()
# process include statements
if line.find("include") == 0 and isspace(line[7]):
filename = line[8:].strip()
options.update(read(filename, included))
continue
# split 'key = value' into key and value and strip results
pair = map(str.strip, line.split('=', 1))
# found key and value
if len(pair) == 2:
key, val = pair
# found quoted string?
if val and val[0] == val[-1] == '"':
val = val[1:-1]
# unquoted, found num?
elif val:
try:
if "." in val:
val = float(val)
elif val[0] == '0':
val = int(val, 8)
else:
val = int(val)
except ValueError:
pass
# save key and value
options[key] = val
# found only key, value = None
elif len(pair[0]) > 1:
key = pair[0]
options[key] = None
return options
def check_string_fields(filename, field_list, cfg):
"""Function to verify thatfields are strings."""
for field in field_list:
if field not in cfg or type(cfg[field]) is not str:
raise ConfigurationException('expected string value for option "%s" in "%s"' % (field, filename))
def check_integer_fields(filename, field_list, cfg):
"""Function to verify that fields are integers."""
for field in field_list:
if field not in cfg or type(cfg[field]) not in (int, long):
raise ConfigurationException('expected numeric value for option "%s" in "%s"' % (field, filename))
def check_float_fields(filename, field_list, cfg):
"""Function to verify that fields are integers or floats."""
for field in field_list:
if field not in cfg or type(cfg[field]) not in (float, long, int):
raise ConfigurationException('expected float value for option "%s" in "%s"' % (field, filename))

View File

@ -1 +0,0 @@
"""Console Interface"""

View File

@ -1,40 +0,0 @@
import sys, ldap
from ceo import members, uwldap, terms, ldapi
def max_term(term1, term2):
if terms.compare(term1, term2) > 0:
return term1
else:
return term2
class ExpiredAccounts:
help = '''
expiredaccounts [--email]
Displays a list of expired accounts. If --email is specified, expired account
owners will be emailed.
'''
def main(self, args):
send_email = False
if len(args) == 1 and args[0] == '--email':
sys.stderr.write("If you want to send an account expiration notice to " \
"these users then type 'Yes, do this' and hit enter\n")
if raw_input() == 'Yes, do this':
send_email = True
uwl = ldap.initialize(uwldap.uri())
mlist = members.expired_accounts()
for member in mlist.values():
term = "f0000"
term = reduce(max_term, member.get("term", []), term)
term = reduce(max_term, member.get("nonMemberTerm", []), term)
expiredfor = terms.delta(term, terms.current())
if expiredfor <= 3:
uid = member['uid'][0]
name = member['cn'][0]
email = None
print '%s (expired for %d terms)' % (uid.ljust(12), expiredfor)
if send_email:
print " sending mail to %s" % uid
members.send_account_expired_email(name, uid)

View File

@ -1,27 +0,0 @@
from ceo import members, terms
def max_term(term1, term2):
if terms.compare(term1, term2) > 0:
return term1
else:
return term2
class Inactive:
help = '''
inactive delta-terms
Prints a list of accounts that have been inactive (i.e. unpaid) for
delta-terms.
'''
def main(self, args):
if len(args) != 1:
print self.help
return
delta = int(args[0])
mlist = members.list_all()
for member in mlist.values():
term = "f0000"
term = reduce(max_term, member.get("term", []), term)
term = reduce(max_term, member.get("nonMemberTerm", []), term)
if terms.delta(term, terms.current()) >= delta:
print "%s %s" % (member['uid'][0].ljust(12), term)

View File

@ -1,49 +0,0 @@
import sys, ldap, termios
from ceo import members, terms, uwldap, ldapi
from ceo.console.memberlist import MemberList
from ceo.console.updateprograms import UpdatePrograms
from ceo.console.expiredaccounts import ExpiredAccounts
from ceo.console.inactive import Inactive
from ceo.console.mysql import MySQL
commands = {
'memberlist' : MemberList(),
'updateprograms' : UpdatePrograms(),
'expiredaccounts' : ExpiredAccounts(),
'inactive': Inactive(),
'mysql': MySQL(),
}
help_opts = [ '--help', '-h' ]
def start():
args = sys.argv[1:]
if args[0] in help_opts:
help()
elif args[0] in commands:
command = commands[args[0]]
if len(args) >= 2 and args[1] in help_opts:
print command.help
else:
command.main(args[1:])
else:
print "Invalid command '%s'" % args[0]
def help():
args = sys.argv[2:]
if len(args) == 1:
if args[0] in commands:
print commands[args[0]].help
else:
print 'Unknown command %s.' % args[0]
else:
print ''
print 'To run the ceo GUI, type \'ceo\''
print ''
print 'To run a ceo console command, type \'ceo command\''
print ''
print 'Available console commands:'
for c in commands:
print ' %s' % c
print ''
print 'Run \'ceo command --help\' for help on a specific command.'
print ''

View File

@ -1,24 +0,0 @@
from ceo import members, terms
class MemberList:
help = '''
memberlist [term]
Displays a list of members for a term; defaults to the current term if term
is not given.
'''
def main(self, args):
mlist = {}
if len(args) == 1:
mlist = members.list_term(args[0])
else:
mlist = members.list_term(terms.current())
dns = mlist.keys()
dns.sort()
for dn in dns:
member = mlist[dn]
print '%s %s %s' % (
member['uid'][0].ljust(12),
member['cn'][0].ljust(30),
member.get('program', [''])[0]
)

View File

@ -1,38 +0,0 @@
from ceo import members, terms, mysql
class MySQL:
help = '''
mysql create <username>
Creates a mysql database for a user.
'''
def main(self, args):
if len(args) != 2 or args[0] != 'create':
print self.help
return
username = args[1]
problem = None
try:
password = mysql.create_mysql(username)
try:
mysql.write_mysql_info(username, password)
helpfiletext = "Settings written to ~%s/ceo-mysql-info." % username
except (KeyError, IOError, OSError), e:
helpfiletext = "An error occured writing the settings file: %s" % e
print "MySQL database created"
print ("Connection Information: \n"
"\n"
"Database: %s\n"
"Username: %s\n"
"Hostname: localhost\n"
"Password: %s\n"
"\n"
"%s\n"
% (username, username, password, helpfiletext))
except mysql.MySQLException, e:
print "Failed to create MySQL database"
print
print "We failed to create the database. The error was:\n\n%s" % e

View File

@ -1,49 +0,0 @@
import ldap, sys, termios
from ceo import members, uwldap, ldapi
blacklist = ('orphaned', 'expired')
class UpdatePrograms:
help = '''
updateprograms
Interactively updates the program field for an account by querying uwdir.
'''
def main(self, args):
mlist = members.list_all().items()
uwl = ldap.initialize(uwldap.uri())
fd = sys.stdin.fileno()
for (dn, member) in mlist:
uid = member['uid'][0]
user = uwl.search_s(uwldap.base(), ldap.SCOPE_SUBTREE,
'(uid=%s)' % ldapi.escape(uid))
if len(user) == 0:
continue
user = user[0][1]
oldprog = member.get('program', [''])[0]
newprog = user.get('ou', [''])[0]
if oldprog == newprog or newprog == '' or newprog.lower() in blacklist:
continue
sys.stdout.write("%s: '%s' => '%s'? (y/n) " % (uid, oldprog, newprog))
new = old = termios.tcgetattr(fd)
new[3] = new[3] & ~termios.ICANON
try:
termios.tcsetattr(fd, termios.TCSANOW, new)
try:
if sys.stdin.read(1) != 'y':
continue
except KeyboardInterrupt:
return ''
finally:
print ''
termios.tcsetattr(fd, termios.TCSANOW, old)
old = new = {}
if oldprog != '':
old = {'program': [oldprog]}
if newprog != '':
new = {'program': [newprog]}
mlist = ldapi.make_modlist(old, new)
# TODO: don't use members.ld directly
#if newprog != '':
# members.set_program(uid, newprog)
members.ld.modify_s(dn, mlist)

View File

@ -1,13 +0,0 @@
"""
Exceptions Module
This module provides some simple but generally useful exception classes.
"""
class InvalidArgument(Exception):
"""Exception class for bad argument values."""
def __init__(self, argname, argval, explanation):
Exception.__init__(self)
self.argname, self.argval, self.explanation = argname, argval, explanation
def __str__(self):
return 'Bad argument value "%s" for %s: %s' % (self.argval, self.argname, self.explanation)

35
ceo/krb_check.py Normal file
View File

@ -0,0 +1,35 @@
import subprocess
import gssapi
_username = None
def get_username():
"""Get the user currently logged into CEO."""
return _username
def krb_check():
"""
Spawns a `kinit` process if no credentials are available or the
credentials have expired.
Stores the username for later use by get_username().
"""
global _username
for _ in range(2):
try:
creds = gssapi.Credentials(usage='initiate')
result = creds.inquire()
princ = str(result.name)
_username = princ[:princ.index('@')]
return
except (gssapi.raw.misc.GSSError, gssapi.raw.exceptions.ExpiredCredentialsError):
kinit()
raise Exception('could not acquire GSSAPI credentials')
def kinit():
subprocess.run(['kinit'], check=True)

View File

@ -1,148 +0,0 @@
"""
LDAP Utilities
This module makes use of python-ldap, a Python module with bindings
to libldap, OpenLDAP's native C client library.
"""
import ldap.modlist, os, pwd
from subprocess import Popen, PIPE
def connect_sasl(uri, mech, realm, password):
try:
# open the connection
ld = ldap.initialize(uri)
# authenticate
sasl = Sasl(mech, realm, password)
ld.sasl_interactive_bind_s('', sasl)
except ldap.LOCAL_ERROR, e:
raise e
except:
print "Shit, something went wrong!"
return ld
def abslookup(ld, dn, objectclass=None):
# search for the specified dn
try:
if objectclass:
search_filter = '(objectclass=%s)' % escape(objectclass)
matches = ld.search_s(dn, ldap.SCOPE_BASE, search_filter)
else:
matches = ld.search_s(dn, ldap.SCOPE_BASE)
except ldap.NO_SUCH_OBJECT:
return None
# dn was found, but didn't match the objectclass filter
if len(matches) < 1:
return None
# return the attributes of the single successful match
match = matches[0]
match_dn, match_attributes = match
return match_attributes
def lookup(ld, rdntype, rdnval, base, objectclass=None):
dn = '%s=%s,%s' % (rdntype, escape(rdnval), base)
return abslookup(ld, dn, objectclass)
def search(ld, base, search_filter, params=[], scope=ldap.SCOPE_SUBTREE, attrlist=None, attrsonly=0):
real_filter = search_filter % tuple(escape(x) for x in params)
# search for entries that match the filter
matches = ld.search_s(base, scope, real_filter, attrlist, attrsonly)
return matches
def modify(ld, rdntype, rdnval, base, mlist):
dn = '%s=%s,%s' % (rdntype, escape(rdnval), base)
ld.modify_s(dn, mlist)
def modify_attrs(ld, rdntype, rdnval, base, old, attrs):
dn = '%s=%s,%s' % (rdntype, escape(rdnval), base)
# build list of modifications to make
changes = ldap.modlist.modifyModlist(old, attrs)
# apply changes
ld.modify_s(dn, changes)
def modify_diff(ld, rdntype, rdnval, base, old, new):
dn = '%s=%s,%s' % (rdntype, escape(rdnval), base)
# build list of modifications to make
changes = make_modlist(old, new)
# apply changes
ld.modify_s(dn, changes)
def escape(value):
"""
Escapes special characters in a value so that it may be safely inserted
into an LDAP search filter.
"""
value = str(value)
value = value.replace('\\', '\\5c').replace('*', '\\2a')
value = value.replace('(', '\\28').replace(')', '\\29')
value = value.replace('\x00', '\\00')
return value
def make_modlist(old, new):
keys = set(old.keys()).union(set(new))
mlist = []
for key in keys:
if key in old and not key in new:
mlist.append((ldap.MOD_DELETE, key, list(set(old[key]))))
elif key in new and not key in old:
mlist.append((ldap.MOD_ADD, key, list(set(new[key]))))
else:
to_add = list(set(new[key]) - set(old[key]))
if len(to_add) > 0:
mlist.append((ldap.MOD_ADD, key, to_add))
to_del = list(set(old[key]) - set(new[key]))
if len(to_del) > 0:
mlist.append((ldap.MOD_DELETE, key, to_del))
return mlist
def format_ldaperror(ex):
desc = ex[0].get('desc', '')
info = ex[0].get('info', '')
if desc and info:
return "%s: %s" % (desc, info)
elif desc:
return desc
else:
return str(ex)
class Sasl:
def __init__(self, mech, realm, password):
self.mech = mech
self.realm = realm
if mech == 'GSSAPI' and password is not None:
userid = pwd.getpwuid(os.getuid()).pw_name
kinit = '/usr/bin/kinit'
kinit_args = [ kinit, '%s@%s' % (userid, realm) ]
kinit = Popen(kinit_args, stdin=PIPE, stdout=PIPE, stderr=PIPE)
kinit.stdin.write('%s\n' % password)
kinit.wait()
def callback(self, id, challenge, prompt, defresult):
return ''

View File

@ -1,609 +0,0 @@
"""
CSC Member Management
This module contains functions for registering new members, registering
members for terms, searching for members, and other member-related
functions.
Transactions are used in each method that modifies the database.
Future changes to the members database that need to be atomic
must also be moved into this module.
"""
import os, re, subprocess, ldap, socket
from ceo import conf, ldapi, terms, remote, ceo_pb2
from ceo.excep import InvalidArgument
import dns.resolver
### Configuration ###
CONFIG_FILE = '/etc/csc/accounts.cf'
cfg = {}
def configure():
"""Load Members Configuration"""
string_fields = [ 'username_regex', 'shells_file', 'ldap_server_url',
'ldap_users_base', 'ldap_groups_base', 'ldap_sasl_mech', 'ldap_sasl_realm',
'expire_hook' ]
numeric_fields = [ 'min_password_length' ]
# read configuration file
cfg_tmp = conf.read(CONFIG_FILE)
# verify configuration
conf.check_string_fields(CONFIG_FILE, string_fields, cfg_tmp)
conf.check_integer_fields(CONFIG_FILE, numeric_fields, cfg_tmp)
# update the current configuration with the loaded values
cfg.update(cfg_tmp)
### Exceptions ###
class MemberException(Exception):
"""Base exception class for member-related errors."""
def __init__(self, ex=None):
Exception.__init__(self)
self.ex = ex
def __str__(self):
return str(self.ex)
class InvalidTerm(MemberException):
"""Exception class for malformed terms."""
def __init__(self, term):
MemberException.__init__(self)
self.term = term
def __str__(self):
return "Term is invalid: %s" % self.term
class NoSuchMember(MemberException):
"""Exception class for nonexistent members."""
def __init__(self, memberid):
MemberException.__init__(self)
self.memberid = memberid
def __str__(self):
return "Member not found: %d" % self.memberid
### Connection Management ###
# global directory connection
ld = None
def connect(auth_callback):
"""Connect to LDAP."""
global ld
password = None
tries = 0
while ld is None:
try:
ld = ldapi.connect_sasl(cfg['ldap_server_url'], cfg['ldap_sasl_mech'],
cfg['ldap_sasl_realm'], password)
except ldap.LOCAL_ERROR, e:
tries += 1
if tries > 3:
raise e
password = auth_callback.callback(e)
if password == None:
raise e
def connect_anonymous():
"""Connect to LDAP."""
global ld
ld = ldap.initialize(cfg['ldap_server_url'])
def disconnect():
"""Disconnect from LDAP."""
global ld
ld.unbind_s()
ld = None
def connected():
"""Determine whether the connection has been established."""
return ld and ld.connected()
### Members ###
def create_member(username, password, name, program, email, club_rep=False):
"""
Creates a UNIX user account with options tailored to CSC members.
Parameters:
username - the desired UNIX username
password - the desired UNIX password
name - the member's real name
program - the member's program of study
club_rep - whether the user is a club rep
email - email to place in .forward
Exceptions:
InvalidArgument - on bad account attributes provided
Returns: the uid number of the new account
See: create()
"""
# check username format
if not username or not re.match(cfg['username_regex'], username):
raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
# check password length
if not password or len(password) < cfg['min_password_length']:
raise InvalidArgument("password", "<hidden>", "too short (minimum %d characters)" % cfg['min_password_length'])
try:
request = ceo_pb2.AddUser()
request.username = username
request.password = password
request.realname = name
request.program = program
request.email = email
if club_rep:
request.type = ceo_pb2.AddUser.CLUB_REP
else:
request.type = ceo_pb2.AddUser.MEMBER
out = remote.run_remote('adduser', request.SerializeToString())
response = ceo_pb2.AddUserResponse()
response.ParseFromString(out)
if any(message.status != 0 for message in response.messages):
raise MemberException('\n'.join(message.message for message in response.messages))
except remote.RemoteException, e:
raise MemberException(e)
except OSError, e:
raise MemberException(e)
def check_email(email):
match = re.match('^\S+?@(\S+)$', email)
if not match:
return 'Invalid email address'
# some characters are treated specially in .forward
for c in email:
if c in ('"', "'", ',', '|', '$', '/', '#', ':'):
return 'Invalid character in address: %s' % c
# Start by searching for host record
host = match.group(1)
try:
ip = socket.getaddrinfo(host, None)
except:
# Check for MX record
try:
dns.resolver.query(host, 'MX')
except:
return 'Invalid host: %s' % host
def current_email(username):
fwdpath = '%s/%s/.forward' % (cfg['member_home'], username)
try:
fwd = open(fwdpath).read().strip()
if not check_email(fwd):
return fwd
except OSError:
pass
except IOError:
pass
def change_email(username, forward):
try:
request = ceo_pb2.UpdateMail()
request.username = username
request.forward = forward
out = remote.run_remote('mail', request.SerializeToString())
response = ceo_pb2.AddUserResponse()
response.ParseFromString(out)
if any(message.status != 0 for message in response.messages):
return '\n'.join(message.message for message in response.messages)
except remote.RemoteException, e:
raise MemberException(e)
except OSError, e:
raise MemberException(e)
def get(userid):
"""
Look up attributes of a member by userid.
Returns: a dictionary of attributes
Example: get('mspang') -> {
'cn': [ 'Michael Spang' ],
'program': [ 'Computer Science' ],
...
}
"""
return ldapi.lookup(ld, 'uid', userid, cfg['ldap_users_base'])
def get_group(group):
"""
Look up group by groupname
Returns a dictionary of group attributes
"""
return ldapi.lookup(ld, 'cn', group, cfg['ldap_groups_base'])
def uid2dn(uid):
return 'uid=%s,%s' % (ldapi.escape(uid), cfg['ldap_users_base'])
def list_term(term):
"""
Build a list of members in a term.
Parameters:
term - the term to match members against
Returns: a list of members
Example: list_term('f2006'): -> {
'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
'uid=ctdalek, ou=...': { 'cn': 'Calum T. Dalek', ... },
...
}
"""
members = ldapi.search(ld, cfg['ldap_users_base'],
'(&(objectClass=member)(term=%s))', [ term ])
return dict([(member[0], member[1]) for member in members])
def list_name(name):
"""
Build a list of members with matching names.
Parameters:
name - the name to match members against
Returns: a list of member dictionaries
Example: list_name('Spang'): -> {
'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
...
]
"""
members = ldapi.search(ld, cfg['ldap_users_base'],
'(&(objectClass=member)(cn~=%s))', [ name ])
return dict([(member[0], member[1]) for member in members])
def list_group(group):
"""
Build a list of members in a group.
Parameters:
group - the group to match members against
Returns: a list of member dictionaries
Example: list_name('syscom'): -> {
'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
...
]
"""
members = group_members(group)
ret = {}
if members:
for member in members:
info = get(member)
if info:
ret[uid2dn(member)] = info
return ret
def list_all():
"""
Build a list of all members
Returns: a list of member dictionaries
Example: list_name('Spang'): -> {
'uid=mspang, ou=...': { 'cn': 'Michael Spang', ... },
...
]
"""
members = ldapi.search(ld, cfg['ldap_users_base'], '(objectClass=member)')
return dict([(member[0], member[1]) for member in members])
def list_positions():
"""
Build a list of positions
Returns: a list of positions and who holds them
Example: list_positions(): -> {
'president': { 'mspang': { 'cn': 'Michael Spang', ... } } ],
...
]
"""
members = ld.search_s(cfg['ldap_users_base'], ldap.SCOPE_SUBTREE, '(position=*)')
positions = {}
for (_, member) in members:
for position in member['position']:
if not position in positions:
positions[position] = {}
positions[position][member['uid'][0]] = member
return positions
def set_position(position, members):
"""
Sets a position
Parameters:
position - the position to set
members - an array of members that hold the position
Example: set_position('president', ['dtbartle'])
"""
res = ld.search_s(cfg['ldap_users_base'], ldap.SCOPE_SUBTREE,
'(&(objectClass=member)(position=%s))' % ldapi.escape(position))
old = set([ member['uid'][0] for (_, member) in res ])
new = set(members)
mods = {
'del': set(old) - set(new),
'add': set(new) - set(old),
}
if len(mods['del']) == 0 and len(mods['add']) == 0:
return
for action in ['del', 'add']:
for userid in mods[action]:
dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
entry1 = {'position' : [position]}
entry2 = {} #{'position' : []}
entry = ()
if action == 'del':
entry = (entry1, entry2)
elif action == 'add':
entry = (entry2, entry1)
mlist = ldapi.make_modlist(entry[0], entry[1])
ld.modify_s(dn, mlist)
def change_group_member(action, group, userid):
user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
group_dn = 'cn=%s,%s' % (ldapi.escape(group), cfg['ldap_groups_base'])
entry1 = {'uniqueMember' : []}
entry2 = {'uniqueMember' : [user_dn]}
entry = []
if action == 'add' or action == 'insert':
entry = (entry1, entry2)
elif action == 'remove' or action == 'delete':
entry = (entry2, entry1)
else:
raise InvalidArgument("action", action, "invalid action")
mlist = ldapi.make_modlist(entry[0], entry[1])
ld.modify_s(group_dn, mlist)
### Shells ###
def get_shell(userid):
member = ldapi.lookup(ld, 'uid', userid, cfg['ldap_users_base'])
if not member:
raise NoSuchMember(userid)
if 'loginShell' not in member:
return
return member['loginShell'][0]
def get_shells():
return [ sh for sh in open(cfg['shells_file']).read().split("\n")
if sh
and sh[0] == '/'
and not '#' in sh
and os.access(sh, os.X_OK) ]
def set_shell(userid, shell):
if not shell in get_shells():
raise InvalidArgument("shell", shell, "is not in %s" % cfg['shells_file'])
ldapi.modify(ld, 'uid', userid, cfg['ldap_users_base'], [ (ldap.MOD_REPLACE, 'loginShell', [ shell ]) ])
### Clubs ###
def create_club(username, name):
"""
Creates a UNIX user account with options tailored to CSC-hosted clubs.
Parameters:
username - the desired UNIX username
name - the club name
Exceptions:
InvalidArgument - on bad account attributes provided
Returns: the uid number of the new account
See: create()
"""
# check username format
if not username or not re.match(cfg['username_regex'], username):
raise InvalidArgument("username", username, "expected format %s" % repr(cfg['username_regex']))
try:
request = ceo_pb2.AddUser()
request.type = ceo_pb2.AddUser.CLUB
request.username = username
request.realname = name
out = remote.run_remote('adduser', request.SerializeToString())
response = ceo_pb2.AddUserResponse()
response.ParseFromString(out)
if any(message.status != 0 for message in response.messages):
raise MemberException('\n'.join(message.message for message in response.messages))
except remote.RemoteException, e:
raise MemberException(e)
except OSError, e:
raise MemberException(e)
### Terms ###
def register(userid, term_list):
"""
Registers a member for one or more terms.
Parameters:
userid - the member's username
term_list - the term to register for, or a list of terms
Exceptions:
InvalidTerm - if a term is malformed
Example: register(3349, "w2007")
Example: register(3349, ["w2007", "s2007"])
"""
user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
if type(term_list) in (str, unicode):
term_list = [ term_list ]
ldap_member = get(userid)
if ldap_member and 'term' not in ldap_member:
ldap_member['term'] = []
if not ldap_member:
raise NoSuchMember(userid)
new_member = ldap_member.copy()
new_member['term'] = new_member['term'][:]
for term in term_list:
# check term syntax
if not re.match('^[wsf][0-9]{4}$', term):
raise InvalidTerm(term)
# add the term to the entry
if not term in ldap_member['term']:
new_member['term'].append(term)
mlist = ldapi.make_modlist(ldap_member, new_member)
ld.modify_s(user_dn, mlist)
def register_nonmember(userid, term_list):
"""Registers a non-member for one or more terms."""
user_dn = 'uid=%s,%s' % (ldapi.escape(userid), cfg['ldap_users_base'])
if type(term_list) in (str, unicode):
term_list = [ term_list ]
ldap_member = get(userid)
if not ldap_member:
raise NoSuchMember(userid)
if 'term' not in ldap_member:
ldap_member['term'] = []
if 'nonMemberTerm' not in ldap_member:
ldap_member['nonMemberTerm'] = []
new_member = ldap_member.copy()
new_member['nonMemberTerm'] = new_member['nonMemberTerm'][:]
for term in term_list:
# check term syntax
if not re.match('^[wsf][0-9]{4}$', term):
raise InvalidTerm(term)
# add the term to the entry
if not term in ldap_member['nonMemberTerm'] \
and not term in ldap_member['term']:
new_member['nonMemberTerm'].append(term)
mlist = ldapi.make_modlist(ldap_member, new_member)
ld.modify_s(user_dn, mlist)
def registered(userid, term):
"""
Determines whether a member is registered
for a term.
Parameters:
userid - the member's username
term - the term to check
Returns: whether the member is registered
Example: registered("mspang", "f2006") -> True
"""
member = get(userid)
if not member is None:
return 'term' in member and term in member['term']
else:
return False
def group_members(group):
"""
Returns a list of group members
"""
group = ldapi.lookup(ld, 'cn', group, cfg['ldap_groups_base'])
if group and 'uniqueMember' in group:
r = re.compile('^uid=([^,]*)')
return map(lambda x: r.match(x).group(1), group['uniqueMember'])
return []
def expired_accounts():
members = ldapi.search(ld, cfg['ldap_users_base'],
'(&(objectClass=member)(!(|(term=%s)(nonMemberTerm=%s))))' %
(terms.current(), terms.current()))
return dict([(member[0], member[1]) for member in members])
def send_account_expired_email(name, email):
args = [ cfg['expire_hook'], name, email ]
os.spawnv(os.P_WAIT, cfg['expire_hook'], args)
def subscribe_to_mailing_list(name):
member = get(name)
if member is not None:
return remote.run_remote('mailman', name)
else:
return 'Error: member does not exist'

View File

@ -1,54 +0,0 @@
import os, re, subprocess, ldap, socket, pwd
from ceo import conf, ldapi, terms, remote, ceo_pb2
from ceo.excep import InvalidArgument
class MySQLException(Exception):
pass
def write_mysql_info(username, password):
homedir = pwd.getpwnam(username).pw_dir
password_file = '%s/ceo-mysql-info' % homedir
if os.path.exists(password_file):
os.rename(password_file, password_file + '.old')
fd = os.open(password_file, os.O_CREAT|os.O_EXCL|os.O_WRONLY, 0660)
fh = os.fdopen(fd, 'w')
fh.write("""MySQL Database Information for %(username)s
Your new MySQL database was created. To connect, use
the following options:
Database: %(username)s
Username: %(username)s
Password: %(password)s
Hostname: localhost
The command to connect using the MySQL command-line client is
mysql %(username)s -u %(username)s -p
If you prefer a GUI you can use phpmyadmin at
http://csclub.uwaterloo.ca/phpmyadmin
This database is only accessible from caffeine.
""" % { 'username': username, 'password': password })
fh.close()
def create_mysql(username):
try:
request = ceo_pb2.AddMySQLUser()
request.username = username
out = remote.run_remote('mysql', request.SerializeToString())
response = ceo_pb2.AddMySQLUserResponse()
response.ParseFromString(out)
if any(message.status != 0 for message in response.messages):
raise MySQLException('\n'.join(message.message for message in response.messages))
return response.password
except remote.RemoteException, e:
raise MySQLException(e)

30
ceo/operation_strings.py Normal file
View File

@ -0,0 +1,30 @@
# These descriptions are printed to the console while a transaction
# is performed, in real time.
descriptions = {
'add_user_to_ldap': 'Add user to LDAP',
'add_group_to_ldap': 'Add group to LDAP',
'add_user_to_kerberos': 'Add user to Kerberos',
'create_home_dir': 'Create home directory',
'set_forwarding_addresses': 'Set forwarding addresses',
'send_welcome_message': 'Send welcome message',
'subscribe_to_mailing_list': 'Subscribe to mailing list',
'announce_new_user': 'Announce new user to mailing list',
'replace_login_shell': 'Replace login shell',
'replace_forwarding_addresses': 'Replace forwarding addresses',
'remove_user_from_ldap': 'Remove user from LDAP',
'remove_group_from_ldap': 'Remove group from LDAP',
'remove_user_from_kerberos': 'Remove user from Kerberos',
'delete_home_dir': 'Delete home directory',
'unsubscribe_from_mailing_list': 'Unsubscribe from mailing list',
'add_sudo_role': 'Add sudo role to LDAP',
'add_user_to_group': 'Add user to group',
'add_user_to_auxiliary_groups': 'Add user to auxiliary groups',
'subscribe_user_to_auxiliary_mailing_lists': 'Subscribe user to auxiliary mailing lists',
'remove_user_from_group': 'Remove user from group',
'remove_user_from_auxiliary_groups': 'Remove user from auxiliary groups',
'unsubscribe_user_from_auxiliary_mailing_lists': 'Unsubscribe user from auxiliary mailing lists',
'remove_sudo_role': 'Remove sudo role from LDAP',
'update_positions_ldap': 'Update positions in LDAP',
'update_exec_group_ldap': 'Update executive group in LDAP',
'subscribe_to_mailing_lists': 'Subscribe to mailing lists',
}

View File

@ -1,24 +0,0 @@
import os, syslog, grp
def response_message(response, status, message):
if status:
priority = syslog.LOG_ERR
else:
priority = syslog.LOG_INFO
syslog.syslog(priority, message)
msg = response.messages.add()
msg.status = status
msg.message = message
return status
def get_ceo_user():
user = os.environ.get('CEO_USER')
if not user:
raise Exception("environment variable CEO_USER not set");
return user
def check_group(user, group):
try:
return user in grp.getgrnam(group).gr_mem
except KeyError:
return False

View File

@ -1,155 +0,0 @@
#!/usr/bin/python
from xml.dom import minidom, Node
import urllib
import time
import datetime
import hashlib
import base64
import hmac
class PyMazonError(Exception):
"""Holds information about an error that occured during a pymazon request"""
def __init__(self, messages):
self.__message = '\n'.join(messages)
def __get_message(self):
return self.__message
def __str__(self):
return repr(self.__message)
message = property(fget=__get_message)
class PyMazonBook:
"""Stores information about a book retrieved via PyMazon."""
def __init__(self, title, authors, publisher, year, isbn10, isbn13, edition):
self.__title = title
self.__authors = authors
self.__publisher = publisher
self.__year = year
self.__isbn10 = isbn10
self.__isbn13 = isbn13
self.__edition = edition
def __str__(self):
return 'Title: ' + self.title + '\n' + \
'Author(s): ' + ', '.join(self.authors) + '\n' \
'Publisher: ' + self.publisher + '\n' + \
'Year: ' + self.year + '\n' + \
'ISBN-10: ' + self.isbn10 + '\n' + \
'ISBN-13: ' + self.isbn13 + '\n' + \
'Edition: ' + self.edition
def __get_title(self):
return self.__title
def __get_authors(self):
return self.__authors
def __get_publisher(self):
return self.__publisher
def __get_year(self):
return self.__year
def __get_isbn10(self):
return self.__isbn10
def __get_isbn13(self):
return self.__isbn13
def __get_edition(self):
return self.__edition
title = property(fget=__get_title)
authors = property(fget=__get_authors)
publisher = property(fget=__get_publisher)
year = property(fget=__get_year)
isbn10 = property(fget=__get_isbn10)
isbn13 = property(fget=__get_isbn13)
edition = property(fget=__get_edition)
class PyMazon:
"""A method of looking up book information on Amazon."""
def __init__(self, accesskey, secretkey):
self.__key = accesskey
self.__secret = secretkey
self.__last_query_time = 0
def __form_request(self, isbn):
content = {}
dstamp = datetime.datetime.utcfromtimestamp(time.time())
content['Timestamp'] = dstamp.strftime('%Y-%m-%dT%H:%M:%S.000Z')
content['Service'] = 'AWSECommerceService'
content['Version'] = '2008-08-19'
content['Operation'] = 'ItemLookup'
content['ResponseGroup'] = 'ItemAttributes'
content['IdType'] = 'ISBN'
content['SearchIndex'] = 'Books'
content['ItemId'] = isbn
content['AWSAccessKeyId'] = self.__key
URI_String = []
for key, value in sorted(content.items()):
URI_String.append('%s=%s' % (key, urllib.quote(value)))
req = '&'.join(URI_String)
to_sign_req = 'GET\necs.amazonaws.com\n/onca/xml\n' + req
h = hmac.new(self.__secret, to_sign_req, hashlib.sha256)
sig = base64.b64encode(h.digest())
req += '&Signature=%s' % urllib.quote(sig)
return 'http://ecs.amazonaws.com/onca/xml?' + req
def __elements_text(self, element, name):
result = []
matching = element.getElementsByTagName(name)
for match in matching:
if len(match.childNodes) != 1:
continue
child = match.firstChild
if child.nodeType != Node.TEXT_NODE:
continue
result.append(child.nodeValue.strip())
return result
def __format_errors(self, errors):
error_list = []
for error in errors:
error_list.extend(self.__elements_text(error, 'Message'))
return error_list
def __extract_single(self, element, name):
matches = self.__elements_text(element, name)
if len(matches) == 0:
return ''
return matches[0]
def lookup(self, isbn):
file = urllib.urlretrieve(self.__form_request(isbn))[0]
xmldoc = minidom.parse(file)
cur_time = time.time()
while cur_time - self.__last_query_time < 1.0:
sleep(cur_time - self.__last_query_time)
cur_time = time.time()
self.__last_query_time = cur_time
errors = xmldoc.getElementsByTagName('Errors')
if len(errors) != 0:
raise PyMazonError, self.__format_errors(errors)
title = self.__extract_single(xmldoc, 'Title')
authors = self.__elements_text(xmldoc, 'Author')
publisher = self.__extract_single(xmldoc, 'Publisher')
year = self.__extract_single(xmldoc, 'PublicationDate')[0:4]
isbn10 = self.__extract_single(xmldoc, 'ISBN')
isbn13 = self.__extract_single(xmldoc, 'EAN')
edition = self.__extract_single(xmldoc, 'Edition')
return PyMazonBook(title, authors, publisher, year, isbn10, isbn13, edition)

View File

@ -1,18 +0,0 @@
import os
import subprocess
class RemoteException(Exception):
"""Exception class for bad argument values."""
def __init__(self, status, stdout, stderr):
self.status, self.stdout, self.stderr = status, stdout, stderr
def __str__(self):
return 'Error executing ceoc (%d)\n\n%s' % (self.status, self.stderr)
def run_remote(op, data):
ceoc = '%s/ceoc' % os.environ.get('CEO_LIB_DIR', '/usr/lib/ceod')
addmember = subprocess.Popen([ceoc, op], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
out, err = addmember.communicate(data)
status = addmember.wait()
if status:
raise RemoteException(status, out, err)
return out

22
ceo/term_utils.py Normal file
View File

@ -0,0 +1,22 @@
from typing import List
from .utils import http_get
from ceo_common.model.Term import get_terms_for_renewal
import ceo.cli.utils as cli_utils
import ceo.tui.utils as tui_utils
def get_terms_for_renewal_for_user(
username: str, num_terms: int, clubrep: bool, tui_controller=None,
) -> List[str]:
resp = http_get('/api/members/' + username)
# FIXME: this is ugly, we shouldn't need a hacky if statement like this
if tui_controller is None:
result = cli_utils.handle_sync_response(resp)
else:
result = tui_utils.handle_sync_response(resp, tui_controller)
if clubrep:
return get_terms_for_renewal(result.get('non_member_terms'), num_terms)
else:
return get_terms_for_renewal(result.get('terms'), num_terms)

View File

@ -1,254 +0,0 @@
"""
Terms Routines
This module contains functions for manipulating terms, such as determining
the current term, finding the next or previous term, converting dates to
terms, and more.
"""
import time, datetime, re
# year to count terms from
EPOCH = 1970
# seasons list
SEASONS = [ 'w', 's', 'f' ]
def validate(term):
"""
Determines whether a term is well-formed.
Parameters:
term - the term string
Returns: whether the term is valid (boolean)
Example: validate("f2006") -> True
"""
regex = '^[wsf][0-9]{4}$'
return re.match(regex, term) is not None
def parse(term):
"""Helper function to convert a term string to the number of terms
since the epoch. Such numbers are intended for internal use only."""
if not validate(term):
raise Exception("malformed term: %s" % term)
year = int( term[1:] )
season = SEASONS.index( term[0] )
return (year - EPOCH) * len(SEASONS) + season
def generate(term):
"""Helper function to convert a year and season to a term string."""
year = int(term / len(SEASONS)) + EPOCH
season = term % len(SEASONS)
return "%s%04d" % ( SEASONS[season], year )
def next(term):
"""
Returns the next term. (convenience function)
Parameters:
term - the term string
Retuns: the term string of the following term
Example: next("f2006") -> "w2007"
"""
return add(term, 1)
def previous(term):
"""
Returns the previous term. (convenience function)
Parameters:
term - the term string
Returns: the term string of the preceding term
Example: previous("f2006") -> "s2006"
"""
return add(term, -1)
def add(term, offset):
"""
Calculates a term relative to some base term.
Parameters:
term - the base term
offset - the number of terms since term (may be negative)
Returns: the term that comes offset terms after term
"""
return generate(parse(term) + offset)
def delta(initial, final):
"""
Calculates the distance between two terms.
It should be true that add(a, delta(a, b)) == b.
Parameters:
initial - the base term
final - the term at some offset from the base term
Returns: the offset of final relative to initial
"""
return parse(final) - parse(initial)
def compare(first, second):
"""
Compares two terms. This function is suitable
for use with list.sort().
Parameters:
first - base term for comparison
second - term to compare to
Returns: > 0 (if first > second)
= 0 (if first == second)
< 0 (if first < second)
"""
return delta(second, first)
def interval(base, count):
"""
Returns a list of adjacent terms.
Parameters:
base - the first term in the interval
count - the number of terms to include
Returns: a list of count terms starting with initial
Example: interval('f2006', 3) -> [ 'f2006', 'w2007', 's2007' ]
"""
terms = []
for num in xrange(count):
terms.append( add(base, num) )
return terms
def tstamp(timestamp):
"""Helper to convert seconds since the epoch
to terms since the epoch."""
# let python determine the month and year
date = datetime.date.fromtimestamp(timestamp)
# determine season
if date.month <= 4:
season = SEASONS.index('w')
elif date.month <= 8:
season = SEASONS.index('s')
else:
season = SEASONS.index('f')
return (date.year - EPOCH) * len(SEASONS) + season
def from_timestamp(timestamp):
"""
Converts a number of seconds since
the epoch to a number of terms since
the epoch.
This function notes that:
WINTER = JANUARY to APRIL
SPRING = MAY to AUGUST
FALL = SEPTEMBER to DECEMBER
Parameters:
timestamp - number of seconds since the epoch
Returns: the number of terms since the epoch
Example: from_timestamp(1166135779) -> 'f2006'
"""
return generate( tstamp(timestamp) )
def curr():
"""Helper to determine the current term."""
return tstamp( time.time() )
def current():
"""
Determines the current term.
Returns: current term
Example: current() -> 'f2006'
"""
return generate( curr() )
def next_unregistered(registered):
"""
Find the first future or current unregistered term.
Intended as the 'default' for registrations.
Parameters:
registered - a list of terms a member is registered for
Returns: the next unregistered term
"""
# get current term number
now = curr()
# never registered -> current term is next
if len( registered) < 1:
return generate( now )
# return the first unregistered, or the current term (whichever is greater)
return generate(max([max(map(parse, registered))+1, now]))
### Tests ###
if __name__ == '__main__':
from ceo.test import test, assert_equal, success
test(parse); assert_equal(110, parse('f2006')); success()
test(generate); assert_equal('f2006', generate(110)); success()
test(next); assert_equal('w2007', next('f2006')); success()
test(previous); assert_equal('s2006', previous('f2006')); success()
test(delta); assert_equal(1, delta('f2006', 'w2007')); success()
test(compare); assert_equal(-1, compare('f2006', 'w2007')); success()
test(add); assert_equal('w2010', add('f2006', delta('f2006', 'w2010'))); success()
test(interval); assert_equal(['f2006', 'w2007', 's2007'], interval('f2006', 3)); success()
test(from_timestamp); assert_equal('f2006', from_timestamp(1166135779)); success()
test(current); assert_equal(True, parse( current() ) >= 110 ); success()
test(next_unregistered)
assert_equal( next(current()), next_unregistered([ current() ]))
assert_equal( current(), next_unregistered([]))
assert_equal( current(), next_unregistered([ previous(current()) ]))
assert_equal( current(), next_unregistered([ add(current(), -2) ]))
success()

View File

@ -1,42 +0,0 @@
"""
Common Test Routines
This module contains helpful functions called by each module's test suite.
"""
from types import FunctionType, MethodType, ClassType, TypeType
class TestException(Exception):
"""Exception class for test failures."""
def test(subject):
"""Print a test message."""
if type(subject) in (MethodType, FunctionType, ClassType, TypeType):
print "testing %s()..." % subject.__name__,
else:
print "testing %s..." % subject,
def success():
"""Print a success message."""
print "pass."
def assert_equal(expected, actual):
if expected != actual:
message = "Expected (%s)\nWas (%s)" % (repr(expected), repr(actual))
fail(message)
def fail(message):
print "failed!"
raise TestException("Test failed:\n%s" % message)
def negative(call, args, excep, message):
try:
call(*args)
fail(message)
except excep:
pass

0
ceo/tui/__init__.py Normal file
View File

30
ceo/tui/app.py Normal file
View File

@ -0,0 +1,30 @@
import os
from queue import SimpleQueue
class App:
REL_WIDTH_PCT = 60
REL_HEIGHT_PCT = 70
# On a full-screen (1366x768) gnome-terminal window,
# I had 168 cols and 36 rows
WIDTH = int(0.6 * 168)
HEIGHT = int(0.7 * 36)
def __init__(self, loop, main_widget):
self.loop = loop
self.main_widget = main_widget
self.history = []
self.queued_pipe_callbacks = SimpleQueue()
self.pipefd = loop.watch_pipe(self._pipe_callback)
def run_in_main_loop(self, func):
self.queued_pipe_callbacks.put(func)
os.write(self.pipefd, b'\x00')
def _pipe_callback(self, data):
# We need to clear the whole queue because select()
# will only send one "notification" if there are two
# consecutive writes
while not self.queued_pipe_callbacks.empty():
self.queued_pipe_callbacks.get()()
return True

View File

@ -0,0 +1,36 @@
from .Controller import Controller
from .TransactionController import TransactionController
from ceo.tui.models import TransactionModel
from ceo.tui.views import AddGroupConfirmationView, TransactionView
from ceod.transactions.groups import AddGroupTransaction
class AddGroupController(Controller):
def __init__(self, model, app):
super().__init__(model, app)
def on_next_button_pressed(self, button):
try:
self.model.name = self.get_group_name_from_view()
self.model.description = self.view.description_edit.edit_text
if not self.model.description:
self.view.popup('Description must not be empty')
raise Controller.InvalidInput()
except Controller.InvalidInput:
return
view = AddGroupConfirmationView(self.model, self, self.app)
self.switch_to_view(view)
def on_confirmation_button_pressed(self, button):
body = {
'cn': self.model.name,
'description': self.model.description,
}
model = TransactionModel(
AddGroupTransaction.operations,
'POST', '/api/groups', json=body
)
controller = TransactionController(model, self.app)
view = TransactionView(model, controller, self.app)
controller.view = view
self.switch_to_view(view)

View File

@ -0,0 +1,37 @@
from .Controller import Controller
from ceod.transactions.groups import AddMemberToGroupTransaction
from .TransactionController import TransactionController
from ceo.tui.models import TransactionModel
from ceo.tui.views import AddMemberToGroupConfirmationView, TransactionView
class AddMemberToGroupController(Controller):
def __init__(self, model, app):
super().__init__(model, app)
def on_list_subscribe_checkbox_change(self, checkbox, new_state):
self.model.subscribe_to_lists = new_state
def on_next_button_pressed(self, button):
try:
self.model.name = self.get_group_name_from_view()
self.model.username = self.get_username_from_view()
except Controller.InvalidInput:
return
view = AddMemberToGroupConfirmationView(self.model, self, self.app)
self.switch_to_view(view)
def on_confirmation_button_pressed(self, button):
cn = self.model.name
uid = self.model.username
url = f'/api/groups/{cn}/members/{uid}'
if not self.model.subscribe_to_lists:
url += '?subscribe_to_lists=false'
model = TransactionModel(
AddMemberToGroupTransaction.operations,
'POST', url
)
controller = TransactionController(model, self.app)
view = TransactionView(model, controller, self.app)
controller.view = view
self.switch_to_view(view)

View File

@ -0,0 +1,110 @@
from threading import Thread
from ...utils import http_get
from .Controller import Controller
from .AddUserTransactionController import AddUserTransactionController
from ceo.tui.models import TransactionModel
from ceo.tui.views import AddUserConfirmationView, TransactionView
from ceo_common.model.Term import get_terms_for_new_user
from ceod.transactions.members import AddMemberTransaction
class AddUserController(Controller):
def __init__(self, model, app):
super().__init__(model, app)
self.right_col_idx = 0
self.prev_searched_username = None
def on_confirmation_button_pressed(self, button):
body = {
'uid': self.model.username,
'cn': self.model.full_name,
'given_name': self.model.first_name,
'sn': self.model.last_name,
}
if self.model.program:
body['program'] = self.model.program
if self.model.forwarding_address:
body['forwarding_addresses'] = [self.model.forwarding_address]
new_terms = get_terms_for_new_user(self.model.num_terms)
if self.model.membership_type == 'club_rep':
body['non_member_terms'] = new_terms
else:
body['terms'] = new_terms
model = TransactionModel(
AddMemberTransaction.operations,
'POST', '/api/members',
json=body
)
controller = AddUserTransactionController(model, self.app)
view = TransactionView(model, controller, self.app)
controller.view = view
self.switch_to_view(view)
def on_next_button_pressed(self, button):
try:
username = self.get_username_from_view()
num_terms = self.get_num_terms_from_view()
except Controller.InvalidInput:
return
full_name = self.view.full_name_edit.edit_text
# TODO: share validation logic between CLI and TUI
if not full_name:
self.view.popup('Full name must not be empty')
return
self.model.username = username
self.model.full_name = full_name
self.model.first_name = self.view.first_name_edit.edit_text
self.model.last_name = self.view.last_name_edit.edit_text
self.model.program = self.view.program_edit.edit_text
self.model.forwarding_address = self.view.forwarding_address_edit.edit_text
self.model.num_terms = num_terms
view = AddUserConfirmationView(self.model, self, self.app)
self.switch_to_view(view)
def on_membership_type_changed(self, radio_button, new_state, selected_type):
if new_state:
self.model.membership_type = selected_type
def on_row_focus_changed(self):
_, idx = self.view.listwalker.get_focus()
old_idx = self.right_col_idx
self.right_col_idx = idx
# The username field is the third row, so when
# idx changes from 2 to 3, this means the user
# moved from the username field to the next field
if old_idx == 2 and idx == 3:
Thread(
target=self._lookup_user,
args=(self.view.username_edit.edit_text,)
).start()
def _set_flash_text(self, *args):
self.view.flash_text.set_text('Looking up user...')
def _clear_flash_text(self):
self.view.flash_text.set_text('')
def _on_lookup_user_success(self):
self._clear_flash_text()
self.view.update_fields()
def _lookup_user(self, username):
if not username:
return
if username == self.prev_searched_username:
return
self.prev_searched_username = username
self.app.run_in_main_loop(self._set_flash_text)
resp = http_get('/api/uwldap/' + username)
if not resp.ok:
self.app.run_in_main_loop(self._clear_flash_text)
return
data = resp.json()
self.model.full_name = data.get('cn', '')
self.model.first_name = data.get('given_name', '')
self.model.last_name = data.get('sn', '')
self.model.program = data.get('program', '')
self.model.forwarding_address = (data.get('mail_local_addresses') or [''])[0]
self.app.run_in_main_loop(self._on_lookup_user_success)

View File

@ -0,0 +1,38 @@
from typing import Dict, List
from ...utils import get_failed_operations
from .TransactionController import TransactionController
class AddUserTransactionController(TransactionController):
def __init__(self, model, app):
super().__init__(model, app)
def handle_completed(self):
# We don't want to write to the message_text yet, but
# we still need to enable the Next button.
self.app.run_in_main_loop(self.view.enable_next_button)
def write_extra_txn_info(self, data: List[Dict]):
if data[-1]['status'] != 'completed':
return
result = data[-1]['result']
failed_operations = get_failed_operations(data)
lines = []
if failed_operations:
lines.append('Transaction successfully completed with some errors.')
else:
lines.append('Transaction successfully completed.')
lines.append('')
lines.append('User password is: ' + result['password'])
if 'send_welcome_message' in failed_operations:
lines.extend([
'',
'Since the welcome message was not sent, '
'you need to email this password to the user.'
])
def target():
self._show_lines(lines)
self.app.run_in_main_loop(target)

View File

@ -0,0 +1,79 @@
from threading import Thread
from ...utils import http_get
from .Controller import Controller
from .TransactionController import TransactionController
from ceo.tui.models import TransactionModel
from ceo.tui.views import ChangeLoginShellConfirmationView, TransactionView
class ChangeLoginShellController(Controller):
def __init__(self, model, app):
super().__init__(model, app)
self.right_col_idx = 0
self.prev_searched_username = None
def on_next_button_pressed(self, button):
try:
self.model.username = self.get_username_from_view()
self.model.login_shell = self.view.login_shell_edit.edit_text
if not self.model.login_shell:
self.view.popup('Login shell must not be empty')
raise Controller.InvalidInput()
except Controller.InvalidInput:
return
view = ChangeLoginShellConfirmationView(self.model, self, self.app)
self.switch_to_view(view)
def on_confirmation_button_pressed(self, button):
body = {'login_shell': self.model.login_shell}
model = TransactionModel(
['replace_login_shell'],
'PATCH', f'/api/members/{self.model.username}',
json=body
)
controller = TransactionController(model, self.app)
view = TransactionView(model, controller, self.app)
controller.view = view
self.switch_to_view(view)
# TODO: reduce code duplication with AddUserController
def on_row_focus_changed(self):
_, idx = self.view.listwalker.get_focus()
old_idx = self.right_col_idx
self.right_col_idx = idx
# The username field is the first row, so when
# idx changes from 0 to 1, this means the user
# moved from the username field to the next field
if old_idx == 0 and idx == 1:
Thread(
target=self._lookup_user,
args=(self.view.username_edit.edit_text,)
).start()
def _set_flash_text(self, *args):
self.view.flash_text.set_text('Looking up user...')
def _clear_flash_text(self):
self.view.flash_text.set_text('')
def _on_lookup_user_success(self):
self._clear_flash_text()
self.view.update_fields()
def _lookup_user(self, username):
if not username:
return
if username == self.prev_searched_username:
return
self.prev_searched_username = username
self.app.run_in_main_loop(self._set_flash_text)
resp = http_get('/api/members/' + username)
if not resp.ok:
self.app.run_in_main_loop(self._clear_flash_text)
return
data = resp.json()
self.model.login_shell = data.get('login_shell', '')
self.app.run_in_main_loop(self._on_lookup_user_success)

View File

@ -0,0 +1,80 @@
from abc import ABC
import ceo.tui.utils as utils
from ceo_common.utils import validate_username
# NOTE: one controller can control multiple views,
# but each view must have exactly one controller
class Controller(ABC):
class InvalidInput(Exception):
pass
class RequestFailed(Exception):
pass
def __init__(self, model, app):
super().__init__()
self.model = model
self.app = app
# Since the view and the controller both have a reference to each
# other, this needs to be initialized in a separate step
self.view = None
def _push_history(self, old_view, new_view):
if new_view.model.name == 'Welcome':
self.app.history.clear()
else:
self.app.history.append(old_view)
def switch_to_view(self, new_view):
self._push_history(self.view, new_view)
self.view = new_view
new_view.activate()
def go_to_next_menu(self, next_menu_name):
_, new_view, _ = utils.get_mvc(self.app, next_menu_name)
self._push_history(self.view, new_view)
new_view.activate()
def prev_menu_callback(self, button):
prev_view = self.app.history.pop()
prev_view.controller.view = prev_view
prev_view.activate()
def next_menu_callback(self, button, next_menu_name):
self.go_to_next_menu(next_menu_name)
def get_next_menu_callback(self, next_menu_name):
def callback(button):
self.next_menu_callback(button, next_menu_name)
return callback
def get_username_from_view(self):
username = self.view.username_edit.edit_text
# TODO: share validation logic between CLI and TUI
verification_res = validate_username(username)
if not verification_res.is_valid:
self.view.popup(verification_res.error_message)
raise Controller.InvalidInput()
return username
def get_group_name_from_view(self):
name = self.view.name_edit.edit_text
# TODO: share validation logic between CLI and TUI
if not name:
self.view.popup('Name must not be empty')
raise Controller.InvalidInput()
return name
def get_num_terms_from_view(self):
num_terms_str = self.view.num_terms_edit.edit_text
if num_terms_str:
num_terms = int(num_terms_str)
else:
num_terms = 0
# TODO: share validation logic between CLI and TUI
if num_terms <= 0:
self.view.popup('Number of terms must be a positive integer')
raise Controller.InvalidInput()
return num_terms

View File

@ -0,0 +1,50 @@
import os
from zope import component
from ...utils import http_get, http_post, write_db_creds
from .SyncRequestController import SyncRequestController
import ceo.krb_check as krb
from ceo.tui.views import CreateDatabaseConfirmationView, CreateDatabaseResponseView
from ceo_common.interfaces import IConfig
class CreateDatabaseController(SyncRequestController):
def __init__(self, model, app):
super().__init__(model, app)
def on_db_type_changed(self, radio_button, new_state, selected_type):
if new_state:
self.model.db_type = selected_type
def on_next_button_pressed(self, button):
view = CreateDatabaseConfirmationView(self.model, self, self.app)
self.switch_to_view(view)
def get_resp(self):
db_type = self.model.db_type
username = krb.get_username()
resp = http_get(f'/api/members/{username}')
if not resp.ok:
return resp
self.model.user_dict = resp.json()
return http_post(f'/api/db/{db_type}/{username}')
def get_response_view(self):
return CreateDatabaseResponseView(self.model, self, self.app)
def write_db_creds_to_file(self):
password = self.model.resp_json['password']
db_type = self.model.db_type
cfg = component.getUtility(IConfig)
db_host = cfg.get(f'{db_type}_host')
homedir = self.model.user_dict['home_directory']
filename = os.path.join(homedir, f"ceo-{db_type}-info")
wrote_to_file = write_db_creds(
filename, self.model.user_dict, password, db_type, db_host
)
self.model.password = password
self.model.db_host = db_host
self.model.filename = filename
self.model.wrote_to_file = wrote_to_file

View File

@ -0,0 +1,22 @@
from ...utils import http_get
from .Controller import Controller
from .SyncRequestController import SyncRequestController
from ceo.tui.views import GetGroupResponseView
class GetGroupController(SyncRequestController):
def __init__(self, model, app):
super().__init__(model, app)
def get_resp(self):
return http_get(f'/api/groups/{self.model.name}')
def get_response_view(self):
return GetGroupResponseView(self.model, self, self.app)
def on_next_button_pressed(self, button):
try:
self.model.name = self.get_group_name_from_view()
except Controller.InvalidInput:
return
self.on_confirmation_button_pressed(button)

View File

@ -0,0 +1,29 @@
from threading import Thread
from ...utils import http_get
from .Controller import Controller
import ceo.tui.utils as tui_utils
class GetPositionsController(Controller):
def __init__(self, model, app):
super().__init__(model, app)
def lookup_positions_async(self):
self.view.flash_text.set_text('Looking up positions...')
Thread(target=self.lookup_positions_sync).start()
def lookup_positions_sync(self):
resp = http_get('/api/positions')
try:
positions = tui_utils.handle_sync_response(resp, self)
except Controller.RequestFailed:
return
for pos, usernames in positions.items():
self.model.positions[pos] = ','.join(usernames)
def target():
self.view.flash_text.set_text('')
self.view.update_fields()
self.app.run_in_main_loop(target)

View File

@ -0,0 +1,22 @@
from ...utils import http_get
from .Controller import Controller
from .SyncRequestController import SyncRequestController
from ceo.tui.views import GetUserResponseView
class GetUserController(SyncRequestController):
def __init__(self, model, app):
super().__init__(model, app)
def get_resp(self):
return http_get(f'/api/members/{self.model.username}')
def get_response_view(self):
return GetUserResponseView(self.model, self, self.app)
def on_next_button_pressed(self, button):
try:
self.model.username = self.get_username_from_view()
except Controller.InvalidInput:
return
self.on_confirmation_button_pressed(button)

View File

@ -0,0 +1,37 @@
from .Controller import Controller
from ceod.transactions.groups import RemoveMemberFromGroupTransaction
from .TransactionController import TransactionController
from ceo.tui.models import TransactionModel
from ceo.tui.views import RemoveMemberFromGroupConfirmationView, TransactionView
class RemoveMemberFromGroupController(Controller):
def __init__(self, model, app):
super().__init__(model, app)
def on_list_unsubscribe_checkbox_change(self, checkbox, new_state):
self.model.unsubscribe_from_lists = new_state
def on_next_button_pressed(self, button):
try:
self.model.name = self.get_group_name_from_view()
self.model.username = self.get_username_from_view()
except Controller.InvalidInput:
return
view = RemoveMemberFromGroupConfirmationView(self.model, self, self.app)
self.switch_to_view(view)
def on_confirmation_button_pressed(self, button):
cn = self.model.name
uid = self.model.username
url = f'/api/groups/{cn}/members/{uid}'
if not self.model.unsubscribe_from_lists:
url += '?unsubscribe_from_lists=false'
model = TransactionModel(
RemoveMemberFromGroupTransaction.operations,
'DELETE', url
)
controller = TransactionController(model, self.app)
view = TransactionView(model, controller, self.app)
controller.view = view
self.switch_to_view(view)

View File

@ -0,0 +1,54 @@
from threading import Thread
from ...utils import http_post
from .Controller import Controller
from .SyncRequestController import SyncRequestController
import ceo.term_utils as term_utils
from ceo.tui.views import RenewUserConfirmationView
class RenewUserController(SyncRequestController):
def __init__(self, model, app):
super().__init__(model, app)
def on_membership_type_changed(self, radio_button, new_state, selected_type):
if new_state:
self.model.membership_type = selected_type
def on_next_button_pressed(self, button):
try:
username = self.get_username_from_view()
num_terms = self.get_num_terms_from_view()
except Controller.InvalidInput:
return
self.model.username = username
self.model.num_terms = num_terms
self.view.flash_text.set_text('Looking up user...')
Thread(target=self._get_next_terms).start()
def _get_next_terms(self):
try:
self.model.new_terms = term_utils.get_terms_for_renewal_for_user(
self.model.username,
self.model.num_terms,
self.model.membership_type == 'club_rep',
self
)
except Controller.RequestFailed:
return
def target():
self.view.flash_text.set_text('')
view = RenewUserConfirmationView(self.model, self, self.app)
self.switch_to_view(view)
self.app.run_in_main_loop(target)
def get_resp(self):
uid = self.model.username
body = {'uid': uid}
if self.model.membership_type == 'club_rep':
body['non_member_terms'] = self.model.new_terms
else:
body['terms'] = self.model.new_terms
return http_post(f'/api/members/{uid}/renew', json=body)

View File

@ -0,0 +1,49 @@
import os
from zope import component
from ...utils import http_get, http_post, write_db_creds
from .SyncRequestController import SyncRequestController
import ceo.krb_check as krb
from ceo.tui.views import ResetDatabasePasswordConfirmationView, ResetDatabasePasswordResponseView
from ceo_common.interfaces import IConfig
class ResetDatabasePasswordController(SyncRequestController):
def __init__(self, model, app):
super().__init__(model, app)
def on_db_type_changed(self, radio_button, new_state, selected_type):
if new_state:
self.model.db_type = selected_type
def on_next_button_pressed(self, button):
view = ResetDatabasePasswordConfirmationView(self.model, self, self.app)
self.switch_to_view(view)
def get_resp(self):
db_type = self.model.db_type
username = krb.get_username()
resp = http_get(f'/api/members/{username}')
if not resp.ok:
return resp
self.model.user_dict = resp.json()
return http_post(f'/api/db/{db_type}/{username}/pwreset')
def get_response_view(self):
return ResetDatabasePasswordResponseView(self.model, self, self.app)
def write_db_creds_to_file(self):
password = self.model.resp_json['password']
db_type = self.model.db_type
cfg = component.getUtility(IConfig)
db_host = cfg.get(f'{db_type}_host')
homedir = self.model.user_dict['home_directory']
filename = os.path.join(homedir, f"ceo-{db_type}-info")
wrote_to_file = write_db_creds(
filename, self.model.user_dict, password, db_type, db_host
)
self.model.password = password
self.model.filename = filename
self.model.wrote_to_file = wrote_to_file

View File

@ -0,0 +1,27 @@
from ...utils import http_post
from .Controller import Controller
from .SyncRequestController import SyncRequestController
import ceo.krb_check as krb
from ceo.tui.views import ResetPasswordUsePasswdView, ResetPasswordConfirmationView, ResetPasswordResponseView
class ResetPasswordController(SyncRequestController):
def __init__(self, model, app):
super().__init__(model, app)
def get_resp(self):
return http_post(f'/api/members/{self.model.username}/pwreset')
def get_response_view(self):
return ResetPasswordResponseView(self.model, self, self.app)
def on_next_button_pressed(self, button):
try:
self.model.username = self.get_username_from_view()
except Controller.InvalidInput:
return
if self.model.username == krb.get_username():
view = ResetPasswordUsePasswdView(self.model, self, self.app)
else:
view = ResetPasswordConfirmationView(self.model, self, self.app)
self.switch_to_view(view)

View File

@ -0,0 +1,40 @@
from ceo.utils import http_get
from .Controller import Controller
from .SyncRequestController import SyncRequestController
from ceo.tui.views import SearchGroupResponseView, GetGroupResponseView
# this is a little bit bad because it relies on zero coupling between
# the GetGroupResponseView and the GetGroupController
# coupling is also introduced between this controller and the
# SearchGroupResponseView as it requires this class's callback
class SearchGroupController(SyncRequestController):
def __init__(self, model, app):
super().__init__(model, app)
def get_resp(self):
if self.model.want_info:
return http_get(f'/api/groups/{self.model.name}')
else:
return http_get(f'/api/groups/search/{self.model.name}/{self.model.count}')
def get_response_view(self):
if self.model.want_info:
return GetGroupResponseView(self.model, self, self.app)
else:
return SearchGroupResponseView(self.model, self, self.app)
def group_info_callback(self, button, cn):
self.model.name = cn
self.model.want_info = True
self.request_in_progress = False
self.on_next_button_pressed(button)
def on_next_button_pressed(self, button):
try:
if not self.model.want_info:
self.model.name = self.get_username_from_view()
self.model.count = 10
except Controller.InvalidInput:
return
self.on_confirmation_button_pressed(button)

View File

@ -0,0 +1,47 @@
from threading import Thread
from ...utils import http_get
from .Controller import Controller
from .TransactionController import TransactionController
from ceo.tui.models import TransactionModel
import ceo.tui.utils as tui_utils
from ceo.tui.views import TransactionView
from ceod.transactions.members import UpdateMemberPositionsTransaction
class SetPositionsController(Controller):
def __init__(self, model, app):
super().__init__(model, app)
def on_next_button_pressed(self, button):
body = {}
for pos, field in self.view.position_fields.items():
if field.edit_text != '':
body[pos] = field.edit_text.replace(' ', '').split(',')
model = TransactionModel(
UpdateMemberPositionsTransaction.operations,
'POST', '/api/positions', json=body
)
controller = TransactionController(model, self.app)
view = TransactionView(model, controller, self.app)
controller.view = view
self.switch_to_view(view)
def lookup_positions_async(self):
self.view.flash_text.set_text('Looking up positions...')
Thread(target=self.lookup_positions_sync).start()
def lookup_positions_sync(self):
resp = http_get('/api/positions')
try:
positions = tui_utils.handle_sync_response(resp, self)
except Controller.RequestFailed:
return
for pos, usernames in positions.items():
self.model.positions[pos] = ','.join(usernames)
def target():
self.view.flash_text.set_text('')
self.view.update_fields()
self.app.run_in_main_loop(target)

View File

@ -0,0 +1,39 @@
from threading import Thread
from .Controller import Controller
import ceo.tui.utils as tui_utils
from ceo.tui.views import SyncResponseView
class SyncRequestController(Controller):
def __init__(self, model, app):
super().__init__(model, app)
self.request_in_progress = False
def get_resp(self):
# To be implemented by child classes
raise NotImplementedError()
def get_response_view(self):
return SyncResponseView(self.model, self, self.app)
def on_confirmation_button_pressed(self, button):
if self.request_in_progress:
return
self.request_in_progress = True
self.view.flash_text.set_text('Sending request...')
def main_loop_target():
self.view.flash_text.set_text('')
view = self.get_response_view()
self.switch_to_view(view)
def thread_target():
resp = self.get_resp()
try:
self.model.resp_json = tui_utils.handle_sync_response(resp, self)
except Controller.RequestFailed:
return
self.app.run_in_main_loop(main_loop_target)
Thread(target=thread_target).start()

View File

@ -0,0 +1,110 @@
from threading import Thread
from typing import Dict, List
from ...StreamResponseHandler import StreamResponseHandler
from ...utils import http_request, generic_handle_stream_response
from .Controller import Controller
class TransactionController(Controller, StreamResponseHandler):
def __init__(self, model, app):
super().__init__(model, app)
self.op_idx = 0
self.error_messages = []
def start(self):
Thread(target=self._start_txn).start()
def _start_txn(self):
resp = http_request(
self.model.http_verb,
self.model.req_path,
**self.model.req_kwargs
)
data = generic_handle_stream_response(resp, self.model.operations, self)
self.write_extra_txn_info(data)
# to be overridden in child classes if desired
def write_extra_txn_info(self, data: List[Dict]):
pass
def _show_lines(self, lines):
num_lines = len(lines)
# Since the message_text is at the bottom of the window,
# we want to add sufficient padding to the bottom of the text
lines += [''] * max(4 - num_lines, 0)
for i, line in enumerate(lines):
if type(line) is str:
lines[i] = line + '\n'
else: # tuple (attr, text)
lines[i] = (line[0], line[1] + '\n')
self.view.message_text.set_text(lines)
def _abort(self):
for elem in self.view.right_col_elems[self.op_idx:]:
elem.set_text(('red', 'ABORTED'))
self.view.enable_next_button()
def begin(self):
pass
def handle_non_200(self, resp):
def target():
self._abort()
lines = ['An error occurred:']
if resp.headers.get('content-type') == 'application/json':
err_msg = resp.json()['error']
else:
err_msg = resp.text
lines.extend(err_msg.split('\n'))
self._show_lines(lines)
self.app.run_in_main_loop(target)
def handle_aborted(self, err_msg):
def target():
self._abort()
lines = [
'The transaction was rolled back.',
'The error was:',
'',
*err_msg.split('\n'),
]
self._show_lines(lines)
self.app.run_in_main_loop(target)
def handle_completed(self):
def target():
lines = ['Transaction successfully completed.']
if len(self.error_messages) > 0:
lines.append('There were some errors:')
for msg in self.error_messages:
lines.extend(msg.split('\n'))
self._show_lines(lines)
self.view.enable_next_button()
self.app.run_in_main_loop(target)
def handle_successful_operation(self):
def target():
self.view.right_col_elems[self.op_idx].set_text(('green', 'Done'))
self.op_idx += 1
self.app.run_in_main_loop(target)
def handle_failed_operation(self, err_msg):
def target():
self.view.right_col_elems[self.op_idx].set_text(('red', 'Failed'))
self.op_idx += 1
if err_msg is not None:
self.error_messages.append(err_msg)
self.app.run_in_main_loop(target)
def handle_skipped_operation(self):
def target():
self.view.right_col_elems[self.op_idx].set_text('Skipped')
self.op_idx += 1
self.app.run_in_main_loop(target)
def handle_unrecognized_operation(self, operation):
def target():
self.error_messages.append('Unrecognized operation: ' + operation)
self.op_idx += 1
self.app.run_in_main_loop(target)

View File

@ -0,0 +1,6 @@
from .Controller import Controller
class WelcomeController(Controller):
def __init__(self, model, app):
super().__init__(model, app)

View File

@ -0,0 +1,19 @@
from .Controller import Controller
from .WelcomeController import WelcomeController
from .AddUserController import AddUserController
from .AddUserTransactionController import AddUserTransactionController
from .RenewUserController import RenewUserController
from .GetUserController import GetUserController
from .ResetPasswordController import ResetPasswordController
from .ChangeLoginShellController import ChangeLoginShellController
from .AddGroupController import AddGroupController
from .GetGroupController import GetGroupController
from .SearchGroupController import SearchGroupController
from .AddMemberToGroupController import AddMemberToGroupController
from .RemoveMemberFromGroupController import RemoveMemberFromGroupController
from .CreateDatabaseController import CreateDatabaseController
from .ResetDatabasePasswordController import ResetDatabasePasswordController
from .GetPositionsController import GetPositionsController
from .SetPositionsController import SetPositionsController
from .TransactionController import TransactionController
from .SyncRequestController import SyncRequestController

View File

@ -0,0 +1,7 @@
class AddGroupModel:
name = 'AddGroup'
title = 'Add group'
def __init__(self):
self.name = ''
self.description = ''

View File

@ -0,0 +1,8 @@
class AddMemberToGroupModel:
name = 'AddMemberToGroup'
title = 'Add member to group'
def __init__(self):
self.name = ''
self.username = ''
self.subscribe_to_lists = True

View File

@ -0,0 +1,13 @@
class AddUserModel:
name = 'AddUser'
title = 'Add user'
def __init__(self):
self.membership_type = 'general_member'
self.username = ''
self.full_name = ''
self.first_name = ''
self.last_name = ''
self.program = ''
self.forwarding_address = ''
self.num_terms = 1

View File

@ -0,0 +1,8 @@
class ChangeLoginShellModel:
name = 'ChangeLoginShell'
title = 'Change login shell'
def __init__(self):
self.username = ''
self.login_shell = ''
self.resp_json = None

View File

@ -0,0 +1,12 @@
class CreateDatabaseModel:
name = 'CreateDatabase'
title = 'Create database'
def __init__(self):
self.db_type = 'mysql'
self.user_dict = None
self.resp_json = None
self.password = None
self.db_host = None
self.filename = None
self.wrote_to_file = False

View File

@ -0,0 +1,7 @@
class GetGroupModel:
name = 'GetGroup'
title = 'Get group members'
def __init__(self):
self.name = ''
self.resp_json = None

View File

@ -0,0 +1,4 @@
class GetPositionsModel:
name = 'GetPositions'
title = 'Get positions'
positions = {}

View File

@ -0,0 +1,7 @@
class GetUserModel:
name = 'GetUser'
title = 'Get user info'
def __init__(self):
self.username = ''
self.resp_json = None

Some files were not shown because too many files have changed in this diff Show More