From a2916c0635fec5b45ad742904db9f5769b48f53d Mon Sep 17 00:00:00 2001 Message-Id: From: Mark Wooding Date: Thu, 7 Mar 2013 18:47:57 +0000 Subject: [PATCH] Initial commit. Organization: Straylight/Edgeware From: Mark Wooding --- .gitignore | 10 + .skelrc | 9 + AGPLv3 | 661 +++++++++++++++++++++++ Makefile | 118 +++++ about.fhtml | 57 ++ agpl.py | 112 ++++ backend.py | 309 +++++++++++ cgi.py | 603 +++++++++++++++++++++ chpwd | 272 ++++++++++ chpwd.css | 100 ++++ chpwd.js | 148 ++++++ cmd-admin.py | 172 ++++++ cmd-cgi.py | 188 +++++++ cmd-remote.py | 47 ++ cmd-user.py | 116 +++++ cmdutil.py | 300 +++++++++++ config.py | 91 ++++ cookies.fhtml | 103 ++++ crypto.py | 82 +++ dbmaint.py | 92 ++++ error.fhtml | 31 ++ exception.fhtml | 62 +++ format.py | 1332 +++++++++++++++++++++++++++++++++++++++++++++++ get-version | 10 + hash.py | 337 ++++++++++++ httpauth.py | 267 ++++++++++ list.fhtml | 127 +++++ login.fhtml | 47 ++ operate.fhtml | 48 ++ operation.py | 320 ++++++++++++ output.py | 209 ++++++++ service.py | 382 ++++++++++++++ subcommand.py | 403 ++++++++++++++ util.py | 582 +++++++++++++++++++++ wrapper.fhtml | 54 ++ 35 files changed, 7801 insertions(+) create mode 100644 .gitignore create mode 100644 .skelrc create mode 100644 AGPLv3 create mode 100644 Makefile create mode 100644 about.fhtml create mode 100644 agpl.py create mode 100644 backend.py create mode 100644 cgi.py create mode 100755 chpwd create mode 100644 chpwd.css create mode 100644 chpwd.js create mode 100644 cmd-admin.py create mode 100644 cmd-cgi.py create mode 100644 cmd-remote.py create mode 100644 cmd-user.py create mode 100644 cmdutil.py create mode 100644 config.py create mode 100644 cookies.fhtml create mode 100644 crypto.py create mode 100644 dbmaint.py create mode 100644 error.fhtml create mode 100644 exception.fhtml create mode 100644 format.py create mode 100755 get-version create mode 100644 hash.py create mode 100644 httpauth.py create mode 100644 list.fhtml create mode 100644 login.fhtml create mode 100644 operate.fhtml create mode 100644 operation.py create mode 100644 output.py create mode 100644 service.py create mode 100644 subcommand.py create mode 100644 util.py create mode 100644 wrapper.fhtml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d55bb2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +auto.py +auto-*.py +RELEASE + +chpwd.db +chpwd.conf + +static/ +*.pyc +*.new diff --git a/.skelrc b/.skelrc new file mode 100644 index 0000000..fbcaea9 --- /dev/null +++ b/.skelrc @@ -0,0 +1,9 @@ +;;; -*-emacs-lisp-*- + +(setq skel-alist + (append + '((author . "Mark Wooding") + (licence-text . "[[agpl]]") + (full-title . "Chopwood: a password-changing service") + (program . "Chopwood")) + skel-alist)) diff --git a/AGPLv3 b/AGPLv3 new file mode 100644 index 0000000..dba13ed --- /dev/null +++ b/AGPLv3 @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9cedd7f --- /dev/null +++ b/Makefile @@ -0,0 +1,118 @@ +### -*-makefile-*- +### +### Build and setup script +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +## Basic naming stuff. +PACKAGE = chopwood +VERSION = $(shell ./get-version) + +###-------------------------------------------------------------------------- +### The big list of source files. + +## The main source files. +SOURCES += chpwd +SOURCES += backend.py +SOURCES += cgi.py +SOURCES += cmdutil.py +SOURCES += config.py +SOURCES += crypto.py +SOURCES += dbmaint.py +SOURCES += format.py +SOURCES += httpauth.py +SOURCES += operation.py +SOURCES += output.py +SOURCES += service.py +SOURCES += subcommand.py +SOURCES += util.py + +## The command implementations. +SOURCES += cmd-admin.py +SOURCES += cmd-cgi.py +SOURCES += cmd-remote.py +SOURCES += cmd-user.py + +## Template HTML files. +SOURCES += about.fhtml +SOURCES += cookies.fhtml +SOURCES += error.fhtml +SOURCES += exception.fhtml +SOURCES += list.fhtml +SOURCES += login.fhtml +SOURCES += operate.fhtml +SOURCES += wrapper.fhtml + +## Other static files. +SOURCES += chpwd.css +SOURCES += chpwd.js + +###-------------------------------------------------------------------------- +### Default rules. + +all:: +.PHONY: all + +CLEANFILES = *.pyc + +###-------------------------------------------------------------------------- +### The automatically-generated installation module. + +TARGETS += auto.py auto-$(VERSION).py + +auto-$(VERSION).py: Makefile get-version $(SOURCES) + { echo "### -*-python-*-"; \ + echo "PACKAGE = '$(PACKAGE)'"; \ + echo "VERSION = '$(VERSION)'"; \ + echo "HOME = '$$(pwd)'"; \ + } >$@.new + mv $@.new $@ + rm -f auto.py.new && ln -s $@ auto.py.new && mv auto.py.new auto.py + +auto.py: auto-$(VERSION).py + for i in auto-*.py; do \ + case $$i in auto-$(VERSION).py) ;; *) rm -f $$i ;; esac; \ + done + +###-------------------------------------------------------------------------- +### Generate the static files. + +TARGETS += static/stamp + +static/stamp: $(SOURCES) auto.py + rm -rf static.new + ./chpwd static static.new + touch static.new/stamp + rm -rf static && mv static.new static + +clean::; rm -rf static + +###-------------------------------------------------------------------------- +### The standard rules. + +all:: $(TARGETS) + +CLEANFILES += $(TARGETS) +clean::; rm -f $(CLEANFILES) +.PHONY: clean + +###----- That's all, folks -------------------------------------------------- diff --git a/about.fhtml b/about.fhtml new file mode 100644 index 0000000..9579700 --- /dev/null +++ b/about.fhtml @@ -0,0 +1,57 @@ +~1[ + +~]~ + +

About this program

+ +

This is Chopwood version ~={version}H. It's a tool which lets users +edit their passwords for services such as email, and web proxies, using +a variety of interfaces. This is the CGI interface, but there are many +others. + +

Source code

+ +

Chopwood is free software. You +can download the +code running on this server. + +

Licence

+ +

Chopwood is free software; you can redistribute it and/or modify it under +the terms of the +GNU Affero General +Public License as published by the Free Software Foundation; either +version 3 of the License, or (at your option) any later version. + +

Chopwood is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero +General Public License for more details. + +

You should have received a copy of the GNU Affero General Public +License along with Chopwood; if not, see +<http://www.gnu.org/licenses/>. + +~1[~]~ diff --git a/agpl.py b/agpl.py new file mode 100644 index 0000000..b89330c --- /dev/null +++ b/agpl.py @@ -0,0 +1,112 @@ +### -*-python-*- +### +### GNU Affero General Public License compliance +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +import contextlib as CTX +import os as OS +import shlex as SL +import shutil as SH +import subprocess as SUB +import sys as SYS +import tarfile as TAR +import tempfile as TF + +from auto import PACKAGE, VERSION +import util as U + +@CTX.contextmanager +def tempdir(): + d = TF.mkdtemp() + try: yield d + finally: SH.rmtree(d, ignore_errors = True) + +def dirs_to_dump(): + dirs = set() + for m in SYS.modules.itervalues(): + try: f = m.__file__ + except AttributeError: continue + d = OS.path.realpath(OS.path.dirname(f)) + if d.startswith('/usr/') and not d.startswith('/usr/local/'): continue + dirs.add(d) + dirs = sorted(dirs) + last = '!' + dump = [] + for d in dirs: + if d.startswith(last): continue + dump.append(d) + last = d + return dump + +def exists_subdir(subdir): + return lambda dir: OS.path.isdir(OS.path.join(dir, subdir)) + +def filez(cmd): + def _(dir): + kid = SUB.Popen(SL.split(cmd), stdout = SUB.PIPE, cwd = dir) + left = '' + while True: + buf = kid.stdout.read(16384) + if not buf: break + buf = left + buf + i = 0 + while True: + z = buf.find('\0', i) + if z < 0: break + f = buf[i:z] + if f.startswith('./'): f = f[2:] + yield f + i = z + 1 + left = buf[i:] + if left: + raise U.ExpectedError, \ + (500, "trailing junk from `%s' in `%s'" % (cmd, dir)) + return _ + +DUMPERS = [ + (exists_subdir('.git'), [filez('git ls-files -coz --exclude-standard'), + filez('find .git -print0')]), + (lambda d: True, [filez('find . ( ! -perm +004 -prune ) -o -print0')])] + +def dump_dir(dir, tf, root): + for test, listers in DUMPERS: + if test(dir): break + else: + raise U.ExpectedError, (500, "no dumper for `%s'" % dir) + for lister in listers: + base = OS.path.basename(dir) + for file in lister(dir): + tf.add(OS.path.join(dir, file), OS.path.join(root, base, file), + recursive = False) + +def source(out): + if SYS.version_info >= (2, 6): + tf = TAR.open(fileobj = out, mode = 'w|gz', format = TAR.USTAR_FORMAT) + else: + tf = TAR.open(fileobj = out, mode = 'w|gz') + tf.posix = True + for d in dirs_to_dump(): + dump_dir(d, tf, '%s-%s' % (PACKAGE, VERSION)) + tf.close() + +###----- That's all, folks -------------------------------------------------- diff --git a/backend.py b/backend.py new file mode 100644 index 0000000..1725d7d --- /dev/null +++ b/backend.py @@ -0,0 +1,309 @@ +### -*-python-*- +### +### Password backends +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +from __future__ import with_statement + +import os as OS; ENV = OS.environ + +import config as CONF; CFG = CONF.CFG +import util as U + +###-------------------------------------------------------------------------- +### Relevant configuration. + +CONF.DEFAULTS.update( + + ## A directory in which we can create lockfiles. + LOCKDIR = OS.path.join(ENV['HOME'], 'var', 'lock', 'chpwd')) + +###-------------------------------------------------------------------------- +### Protocol. +### +### A password backend knows how to fetch and modify records in some password +### database, e.g., a flat passwd(5)-style password file, or a table in some +### proper grown-up SQL database. +### +### A backend's `lookup' method retrieves the record for a named user from +### the database, returning it in a record object, or raises `UnknownUser'. +### The record object maintains `user' (the user name, as supplied to +### `lookup') and `passwd' (the encrypted password, in whatever form the +### underlying database uses) attributes, and possibly others. The `passwd' +### attribute (at least) may be modified by the caller. The record object +### has a `write' method, which updates the corresponding record in the +### database. +### +### The concrete record objects defined here inherit from `BasicRecord', +### which keeps track of its parent backend, and implements `write' by +### calling the backend's `_update' method. Some backends require that their +### record objects implement additional private protocols. + +class UnknownUser (U.ExpectedError): + """The named user wasn't found in the database.""" + def __init__(me, user): + U.ExpectedError.__init__(me, 500, "Unknown user `%s'" % user) + me.user = user + +class BasicRecord (object): + """ + A handy base class for record classes. + + Keep track of the backend in `_be', and call its `_update' method to write + ourselves back. + """ + def __init__(me, backend): + me._be = backend + def write(me): + me._be._update(me) + +class TrivialRecord (BasicRecord): + """ + A trivial record which simply remembers `user' and `passwd' attributes. + + Additional attributes can be set on the object if this is convenient. + """ + def __init__(me, user, passwd, *args, **kw): + super(TrivialRecord, me).__init__(*args, **kw) + me.user = user + me.passwd = passwd + +###-------------------------------------------------------------------------- +### Flat files. + +class FlatFileRecord (BasicRecord): + """ + A record from a flat-file database (like a passwd(5) file). + + Such a file carries one record per line; each record is split into fields + by a delimiter character, specified by the DELIM constructor argument. + + The FMAP argument to the constructor maps names to field index numbers. + The standard `user' and `passwd' fields must be included in this map if the + object is to implement the protocol correctly (though the `FlatFileBackend' + is careful to do this). + """ + + def __init__(me, line, delim, fmap, *args, **kw): + """ + Initialize the record, splitting the LINE into fields separated by DELIM, + and setting attributes under control of FMAP. + """ + super(FlatFileRecord, me).__init__(*args, **kw) + line = line.rstrip('\n') + fields = line.split(delim) + me._delim = delim + me._fmap = fmap + me._raw = fields + for k, v in fmap.iteritems(): + setattr(me, k, fields[v]) + + def _format(me): + """ + Format the record as a line of text. + + The flat-file format is simple, but rather fragile with respect to + invalid characters, and often processed by substandard software, so be + careful not to allow bad characters into the file. + """ + fields = me._raw + for k, v in me._fmap.iteritems(): + val = getattr(me, k) + for badch, what in [(me._delim, "delimiter `%s'" % me._delim), + ('\n', 'newline character'), + ('\0', 'null character')]: + if badch in val: + raise U.ExpectedError, \ + (500, "New `%s' field contains %s" % (k, what)) + fields[v] = val + return me._delim.join(fields) + +class FlatFileBackend (object): + """ + Password storage in a flat passwd(5)-style file. + + The FILE constructor argument names the file. Such a file carries one + record per line; each record is split into fields by a delimiter character, + specified by the DELIM constructor argument. + + The file is updated by writing a new version alongside, as `FILE.new', and + renaming it over the old version. If a LOCK file is named then an + exclusive fcntl(2)-style lock is taken out on `LOCKDIR/LOCK' (creating the + file if necessary) during the update operation. Use of a lockfile is + strongly recommended. + + The DELIM constructor argument specifies the delimiter character used when + splitting lines into fields. The USER and PASSWD arguments give the field + numbers (starting from 0) for the user-name and hashed-password fields; + additional field names may be given using keyword arguments: the values of + these fields are exposed as attributes `f_NAME' on record objects. + """ + + def __init__(me, file, lock = None, + delim = ':', user = 0, passwd = 1, **fields): + """ + Construct a new flat-file backend object. See the class documentation + for details. + """ + me._lock = lock + me._file = file + me._delim = delim + fmap = dict(user = user, passwd = passwd) + for k, v in fields.iteritems(): fmap['f_' + k] = v + me._fmap = fmap + + def lookup(me, user): + """Return the record for the named USER.""" + with open(me._file) as f: + for line in f: + rec = me._parse(line) + if rec.user == user: + return rec + raise UnknownUser, user + + def _update(me, rec): + """Update the record REC in the file.""" + + ## The main update function. + def doit(): + + ## Make sure we preserve the file permissions, and in particular don't + ## allow a window during which the new file has looser permissions than + ## the old one. + st = OS.stat(me._file) + tmp = me._file + '.new' + fd = OS.open(tmp, OS.O_WRONLY | OS.O_CREAT | OS.O_EXCL, st.st_mode) + + ## This is the fiddly bit. + lose = True + try: + + ## Copy the old file to the new one, changing the user's record if + ## and when we encounter it. + with OS.fdopen(fd, 'w') as f_out: + with open(me._file) as f_in: + for line in f_in: + r = me._parse(line) + if r.user != rec.user: + f_out.write(line) + else: + f_out.write(rec._format()) + f_out.write('\n') + + ## Update the permissions on the new file. Don't try to fix the + ## ownership (we shouldn't be running as root) or the group (the + ## parent directory should have the right permissions already). + OS.chmod(tmp, st.st_mode) + OS.rename(tmp, me._file) + lose = False + except OSError, e: + ## I suppose that system errors are to be expected at this point. + raise U.ExpectedError, \ + (500, "Failed to update `%s': %s" % (me._file, e)) + finally: + ## Don't try to delete the new file if we succeeded: it might belong + ## to another instance of us. + if lose: + try: OS.unlink(tmp) + except: pass + + ## If there's a locekfile, then acquire it around the meat of this + ## function; otherwise just do the job. + if me._lock is None: + doit() + else: + with U.lockfile(OS.path.join(CFG.LOCKDIR, me._lock), 5): + doit() + + def _parse(me, line): + """Convenience function for constructing a record.""" + return FlatFileRecord(line, me._delim, me._fmap, backend = me) + +CONF.export('FlatFileBackend') + +###-------------------------------------------------------------------------- +### SQL databases. + +class DatabaseBackend (object): + """ + Password storage in a SQL database table. + + We assume that there's a single table mapping user names to (hashed) + passwords: we won't try anything complicated involving joins. + + We need to know a database module MODNAME and arguments MODARGS to pass to + the `connect' function. We also need to know the TABLE to search, and the + USER and PASSWD field names. Additional field names can be passed to the + constructor: these will be read from the database and attached as + attributes `f_NAME' to the record returned by `lookup'. Changes to these + attributes are currently not propagated back to the database. + """ + + def __init__(me, modname, modargs, table, user, passwd, *fields): + """ + Create a database backend object. See the class docstring for details. + """ + me._table = table + me._user = user + me._passwd = passwd + me._fields = list(fields) + + ## We don't connect immediately. That would be really bad if we had lots + ## of database backends running at a time, because we probably only want + ## to use one. + me._db = None + me._modname = modname + me._modargs = modargs + + def _connect(me): + """Set up the lazy connection to the database.""" + if me._db is None: + me._db = U.SimpleDBConnection(me._modname, me._modargs) + + def lookup(me, user): + """Return the record for the named USER.""" + me._connect() + me._db.execute("SELECT %s FROM %s WHERE %s = $user" % + (', '.join([me._passwd] + me._fields), + me._table, me._user), + user = user) + row = me._db.fetchone() + if row is None: raise UnknownUser, user + passwd = row[0] + rec = TrivialRecord(backend = me, user = user, passwd = passwd) + for f, v in zip(me._fields, row[1:]): + setattr(rec, 'f_' + f, v) + return rec + + def _update(me, rec): + """Update the record REC in the database.""" + me._connect() + with me._db: + me._db.execute( + "UPDATE %s SET %s = $passwd WHERE %s = $user" % ( + me._table, me._passwd, me._user), + user = rec.user, passwd = rec.passwd) + +CONF.export('DatabaseBackend') + +###----- That's all, folks -------------------------------------------------- diff --git a/cgi.py b/cgi.py new file mode 100644 index 0000000..f69646c --- /dev/null +++ b/cgi.py @@ -0,0 +1,603 @@ +### -*-python-*- +### +### CGI machinery +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +from __future__ import with_statement + +import contextlib as CTX +import os as OS; ENV = OS.environ +import re as RX +import sys as SYS +import time as T +import traceback as TB + +from auto import HOME, PACKAGE, VERSION +import config as CONF; CFG = CONF.CFG +import format as F +import output as O; OUT = O.OUT; PRINT = O.PRINT +import subcommand as SC +import util as U + +###-------------------------------------------------------------------------- +### Configuration tweaks. + +_script_name = ENV.get('SCRIPT_NAME', '/cgi-bin/chpwd') + +CONF.DEFAULTS.update( + + ## The URL of this program, when it's run through CGI. + SCRIPT_NAME = _script_name, + + ## A (maybe relative) URL for static content. By default this comes from + ## the main script, but we hope that user agents cache it. + STATIC = _script_name + '/static') + +###-------------------------------------------------------------------------- +### Escaping and encoding. + +## Some handy regular expressions. +R_URLESC = RX.compile('%([0-9a-fA-F]{2})') +R_URLBAD = RX.compile('[^-\\w,.!]') +R_HTMLBAD = RX.compile('[&<>]') + +def urldecode(s): + """Decode a single form-url-encoded string S.""" + return R_URLESC.sub(lambda m: chr(int(m.group(1), 16)), + s.replace('+', ' ')) + return s + +def urlencode(s): + """Encode a single string S using form-url-encoding.""" + return R_URLBAD.sub(lambda m: '%%%02x' % ord(m.group(0)), s) + +def htmlescape(s): + """Escape a literal string S so that HTML doesn't misinterpret it.""" + return R_HTMLBAD.sub(lambda m: '&#x%02x;' % ord(m.group(0)), s) + +## Some standard character sequences, and HTML entity names for prettier +## versions. +_quotify = U.StringSubst({ + "`": '‘', + "'": '’', + "``": '“', + "''": '”', + "--": '–', + "---": '—' +}) +def html_quotify(s): + """Return a pretty HTML version of S.""" + return _quotify(htmlescape(s)) + +###-------------------------------------------------------------------------- +### Output machinery. + +class HTTPOutput (O.FileOutput): + """ + Output driver providing an automatic HTTP header. + + The `headerp' attribute is true if we've written a header. The `header' + method will print a custom header if this is wanted. + """ + + def __init__(me, *args, **kw): + """Constructor: initialize `headerp' flag.""" + super(HTTPOutput, me).__init__(*args, **kw) + me.headerp = False + + def write(me, msg): + """Output protocol: print a header if we've not written one already.""" + if not me.headerp: me.header('text/plain') + super(HTTPOutput, me).write(msg) + + def header(me, content_type = 'text/plain', **kw): + """ + Print a header, if none has yet been printed. + + Keyword arguments can be passed to emit HTTP headers: see `http_header' + for the formatting rules. + """ + if me.headerp: return + me.headerp = True + for h in O.http_headers(content_type = content_type, **kw): + me.writeln(h) + me.writeln('') + +def cookie(name, value, **kw): + """ + Return a HTTP `Set-Cookie' header. + + The NAME and VALUE give the name and value of the cookie; both are + form-url-encoded to prevent misinterpretation (fortunately, `cgiparse' + knows to undo this transformation). The KW are other attributes to + declare: the names are forced to lower-case and underscores `_' are + replaced by hyphens `-'; a `True' value is assumed to indicate that the + attribute is boolean, and omitted. + """ + attr = {} + for k, v in kw.iteritems(): + k = '-'.join(i.lower() for i in k.split('_')) + attr[k] = v + try: maxage = int(attr['max-age']) + except KeyError: pass + else: + attr['expires'] = T.strftime('%a, %d %b %Y %H:%M:%S GMT', + T.gmtime(U.NOW + maxage)) + return '; '.join(['%s=%s' % (urlencode(name), urlencode(value))] + + [v is not True and '%s=%s' % (k, v) or k + for k, v in attr.iteritems()]) + +def action(*v, **kw): + """ + Build a URL invoking this script. + + The positional arguments V are used to construct a path which is appended + to the (deduced or configured) script name (and presumably will be read + back as `PATH_INFO'). The keyword arguments are (form-url-encoded and) + appended as a query string, if present. + """ + url = '/'.join([CFG.SCRIPT_NAME] + list(v)) + if kw: + url += '?' + ';'.join('%s=%s' % (urlencode(k), urlencode(kw[k])) + for k in sorted(kw)) + return htmlescape(url) + +def static(name): + """Build a URL for the static file NAME.""" + return htmlescape(CFG.STATIC + '/' + name) + +@CTX.contextmanager +def html(title, **kw): + """ + Context manager for HTML output. + + Keyword arguments are output as HTTP headers (if no header has been written + yet). A `' element is written, and a `' opened, before the + context body is executed; the elements are closed off properly at the end. + """ + + kw = dict(kw, content_type = 'text/html') + OUT.header(**kw) + + ## Write the HTML header. + PRINT("""\ + + + + %(title)s + + + +""" % dict(title = html_quotify(title), + style = static('chpwd.css'), + script = static('chpwd.js'))) + + ## Write the body. + PRINT('') + yield None + PRINT('''\ + +

+ Chopwood, version %(version)s: + copyright © 2012 Mark Wooding +
+ + +''' % dict(about = static('about.html'), + version = VERSION)) + +def redirect(where, **kw): + """ + Write a complete redirection to some other URL. + """ + OUT.header(content_type = 'text/html', + status = 302, location = where, + **kw) + PRINT("""\ + +No, sorry, it's moved again. +

I'm over here now. +""" % htmlescape(where)) + +###-------------------------------------------------------------------------- +### Templates. + +## Where we find our templates. +TMPLDIR = HOME + +## Keyword arguments for templates. +STATE = U.Fluid() +STATE.kw = {} + +## Set some basic keyword arguments. +@CONF.hook +def set_template_keywords(): + STATE.kw.update( + package = PACKAGE, + version = VERSION, + script = CFG.SCRIPT_NAME, + static = CFG.STATIC) + +class TemplateFinder (object): + """ + A magical fake dictionary whose keys are templates. + """ + def __init__(me, dir): + me._cache = {} + me._dir = dir + def __getitem__(me, key): + try: return me._cache[key] + except KeyError: pass + with open(OS.path.join(me._dir, key)) as f: tmpl = f.read() + me._cache[key] = tmpl + return tmpl +TMPL = TemplateFinder(TMPLDIR) + +@CTX.contextmanager +def tmplkw(**kw): + """ + Context manager: execute the body with additional keyword arguments + """ + d = dict() + d.update(STATE.kw) + d.update(kw) + with STATE.bind(kw = d): yield + +FORMATOPS = {} + +class FormatHTML (F.SimpleFormatOperation): + """ + ~H: escape output suitable for inclusion in HTML. + + With `:', instead apply form-urlencoding. + """ + def _convert(me, arg): + if me.colonp: return html_quotify(arg) + else: return htmlescape(arg) +FORMATOPS['H'] = FormatHTML + +def format_tmpl(control, **kw): + with F.COMPILE.bind(opmaps = [FORMATOPS, F.BASEOPS]): + with tmplkw(**kw): + F.format(OUT, control, **STATE.kw) + +def page(template, header = {}, title = 'Chopwood', **kw): + header = dict(header, content_type = 'text/html') + OUT.header(**header) + format_tmpl(TMPL['wrapper.fhtml'], + title = title, payload = TMPL[template], **kw) + +###-------------------------------------------------------------------------- +### Error reporting. + +def cgi_error_guts(): + """ + Report an exception while we're acting as a CGI, together with lots of + information about our state. + + Our caller has, probably at great expense, arranged that we can format lots + of text. + """ + + ## Grab the exception information. + exty, exval, extb = SYS.exc_info() + + ## Print the exception itself. + PRINT("""\ +

Exception

+
%s
""" % html_quotify( + '\n'.join(TB.format_exception_only(exty, exval)))) + + ## Format a traceback so we can find out what has gone wrong. + PRINT("""\ +

Traceback

+
    """) + for file, line, func, text in TB.extract_tb(extb, 20): + PRINT("
  1. %s:%d (%s)" % ( + htmlescape(file), line, htmlescape(func))) + if text is not None: + PRINT("
    %s" % htmlescape(text)) + PRINT("
") + + ## Format various useful tables. + def fmt_dict(d): + fmt_kvlist(d.iteritems()) + def fmt_kvlist(l): + for k, v in sorted(l): + PRINT("%s%s" % ( + htmlescape(k), htmlescape(v))) + def fmt_list(l): + for i in l: + PRINT("%s" % htmlescape(i)) + + PRINT("""\ +

Parameters

""") + for what, thing, how in [('Query', PARAM, fmt_kvlist), + ('Cookies', COOKIE, fmt_dict), + ('Path', PATH, fmt_list), + ('Environment', ENV, fmt_dict)]: + PRINT("

%s

\n" % what) + how(thing) + PRINT("
") + +def cgi_error(): + """ + Report an exception while in CGI mode. + + If we've not produced a header yet, then we can do that, and produce a + status code and everything; otherwise we'll have to make do with a small + piece of the page. + """ + if OUT.headerp: + PRINT("
") + cgi_error_guts() + PRINT("
\n") + else: + with html("chpwd internal error", status = 500): + PRINT("

chpwd internal error

") + cgi_error_guts() + SYS.exit(1) + +@CTX.contextmanager +def cgi_errors(hook = None): + """ + Context manager: report errors in the body as useful HTML. + + If HOOK is given, then call it before reporting errors. It may have set up + useful stuff. + """ + try: + yield None + except Exception, e: + if hook: hook() + if isinstance(e, U.ExpectedError) and not OUT.headerp: + page('error.fhtml', + headers = dict(status = e.code), + title = 'Chopwood: error', error = e) + else: + exty, exval, extb = SYS.exc_info() + with tmplkw(exception = TB.format_exception_only(exty, exval), + traceback = TB.extract_tb(extb), + PARAM = sorted(PARAM), + COOKIE = sorted(COOKIE.items()), + PATH = PATH, + ENV = sorted(ENV.items())): + if OUT.headerp: + format_tmpl(TMPL['exception.fhtml'], toplevel = False) + else: + page('exception.fhtml', + headers = dict(status = 500), + title = 'Chopwood: internal error', + toplevel = True) + +###-------------------------------------------------------------------------- +### CGI input. + +## Lots of global variables to be filled in by `cgiparse'. +COOKIE = {} +SPECIAL = {} +PARAM = [] +PARAMDICT = {} +PATH = [] + +## Regular expressions for splitting apart query and cookie strings. +R_QSPLIT = RX.compile('[;&]') +R_CSPLIT = RX.compile(';') + +def split_keyvalue(string, delim, default): + """ + Split a STRING, and generate the resulting KEY=VALUE pairs. + + The string is split at DELIM; the components are parsed into KEY[=VALUE] + pairs. The KEYs and VALUEs are stripped of leading and trailing + whitespace, and form-url-decoded. If the VALUE is omitted, then the + DEFAULT is used unless the DEFAULT is `None' in which case the component is + simply ignored. + """ + for kv in delim.split(string): + try: + k, v = kv.split('=', 1) + except ValueError: + if default is None: continue + else: k, v = kv, default + k, v = k.strip(), v.strip() + if not k: continue + k, v = urldecode(k), urldecode(v) + yield k, v + +def cgiparse(): + """ + Process all of the various exciting CGI environment variables. + + We read environment variables and populate some tables left in global + variables: it's all rather old-school. Variables set are as follows. + + `COOKIE' + A dictionary mapping cookie names to the values provided by the user + agent. + + `SPECIAL' + A dictionary holding some special query parameters which are of + interest at a global level, and should not be passed to a subcommand + handler. No new entries will be added to this dictionary, though + values will be modified to reflect the query parameters discovered. + Conventionally, such parameters have names beginning with `%'. + + `PARAM' + The query parameters as a list of (KEY, VALUE) pairs. Special + parameters are omitted. + + `PARAMDICT' + The query parameters as a dictionary. Special parameters, and + parameters which appear more than once, are omitted. + + `PATH' + The trailing `PATH_INFO' path, split at `/' markers, with any + trailing empty component removed. + """ + + def getenv(var): + try: return ENV[var] + except KeyError: raise U.ExpectedError, (500, "No `%s' supplied" % var) + + ## Yes, we want the request method. + method = getenv('REQUEST_METHOD') + + ## Acquire the query string. + if method == 'GET': + q = getenv('QUERY_STRING') + + elif method == 'POST': + + ## We must read the query string from stdin. + n = getenv('CONTENT_LENGTH') + if not n.isdigit(): + raise U.ExpectedError, (500, "Invalid CONTENT_LENGTH") + n = int(n, 10) + if getenv('CONTENT_TYPE') != 'application/x-www-form-urlencoded': + raise U.ExpectedError, (500, "Unexpected content type `%s'" % ct) + q = SYS.stdin.read(n) + if len(q) != n: + raise U.ExpectedError, (500, "Failed to read correct length") + + else: + raise U.ExpectedError, (500, "Unexpected request method `%s'" % method) + + ## Populate the `SPECIAL', `PARAM' and `PARAMDICT' tables. + seen = set() + for k, v in split_keyvalue(q, R_QSPLIT, 't'): + if k in SPECIAL: + SPECIAL[k] = v + else: + PARAM.append((k, v)) + if k in seen: + del PARAMDICT[k] + else: + PARAMDICT[k] = v + seen.add(k) + + ## Parse out the cookies, if any. + try: c = ENV['HTTP_COOKIE'] + except KeyError: pass + else: + for k, v in split_keyvalue(c, R_CSPLIT, None): COOKIE[k] = v + + ## Set up the `PATH'. + try: p = ENV['PATH_INFO'] + except KeyError: pass + else: + pp = p.lstrip('/').split('/') + if pp and not pp[-1]: pp.pop() + PATH[:] = pp + +###-------------------------------------------------------------------------- +### CGI subcommands. + +class Subcommand (SC.Subcommand): + """ + A CGI subcommand object. + + As for `subcommand.Subcommand', but with additional protocol for processing + CGI parameters. + """ + + def cgi(me, param, path): + """ + Invoke the subcommand given a collection of CGI parameters. + + PARAM is a list of (KEY, VALUE) pairs from the CGI query. The CGI query + parameters are checked against the subcommand's parameters (making sure + that mandatory parameters are supplied, that any switches are given + boolean values, and that only the `rest' parameter, if any, is + duplicated). + + PATH is a list of trailing path components. They are used to satisfy the + `rest' parameter if there is one and there are no query parameters which + satisfy the `rest' parameter; otherwise, an `ExpectedError' is raised if + the list of path elements is non-empty. + """ + + ## We're going to make a pass over the supplied parameters, and we'll + ## check them off against the formal parameters as we go; so we'll need + ## to be able to look them up. We'll also keep track of the ones we've + ## seen so that we can make sure that all of the mandatory parameters + ## were actually supplied. + ## + ## To that end: `want' is a dictionary mapping parameter names to + ## functions which will do something useful with the value; `seen' is a + ## set of the parameters which have been assigned; and `kw' is going to + ## be the keyword-argument dictionary we pass to the handler function. + want = {} + kw = {} + + def set_value(k, v): + """Set a simple value: we shouldn't see multiple values.""" + if k in kw: + raise U.ExpectedError, (400, "Repeated parameter `%s'" % k) + kw[k] = v + def set_bool(k, v): + """Set a simple boolean value: for switches.""" + set_value(k, v.lower() in ['true', 't', 'yes', 'y']) + def set_list(k, v): + """Append the value to a list: for the `rest' parameter.""" + kw.setdefault(k, []).append(v) + + ## Set up the `want' map. + for o in me.opts: + if o.argname: want[o.name] = set_value + else: want[o.name] = set_bool + for p in me.params: want[p.name] = set_value + for p in me.oparams: want[p.name] = set_value + if me.rparam: want[me.rparam.name] = set_list + + ## Work through the list of supplied parameters. + for k, v in param: + try: + f = want[k] + except KeyError: + if v: + raise U.ExpectedError, (400, "Unexpected parameter `%s'" % k) + else: + f(k, v) + + ## Deal with a path, if there is one. + if path: + if me.rparam and me.rparam.name not in kw: + kw[me.rparam.name] = path + else: + raise U.ExpectedError, (404, "Superfluous path elements") + + ## Make sure we saw all of the mandatory parameters. + for p in me.params: + if p.name not in kw: + raise U.ExpectedError, (400, "Missing parameter `%s'" % p.name) + + ## Invoke the subcommand. + me.func(**kw) + +def subcommand(name, contexts, desc, cls = Subcommand, *args, **kw): + """Decorator for defining CGI subcommands.""" + return SC.subcommand(name, contexts, desc, cls = cls, *args, **kw) + +###----- That's all, folks -------------------------------------------------- diff --git a/chpwd b/chpwd new file mode 100755 index 0000000..5517274 --- /dev/null +++ b/chpwd @@ -0,0 +1,272 @@ +#! /usr/bin/python +### +### Password management +### +### (c) 2012 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +from __future__ import with_statement + +import contextlib as CTX +import optparse as OP +import os as OS; ENV = OS.environ +import shlex as SL +import sys as SYS + +from auto import HOME, VERSION +import cgi as CGI +import cmdutil as CU +import config as CONF; CFG = CONF.CFG +import dbmaint as D +import httpauth as HA +import output as O; OUT = O.OUT +import subcommand as SC +import util as U + +for i in ['admin', 'cgi', 'remote', 'user']: + __import__('cmd-' + i) + +###-------------------------------------------------------------------------- +### Parsing command-line options. + +## Command-line options parser. +OPTPARSE = SC.SubcommandOptionParser( + usage = '%prog SUBCOMMAND [ARGS ...]', + version = '%%prog, verion %s' % VERSION, + contexts = ['admin', 'userv', 'remote', 'cgi', 'cgi-query', 'cgi-noauth'], + commands = SC.COMMANDS, + description = """\ +Manage all of those annoying passwords. + +This is free software, and you can redistribute it and/or modify it +under the terms of the GNU Affero General Public License +. For a `.tar.gz' file +of the source code, use the `source' command. +""") + +OPTS = None + +## Set up the global options. +for short, long, props in [ + ('-c', '--context', { + 'metavar': 'CONTEXT', 'dest': 'context', 'default': None, + 'help': 'run commands with the given CONTEXT' }), + ('-f', '--config-file', { + 'metavar': 'FILE', 'dest': 'config', + 'default': OS.path.join(HOME, 'chpwd.conf'), + 'help': 'read configuration from FILE.' }), + ('-u', '--user', { + 'metavar': 'USER', 'dest': 'user', 'default': None, + 'help': "impersonate USER, and default context to `userv'." })]: + OPTPARSE.add_option(short, long, **props) + +###-------------------------------------------------------------------------- +### CGI dispatch. + +## The special variables, to be picked out by `cgiparse'. +CGI.SPECIAL['%act'] = None +CGI.SPECIAL['%nonce'] = None + +## We don't want to parse arguments until we've settled on a context; but +## issuing redirects in the early setup phase fails because we don't know +## the script name. So package the setup here. +def cgi_setup(ctx = 'cgi-noauth'): + global OPTS + if OPTS: return + OPTPARSE.context = ctx + OPTS, args = OPTPARSE.parse_args() + if args: raise U.ExpectedError, (500, 'Unexpected arguments to CGI') + CONF.loadconfig(OPTS.config) + D.opendb() + +def dispatch_cgi(): + """Examine the CGI request and invoke the appropriate command.""" + + ## Start by picking apart the request. + CGI.cgiparse() + + ## We'll be taking items off the trailing path. + i, np = 0, len(CGI.PATH) + + ## Sometimes, we want to run several actions out of the same form, so the + ## subcommand name needs to be in the query string. We use the special + ## variable `%act' for this. If it's not set, then we use the first elment + ## of the path. + act = CGI.SPECIAL['%act'] + if act is None: + if i >= np: + cgi_setup() + CGI.redirect(CGI.action('login')) + return + act = CGI.PATH[i] + i += 1 + + ## Figure out which context we're meant to be operating in, according to + ## the requested action. Unknown actions result in an error here; known + ## actions where we don't have enough authorization send the user back to + ## the login page. + for ctx in ['cgi-noauth', 'cgi-query', 'cgi']: + try: + c = OPTPARSE.lookup_subcommand(act, exactp = True, context = ctx) + except U.ExpectedError, e: + if e.code != 404: raise + else: + break + else: + raise e + + ## Parse the command line, and load configuration. + cgi_setup(ctx) + + ## Check whether we have enough authorization. There's always enough for + ## `cgi-noauth'. + if ctx != 'cgi-noauth': + + ## If there's no token cookie, then we have to bail. + try: token = CGI.COOKIE['chpwd-token'] + except KeyError: + CGI.redirect(CGI.action('login', why = 'NOAUTH')) + return + + ## If we only want read-only access, then the cookie is good enough. + ## Otherwise we must check that a nonce was supplied, and that it is + ## correct. + if ctx == 'cgi-query': + nonce = None + else: + nonce = CGI.SPECIAL['%nonce'] + if not nonce: + CGI.redirect(CGI.action('login', why = 'NONONCE')) + return + + ## Verify the token and nonce. + try: + CU.USER = HA.check_auth(token, nonce) + except HA.AuthenticationFailed, e: + CGI.redirect(CGI.action('login', why = e.why)) + return + + ## Invoke the subcommand handler. + c.cgi(CGI.PARAM, CGI.PATH[i:]) + +###-------------------------------------------------------------------------- +### Main dispatch. + +@CTX.contextmanager +def cli_errors(): + """Catch expected errors and report them in the traditional Unix style.""" + try: + yield None + except U.ExpectedError, e: + SYS.stderr.write('%s: %s\n' % (OS.path.basename(SYS.argv[0]), e.msg)) + if 400 <= e.code < 500: SYS.exit(1) + else: SYS.exit(2) + +### Main dispatch. + +if __name__ == '__main__': + + if 'REQUEST_METHOD' in ENV: + ## This looks like a CGI request. The heavy lifting for authentication + ## over HTTP is done in `dispatch_cgi'. + + with OUT.redirect_to(CGI.HTTPOutput()): + with CGI.cgi_errors(cgi_setup): dispatch_cgi() + + elif 'USERV_SERVICE' in ENV: + ## This is a Userv request. The caller's user name is helpfully in the + ## `USERV_USER' environment variable. + + with cli_errors(): + OPTS, args = OPTPARSE.parse_args() + CONF.loadconfig(OPTS.config) + try: CU.set_user(ENV['USERV_USER']) + except KeyError: raise ExpectedError, (500, 'USERV_USER unset') + with OUT.redirect_to(O.FileOutput()): + OPTPARSE.dispatch('userv', [ENV['USERV_SERVICE']] + args) + + elif 'SSH_ORIGINAL_COMMAND' in ENV: + ## This looks like an SSH request; but we present two different + ## interfaces over SSH. We must distinguish them -- carefully: they have + ## very different error-reporting conventions. + + def ssh_setup(): + """Extract and parse the client's request from where SSH left it.""" + global OPTS + OPTS, args = OPTPARSE.parse_args() + CONF.loadconfig(OPTS.config) + cmd = SL.split(ENV['SSH_ORIGINAL_COMMAND']) + if args: raise ExpectedError, (500, 'Unexpected arguments via SSH') + return cmd + + if 'CHPWD_SSH_USER' in ENV: + ## Setting `CHPWD_SSH_USER' to a user name is the administrator's way + ## of telling us that this is a user request, so treat it like Userv. + + with cli_errors(): + cmd = ssh_setup() + CU.set_user(ENV['CHPWD_SSH_USER']) + SERVICES['master'].find(USER) + with OUT.redirect_to(O.FileOutput()): + OPTPARSE.dispatch('userv', cmd) + + elif 'CHPWD_SSH_MASTER' in ENV: + ## Setting `CHPWD_SSH_MASTER' to anything tells us that the client is + ## making a remote-service request. We must turn on the protocol + ## decoration machinery, but don't need to -- mustn't, indeed -- set up + ## a user. + + try: + cmd = ssh_setup() + with OUT.redirect_to(O.RemoteOutput()): + OPTPARSE.dispatch('remote', map(urldecode, cmd)) + except ExpectedError, e: + print 'ERR', e.code, e.msg + else: + print 'OK' + + else: + ## There's probably some strange botch in the `.ssh/authorized_keys' + ## file, but we can't do much about it from here. + + with cli_errors(): + raise ExpectedError, (400, "Unabled to determine SSH context") + + else: + ## Plain old command line, apparently. We default to administration + ## commands, but allow any kind, since this is useful for debugging, and + ## this isn't a security problem since our caller is just as privileged + ## as we are. + + with cli_errors(): + OPTS, args = OPTPARSE.parse_args() + CONF.loadconfig(OPTS.config) + ctx = OPTS.context + if OPTS.user: + CU.set_user(OPTS.user) + if ctx is None: ctx = 'userv' + else: + D.opendb() + if ctx is None: ctx = 'admin' + with OUT.redirect_to(O.FileOutput()): + OPTPARSE.dispatch(ctx, args) + +###----- That's all, folks -------------------------------------------------- diff --git a/chpwd.css b/chpwd.css new file mode 100644 index 0000000..64b98be --- /dev/null +++ b/chpwd.css @@ -0,0 +1,100 @@ +/* -*-css-*- + * + * Style sheet for Chopwood + * + * (c) 2013 Mark Wooding + */ + +/*----- Licensing notice --------------------------------------------------* + * + * This file is part of Chopwood: a password-changing service. + * + * Chopwood is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Chopwood is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with Chopwood; if not, see + * . + */ + +/*----- General typesetting and layout -----------------------------------*/ + +h1 { + border-bottom-style: solid; + border-bottom-width: medium; + padding-bottom: 1ex; +} + +h2 { + border-top-style: solid; + border-top-width: thin; + padding-top: 1ex; + margin-top: 4ex; +} + +h1 + h2, h2:first-child { + border-top-style: hidden; + margin-top: inherit; +} + +div.credits { + border-top-style: solid; + border-top-width: thin; + padding-top: 0.5ex; + margin-top: 2ex; + text-align: right; + font-size: small; + font-style: italic; +} + +/*----- Form layout -------------------------------------------------------*/ + +/* Common form validation styling. */ + +.whinge { + font-size: smaller; + visibility: hidden; +} + +.wrong { + color: red; + visibility: visible; +} + +/* Specific forms. */ + +td.label { text-align: right; } + +.expand { height: 100%; } +div.expand-outer { position: relative; } +div.expand-inner { + position: absolute; + width: 50%; + height: 100%; +} +div.expand-reference { + margin-left: 50%; +} + +table.expand { width: 95%; } +table.expand, +table.expand tbody, +table.expand tr { + border-collapse: collapse; + border-spacing: 0; +} +table.expand td { padding: 0; } + +#acct-list { + width: 100%; + height: 100%; +} + +/*----- That's all, folks -------------------------------------------------*/ diff --git a/chpwd.js b/chpwd.js new file mode 100644 index 0000000..9ad74ad --- /dev/null +++ b/chpwd.js @@ -0,0 +1,148 @@ +/* -*-js-*- + * + * Common JavaScript code for Chopwood + * + * (c) 2013 Mark Wooding + */ + +/*----- Licensing notice --------------------------------------------------* + * + * This file is part of Chopwood: a password-changing service. + * + * Chopwood is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Chopwood is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with Chopwood; if not, see + * . + */ + +/*----- Some utilities ----------------------------------------------------*/ + +function elt(id) { + /* Return the element with the requested ID. */ + return document.getElementById(id); +} + +function map(func, list) { + /* Apply FUNC to each element of LIST, which may actually be any object; + * return a new object mapping the same keys to the images of the values in + * the function FUNC. + */ + var i; + var out = {}; + for (i in list) out[i] = func(list[i]); + return out; +} + +/*----- Form validation ---------------------------------------------------*/ + +var FORMS = {}; +/* A map of form names to information about them. Each form is an object + * with the following slots: + * + * * elts: A list of element-ids for the form widgets. These widgets will + * be checked periodically to see whether the input data is acceptable. + * + * * check: A function of no arguments, which returns either `null' if + * everything is OK, or an error message as a string. + * + * Form names aren't just for show. Some element-ids are constructed using + * the form name as a base: + * + * * FORM-whinge: An output element in which to display an error message if + * the form's input is unacceptable. + * + * * FORM-submit: The Submit button, which needs hooking to inhibit + * submitting a form with invalid data. + */ + +function check() { + /* Check through the various forms to make sure they're filled in OK. If + * not, set the `F-whinge' elements, and disable `F-submit'. + */ + var f, form, whinge; + + for (f in FORMS) { + form = FORMS[f]; + we = elt(f + '-whinge'); + sb = elt(f + '-submit'); + whinge = form.check(); + if (sb !== null) sb.disabled = (whinge !== null); + if (we !== null) { + we.textContent = whinge || 'OK'; + we.className = whinge === null ? 'whinge' : 'whinge wrong'; + } + } + + // We can't catch all possible change events: in particular, it seems + // really hard to capture changes as a result of selections from a menu -- + // e.g., delete or paste. Accept this, and just recheck periodically. + check_again(1000); +} + +var timer = null; +/* The timer for the periodic validation job. */ + +function check_again(when) { + /* Arrange to check the forms again in WHEN milliseconds. */ + if (timer !== null) clearTimeout(timer); + timer = setTimeout(check, when); +} + +var Q = 0; +function check_soon(ev) { + /* Arrange to check the forms again very soon. */ + check_again(50); +} + +function check_presubmit(ev, f) { + /* Check the form F now, popping up an alert and preventing the event EV if + * there's something wrong. + */ + var whinge = FORMS[f].check(); + + if (whinge !== null) { + ev.preventDefault(); + alert(whinge); + } +} + +function init() { + /* Attach event handlers to the various widgets so that we can keep track + * of how well things are being filled in. + */ + var f, form, w, e; + + // Start watching for changes. + check_soon(); + + for (f in FORMS) (function (f, form) { + + // Ugh. We have to lambda-bind `f' here so that we can close over it + // properly. + for (w in form.elts) { + if ((e = elt(f + '-' + form.elts[w])) === null) continue; + e.addEventListener('click', check_soon); + e.addEventListener('change', check_soon); + e.addEventListener('keypress', check_soon); + e.addEventListener('blur', check_soon); + } + if ((e = elt(f + '-submit')) !== null) { + e.addEventListener('click', function (ev) { + return check_presubmit(ev, f) + }); + } + })(f, FORMS[f]); +} + +window.addEventListener('load', init); + +/*----- That's all, folks -------------------------------------------------*/ diff --git a/cmd-admin.py b/cmd-admin.py new file mode 100644 index 0000000..dc52461 --- /dev/null +++ b/cmd-admin.py @@ -0,0 +1,172 @@ +### -*-python-*- +### +### Administrative commands +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +from __future__ import with_statement + +import agpl as AGPL +import cmdutil as CU +import dbmaint as D +from output import OUT, PRINT +import subcommand as SC +import util as U + +@SC.subcommand( + 'listusers', ['admin'], 'List the existing users.', + opts = [SC.Opt('service', '-s', '--service', + 'list users with SERVICE accounts', + argname = 'SERVICE')], + oparams = [SC.Arg('pat')]) +def cmd_listuser(service = None, pat = None): + CU.format_list(CU.list_users(service, pat), + [CU.column('USER', "~={0.user}A", 3), + CU.column('EMAIL', "~={0.email}:[---~;~={0.email}A~]")]) + +@SC.subcommand( + 'adduser', ['admin'], 'Add a user to the master database.', + opts = [SC.Opt('email', '-e', '--email', + "email address for the new user", + argname = 'EMAIL')], + params = [SC.Arg('user')]) +def cmd_adduser(user, email = None): + with D.DB: + CU.check_user(user, False) + D.DB.execute("INSERT INTO users (user, email) VALUES ($user, $email)", + user = user, email = email) + D.DB.execute("""INSERT INTO services (user, service) + VALUES ($user, $service)""", + user = user, service = 'master') + +@SC.subcommand( + 'deluser', ['admin'], 'Remove USER from the master database.', + params = [SC.Arg('user')]) +def cmd_deluser(user, email = None): + with D.DB: + CU.check_user(user) + D.DB.execute("DELETE FROM users WHERE user = $user", user = user) + +@SC.subcommand( + 'edituser', ['admin'], 'Modify a user record.', + opts = [SC.Opt('email', '-e', '--email', + "change USER's email address", + argname = 'EMAIL'), + SC.Opt('noemail', '-E', '--no-email', + "forget USER's email address"), + SC.Opt('rename', '-r', '--rename', + "rename USER", + argname = 'NEW-NAME')], + params = [SC.Arg('user')]) +def cmd_edituser(user, email = None, noemail = False, rename = None): + with D.DB: + CU.check_user(user) + if rename is not None: check_user(rename, False) + CU.edit_records('users', 'user = $user', + [('email', email, noemail), + ('user', rename, False)], + user = user) + +@SC.subcommand( + 'delsvc', ['admin'], "Remove all records for SERVICE.", + params = [SC.Arg('service')]) +def cmd_delsvc(service): + with D.DB: + CU.check_service(service, must_config_p = False, must_records_p = True) + D.DB.execute("DELETE FROM services WHERE service = $service", + service = service) + +@SC.subcommand( + 'editsvc', ['admin'], "Edit a given SERVICE.", + opts = [SC.Opt('rename', '-r', '--rename', "rename the SERVICE", + argname = 'NEW-NAME')], + params = [SC.Arg('service')]) +def cmd_editsvc(service, rename = None): + with D.DB: + if service == 'master': + raise U.ExpectedError, (400, "Can't edit the master service") + if rename is None: + CU.check_service(service, must_config_p = True, must_records_p = True) + else: + CU.check_service(service, must_config_p = False, must_records_p = True) + CU.check_service(rename, must_config_p = True, must_records_p = False) + CU.edit_records('services', 'service = $service', + [('service', rename, False)], + service = service) + +@SC.subcommand( + 'addacct', ['admin'], 'Add an account for a user.', + opts = [SC.Opt('alias', '-a', '--alias', + "alias by which USER is known to SERVICE", + argname = 'ALIAS')], + params = [SC.Arg('user'), SC.Arg('service')]) +def cmd_addacct(user, service, alias = None): + with D.DB: + CU.check_user(user) + CU.check_service(service) + D.DB.execute("""SELECT 1 FROM services + WHERE user = $user AND service = $service""", + user = user, service = service) + if D.DB.fetchone() is not None: + raise U.ExpectedError, ( + 400, "User `%s' already has `%s' account" % (user, service)) + D.DB.execute("""INSERT INTO services (service, user, alias) + VALUES ($service, $user, $alias)""", + service = service, user = user, alias = alias) + +@SC.subcommand( + 'delacct', ['admin'], "Remove USER's SERVICE account.", + params = [SC.Arg('user'), SC.Arg('service')]) +def cmd_delacct(user, service): + with D.DB: + CU.resolve_account(service, user) + if service == 'master': + raise U.ExpectedError, \ + (400, "Can't delete master accounts: use `deluser'") + D.DB.execute("""DELETE FROM services + WHERE service = $service AND user = $user""", + service = service, user = user) + +@SC.subcommand( + 'editacct', ['admin'], "Modify USER's SERVICE account record.", + opts = [SC.Opt('alias', '-a', '--alias', + "change USER's login name for SERVICE", + argname = 'ALIAS'), + SC.Opt('noalias', '-A', '--no-alias', + "use USER's master login name")], + params = [SC.Arg('user'), SC.Arg('service')]) +def cmd_editacct(user, service, alias = None, noalias = False): + with D.DB: + CU.resolve_account(service, user) + if service == 'master': + raise U.ExpectedError, (400, "Can't edit master accounts") + CU.edit_records('services', 'user = $user AND service = $service', + [('alias', alias, noalias)], + user = user, service = service) + +@SC.subcommand( + 'source', ['admin', 'userv'], """\ +Write source code (in `.tar.gz' format) to standard output.""") +def cmd_source_admin(): + AGPL.source(OUT) + +###----- That's all, folks -------------------------------------------------- diff --git a/cmd-cgi.py b/cmd-cgi.py new file mode 100644 index 0000000..b06ad6a --- /dev/null +++ b/cmd-cgi.py @@ -0,0 +1,188 @@ +### -*-python-*- +### +### CGI commands +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +from __future__ import with_statement + +import errno as E +import os as OS + +from auto import PACKAGE, VERSION +import agpl as AGPL +import cgi as CGI +import cmdutil as CU +import dbmaint as D +import httpauth as HA +import operation as OP +import output as O; OUT = O.OUT; PRINT = O.PRINT +import service as S +import subcommand as SC +import util as U + +###-------------------------------------------------------------------------- +### Utilities. + +def operate(what, op, services, *args, **kw): + accts = CU.resolve_accounts(CU.USER, services) + o, ii, rq, ops = OP.operate(op, accts, *args, **kw) + CGI.page('operate.fhtml', + header = dict(pragma = 'no-cache', cache_control = 'no-cache'), + title = 'Chopwood: %s' % what, + what = what, + outcome = o, info = ii, results = ops) + +###-------------------------------------------------------------------------- +### Commands. + +@CGI.subcommand('list', ['cgi-query'], 'List available accounts') +def cmd_list_cgi(): + CGI.page('list.fhtml', + title = 'Chopwood: accounts list', + accts = CU.list_accounts(CU.USER), + nonce = HA.NONCE) + +@CGI.subcommand( + 'set', ['cgi'], 'Set password for a collection of services.', + params = [SC.Arg('first'), SC.Arg('second')], + rparam = SC.Arg('services')) +def cmd_set_cgi(first, second, services = []): + if first != second: raise U.ExpectedError, (400, "Passwords don't match") + operate('set passwords', 'set', services, first) + +@CGI.subcommand( + 'reset', ['cgi'], + 'Reset passwords for a collection of services.', + rparam = SC.Arg('services')) +def cmd_reset_cgi(services = []): + operate('reset passwords', 'reset', services) + +@CGI.subcommand( + 'clear', ['cgi'], + 'Clear passwords for a collection of services.', + rparam = SC.Arg('services')) +def cmd_clear_cgi(services = []): + operate('clear passwords', 'clear', services) + +@CGI.subcommand( + 'fail', ['cgi-noauth'], + 'Raise an exception, to test the error reporting machinery.', + opts = [SC.Opt('partial', '-p', '--partial', + 'Raise exception after producing partial output.')]) +def cmd_fail_cgi(partial = False): + if partial: + OUT.header(content_type = 'text/html') + PRINT("""\ + +Chopwood: filler text + +

Failure expected soon +

This is some normal output which will be rudely interrupted.""") + raise Exception, 'You asked for this.' + +###-------------------------------------------------------------------------- +### Static content. + +## A map of file names to content objects. See below. +CONTENT = {} + +class PlainOutput (O.FileOutput): + def header(me, **kw): + pass + +class StaticContent (object): + def __init__(me, type): + me._type = type + def emit(me): + OUT.header(content_type = me._type) + me._emit() + def _write(me, dest): + with open(dest, 'w') as f: + with OUT.redirect_to(PlainOutput(f)): + me.emit() + def write(me, dest): + new = dest + '.new' + try: OS.unlink(new) + except OSError, e: + if e.errno != E.ENOENT: raise + me._write(new) + OS.rename(new, dest) + +class TemplateContent (StaticContent): + def __init__(me, template, *args, **kw): + super(TemplateContent, me).__init__(*args, **kw) + me._template = template + def _emit(me): + CGI.format_tmpl(CGI.TMPL[me._template]) + +class HTMLContent (StaticContent): + def __init__(me, title, template, type = 'text/html', *args, **kw): + super(HTMLContent, me).__init__(type = type, *args, **kw) + me._template = template + me._title = title + def emit(me): + CGI.page(me._template, title = me._title) + +CONTENT.update({ + 'chpwd.css': TemplateContent(template = 'chpwd.css', + type = 'text/css'), + 'chpwd.js': TemplateContent(template = 'chpwd.js', + type = 'text/javascript'), + 'about.html': HTMLContent('Chopwood: about this program', + template = 'about.fhtml'), + 'cookies.html': HTMLContent('Chopwood: use of cookies', + template = 'cookies.fhtml') +}) + +@CGI.subcommand( + 'static', ['cgi-noauth'], 'Output a static file.', + rparam = SC.Arg('path')) +def cmd_static_cgi(path): + name = '/'.join(path) + try: content = CONTENT[name] + except KeyError: raise U.ExpectedError, (404, "Unknown file `%s'" % name) + content.emit() + +@SC.subcommand( + 'static', ['admin'], 'Write the static files to DIR.', + params = [SC.Arg('dir')]) +def cmd_static_admin(dir): + try: OS.makedirs(dir, 0777) + except OSError, e: + if e.errno != E.EEXIST: raise + for f, c in CONTENT.iteritems(): + c.write(OS.path.join(dir, f)) + +TARBALL = '%s-%s.tar.gz' % (PACKAGE, VERSION) +@CGI.subcommand(TARBALL, ['cgi-noauth'], """\ +Download source code (in `.tar.gz' format).""") +def cmd_source_cgi(): + OUT.header(content_type = 'application/octet-stream') + AGPL.source(OUT) + +@CGI.subcommand('source', ['cgi-noauth'], """\ +Redirect to the source code tarball (so that it's correctly named.""") +def cmd_sourceredirect_cgi(): + CGI.redirect(CGI.action(TARBALL)) + +###----- That's all, folks -------------------------------------------------- diff --git a/cmd-remote.py b/cmd-remote.py new file mode 100644 index 0000000..b140329 --- /dev/null +++ b/cmd-remote.py @@ -0,0 +1,47 @@ +### -*-python-*- +### +### Remote service commands +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +import cmdutil as CU +import subcommand as SC +import util as U + +@SC.subcommand( + 'set', ['remote'], + 'Set password for remote service', + params = [SC.Arg('service'), SC.Arg('user')]) +def cmd_set_svc(service, user): + new = readline() + svc = CU.check_service(service) + svc.setpasswd(user, new) + +@SC.subcommand( + 'clear', ['remote'], + 'Clear password for remote service', + params = [SC.Arg('service'), SC.Arg('user')]) +def cmd_set_svc(service, user): + svc = CU.check_service(service) + svc.clearpasswd(user) + +###----- That's all, folks -------------------------------------------------- diff --git a/cmd-user.py b/cmd-user.py new file mode 100644 index 0000000..671ab20 --- /dev/null +++ b/cmd-user.py @@ -0,0 +1,116 @@ +### -*-python-*- +### +### User commands +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +from __future__ import with_statement + +import getpass as GP +import sys as SYS + +import cmdutil as CU +import dbmaint as D +import operation as OP +from output import PRINT +import service as S +import subcommand as SC +import util as U + +OMSG = [None, + "Partial failure", + "Operation failed", + "No services selected"] + +def operate(op, accts, *args, **kw): + """ + Perform a request as indicated by the arguments (see `operation.operate' + for the full details), and report the results. + """ + + ## Collect the results. + o, ii, rq, ops = OP.operate(op, accts, *args, **kw) + + ## Report any additional information collected. + if o.nwin and ii: + CU.format_list(ii, + [CU.column('RESULT', "~={0.desc}A"), + CU.column('VALUE', "~={0.value}A")]) + PRINT() + + ## Report the outcomes of the indvidual operations. + if ops: + CU.format_list(ops, + [CU.column('SERVICE', + "~={0.svc.friendly}A"), + CU.column('RESULT', "~={0.error}:[" + "OK~={0.result}@[ ~A~]~;" + "FAILED: ~={0.error.msg}A~]")]) + + ## If it failed, report an appropriate error. + if o.rc: + if o.nlose: PRINT() + raise U.ExpectedError, (400, OMSG[o.rc]) + +@SC.subcommand( + 'set', ['userv'], + """Sets the password for the SERVICES to a given string. If standard input +is a terminal, read the password interactively, with prompts, disabling echo, +and asking for confirmation to catch typos. Otherwise, just read one line +and use the result as the password.""", + rparam = SC.Arg('services')) +def cmd_set_userv(services): + accts = CU.resolve_accounts(CU.USER, services) + if not SYS.stdin.isatty(): + new = U.readline('new password') + else: + first = GP.getpass('Enter new password: ') + second = GP.getpass('Confirm new password: ') + if first != second: raise U.ExpectedError, (400, "Passwords don't match") + new = first + operate('set', accts, new) + +@SC.subcommand( + 'reset', ['userv'], + """Resets the password for the SERVICES.""", + rparam = SC.Arg('services')) +def cmd_reset_userv(services): + accts = CU.resolve_accounts(CU.USER, services) + operate('reset', accts) + +@SC.subcommand( + 'clear', ['userv'], + """Clears the password for the SERVICES, preventing access. This doesn't +work for all services, depending on how passwords are represented.""", + rparam = SC.Arg('services')) +def cmd_clear_userv(services): + accts = CU.resolve_accounts(CU.USER, services) + operate('clear', accts) + +@SC.subcommand('list', ['userv'], 'List available accounts') +def cmd_list_userv(): + CU.format_list(CU.list_accounts(CU.USER), + [CU.column('NAME', "~={0.service}A"), + CU.column('DESCRIPTION', "~={0.friendly}A"), + CU.column('LOGIN', "~={0.alias}:[---~;~={0.alias}A~]")]) + +###----- That's all, folks -------------------------------------------------- diff --git a/cmdutil.py b/cmdutil.py new file mode 100644 index 0000000..a20b8dd --- /dev/null +++ b/cmdutil.py @@ -0,0 +1,300 @@ +### -*-python-*- +### +### Utilities for the various commands +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +from __future__ import with_statement + +import dbmaint as D +import format as F +import operation as OP +import output as O +import service as S +import util as U + +def check_user(user, must_exist_p = True): + """ + Check the existence state of the USER. + + If MUST_EXIST_P is true (the default), ensure that USER exists; otherwise, + ensure that it does not. Raise an appropriate `ExpectedError' if the check + fails. + """ + + D.DB.execute("SELECT 1 FROM users WHERE user = $user", user = user) + existsp = D.DB.fetchone() is not None + + if must_exist_p and not existsp: + raise U.ExpectedError, (400, "Unknown user `%s'" % user) + elif not must_exist_p and existsp: + raise U.ExpectedError, (400, "User `%s' already exists" % user) + +def set_user(u): + """Check that U is a known user, and, if so, store it in `USER'.""" + global USER + D.opendb() + check_user(u) + USER = u + +def check_service(service, must_config_p = True, must_records_p = None): + """ + Check the existence state of the SERVICE. + + If MUST_CONFIG_P is true (the default), ensure that the service is + configured, i.e., there is an entry in the `SERVICES' dictionary; if false, + ensure tht it is not configured; if `None', then don't care either way. + + Similarly, if MUST_RECORDS_P is true, ensure that there is at least one + account defined for the service in the database; if false, ensure that + there are no accounts; and if `None' (the default), then don't care either + way. + + Raise an appropriate `ExpectedError' if the check fails. The return value + on successful completion is the service object, or `None' if it is not + configured. + """ + + try: svc = S.SERVICES[service] + except KeyError: svc = None + + ## Check whether the service is configured. + if must_config_p is not None: + if must_config_p and not svc: + raise U.ExpectedError, (400, "Unknown service `%s'" % service) + elif not must_config_p and svc: + raise U.ExpectedError, \ + (400, "Service `%s' is still configured" % service) + + ## Check whether the service has any accounts. + if must_records_p is not None: + D.DB.execute("SELECT 1 FROM services WHERE service = $service", + service = service) + recordsp = D.DB.fetchone() is not None + if must_records_p and not recordsp: + raise U.ExpectedError, (400, "Service `%s' is unused" % service) + elif not must_records_p and recordsp: + raise U.ExpectedError, \ + (400, "Service `%s' is already in use" % service) + + ## Done. + return svc + +def resolve_accounts(user, services): + """ + Resolve multiple accounts, returning a list of `acct' objects. + """ + + ## Make sure the user actually exists. + check_user(user) + + ## Work through the list of services. + accts = [] + for service in services: + svc = check_service(service) + + ## Find the account record from the services table. + with D.DB: + D.DB.execute("""SELECT alias FROM services + WHERE user = $user AND service = $service""", + user = user, service = service) + row = D.DB.fetchone() + if row is None: + raise U.ExpectedError, \ + (400, "No `%s' account for `%s'" % (service, user)) + + ## Pick the result apart and extend the list. + alias, = row + if alias is None: alias = user + accts.append(OP.acct(svc, alias)) + + ## Done. + return accts + +def resolve_account(service, user): + """ + Resolve a pair of SERVICE and USER names, and return a pair (SVC, ALIAS) of + the (local or remote) service object, and the USER's alias for the service. + Raise an appropriate `ExpectedError' if the service or user don't exist. + """ + + acct, = resolve_accounts(user, [service]) + return acct.svc, acct.user + +def matching_items(want, tab, cond = [], tail = '', **kw): + """ + Generate the matching items from a query constructed dynamically. + + Usually you wouldn't go through this palaver for a static query, but his + function helps with building queries in pieces. WANT should be a list of + column names we should output, appropriately qualified if there are + multiple tables; TAB should be a list of table names, in the form `FOO as + F' if aliases are wanted; COND should be a list of SQL expressions all of + which the generated records must satisfy; TAIL should be a string + containing any other bits of the query wanted; and the remaining keyword + arguments are made available to the query conditions via `$KEY' + placeholders. + """ + for row in D.DB.execute("SELECT %s FROM %s %s %s" % + (', '.join(want), ', '.join(tab), + cond and "WHERE " + " AND ".join(cond) or "", + tail), + **kw): + yield row + +class acctinfo (U.struct): + """Information about an account, returned by `list_accounts'.""" + __slots__ = ['service', 'friendly', 'alias'] + +def list_accounts(user): + """ + Return a list of `acctinfo' objets representing the USER's accounts. + """ + def friendly_name(service): + try: return S.SERVICES[service].friendly + except KeyError: return "" % service + return [acctinfo(service, friendly_name(service), alias) + for service, alias in + matching_items(['service', 'alias'], ['services'], + ['user = $user'], "ORDER BY service", + user = user)] + +class userinfo (U.struct): + """Information about a user, returned by `list_uesrs'.""" + __slots__ = ['user', 'email'] + +def list_users(service = None, pat = None): + """ + Return a list of `userinfo' objects for the matching users. + + If SERVICE is given, return only users who have accounts for that service. + If PAT is given, it should be a glob-like pattern; return only users whose + names match it. + """ + + ## Basic pieces of the query. + kw = {} + tab = ['users AS u'] + cond = [] + + ## Restrict according to the services. + if service is not None: + tab.append('services AS s') + cond.append('u.user = s.user AND s.service = $service') + kw['service'] = service + + ## Restrict according to the user name. + if pat is not None: + cond.append("u.user LIKE $pat ESCAPE '\\'") + kw['pat'] = U.globtolike(pat) + + ## Build and return the list. + return [userinfo(user, email) for user, email in + matching_items(['u.user', 'u.email'], + tab, cond, "ORDER BY u.user", **kw)] + +class column (U.struct): + """Description of a column, to be passed to `format_list'.""" + __slots__ = ['head', 'format', 'width'] + DEFAULTS = dict(width = 0) + +def format_list(items, columns): + """ + Present the ITEMS in tabular form on the current output. + + The COLUMNS are a list of `column' objects, describing the columns in the + table to be written: the `head' slot gives a string to be printed in the + first line; the `format' slot gives a `format' string to produce the text + for a given item, provided as the positional argument, in that column; and + `width' gives the minimum width for the column, in characters. Note that + the column may be wider than requested. + """ + + ## First pass: format the items and work out the actual column widths. + n = len(columns) + wd = [c.width for c in columns] + cells = [] + def addrow(row): + for i in xrange(n): + if len(row[i]) > wd[i]: + wd[i] = len(row[i]) + cells.append(row) + addrow([c.head for c in columns]) + for i in items: + addrow([F.format(None, c.format, i) for c in columns]) + + ## Second pass: print the table. We've already formatted the items, but we + ## need to set the column widths, so do that by compiling a formatter. + ## Note that the width of the last column is irrelevant: in this way, we + ## suppress trailing spaces. + fmt = F.compile(F.format(None, "~{~#[~;~~~*A~:;~~~DA~]~^ ~}~~%", wd)) + for row in cells: + F.format(O.OUT, fmt, *row) + +def edit_records(table, cond, edits, **kw): + """ + Edit some database records. + + This function modifies one or more records in TABLE (which, I suppose, + could actually be a join of multiple tables), specifically the ones which + match COND (with $TAG placeholders filled in from the keyword arguments), + according to EDITS. + + EDITS is a list of tuples of the form (FIELD, VALUE, NULLP): FIELD names a + field to be modified: VALUE, if it is not `None', is the new value to set; + if NULLP is true, then set the field to SQL `NULL'. If both actions are + requested then raise an exception. + + Exceptions are also raised if there are no operations to perform, or if + there are no records which match the condition. + """ + + ## We'll build up the query string in pieces. + d = dict(kw) + ops = [] + q = 0 + + ## Work through the edits, building up the pieces of the query. + for field, value, nullp in edits: + if value is not None and nullp: + raise U.ExpectedError, (400, "Can't set and clear `%s' field" % field) + elif nullp: + ops.append('%s = NULL' % field) + elif value is not None: + tag = 't%d' % q + q += 1 + ops.append('%s = $%s' % (field, tag)) + d[tag] = value + + ## If there are no changes to be made, then we're done. + if not ops: raise U.ExpectedError, (400, 'Nothing to do') + + ## See whether the query actually matches any records at all. + D.DB.execute("SELECT 1 FROM %s WHERE %s" % (table, cond), **d) + if D.DB.fetchone() is None: + raise U.ExpectedError, (400, 'No records to edit') + + ## Go ahead and make the changes. + D.DB.execute("UPDATE %s SET %s WHERE %s" % (table, ', '.join(ops), cond), + **d) + +###----- That's all, folks -------------------------------------------------- diff --git a/config.py b/config.py new file mode 100644 index 0000000..2c13232 --- /dev/null +++ b/config.py @@ -0,0 +1,91 @@ +### -*-python-*- +### +### Configuration handling +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +from __future__ import with_statement + +import os as OS; ENV = OS.environ +import sys as SYS +import types as TY + +from auto import PACKAGE, VERSION + +### Configuration is done by interpreting a file of Python code. We expect +### the code to define a number of variables in its global scope. We import +### a number of identifiers (named in the `EXPORT' list) to this module, and +### also our entire parent module as `chpwd'. + +## Names which ought to be exported to the configuration module. +_EXPORT = {} + +## The configuration module. +CFG = TY.ModuleType('chpwd_config') + +## A list of hooks to call once configuration is complete. +_HOOKS = [] +def hook(func): + """Decorator for post-configuration hooks.""" + _HOOKS.append(func) + return func + +## A suitable set of defaults. +DEFAULTS = {} + +def export(*names, **kw): + """ + Export the names to the configuration module from the caller's environment. + """ + + ## Find the caller's global environment. Please don't try this at home. + try: raise Exception + except: tb = SYS.exc_info()[2] + env = tb.tb_frame.f_back.f_globals + + ## Export things. + for name in names: + _EXPORT[name] = env[name] + _EXPORT.update(kw) + +## Some things to export for sure. +export('PACKAGE', 'VERSION', 'ENV', CONF = SYS.modules[__name__]) + +def loadconfig(config): + """ + Load the configuration, populating the `CFG' module with settings. + """ + + ## Make a new module for the configuration, and import ourselves into it. + d = CFG.__dict__ + d.update(_EXPORT) + d.update(DEFAULTS) + + ## And run the configuration code. + with open(config) as f: + exec f in d + + ## Run the hooks. + for func in _HOOKS: + func() + +###----- That's all, folks -------------------------------------------------- diff --git a/cookies.fhtml b/cookies.fhtml new file mode 100644 index 0000000..cfc340b --- /dev/null +++ b/cookies.fhtml @@ -0,0 +1,103 @@ +~1[ + +~]~ + +

Why and how Chopwood uses cookies

+ +

Which cookies does Chopwood actually store?

+ +

Chopwood uses only one cookie, named chpwd-token. The cookie is +stored with a maximum lifetime of 25 minutes: after this time, your browser +should forget all about it (and the server will stop caring about what it +means). + +

What do you need this cookie for?

+ +

The cookie contains a token which tells the server that you've logged in +properly. We could have chosen to use a hidden form field to carry this +token about, but that causes other trouble. + +

For example, if we used GET requests then the token would appear as +part of a URL, where it would end up being written in the location bar of +many browsers, stored in history databases, many even sent to random cloud +services; this obviously has an adverse effect on security. Also, the token +is kind of long and ugly. + +

We could avoid this problem by using POST requests everywhere, but +that causes other trouble. In particular, you'd get that annoying +

+ The page that you’re looking for used information that you + entered. Returning to hat page might cause any action that you took to be + repeated. +
+message whenever you hit the reload button. + +

What's in this cookie?

+ +

If you actually look at the cookie, you find that it looks something like +this: +

+ 1357322139.HFsD16dOh1jjdhXdO%24gkjQ.eBcBNYFhi6sKpGuahfr7yQDzqOJuYZZexJbVug9ultU.mdw +
+(Did I say something about long and ugly?) It consists of four pieces +separated by dots ‘.’. + +
+
Datestamp +
The time at which the cookie was issued, as a simple count of (non-leap) +seconds since 1974–01–01 00:00:00 UTC (or what would have been +that if UTC had existed back then in its current form). + +
Nonce +
This is just a random string. When you change a password, the server +checks that the request includes a copy of this nonce, as a protection +against +cross-site +request forgery attacks. + +
Tag +
This is a cryptographic check that the other parts of the token haven't +been modfied by an attacker. + +
User name +
Your user name, in plain text. +
+ +

How do I know you're not using this as part of some hideous behavioural +advertising scheme?

+ +

That's tricky. I could tell you that this program is +free software, and +that you can download its source code and check for +yourself. + +

That's true, except that it shouldn't do much to convince you that this +server is actually running the code it claims to be. And anyway, Chopwood +itself represents only one of many bits of software which could be keeping +track of you somehow through this cookie. + +

So, really, it comes down to trust. Sorry. + +~1[~]~ diff --git a/crypto.py b/crypto.py new file mode 100644 index 0000000..749ac2a --- /dev/null +++ b/crypto.py @@ -0,0 +1,82 @@ +### -*-python-*- +### +### Cryptographic primitives +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +###-------------------------------------------------------------------------- +### The core of MD5. + +def U32(x): return x&0xffffffff +def FF(x, y, z): return (x&y) | (~x&z) +def GG(x, y, z): return (z&x) | (~z&y) +def HH(x, y, z): return x^y^z +def II(x, y, z): return y^(x|~z) +def rot(x, n): return U32((x << n) | (x >> 32 - n)) +MD5_INIT = '0123456789abcdeffedcba9876543210'.decode('hex') +def compress_md5(buf, st = MD5_INIT): + """ + The MD5 compression function, in pure Python. + + This is about as small as I could make it. Apply the MD5 compression + function to BUF, using the initial state ST (defaults to the standard + initialization vector); return the new state as the function value. + """ + a, b, c, d = unpack('<4L', st) + aa, bb, cc, dd = a, b, c, d + x = xx = unpack('<16L', buf) + for f, i, r, k in [(FF, 0, 7, 0xd76aa478), (FF, 1, 12, 0xe8c7b756), + (FF, 2, 17, 0x242070db), (FF, 3, 22, 0xc1bdceee), + (FF, 4, 7, 0xf57c0faf), (FF, 5, 12, 0x4787c62a), + (FF, 6, 17, 0xa8304613), (FF, 7, 22, 0xfd469501), + (FF, 8, 7, 0x698098d8), (FF, 9, 12, 0x8b44f7af), + (FF, 10, 17, 0xffff5bb1), (FF, 11, 22, 0x895cd7be), + (FF, 12, 7, 0x6b901122), (FF, 13, 12, 0xfd987193), + (FF, 14, 17, 0xa679438e), (FF, 15, 22, 0x49b40821), + (GG, 1, 5, 0xf61e2562), (GG, 6, 9, 0xc040b340), + (GG, 11, 14, 0x265e5a51), (GG, 0, 20, 0xe9b6c7aa), + (GG, 5, 5, 0xd62f105d), (GG, 10, 9, 0x02441453), + (GG, 15, 14, 0xd8a1e681), (GG, 4, 20, 0xe7d3fbc8), + (GG, 9, 5, 0x21e1cde6), (GG, 14, 9, 0xc33707d6), + (GG, 3, 14, 0xf4d50d87), (GG, 8, 20, 0x455a14ed), + (GG, 13, 5, 0xa9e3e905), (GG, 2, 9, 0xfcefa3f8), + (GG, 7, 14, 0x676f02d9), (GG, 12, 20, 0x8d2a4c8a), + (HH, 5, 4, 0xfffa3942), (HH, 8, 11, 0x8771f681), + (HH, 11, 16, 0x6d9d6122), (HH, 14, 23, 0xfde5380c), + (HH, 1, 4, 0xa4beea44), (HH, 4, 11, 0x4bdecfa9), + (HH, 7, 16, 0xf6bb4b60), (HH, 10, 23, 0xbebfbc70), + (HH, 13, 4, 0x289b7ec6), (HH, 0, 11, 0xeaa127fa), + (HH, 3, 16, 0xd4ef3085), (HH, 6, 23, 0x04881d05), + (HH, 9, 4, 0xd9d4d039), (HH, 12, 11, 0xe6db99e5), + (HH, 15, 16, 0x1fa27cf8), (HH, 2, 23, 0xc4ac5665), + (II, 0, 6, 0xf4292244), (II, 7, 10, 0x432aff97), + (II, 14, 15, 0xab9423a7), (II, 5, 21, 0xfc93a039), + (II, 12, 6, 0x655b59c3), (II, 3, 10, 0x8f0ccc92), + (II, 10, 15, 0xffeff47d), (II, 1, 21, 0x85845dd1), + (II, 8, 6, 0x6fa87e4f), (II, 15, 10, 0xfe2ce6e0), + (II, 6, 15, 0xa3014314), (II, 13, 21, 0x4e0811a1), + (II, 4, 6, 0xf7537e82), (II, 11, 10, 0xbd3af235), + (II, 2, 15, 0x2ad7d2bb), (II, 9, 21, 0xeb86d391)]: + b, c, d, a = U32(rot(U32(a + f(b, c, d) + x[i] + k), r) + b), b, c, d + return pack('<4L', U32(a + aa), U32(b + bb), U32(c + cc), U32(d + dd)) + +###----- That's all, folks -------------------------------------------------- diff --git a/dbmaint.py b/dbmaint.py new file mode 100644 index 0000000..e9082c8 --- /dev/null +++ b/dbmaint.py @@ -0,0 +1,92 @@ +### -*-python-*- +### +### Database maintenance +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +from __future__ import with_statement + +import config as CONF; CFG = CONF.CFG +import subcommand as SC +import util as U + +###-------------------------------------------------------------------------- +### Opening the database. + +## Current database schema version. +DB_VERSION = 0 + +def opendb(): + """Open connections to the master database, updating it if necessary.""" + + global DB + + ## Open the database. + dbmod, dbargs = CFG.DB + DB = U.SimpleDBConnection(dbmod, dbargs) + + ## Find out the current version. + try: + DB.execute("SELECT version FROM meta") + except DB.Error: + ver = 0 + else: + ver, = DB.fetchone() + if ver > DB_VERSION: + raise ExpectedError, (500, "Database schema version not understood.") + + ## In future, there will be an attempt to upgrade databases with old + ## schemata to the current version. But not yet. + pass + +###-------------------------------------------------------------------------- +### Database setup. + +@SC.subcommand('setup', ['admin'], 'Initialize the master database.') +def cmd_setup(): + script = """ + CREATE TABLE users ( + user VARCHAR(32) PRIMARY KEY NOT NULL, + passwd TEXT NOT NULL DEFAULT('*'), + email TEXT); + CREATE TABLE services ( + user VARCHAR(32) NOT NULL, + service VARCHAR(32) NOT NULL, + alias VARCHAR(32), + PRIMARY KEY (user, service), + FOREIGN KEY (user) REFERENCES users(user) + ON UPDATE CASCADE + ON DELETE CASCADE); + CREATE TABLE meta ( + version INTEGER NOT NULL); + CREATE TABLE secrets ( + stamp INTEGER PRIMARY KEY NOT NULL, + secret TEXT NOT NULL); + """ + with DB: + for cmd in script.split(';'): + if not cmd.isspace(): + DB.execute(cmd) + DB.execute("INSERT INTO meta(version) VALUES ($version)", + version = DB_VERSION) + +###----- That's all, folks -------------------------------------------------- diff --git a/error.fhtml b/error.fhtml new file mode 100644 index 0000000..c602dc5 --- /dev/null +++ b/error.fhtml @@ -0,0 +1,31 @@ +~1[ + +~]~ + +

Chopwood: error

+ +

~={error.msg}:H + +~1[~]~ diff --git a/exception.fhtml b/exception.fhtml new file mode 100644 index 0000000..5d72429 --- /dev/null +++ b/exception.fhtml @@ -0,0 +1,62 @@ +~1[ + +~]~ + +~={toplevel}:[~ +

~%~;~ +

Chopwood: internal error

+

(That means a bug. Please report it.)~2%~]~ + +

Exception

+
+~={exception}{~H~^~%~}~
+
+ +

Traceback

+
    ~={traceback}:{ +
  1. ~H:~D (~H)~@[~%
    ~H~]~} +
+ +

Parameters

+

Query

+~ +~={PARAM}:{~%
~H~H~} +
+

Cookies

+~ +~={COOKIE}:{~%
~H~H~} +
+

Path

+~ +~={PATH}{~%~H~} +
+

Environment

+~ +~={ENV}:{~%
~H~H~} +
~ + +~={toplevel}:[~2%
~%~%~;~]~ + +~1[~]~ diff --git a/format.py b/format.py new file mode 100644 index 0000000..231f922 --- /dev/null +++ b/format.py @@ -0,0 +1,1332 @@ +### -*-python-*- +### +### String formatting, with bells, whistles, and gongs +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +from __future__ import with_statement + +import contextlib as CTX +import re as RX +from cStringIO import StringIO +import sys as SYS + +import util as U + +###-------------------------------------------------------------------------- +### A quick guide to the formatting machinery. +### +### This is basically a re-implementation of Common Lisp's FORMAT function in +### Python. It differs in a few respects. +### +### * Most essentially, Python's object and argument-passing models aren't +### the same as Lisp's. In fact, for our purposes, they're a bit better: +### Python's sharp distinction between positional and keyword arguments +### is often extremely annoying, but here they become a clear benefit. +### Inspired by Python's own enhanced string-formatting machinery (the +### new `str.format' method, and `string.Formatting' class, we provide +### additional syntax to access keyword arguments by name, positional +### arguments by position (without moving the cursor as manipulated by +### `~*'), and for selecting individual elements of arguments by indexing +### or attribute lookup. +### +### * Unfortunately, Python's I/O subsystem is much less rich than Lisp's. +### We lack streams which remember their cursor position, and so can't +### implmenent the `?&' (fresh line) or `~T' (horizontal tab) operators +### usefully. Moreover, the Python pretty-printer is rather less well +### developed than the XP-based Lisp pretty-printer, so the pretty- +### printing operations are unlikely to be implemented any time soon. +### +### * This implementation is missing a number of formatting directives just +### because they're somewhat tedious to write, such as the detailed +### floating-point printing provided by `~E', `~F' and `~G'. These might +### appear in time. +### +### Formatting takes place in two separable stages. First, a format string +### is compiled into a formatting operation. Then, the formatting operation +### can be applied to sets of arguments. State for these two stages is +### maintained in fluid variable sets `COMPILE' and `FORMAT'. +### +### There are a number of protocols involved in making all of this work. +### They're described in detail as we come across them, but here's an +### overview. +### +### * Output is determined by formatting-operation objects, typically (but +### not necessarily) subclasses of `BaseFormatOperation'. A format +### string is compiled into a single compound formatting operation. +### +### * Formatting operations determine what to output from their own +### internal state and from formatting arguments. The latter are +### collected from argument-collection objects which are subclasses of +### `BaseArg'. +### +### * Formatting operations can be modified using parameters, which are +### supplied either through the format string or from arguments. To +### abstract over this distinction, parameters are collected from +### parameter-collection objects which are subclasses of `BaseParameter'. + +FORMAT = U.Fluid() +## State for format-time processing. The base state is established by the +## `format' function, though various formatting operations will rebind +## portions of the state while they perform recursive processing. The +## variables are as follows. +## +## argmap The map (typically a dictionary) of keyword arguments to be +## formatted. These can be accessed only though `=KEY' or +## `!KEY' syntax. +## +## argpos The index of the next positional argument to be collected. +## The `~*' directive works by setting this variable. +## +## argseq The sequence (typically a list) of positional arguments to be +## formatted. These are collected in order (as modified by the +## `~*' directive), or may be accessed through `=INDEX' or +## `!INDEX' syntax. +## +## escape An escape procedure (i.e., usually created by `Escape()') to +## be called by `~^'. +## +## last_multi_p A boolean, indicating that there are no more lists of +## arguments (e.g., from `~:{...~}'), so `~:^' should escape if +## it is encountered. +## +## multi_escape An escape procedure (i.e., usually created by `Escape()') to +## be called by `~:^'. +## +## pushback Some formatting operations, notably `~@[...~]', read +## arguments without consuming them, so a subsequent operation +## should collect the same argument. This works by pushing the +## arguments onto the `pushback' list. +## +## write A function which writes its single string argument to the +## current output. + +COMPILE = U.Fluid() +## State for compile-time processing. The base state is established by the +## `compile' function, though some formatting operations will rebind portions +## of the state while they perform recursive processing. The variables are +## as follows. +## +## control The control string being parsed. +## +## delim An iterable (usually a string) of delimiter directives. See +## the `FormatDelimeter' class and the `collect_subformat' +## function for details of this. +## +## end The end of the portion of the control string being parsed. +## There might be more of the string, but we should pretend that +## it doesn't exist. +## +## opmaps A list of operation maps, i.e., dictionaries mapping +## formatting directive characters to the corresponding +## formatting operation classes. The list is searched in order, +## and the first match is used. This can be used to provide +## local extensions to the formatting language. +## +## start The current position in the control string. This is advanced +## as pieces of the string are successfully parsed. + +###-------------------------------------------------------------------------- +### A few random utilities. + +def remaining(): + """ + Return the number of positional arguments remaining. + + This will /include/ pushed-back arguments, so this needn't be monotonic + even in the absence of `~*' repositioning. + """ + return len(FORMAT.pushback) + len(FORMAT.argseq) - FORMAT.argpos + +@CTX.contextmanager +def bind_args(args, **kw): + """ + Context manager: temporarily establish a different collection of arguments. + + If the ARGS have a `keys' attribute, then they're assumed to be a mapping + object and are set as the keyword arguments, preserving the positional + arguments; otherwise, the positional arguments are set and the keyword + arguments are preserved. + + Other keyword arguments to this function are treated as additional `FORMAT' + variables to be bound. + """ + if hasattr(args, 'keys'): + with FORMAT.bind(argmap = args, **kw): yield + else: + with FORMAT.bind(argseq = args, argpos = 0, pushback = [], **kw): yield + +## Some regular expressions for parsing things. +R_INT = RX.compile(r'[-+]?[0-9]+') +R_WORD = RX.compile(r'[_a-zA-Z][_a-zA-Z0-9]*') + +###-------------------------------------------------------------------------- +### Format string errors. + +class FormatStringError (Exception): + """ + An exception type for reporting errors in format control strings. + + Its most useful feature is that it points out where the error is in a + vaguely useful way. Attributes are as follows. + + control The offending format control string. + + msg The error message, as a human-readable string. + + pos The position at which the error was discovered. This might + be a little way from the actual problem, but it's usually + good enough. + """ + + def __init__(me, msg, control, pos): + """ + Construct the exception, given a message MSG, a format CONTROL string, + and the position POS at which the error was found. + """ + me.msg = msg + me.control = control + me.pos = pos + + def __str__(me): + """ + Present a string explaining the problem, including a dump of the + offending portion of the string. + """ + s = me.control.rfind('\n', 0, me.pos) + 1 + e = me.control.find('\n', me.pos) + if e < 0: e = len(me.control) + return '%s\n %s\n %*s^\n' % \ + (me.msg, me.control[s:e], me.pos - s, '') + +def format_string_error(msg): + """Report an error in the current format string.""" + raise FormatStringError(msg, COMPILE.control, COMPILE.start) + +###-------------------------------------------------------------------------- +### Argument collection protocol. + +## Argument collectors abstract away the details of collecting formatting +## arguments. They're used both for collecting arguments to be output, and +## for parameters designated using the `v' or `!ARG' syntaxes. +## +## There are a small number of primitive collectors, and some `compound +## collectors' which read an argument using some other collector, and then +## process it in some way. +## +## An argument collector should implement the following methods. +## +## get() Return the argument variable. +## +## pair() Return a pair of arguments. +## +## tostr(FORCEP) +## Return a string representation of the collector. If FORCEP, +## always return a string; otherwise, a `NextArg' collector +## returns `None' to indicate that no syntax is required to +## select it. + +class BaseArg (object): + """ + Base class for argument collectors. + + This implements the `pair' method by calling `get' and hoping that the + corresponding argument is indeed a sequence of two items. + """ + + def __init__(me): + """Trivial constructor.""" + pass + + def pair(me): + """ + Return a pair of arguments, by returning an argument which is a pair. + """ + return me.get() + + def __repr__(me): + """Print a useful string representation of the collector.""" + return '#<%s "=%s">' % (type(me).__name__, me.tostr(True)) + +class NextArg (BaseArg): + """The default argument collector.""" + + def get(me): + """ + Return the next argument. + + If there are pushed-back arguments, then return the one most recently + pushed back. Otherwise, return the next argument from `argseq', + advancing `argpos'. + """ + if FORMAT.pushback: return FORMAT.pushback.pop() + i = FORMAT.argpos + a = FORMAT.argseq[i] + FORMAT.argpos = i + 1 + return a + + def pair(me): + """Return a pair of arguments, by fetching two separate arguments.""" + left = me.get() + right = me.get() + return left, right + + def tostr(me, forcep): + """Convert the default collector to a string.""" + if forcep: return '+' + else: return None + +NEXTARG = NextArg() +## Because a `NextArg' collectors are used so commonly, and they're all the +## same, we make a distinguished one and try to use that instead. Nothing +## goes badly wrong if you don't use this, but you'll use more memory than +## strictly necessary. + +class ThisArg (BaseArg): + """Return the current positional argument without consuming it.""" + def _get(me, i): + """Return the positional argument I on from the current position.""" + n = len(FORMAT.pushback) + if n > i: return FORMAT.pushback[n - i - 1] + else: return FORMAT.argseq[FORMAT.argpos + i - n] + def get(me): + """Return the next argument.""" + return me._get(0) + def pair(me): + """Return the next two arguments without consuming either.""" + return me._get(0), me._get(1) + def tostr(me, forcep): + """Convert the colector to a string.""" + return '@' + +THISARG = ThisArg() + +class SeqArg (BaseArg): + """ + A primitive collector which picks out the positional argument at a specific + index. + """ + def __init__(me, index): me.index = index + def get(me): return FORMAT.argseq[me.index] + def tostr(me, forcep): return '%d' % me.index + +class MapArg (BaseArg): + """ + A primitive collector which picks out the keyword argument with a specific + key. + """ + def __init__(me, key): me.key = key + def get(me): return FORMAT.argmap[me.key] + def tostr(me, forcep): return '%s' % me.key + +class IndexArg (BaseArg): + """ + A compound collector which indexes an argument. + """ + def __init__(me, base, index): + me.base = base + me.index = index + def get(me): + return me.base.get()[me.index] + def tostr(me, forcep): + return '%s[%s]' % (me.base.tostr(True), me.index) + +class AttrArg (BaseArg): + """ + A compound collector which returns an attribute of an argument. + """ + def __init__(me, base, attr): + me.base = base + me.attr = attr + def get(me): + return getattr(me.base.get(), me.attr) + def tostr(me, forcep): + return '%s.%s' % (me.base.tostr(True), me.attr) + +## Regular expression matching compound-argument suffixes. +R_REF = RX.compile(r''' + \[ ( [-+]? [0-9]+ ) \] + | \[ ( [^]]* ) \] + | \. ( [_a-zA-Z] [_a-zA-Z0-9]* ) +''', RX.VERBOSE) + +def parse_arg(): + """ + Parse an argument collector from the current format control string. + + The syntax of an argument is as follows. + + ARG ::= COMPOUND-ARG | `{' COMPOUND-ARG `}' + + COMPOUND-ARG ::= SIMPLE-ARG + | COMPOUND-ARG `[' INDEX `]' + | COMPOUND-ARG `.' WORD + + SIMPLE-ARG ::= INT | WORD | `+' | `@' + + Surrounding braces mean nothing, but may serve to separate the argument + from a following alphabetic formatting directive. + + A `+' means `the next pushed-back or positional argument'. It's useful to + be able to say this explicitly so that indexing and attribute references + can be attached to it: for example, in `~={thing}@[~={+.attr}A~]'. + + An integer argument selects the positional argument with that index; a + negative index counts backwards from the end, as is usual in Python. + + A word argument selects the keyword argument with that key. + """ + + c = COMPILE.control + s, e = COMPILE.start, COMPILE.end + + ## If it's delimited then pick through the delimiter. + brace = None + if s < e and c[s] == '{': + brace = '}' + s += 1 + + ## Make sure there's something to look at. + if s >= e: raise FormatStringError('missing argument specifier', c, s) + + ## Find the start of the breadcrumbs. + if c[s] == '+': + getarg = NEXTARG + s += 1 + if c[s] == '@': + getarg = THISARG + s += 1 + elif c[s].isdigit(): + m = R_INT.match(c, s, e) + getarg = SeqArg(int(m.group())) + s = m.end() + else: + m = R_WORD.match(c, s, e) + if not m: raise FormatStringError('unknown argument specifier', c, s) + getarg = MapArg(m.group()) + s = m.end() + + ## Now parse indices and attribute references. + while True: + m = R_REF.match(c, s, e) + if not m: break + if m.group(1): getarg = IndexArg(getarg, int(m.group(1))) + elif m.group(2): getarg = IndexArg(getarg, m.group(2)) + elif m.group(3): getarg = AttrArg(getarg, m.group(3)) + else: raise FormatStringError('internal error (weird ref)', c, s) + s = m.end() + + ## Finally, check that we have the close delimiter we want. + if brace: + if s >= e or c[s] != brace: + raise FormatStringError('missing close brace', c, s) + s += 1 + + ## Done. + COMPILE.start = s + return getarg + +###-------------------------------------------------------------------------- +### Parameter collectors. + +## These are pretty similar in shape to argument collectors. The required +## methods are as follows. +## +## get() Return the parameter value. +## +## tostr() Return a string representation of the collector. (We don't +## need a FORCEP argument here, because there are no default +## parameters.) + +class BaseParameter (object): + """ + Base class for parameter collector objects. + + This isn't currently very useful, because all it provides is `__repr__', + but the protocol might get more complicated later. + """ + def __init__(me): pass + def __repr__(me): return '#<%s "%s">' % (type(me).__name__, me.tostr()) + +class LiteralParameter (BaseParameter): + """ + A literal parameter, parsed from the control string. + """ + def __init__(me, lit): me.lit = lit + def get(me): return me.lit + def tostr(me): + if me.lit is None: return '' + elif isinstance(me.lit, (int, long)): return str(me.lit) + else: return "'%c" % me.lit + +## Many parameters are omitted, so let's just reuse a distinguished collector +## for them. +LITNONE = LiteralParameter(None) + +class RemainingParameter (BaseParameter): + """ + A parameter which collects the number of remaining positional arguments. + """ + def get(me): return remaining() + def tostr(me): return '#' + +## These are all the same, so let's just have one of them. +REMAIN = RemainingParameter() + +class VariableParameter (BaseParameter): + """ + A variable parameter, fetched from an argument. + """ + def __init__(me, arg): me.arg = arg + def get(me): return me.arg.get() + def tostr(me): + s = me.arg.tostr(False) + if not s: return 'V' + else: return '!' + s +VARNEXT = VariableParameter(NEXTARG) + +###-------------------------------------------------------------------------- +### Formatting protocol. + +## The formatting operation protocol is pretty straightforward. An operation +## must implement a method `format' which takes no arguments, and should +## produce its output (if any) by calling `FORMAT.write'. In the course of +## its execution, it may collect parameters and arguments. +## +## The `opmaps' table maps formatting directives (which are individual +## characters, in upper-case for letters) to functions returning formatting +## operation objects. All of the directives are implemented in this way. +## The functions for the base directives are actually the (callable) class +## objects for subclasses of `BaseFormatOperation', though this isn't +## necessary. +## +## The constructor functions are called as follows: +## +## FUNC(ATP, COLONP, GETARG, PARAMS, CHAR) +## The ATP and COLONP arguments are booleans indicating respectively +## whether the `@' and `:' modifiers were set in the control string. +## GETARG is the collector for the operation's argument(s). The PARAMS +## are a list of parameter collectors. Finally, CHAR is the directive +## character (so directives with siilar behaviour can use the same +## class). + +class FormatLiteral (object): + """ + A special formatting operation for printing literal text. + """ + def __init__(me, s): me.s = s + def __repr__(me): return '#<%s %r>' % (type(me).__name__, me.s) + def format(me): FORMAT.write(me.s) + +class FormatSequence (object): + """ + A special formatting operation for applying collection of other operations + in sequence. + """ + def __init__(me, seq): + me.seq = seq + def __repr__(me): + return '#<%s [%s]>' % (type(me).__name__, + ', '.join(repr(p) for p in me.seq)) + def format(me): + for p in me.seq: p.format() + +class BaseFormatOperation (object): + """ + The base class for built-in formatting operations (and, probably, most + extensions). + + Subclasses should implement a `_format' method. + + _format(ATP, COLONP, [PARAM = DEFAULT, ...]) + Called to produce output. The ATP and COLONP flags are from + the constructor. The remaining function arguments are the + computed parameter values. Arguments may be collected using + the `getarg' attribute. + + Subclasses can set class attributes to influence the constructor. + + MINPARAM The minimal number of parameters acceptable. If fewer + parameters are supplied then an error is reported at compile + time. The default is zero. + + MAXPARAM The maximal number of parameters acceptable. If more + parameters are supplied then an error is reported at compile + time. The default is zero; `None' means that there is no + maximum (but this is unusual). + + Instances have a number of useful attributes. + + atp True if an `@' modifier appeared in the directive. + + char The directive character from the control string. + + colonp True if a `:' modifier appeared in the directive. + + getarg Argument collector; may be called by `_format'. + + params A list of parameter collector objects. + """ + + ## Default bounds on parameters. + MINPARAM = MAXPARAM = 0 + + def __init__(me, atp, colonp, getarg, params, char): + """ + Constructor: store information about the directive, and check the bounds + on the parameters. + + A subclass should call this before doing anything fancy such as parsing + the control string further. + """ + + ## Store information. + me.atp = atp + me.colonp = colonp + me.getarg = getarg + me.params = params + me.char = char + + ## Check the parameters. + bad = False + if len(params) < me.MINPARAM: bad = True + elif me.MAXPARAM is not None and len(params) > me.MAXPARAM: bad = True + if bad: + format_string_error('bad parameters') + + def format(me): + """Produce output: call the subclass's formatting function.""" + me._format(me.atp, me.colonp, *[p.get() for p in me.params]) + + def tostr(me): + """Convert the operation to a directive string.""" + return '~%s%s%s%s%s' % ( + ','.join(a.tostr() for a in me.params), + me.colonp and ':' or '', + me.atp and '@' or '', + (lambda s: s and '={%s}' % s or '')(me.getarg.tostr(False)), + me.char) + + def __repr__(me): + """Produce a readable (ahem) version of the directive.""" + return '#<%s "%s">' % (type(me).__name__, me.tostr()) + +class FormatDelimiter (BaseFormatOperation): + """ + A fake formatting operation which exists to impose additional syntactic + structure on control strings. + + No `_format' method is actually defined, so `FormatDelimiter' objects + should never find their way into the output pipeline. Instead, they are + typically useful in conjunction with the `collect_subformat' function. To + this end, the constructor will fail if its directive character is not in + listed as an expected delimiter in `CONTROL.delim'. + """ + + def __init__(me, *args): + """ + Constructor: make sure this delimiter is expected in the current context. + """ + super(FormatDelimiter, me).__init__(*args) + if me.char not in COMPILE.delim: + format_string_error("unexpected close delimiter `~%s'" % me.char) + +###-------------------------------------------------------------------------- +### Parsing format strings. + +def parse_operator(): + """ + Parse the next portion of the current control string and return a single + formatting operator for it. + + If we have reached the end of the control string (as stored in + `CONTROL.end') then return `None'. + """ + + c = COMPILE.control + s, e = COMPILE.start, COMPILE.end + + ## If we're at the end then stop. + if s >= e: return None + + ## If there's some literal text then collect it. + if c[s] != '~': + i = c.find('~', s, e) + if i < 0: i = e + COMPILE.start = i + return FormatLiteral(c[s:i]) + + ## Otherwise there's a formatting directive to collect. + s += 1 + + ## First, collect arguments. + aa = [] + while True: + if s >= e: break + if c[s] == ',': + aa.append(LITNONE) + s += 1 + continue + elif c[s] == "'": + s += 1 + if s >= e: raise FormatStringError('missing argument character', c, s) + aa.append(LiteralParameter(c[s])) + s += 1 + elif c[s].upper() == 'V': + s += 1 + aa.append(VARNEXT) + elif c[s] == '!': + COMPILE.start = s + 1 + getarg = parse_arg() + s = COMPILE.start + aa.append(VariableParameter(getarg)) + elif c[s] == '#': + s += 1 + aa.append(REMAIN) + else: + m = R_INT.match(c, s, e) + if not m: break + aa.append(LiteralParameter(int(m.group()))) + s = m.end() + if s >= e or c[s] != ',': break + s += 1 + + ## Maybe there's an explicit argument. + if s < e and c[s] == '=': + COMPILE.start = s + 1 + getarg = parse_arg() + s = COMPILE.start + else: + getarg = NEXTARG + + ## Next, collect the flags. + atp = colonp = False + while True: + if s >= e: + break + elif c[s] == '@': + if atp: raise FormatStringError('duplicate at flag', c, s) + atp = True + elif c[s] == ':': + if colonp: raise FormatStringError('duplicate colon flag', c, s) + colonp = True + else: + break + s += 1 + + ## We should now have a directive character. + if s >= e: raise FormatStringError('missing directive', c, s) + ch = c[s].upper() + op = None + for map in COMPILE.opmaps: + try: op = map[ch] + except KeyError: pass + else: break + else: + raise FormatStringError('unknown directive', c, s) + s += 1 + + ## Done. + COMPILE.start = s + return op(atp, colonp, getarg, aa, ch) + +def collect_subformat(delim): + """ + Parse formatting operations from the control string until we find one whose + directive character is listed in DELIM. + + Where an operation accepts multiple sequences of formatting directives, the + first element of DELIM should be the proper closing delimiter. The + traditional separator is `~;'. + """ + pp = [] + with COMPILE.bind(delim = delim): + while True: + p = parse_operator() + if not p: + format_string_error("missing close delimiter `~%s'" % delim[0]) + if isinstance(p, FormatDelimiter) and p.char in delim: break + pp.append(p) + return FormatSequence(pp), p + +def compile(control): + """ + Parse the whole CONTROL string, returning the corresponding formatting + operator. + """ + pp = [] + with COMPILE.bind(control = control, start = 0, end = len(control), + delim = ''): + while True: + p = parse_operator() + if not p: break + pp.append(p) + return FormatSequence(pp) + +###-------------------------------------------------------------------------- +### Formatting text. + +def format(out, control, *args, **kw): + """ + Format the positional args and keywords according to the CONTROL, and write + the result to OUT. + + The output is written to OUT, which may be one of the following. + + `True' Write to standard output. + + `False' Write to standard error. + + `None' Return the output as a string. + + Any object with a `write' attribute + Call `write' repeatedly with strings to be output. + + Any callable object + Call the object repeatedly with strings to be output. + + The CONTROL argument may be one of the following. + + A string or unicode object + Compile the string into a formatting operation and use that. + + A formatting operation + Apply the operation to the arguments. + """ + + ## Turn the output argument into a function which we can use easily. If + ## we're writing to a string, we'll have to extract the result at the end, + ## so keep track of anything we have to do later. + final = U.constantly(None) + if out is True: + write = SYS.stdout.write + elif out is False: + write = SYS.stderr.write + elif out is None: + strio = StringIO() + write = strio.write + final = strio.getvalue + elif hasattr(out, 'write'): + write = out.write + elif callable(out): + write = out + else: + raise TypeError, out + + ## Turn the control argument into a formatting operation. + if isinstance(control, basestring): + op = compile(control) + else: + op = control + + ## Invoke the formatting operation in the correct environment. + with FORMAT.bind(write = write, pushback = [], + argseq = args, argpos = 0, + argmap = kw): + op.format() + + ## Done. + return final() + +###-------------------------------------------------------------------------- +### Standard formatting directives. + +## A dictionary, in which we'll build the basic set of formatting operators. +## Callers wishing to implement extensions should include this in their +## `opmaps' lists. +BASEOPS = {} +COMPILE.opmaps = [BASEOPS] + +## Some standard delimiter directives. +for i in [']', ')', '}', '>', ';']: BASEOPS[i] = FormatDelimiter + +class SimpleFormatOperation (BaseFormatOperation): + """ + Common base class for the `~A' (`str') and `~S' (`repr') directives. + + These take similar parameters, so it's useful to deal with them at the same + time. Subclasses should implement a method `_convert' of one argument, + which returns a string to be formatted. + + The parameters are as follows. + + MINCOL The minimum number of characters to output. Padding is added + if the output string is shorter than this. + + COLINC Lengths of padding groups. The number of padding characters + will be MINPAD more than a multiple of COLINC. + + MINPAD The smallest number of padding characters to write. + + PADCHAR The padding character. + + If the `@' modifier is given, then padding is applied on the left; + otherwise it is applied on the right. + """ + + MAXPARAM = 4 + + def _format(me, atp, colonp, + mincol = 0, colinc = 1, minpad = 0, padchar = ' '): + what = me._convert(me.getarg.get()) + n = len(what) + p = mincol - n - minpad + colinc - 1 + p -= p%colinc + if p < 0: p = 0 + p += minpad + if p <= 0: pass + elif atp: what = (p * padchar) + what + else: what = what + (p * padchar) + FORMAT.write(what) + +class FormatString (SimpleFormatOperation): + """~A: convert argument to a string.""" + def _convert(me, arg): return str(arg) +BASEOPS['A'] = FormatString + +class FormatRepr (SimpleFormatOperation): + """~S: convert argument to readable form.""" + def _convert(me, arg): return repr(arg) +BASEOPS['S'] = FormatRepr + +class IntegerFormat (BaseFormatOperation): + """ + Common base class for the integer formatting directives `~D', `~B', `~O~, + `~X', and `~R'. + + These take similar parameters, so it's useful to deal with them at the same + time. There is a `_convert' method which does the main work. By default, + `_format' calls this with the argument and the value of the class attribute + `RADIX'; complicated subclasses might want to override this behaviour. + + The parameters are as follows. + + MINCOL Minimum column width. If the output is smaller than this + then it will be padded on the left. The default is 0. + + PADCHAR Character to use to pad the output, should this be necessary. + The default is space. + + COMMACHAR If the `:' modifier is present, then use this character to + separate groups of digits. The default is `,'. + + COMMAINTERVAL If the `:' modifier is present, then separate groups of this + many digits. The default is 3. + + If `@' is present, then a sign is always written; otherwise only `-' signs + are written. + """ + + MAXPARAM = 4 + + def _convert(me, n, radix, atp, colonp, + mincol = 0, padchar = ' ', + commachar = ',', commainterval = 3): + """ + Convert the integer N into the given RADIX, under the control of the + formatting parameters supplied. + """ + + ## Sort out the sign. We'll deal with it at the end: for now it's just a + ## distraction. + if n < 0: sign = '-'; n = -n + elif atp: sign = '+' + else: sign = None + + ## Build in `dd' a list of the digits, in reverse order. This will make + ## the commafication easier later. The general radix conversion is + ## inefficient but we can make that better later. + def revdigits(s): + l = list(s) + l.reverse() + return l + if radix == 10: dd = revdigits(str(n)) + elif radix == 8: dd = revdigits(oct(n)) + elif radix == 16: dd = revdigits(hex(n).upper()) + else: + dd = [] + while n: + q, r = divmod(n, radix) + if r < 10: ch = asc(ord('0') + r) + elif r < 36: ch = asc(ord('A') - 10 + r) + else: ch = asc(ord('a') - 36 + r) + dd.append(ch) + if not dd: dd.append('0') + + ## If we must commafy then do that. + if colonp: + ndd = [] + i = 0 + for d in dd: + if i >= commainterval: ndd.append(commachar); i = 0 + ndd.append(d) + dd = ndd + + ## Include the sign. + if sign: dd.append(sign) + + ## Maybe we must pad the result. + s = ''.join(reversed(dd)) + npad = mincol - len(s) + if npad > 0: s = npad*padchar + s + + ## And we're done. + FORMAT.write(s) + + def _format(me, atp, colonp, mincol = 0, padchar = ' ', + commachar = ',', commainterval = 3): + me._convert(me.getarg.get(), me.RADIX, atp, colonp, mincol, padchar, + commachar, commainterval) + +class FormatDecimal (IntegerFormat): + """~D: Decimal formatting.""" + RADIX = 10 +BASEOPS['D'] = FormatDecimal + +class FormatBinary (IntegerFormat): + """~B: Binary formatting.""" + RADIX = 2 +BASEOPS['B'] = FormatBinary + +class FormatOctal (IntegerFormat): + """~O: Octal formatting.""" + RADIX = 8 +BASEOPS['O'] = FormatOctal + +class FormatHex (IntegerFormat): + """~X: Hexadecimal formatting.""" + RADIX = 16 +BASEOPS['X'] = FormatHex + +class FormatRadix (IntegerFormat): + """~R: General integer formatting.""" + MAXPARAM = 5 + def _format(me, atp, colonp, radix = None, mincol = 0, padchar = ' ', + commachar = ',', commainterval = 3): + if radix is None: + raise ValueError, 'Not implemented' + me._convert(me.getarg.get(), radix, atp, colonp, mincol, padchar, + commachar, commainterval) +BASEOPS['R'] = FormatRadix + +class FormatSuppressNewline (BaseFormatOperation): + """ + ~newline: suppressed newline and/or spaces. + + Unless the `@' modifier is present, don't print the newline. Unless the + `:' modifier is present, don't print the following string of whitespace + characters either. + """ + R_SPACE = RX.compile(r'\s*') + def __init__(me, *args): + super(FormatSuppressNewline, me).__init__(*args) + m = me.R_SPACE.match(COMPILE.control, COMPILE.start, COMPILE.end) + me.trail = m.group() + COMPILE.start = m.end() + def _format(me, atp, colonp): + if atp: FORMAT.write('\n') + if colonp: FORMAT.write(me.trail) +BASEOPS['\n'] = FormatSuppressNewline + +class LiteralFormat (BaseFormatOperation): + """ + A base class for formatting operations which write fixed strings. + + Subclasses should have an attribute `CHAR' containing the string (usually a + single character) to be written. + + These operations accept a single parameter: + + COUNT The number of copies of the string to be written. + """ + MAXPARAM = 1 + def _format(me, atp, colonp, count = 1): + FORMAT.write(count * me.CHAR) + +class FormatNewline (LiteralFormat): + """~%: Start a new line.""" + CHAR = '\n' +BASEOPS['%'] = FormatNewline + +class FormatTilde (LiteralFormat): + """~~: Print a literal `@'.""" + CHAR = '~' +BASEOPS['~'] = FormatTilde + +class FormatCaseConvert (BaseFormatOperation): + """ + ~(...~): Case-convert the contained output. + + The material output by the contained directives is subject to case + conversion as follows. + + no modifiers Convert to lower-case. + @ Make initial letter upper-case and remainder lower. + : Make initial letters of words upper-case. + @: Convert to upper-case. + """ + def __init__(me, *args): + super(FormatCaseConvert, me).__init__(*args) + me.sub, _ = collect_subformat(')') + def _format(me, atp, colonp): + strio = StringIO() + try: + with FORMAT.bind(write = strio.write): + me.sub.format() + finally: + inner = strio.getvalue() + if atp: + if colonp: out = inner.upper() + else: out = inner.capitalize() + else: + if colonp: out = inner.title() + else: out = inner.lower() + FORMAT.write(out) +BASEOPS['('] = FormatCaseConvert + +class FormatGoto (BaseFormatOperation): + """ + ~*: Seek in positional arguments. + + There may be a parameter N; the default value depends on which modifiers + are present. Without `@', skip forwards or backwards by N (default + 1) places; with `@', move to argument N (default 0). With `:', negate N, + so move backwards instead of forwards, or count from the end rather than + the beginning. (Exception: `~@:0*' leaves no arguments remaining, whereas + `~@-0*' is the same as `~@0*', and starts again from the beginning. + + BUG: The list of pushed-back arguments is cleared. + """ + MAXPARAM = 1 + def _format(me, atp, colonp, n = None): + if atp: + if n is None: n = 0 + if colonp: + if n > 0: n = -n + else: n = len(FORMAT.argseq) + if n < 0: n += len(FORMAT.argseq) + else: + if n is None: n = 1 + if colonp: n = -n + n += FORMAT.argpos + FORMAT.argpos = n + FORMAT.pushback = [] +BASEOPS['*'] = FormatGoto + +class FormatConditional (BaseFormatOperation): + """ + ~[...[~;...]...[~:;...]~]: Conditional formatting. + + There are three variants, which are best dealt with separately. + + With no modifiers, apply the Nth enclosed piece, where N is either the + parameter, or the argument if no parameter is provided. If there is no + such piece (i.e., N is negative or too large) and the final piece is + introduced by `~:;' then use that piece; otherwise produce no output. + + With `:', there must be exactly two pieces: apply the first if the argument + is false, otherwise the second. + + With `@', there must be exactly one piece: if the argument is not `None' + then push it back and apply the enclosed piece. + """ + + MAXPARAM = 1 + + def __init__(me, *args): + + ## Store the arguments. + super(FormatConditional, me).__init__(*args) + + ## Collect the pieces, and keep track of whether there's a default piece. + pieces = [] + default = None + nextdef = False + while True: + piece, delim = collect_subformat('];') + if nextdef: default = piece + else: pieces.append(piece) + if delim.char == ']': break + if delim.colonp: + if default: format_string_error('multiple defaults') + nextdef = True + + ## Make sure the syntax matches the modifiers we've been given. + if (me.colonp or me.atp) and default: + format_string_error('default not allowed here') + if (me.colonp and len(pieces) != 2) or \ + (me.atp and len(pieces) != 1): + format_string_error('wrong number of pieces') + + ## Store stuff. + me.pieces = pieces + me.default = default + + def _format(me, atp, colonp, n = None): + if colonp: + arg = me.getarg.get() + if arg: me.pieces[1].format() + else: me.pieces[0].format() + elif atp: + arg = me.getarg.get() + if arg is not None: + FORMAT.pushback.append(arg) + me.pieces[0].format() + else: + if n is None: n = me.getarg.get() + if 0 <= n < len(me.pieces): piece = me.pieces[n] + else: piece = me.default + if piece: piece.format() +BASEOPS['['] = FormatConditional + +class FormatIteration (BaseFormatOperation): + """ + ~{...~}: Repeated formatting. + + Repeatedly apply the enclosed formatting directives to a sequence of + different arguments. The directives may contain `~^' to escape early. + + Without `@', an argument is fetched and is expected to be a sequence; with + `@', the remaining positional arguments are processed. + + Without `:', the enclosed directives are simply applied until the sequence + of arguments is exhausted: each iteration may consume any number of + arguments (even zero, though this is likely a bad plan) and any left over + are available to the next iteration. With `:', each element of the + sequence of arguments is itself treated as a collection of arguments -- + either positional or keyword depending on whether it looks like a map -- + and exactly one such element is consumed in each iteration. + + If a parameter is supplied then perform at most this many iterations. If + the closing delimeter bears a `:' modifier, and the parameter is not zero, + then the enclosed directives are applied once even if the argument sequence + is empty. + + If the formatting directives are empty then a formatting string is fetched + using the argument collector associated with the closing delimiter. + """ + + MAXPARAM = 1 + + def __init__(me, *args): + super(FormatIteration, me).__init__(*args) + me.body, me.end = collect_subformat('}') + + def _multi(me, body): + """ + Treat the positional arguments as a sequence of argument sets to be + processed. + """ + args = NEXTARG.get() + with U.Escape() as esc: + with bind_args(args, multi_escape = FORMAT.escape, escape = esc, + last_multi_p = not remaining()): + body.format() + + def _single(me, body): + """ + Format arguments from a single argument sequence. + """ + body.format() + + def _loop(me, each, max): + """ + Apply the function EACH repeatedly. Stop if no positional arguments + remain; if MAX is not `None', then stop after that number of iterations. + The EACH function is passed a formatting operation representing the body + to be applied + """ + if me.body.seq: body = me.body + else: body = compile(me.end.getarg.get()) + oncep = me.end.colonp + i = 0 + while True: + if max is not None and i >= max: break + if (i > 0 or not oncep) and not remaining(): break + each(body) + i += 1 + + def _format(me, atp, colonp, max = None): + if colonp: each = me._multi + else: each = me._single + with U.Escape() as esc: + with FORMAT.bind(escape = esc): + if atp: + me._loop(each, max) + else: + with bind_args(me.getarg.get()): + me._loop(each, max) +BASEOPS['{'] = FormatIteration + +class FormatEscape (BaseFormatOperation): + """ + ~^: Escape from iteration. + + Conditionally leave an iteration early. + + There may be up to three parameters: call then X, Y and Z. If all three + are present then exit unless Y is between X and Z (inclusive); if two are + present then exit if X = Y; if only one is present, then exit if X is + zero. Obviously these are more useful if at least one of X, Y and Z is + variable. + + With no parameters, exit if there are no positional arguments remaining. + With `:', check the number of argument sets (as read by `~:{...~}') rather + than the number of arguments in the current set, and escape from the entire + iteration rather than from the processing the current set. + """ + MAXPARAM = 3 + def _format(me, atp, colonp, x = None, y = None, z = None): + if z is not None: cond = x <= y <= z + elif y is not None: cond = x != y + elif x is not None: cond = x != 0 + elif colonp: cond = not FORMAT.last_multi_p + else: cond = remaining() + if cond: return + if colonp: FORMAT.multi_escape() + else: FORMAT.escape() +BASEOPS['^'] = FormatEscape + +class FormatRecursive (BaseFormatOperation): + """ + ~?: Recursive formatting. + + Without `@', read a pair of arguments: use the first as a format string, + and apply it to the arguments extracted from the second (which may be a + sequence or a map). + + With `@', read a single argument: use it as a format string and apply it to + the remaining arguments. + """ + def _format(me, atp, colonp): + with U.Escape() as esc: + if atp: + control = me.getarg.get() + op = compile(control) + with FORMAT.bind(escape = esc): op.format() + else: + control, args = me.getarg.pair() + op = compile(control) + with bind_args(args, escape = esc): op.format() +BASEOPS['?'] = FormatRecursive + +###----- That's all, folks -------------------------------------------------- diff --git a/get-version b/get-version new file mode 100755 index 0000000..a166923 --- /dev/null +++ b/get-version @@ -0,0 +1,10 @@ +#! /bin/sh -e + +if [ -d .git ] && version=$(git describe --abbrev=4 2>/dev/null); then + case "$(git diff-index --name-only HEAD)" in ?*) version=$version+ ;; esac +elif [ -f RELEASE ]; then + version=$(cat RELEASE) +else + version=UNKNOWN-VERSION +fi +echo "$version" diff --git a/hash.py b/hash.py new file mode 100644 index 0000000..0b3142e --- /dev/null +++ b/hash.py @@ -0,0 +1,337 @@ +### -*-python-*- +### +### Password hashing schemes +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +from __future__ import with_statement + +import crypt as CR +import hashlib as H +import os as OS + +import config as CONF +import crypto as C +import util as U + +###-------------------------------------------------------------------------- +### Protocol. +### +### A password hashing scheme knows about how to convert a plaintext password +### into whatever it is that gets stored in the database. The important +### consideration is that this conversion is potentially randomized, using a +### `salt'. +### +### There are two contexts in which we want to do the conversion. The first +### case is that we've somehow come up with a new password, and wish to write +### it to the database; we therefore need to come up with fresh random salt. +### The second case is that we're verifying a password against the database; +### here, we must extract and reuse the salt used when the database record +### was written. This latter case is only used for verifying passwords in +### the CGI interface, so it might be acceptable to fail to implement it in +### some cases, but we don't need this freedom. +### +### It turns out that there's a useful division of labour we can make, +### because hashing is a two-stage process. The first stage takes a salt and +### a password, and processes them somehow to form a hash. The second stage +### takes this hash, and encodes and decorates it to form something which can +### usefully be stored in a database. Most of the latter processing is +### handled in the `BasicHash' class. +### +### Hashing is not done in isolation: rather, it's done within the context of +### a database record, so that additional material (e.g., the user name, or +### some `realm' indicator) can be fed into the hashing process. None of the +### hashing schemes here do this, but user configuration can easily subclass, +### say, `SimpleHash' to implement something like Dovecot's DIGEST-MD5 +### scheme. +### +### The external password hashing protocol consists of the following pieces. +### +### hash(REC, PASSWD) +### Method: return a freshly salted hash for the password PASSWD, using +### information from the database record REC. +### +### check(REC, HASH, PASSWD) +### Method: if the password PASSWD (and other information in the database +### record REC) matches the HASH, return true; otherwise return false. +### +### NULL +### Attribute: a string which can (probably) be stored safely in the +### password database, but which doesn't equal any valid password hash. +### This is used for clearing passwords. If None, there is no such +### value, and passwords cannot be cleared using this hashing scheme. + +class BasicHash (object): + """ + Convenient base class for password hashing schemes. + + This class implements the `check' and `hash' methods in terms of `_check' + and `_hash' methods, applying an optional encoding and attaching prefix and + suffix strings. The underscore methods have the same interface, but work + in terms of raw binary password hashes. + + There is a trivial implementation of `_check' included which is suitable + for unsalted hashing schemes. + + The `NULL' attribute is defined as `*', which commonly works for nontrivial + password hashes, since it falls outside of the alphabet used in many + encodings, and is anyway too short to match most fixed-length hash + functions. Subclasses should override this if it isn't suitable. + """ + + NULL = '*' + + def __init__(me, encoding = None, prefix = '', suffix = ''): + """ + Initialize a password hashing scheme object. + + A raw password hash is cooked by (a) applying an ENCODING (e.g., + `base64') and then (b) attaching a PREFIX and SUFFIX to the encoded + hash. This cooked hash is presented for storage in the database. + """ + me._prefix = prefix + me._suffix = suffix + me._enc = U.ENCODINGS[encoding] + + def hash(me, rec, passwd): + """Hash the PASSWD using `_hash', and cook the resulting hash.""" + return me._prefix + me._enc.encode(me._hash(rec, passwd)) + me._suffix + + def check(me, rec, hash, passwd): + """ + Uncook the HASH and present the raw version to `_check' for checking. + """ + if not hash.startswith(me._prefix) or \ + not hash.endswith(me._suffix): + return False + raw = me._enc.decode(hash[len(me._prefix):len(hash) - len(me._suffix)]) + return me._check(rec, raw, passwd) + + def _check(me, rec, hash, passwd): + """Trivial password checking: assumes that hashing is deterministic.""" + return me._hash(rec, passwd) == hash + +###-------------------------------------------------------------------------- +### The trivial scheme. + +class TrivialHash (BasicHash): + """ + The trivial hashing scheme doesn't apply any hashing. + + This is sometimes called `plain' format. + """ + NULL = None + def _hash(me, rec, passwd): return passwd + +CONF.export('TrivialHash') + +###-------------------------------------------------------------------------- +### The Unix crypt(3) scheme. + +class CryptScheme (object): + """ + Represents a particular version of the Unix crypt(3) hashing scheme. + + The Unix crypt(3) function has grown over the ages, and now implements many + different hashing schemes, with their own idiosyncratic salt conventions. + Fortunately, most of them fit into a common framework, implemented here. + + We assume that a valid salt consists of: a constant prefix, a fixed number + of symbols chosen from a standard alphabet of 64, and a constant suffix. + """ + + ## The salt alphabet. This is not quite the standard Base64 alphabet, for + ## some reason, but at least it doesn't have the pointless trailing `=' + ## signs. + CHARS = '0123456789ABCDEFHIJKLMNOPQRSTUVWXYZabcdefghiklmnopqrstuvwxyz./' + + def __init__(me, prefix, saltlen, suffix): + """ + Initialize a crypt(3) scheme object. A salt will consist of the PREFIX, + followed by SALTLEN characters from `CHARS', followed by the SUFFIX. + """ + me._prefix = prefix + me._saltlen = saltlen + me._suffix = suffix + + def salt(me): + """ + Return a fresh salt, according to the format appropriate for this + crypt(3) scheme. + """ + nbytes = (me._saltlen*3 + 3)//4 + bytes = OS.urandom(nbytes) + salt = [] + a = 0 + abits = 0 + j = 0 + for i in xrange(me._saltlen): + if abits < 6: + next = ord(bytes[j]) + j += 1 + a |= next << abits + abits += 8 + salt.append(me.CHARS[abits & 0x3f]) + a >>= 6 + abits -= 6 + return me._prefix + ''.join(salt) + me._suffix + +class CryptHash (BasicHash): + """ + Represents a hashing scheme based on the Unix crypt(3) function. + + The parameters are a PREFIX and SUFFIX to be applied to the output hash, + and a SCHEME, which may be any function which produces an appropriate salt + string, or the name of the one of the built-in schemes listed in the + `SCHEME' dictionary. + + The encoding ability inherited from `BasicHash' is not used here, since + crypt(3) already encodes hashes in its own strange way. + """ + + ## A table of built-in schemes. + SCHEME = { + 'des': CryptScheme('', 2, ''), + 'md5': CryptScheme('$1$', 8, '$'), + 'sha256': CryptScheme('$5$', 16, '$'), + 'sha512': CryptScheme('$6$', 16, '$') + } + + def __init__(me, scheme, *args, **kw): + """Initialize a crypt(3) hashing scheme.""" + super(CryptHash, me).__init__(encoding = None, *args, **kw) + try: me._salt = me.SCHEME[scheme].salt + except KeyError: me._salt = scheme + + def _check(me, rec, hash, passwd): + """Check a password, by asking crypt(3) to do it.""" + return CR.crypt(passwd, hash) == hash + + def _hash(me, rec, passwd): + """Hash a password using fresh salt.""" + return CR.crypt(passwd, me._salt()) + +CONF.export('CryptHash') + +###-------------------------------------------------------------------------- +### Simple application of a cryptographic hash. + +class SimpleHash (BasicHash): + """ + Represents a password hashing scheme which uses a cryptographic hash + function simplistically, though possibly with salt. + + There are many formatting choices available here, so we've picked one + (which happens to match Dovecot's conventions) and hidden it behind a + simple bit of protocol so that users can define their own variants. A + subclass could easily implement, say, the DIGEST-MD5 convention. + + See the `hash_preimage', `hash_format' and `hash_parse' methods for details + of the formatting protocol. + """ + + def __init__(me, hash, saltlen = 0, encoding = 'base64', *args, **kw): + """ + Initialize a simple hashing scheme. + + The ENCODING, PREFIX, and SUFFIX arguments are standard. The (required) + HASH argument selects a hash function known to Python's `hashlib' + module. The SALTLEN is the length of salt to generate, in octets. + """ + super(SimpleHash, me).__init__(encoding = encoding, *args, **kw) + me._hashfn = hash + me.hashlen = H.new(me._hashfn).digest_size + me.saltlen = saltlen + + def _hash(me, rec, passwd): + """Generate a fresh salted hash.""" + hc = H.new(me._hashfn) + salt = OS.urandom(me._saltlen) + me.hash_preimage(rec, hc, passwd, salt) + return me.hash_format(rec, hc.digest(), salt) + + def _check(me, rec, hash, passwd): + """Check a password against an existing hash.""" + hash, salt = me.hash_parse(rec, hash) + hc = H.new(me._hashfn) + me.hash_preimage(rec, hc, passwd, salt) + h = hc.digest() + return h == hash + + def hash_preimage(me, hc, rec, passwd, salt): + """ + Feed whatever material is appropriate into the hash context HC. + + The REC, PASSWD and SALT arguments are the database record (which may + carry interesting information in additional fields), the user's password, + and the salt, respectively. + """ + hc.update(passwd) + hc.update(salt) + + def hash_format(me, rec, hash, salt): + """Format a HASH and SALT for storage. See also `hash_parse'.""" + return hash + salt + + def hash_parse(me, rec, raw): + """Parse the result of `hash_format' into a (HASH, SALT) pair.""" + return raw[:me.hashlen], raw[me.hashlen:] + +CONF.export('SimpleHash') + +### The CRAM-MD5 scheme. + +class CRAMMD5Hash (BasicHash): + """ + Dovecot can use partially hashed passwords with the CRAM-MD5 authentication + scheme, if they're formatted just right. This hashing scheme does the + right thing. + + CRAM-MD5 works by applying HMAC-MD5 to a challenge string, using the user's + password as an HMAC key. HMAC(k, m) = H(o || H(i || m)), where o and i are + whole blocks of stuff derived from the key k in a simple way. Rather than + storing k, then, we can store the results of applying the MD5 compression + function to o and i. + """ + + def __init__(me, encoding = 'hex', *args, **kw): + """Initialize the CRAM-MD5 scheme. We change the default encoding.""" + super(CRAMMD5Hash, me).__init__(encoding = encoding, *args, **kw) + + def _hash(me, rec, passwd): + """Hash a password, following the HMAC rules.""" + + ## If the key is longer than the hash function's block size, we're + ## required to hash it first. + if len(passwd) > 64: + h = H.new('md5') + h.update(passwd) + passwd = h.digest() + + ## Compute the key schedule. + return ''.join(C.compress_md5(''.join(chr(ord(c) ^ pad) + for c in passwd.ljust(64, '\0'))) + for pad in [0x5c, 0x36]) + +CONF.export('CRAMMD5Hash') + +###----- That's all, folks -------------------------------------------------- diff --git a/httpauth.py b/httpauth.py new file mode 100644 index 0000000..22648dd --- /dev/null +++ b/httpauth.py @@ -0,0 +1,267 @@ +### -*-python-*- +### +### HTTP authentication +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +from __future__ import with_statement + +import base64 as BN +import hashlib as H +import hmac as HM +import os as OS + +import cgi as CGI +import config as CONF; CFG = CONF.CFG +import dbmaint as D +import output as O; PRINT = O.PRINT +import service as S +import subcommand as SC +import util as U + +###-------------------------------------------------------------------------- +### About the authentication scheme. +### +### We mustn't allow a CGI user to make changes (or even learn about a user's +### accounts) without authenticating first. Curently, that means a username +### and password, though I really dislike this; maybe I'll add a feature for +### handling TLS client certificates some time. +### +### We're particularly worried about cross-site request forgery: a forged +### request to change a password to some known value lets a bad guy straight +### into a restricted service -- and a change to the `master' account lets +### him into all of them. +### +### Once we've satisfied ourselves of the user's credentials, we issue a +### short-lived session token, stored in a cookie namde `chpwd-token'. This +### token has the form `DATE.NONCE.TAG.USER': here, DATE is the POSIX time of +### issue, as a decimal number; NONCE is a randomly chosen string, encoded in +### base64, USER is the user's login name, and TAG is a cryptographic MAC tag +### on the string `DATE.NONCE.USER'. (The USER name is on the end so that it +### can contain `.' characters without introducing parsing difficulties.) +### +### Secrets for these MAC tags are stored in the database: secrets expire +### after 30 minutes (invalidating all tokens issued with them); we only +### issue a token with a secret that's at most five minutes old. A session's +### lifetime, then, is somewhere between 25 and 30 minutes. We choose the +### lower bound as the cookie lifetime, just so that error messages end up +### consistent. +### +### A cookie with a valid token is sufficient to grant read-only access to a +### user's account details. However, this authority is ambient: during the +### validity period of the token, a cross-site request forgery can easily +### succeed, since there's nothing about the rest of a request which is hard +### to forge, and the cookie will be supplied automatically by the user +### agent. Showing the user some information we were quite happy to release +### anyway isn't an interesting attack, but we must certainly require +### something stronger for state-change requests. Here, we also check that a +### special request parameter `%nonce' matches the token's NONCE field: forms +### setting up a `POST' action must include an appropriate hidden input +### element. +### +### Messing about with cookies is a bit annoying, but it's hard to come up +### with alternatives. I'm trying to keep the URLs fairly pretty, and anyway +### putting secrets into them is asking for trouble, since user agents have +### an awful tendecy to store URLs in a history database, send them to +### motherships, leak them in `Referer' headers, and other awful things. Our +### cookie is marked `HttpOnly' so, in particular, user agents must keep them +### out of the grubby mitts of Javascript programs. +### +### I promise that I'm only using these cookies for the purposes of +### maintaining security: I don't log them or do anything else at all with +### them. + +###-------------------------------------------------------------------------- +### Generating and checking authentication tokens. + +## Secret lifetime parameters. +CONF.DEFAULTS.update( + + ## The lifetime of a session cookie, in seconds. + SECRETLIFE = 30*60, + + ## Maximum age of an authentication key, in seconds. + SECRETFRESH = 5*60) + +def cleansecrets(): + """Remove dead secrets from the database.""" + with D.DB: + D.DB.execute("DELETE FROM secrets WHERE stamp < $stale", + stale = U.NOW - CFG.SECRETLIFE) + +def getsecret(when): + """ + Return the newest and most shiny secret no older than WHEN. + + If there is no such secret, or the only one available would have been stale + at WHEN, then return `None'. + """ + cleansecrets() + with D.DB: + D.DB.execute("""SELECT stamp, secret FROM secrets + WHERE stamp <= $when + ORDER BY stamp DESC""", + when = when) + row = D.DB.fetchone() + if row is None: return None + if row[0] < when - CFG.SECRETFRESH: return None + return row[1].decode('base64') + +def freshsecret(): + """Return a fresh secret.""" + cleansecrets() + with D.DB: + D.DB.execute("""SELECT secret FROM secrets + WHERE stamp >= $fresh + ORDER BY stamp DESC""", + fresh = U.NOW - CFG.SECRETFRESH) + row = D.DB.fetchone() + if row is not None: + sec = row[0].decode('base64') + else: + sec = OS.urandom(16) + D.DB.execute("""INSERT INTO secrets(stamp, secret) + VALUES ($stamp, $secret)""", + stamp = U.NOW, secret = sec.encode('base64')) + return sec + +def hack_octets(s): + """Return the octet string S, in a vaguely pretty form.""" + return BN.b64encode(s) \ + .rstrip('=') \ + .replace('/', '$') + +def auth_tag(sec, stamp, nonce, user): + """Compute a tag using secret SEC on `STAMP.NONCE.USER'.""" + hmac = HM.HMAC(sec, digestmod = H.sha256) + hmac.update('%d.%s.%s' % (stamp, nonce, user)) + return hack_octets(hmac.digest()) + +def mint_token(user): + """Make and return a fresh token for USER.""" + sec = freshsecret() + nonce = hack_octets(OS.urandom(16)) + tag = auth_tag(sec, U.NOW, nonce, user) + return '%d.%s.%s.%s' % (U.NOW, nonce, tag, user) + +## Long messages for reasons why one might have been redirected back to the +## login page. +LOGIN_REASONS = { + 'AUTHFAIL': 'incorrect user name or password', + 'NOAUTH': 'not authenticated', + 'NONONCE': 'missing nonce', + 'BADTOKEN': 'malformed token', + 'BADTIME': 'invalid timestamp', + 'BADNONCE': 'nonce mismatch', + 'EXPIRED': 'session timed out', + 'BADTAG': 'incorrect tag', + 'NOUSER': 'unknown user name', + None: None +} + +class AuthenticationFailed (U.ExpectedError): + """ + An authentication error. The most interesting extra feature is an + attribute `why' carrying a reason code, which can be looked up in + `LOGIN_REASONS'. + """ + def __init__(me, why): + msg = LOGIN_REASONS[why] + U.ExpectedError.__init__(me, 403, msg) + me.why = why + +def check_auth(token, nonce = None): + """ + Check that the TOKEN is valid, comparing it against the NONCE if this is + not `None'. + + If the token is OK, then return the correct user name, and set `NONCE' set + to the appropriate portion of the token. Otherwise raise an + `AuthenticationFailed' exception with an appropriate `why'. + """ + + global NONCE + + ## Parse the token. + bits = token.split('.', 3) + if len(bits) != 4: raise AuthenticationFailed, 'BADTOKEN' + stamp, NONCE, tag, user = bits + + ## Check that the nonce matches, if one was supplied. + if nonce is not None and nonce != NONCE: + raise AuthenticationFailed, 'BADNONCE' + + ## Check the stamp, and find the right secret. + if not stamp.isdigit(): raise AuthenticationFailed, 'BADTIME' + when = int(stamp) + sec = getsecret(when) + if sec is None: raise AuthenticationFailed, 'EXPIRED' + + ## Check the tag. + t = auth_tag(sec, when, NONCE, user) + if t != tag: raise AuthenticationFailed, 'BADTAG' + + ## Make sure the user still exists. + try: acct = S.SERVICES['master'].find(user) + except S.UnknownUser: raise AuthenticationFailed, 'NOUSER' + + ## Done. + return user + +###-------------------------------------------------------------------------- +### Authentication commands. + +## A dummy string, for when we're invoked from the command-line. +NONCE = '@DUMMY-NONCE' + +@CGI.subcommand( + 'login', ['cgi-noauth'], + 'Authenticate to the CGI machinery', + opts = [SC.Opt('why', '-w', '--why', + 'Reason for redirection back to the login page.', + argname = 'WHY')]) +def cmd_login(why = None): + CGI.page('login.fhtml', + title = 'Chopwood: login', + why =LOGIN_REASONS.get(why, '' % why)) + +@CGI.subcommand( + 'auth', ['cgi-noauth'], + 'Verify a user name and password', + params = [SC.Arg('u'), SC.Arg('pw')]) +def cmd_auth(u, pw): + svc = S.SERVICES['master'] + try: + acct = svc.find(u) + acct.check(pw) + except (S.UnknownUser, S.IncorrectPassword): + CGI.redirect(CGI.action('login', why = 'AUTHFAIL')) + else: + t = mint_token(u) + CGI.redirect(CGI.action('list'), + set_cookie = CGI.cookie('chpwd-token', t, + httponly = True, + path = CFG.SCRIPT_NAME, + max_age = (CFG.SECRETLIFE - + CFG.SECRETFRESH))) + +###----- That's all, folks -------------------------------------------------- diff --git a/list.fhtml b/list.fhtml new file mode 100644 index 0000000..92d2c2c --- /dev/null +++ b/list.fhtml @@ -0,0 +1,127 @@ +~1[ + +~]~ + +

Chopwood: accounts list

+ +
+ +
+
+ +
+

+
+ +
+ +
+ +
+ +

Set a new password

+ + + + +
+ + + +
+ + + + + +
OK +
+ + +

Generate a new password

+ +OK + + +

Clear the existing passwords

+ +OK + + +
+
+ + +
+ +~1[~]~ diff --git a/login.fhtml b/login.fhtml new file mode 100644 index 0000000..44d3d1e --- /dev/null +++ b/login.fhtml @@ -0,0 +1,47 @@ +~1[ + +~]~ + +

Chopwood: login

+ +~={why}@[

~:H~2%~]~ + +

+ + + +
+ +
+ + +
+
+ +

Logging in will set a short-lived cookie in your browser. If this +worries you, you might like to read about +why and how Chopwood uses cookies. + +~1[~]~ diff --git a/operate.fhtml b/operate.fhtml new file mode 100644 index 0000000..64d09a8 --- /dev/null +++ b/operate.fhtml @@ -0,0 +1,48 @@ +~1[ + +~]~ + +

Chopwood: ~={what}:H – ~={outcome.rc}[~ + successful~;~ + partially successful~;~ + FAILED~;~ + no services specified: nothing to do!~:;~ + unknown status code ~={outcome.rc}D~]~ +

+ +~={info}{~ +

Information

+

~@{ +
~={@.desc}:H~={@.value}H~*~} +
~2%~}~ + +

Results

+

~={results}{ +~ +
~={@.svc.friendly}:H~ +~={@.error}:[OK~={@.result}[: ~H~]~;FAILED: ~={@.error.msg}:H~]~*~} +
+ +~1[~]~ diff --git a/operation.py b/operation.py new file mode 100644 index 0000000..1184e3d --- /dev/null +++ b/operation.py @@ -0,0 +1,320 @@ +### -*-python-*- +### +### Operations and policy switch +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +import os as OS + +import config as CONF; CFG = CONF.CFG +import util as U + +### The objective here is to be able to insert a policy layer between the UI, +### which is where the user makes requests to change a bunch of accounts, and +### the backends, which make requested changes without thinking too much +### about whether they're a good idea. +### +### Here, we convert between (nearly) user-level /requests/, which involve +### doing things to multiple service/user pairs, and /operations/, which +### represent a single change to be made to a particular service. (This is +### slightly nontrivial in the case of reset requests, since the intended +### semantics may be that the services are all assigned the /same/ random +### password.) + +###-------------------------------------------------------------------------- +### Operation protocol. + +## An operation deals with a single service/user pair. The protocol works +## like this. The constructor is essentially passive, storing information +## about the operation but not actually performing it. The `perform' method +## attempts to perform the operation, and stores information about the +## outcome in attributes: +## +## error Either `None' or an `ExpectedError' instance indicating what +## went wrong. +## +## result Either `None' or a string providing additional information +## about the successful completion of the operation. +## +## svc The service object on which the operation was attempted. +## +## user The user name on which the operation was attempted. + +class BaseOperation (object): + """ + Base class for individual operations. + + This is where the basic operation protocol is implemented. Subclasses + should store any additional attributes necessary during initialization, and + implement a method `_perform' which takes no parameters, performs the + operation, and returns any necessary result. + """ + + def __init__(me, svc, user, *args, **kw): + """Initialize the operation, storing the SVC and USER in attributes.""" + super(BaseOperation, me).__init__(*args, **kw) + me.svc = svc + me.user = user + + def perform(me): + """Perform the operation, and return whether it was successful.""" + + ## Set up the `result' and `error' slots here, rather than earlier, to + ## catch callers referencing them too early. + me.result = me.error = None + + ## Perform the operation, and stash the result. + ok = True + try: + try: me.result = me._perform() + except (IOError, OSError), e: raise U.ExpectedError, (500, str(e)) + except U.ExpectedError, e: + me.error = e + ok = False + + ## Done. + return ok +CONF.export('BaseOperation') + +class SetOperation (BaseOperation): + """Operation to set a given password on an account.""" + def __init__(me, svc, user, passwd, *args, **kw): + super(SetOperation, me).__init__(svc, user, *args, **kw) + me.passwd = passwd + def _perform(me): + me.svc.setpasswd(me.user, me.passwd) +CONF.export('SetOperation') + +class ClearOperation (BaseOperation): + """Operation to clear a password from an account, preventing logins.""" + def _perform(me): + me.svc.clearpasswd(me.user) +CONF.export('ClearOperation') + +class FailOperation (BaseOperation): + """A fake operation which just raises an exception.""" + def __init__(me, svc, user, exc): + me.svc = svc + me.uesr = user + me.exc = exc + def perform(me): + me.result = None + me.error = me.exc + return False +CONF.export('FailOperation') + +###-------------------------------------------------------------------------- +### Requests. + +## A request object represents a single user-level operation targetted at +## multiple services. The user might be known under a different alias by +## each service, so requests operate on service/user pairs, bundled in an +## `acct' object. +## +## Request methods are as follows. +## +## check() Verify that the request complies with policy. Note that +## checking that any particular user has authority over the +## necessary accounts has already been done. One might want to +## check that the passwords are sufficiently long and +## complicated (though that rapidly becomes problematic, and I +## don't really recommend it) or that particular services are or +## aren't processed at the same time. +## +## perform() Actually perform the request. A list of completed operation +## objects is left in the `ops' attribute. +## +## Performing the operation may leave additional information in attributes. +## The `INFO' class attribute contains a dictionary mapping attribute names +## to human-readable descriptions of this additional information. +## +## Note that the request object has a fairly free hand in choosing how to +## implement the request in terms of operations. In particular, it might +## process additional services. Callers must not assume that they can +## predict what the resulting operations list will look like. + +class acct (U.struct): + """A simple pairing of a service SVC and USER name.""" + __slots__ = ['svc', 'user'] + +class BaseRequest (object): + """ + Base class for requests, provides basic protocol. In particular, it + provides an empty `INFO' map, a trivial `check' method, and the obvious + `perform' method which assumes that the `ops' list has already been + constructed. + """ + INFO = {} + def check(me): + """ + Check the request to make sure we actually want to proceed. + """ + pass + def makeop(me, optype, svc, user, **kw): + """ + Hook for making operations. A policy class can substitute a + `FailOperation' to partially disallow a request. + """ + return optype(svc, user, **kw) + def perform(me): + """ + Perform the queued-up operations. + """ + for op in me.ops: op.perform() + return me.ops +CONF.export('BaseRequest', ExpectedError = U.ExpectedError) + +class SetRequest (BaseRequest): + """ + Request to set the password for the given ACCTS to NEW. + + The new password is kept in the object's `new' attribute for easy + inspection. The `check' method ensures that the password is not empty, but + imposes no other policy restrictions. + """ + def __init__(me, accts, new): + me.new = new + me.ops = [me.makeop(SetOperation, acct.svc, acct.user, passwd = new) + for acct in accts] + def check(me): + if me.new == '': + raise U.ExpectedError, (400, "Empty password not permitted") + super(SetRequest, me).check() +CONF.export('SetRequest') + +class ResetRequest (BaseRequest): + """ + Request to set the password for the given ACCTS to something new but + nonspeific. The new password is generated based on a number of class + attributes which subclasses can usefully override. + + ENCODING Encoding to apply to random data. + + PWBYTES Number of random bytes to collect. + + Alternatively, subclasses can override the `pwgen' method. + """ + + ## Password generation parameters. + PWBYTES = 16 + ENCODING = 'base32' + + ## Additional information. + INFO = dict(new = 'New password') + + def __init__(me, accts): + me.new = me.pwgen() + me.ops = [me.makeop(SetOperation, acct.svc, acct.user, passwd = me.new) + for acct in accts] + + def pwgen(me): + return U.ENCODINGS[me.ENCODING].encode(OS.urandom(me.PWBYTES)) \ + .rstrip('=') +CONF.export('ResetRequest') + +class ClearRequest (BaseRequest): + """ + Request to clear the password for the given ACCTS. + """ + def __init__(me, accts): + me.ops = [me.makeop(ClearOperation, acct.svc, acct.user) + for acct in accts] +CONF.export('ClearRequest') + +###-------------------------------------------------------------------------- +### Master policy switch. + +class polswitch (U.struct): + __slots__ = ['set', 'reset', 'clear'] + +CONF.DEFAULTS.update( + + ## Map a request type `set', `reset', or `clear', to the appropriate + ## request class. + RQCLASS = polswitch(None, None, None), + + ## Alternatively, set this to a mixin class to apply common policy to all + ## the kinds of requests. + RQMIXIN = None) + +@CONF.hook +def set_policy_classes(): + for op, base in [('set', SetRequest), + ('reset', ResetRequest), + ('clear', ClearRequest)]: + if getattr(CFG.RQCLASS, op): continue + if CFG.RQMIXIN: + cls = type('Custom%sPolicy' % op.title(), (base, CFG.RQMIXIN), {}) + else: + cls = base + setattr(CFG.RQCLASS, op, cls) + +## Outcomes. + +class outcome (U.struct): + __slots__ = ['rc', 'nwin', 'nlose'] + OK = 0 + PARTIAL = 1 + FAIL = 2 + NOTHING = 3 + +class info (U.struct): + __slots__ = ['desc', 'value'] + +def operate(op, accts, *args, **kw): + """ + Perform a request through the policy switch. + + The operation may be one of `set', `reset' or `clear'. An instance of the + appropriate request class is constructed, and additional arguments are + passed directly to the request class constructor; the request is checked + for policy compliance; and then performed. + + The return values are: + + * an `outcome' object holding the general outcome, and a count of the + winning and losing operations; + + * a list of `info' objects holding additional information from the + request; + + * the request object itself; and + + * a list of the individual operation objects. + """ + rq = getattr(CFG.RQCLASS, op)(accts, *args, **kw) + rq.check() + ops = rq.perform() + nwin = nlose = 0 + for o in ops: + if o.error: nlose += 1 + else: nwin += 1 + if nwin: + if nlose: rc = outcome.PARTIAL + else: rc = outcome.OK + else: + if nlose: rc = outcome.FAIL + else: rc = outcome.NOTHING + ii = [info(v, getattr(rq, k)) for k, v in rq.INFO.iteritems()] + return outcome(rc, nwin, nlose), ii, rq, ops + +###----- That's all, folks -------------------------------------------------- diff --git a/output.py b/output.py new file mode 100644 index 0000000..b849985 --- /dev/null +++ b/output.py @@ -0,0 +1,209 @@ +### -*-python-*- +### +### Output machinery +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +from __future__ import with_statement + +import contextlib as CTX +from cStringIO import StringIO +import sys as SYS + +import util as U + +### There are a number of interesting things to do with output. +### +### The remote-service interface needs to prefix its output lines with `INFO' +### tokens so that they get parsed properly. +### +### The CGI interface needs to prefix its output with at least a +### `Content-Type' header. + +###-------------------------------------------------------------------------- +### Utilities. + +def http_headers(**kw): + """ + Generate mostly-formatted HTTP headers. + + KW is a dictionary mapping HTTP header names to values. Each name is + converted to external form by changing underscores `_' to hyphens `-', and + capitalizing each constituent word. The values are converted to strings. + If a value is a list, a header is produced for each element. Subsequent + lines in values containing internal line breaks have a tab character + prepended. + """ + def hack_header(k, v): + return '%s: %s' % ('-'.join(i.title() for i in k.split('_')), + str(v).replace('\n', '\n\t')) + for k, v in kw.iteritems(): + if isinstance(v, list): + for i in v: yield hack_header(k, i) + else: + yield hack_header(k, v) + +###-------------------------------------------------------------------------- +### Protocol. + +class BasicOutputDriver (object): + """ + A base class for output drivers, providing trivial implementations of most + of the protocol. + + The main missing piece is the `_write' method, which should write its + argument to the output with as little ceremony as possible. Any fancy + formatting should be applied by overriding `write'. + """ + + def __init__(me): + """Trivial constructor.""" + pass + + def writeln(me, msg): + """Write MSG, as a complete line.""" + me.write(str(msg) + '\n') + + def write(me, msg): + """Write MSG to the output, with any necessary decoration.""" + me._write(str(msg)) + + def close(me): + """Wrap up when everything that needs saying has been said.""" + pass + + def header(me, **kw): + """Emit HTTP-style headers in a distinctive way.""" + for h in http_headers(**kw): + PRINT('[%s]' % h) + +class BasicLineOutputDriver (BasicOutputDriver): + """ + Mixin class for line-oriented output formatting. + + We override `write' to buffer partial lines; complete lines are passed to + `_writeln' to be written, presumably through the low-level `_write' method. + """ + + def __init__(me, *args, **kw): + """Contructor.""" + super(BasicLineOutputDriver, me).__init__(*args, **kw) + me._buf = None + + def _flush(me): + """Write any incomplete line accumulated so far, and clear the buffer.""" + if me._buf: + me._writeln(me._buf.getvalue()) + me._buf = None + + def write(me, msg): + """Write MSG, sending any complete lines to the `_writeln' method.""" + + if '\n' not in msg: + ## If there's not a complete line here then we just accumulate the + ## message into our buffer. + + if not me._buf: me._buf = StringIO() + me._buf.write(msg) + + else: + ## There's at least one complete line here. We take the final + ## incomplete line off the end. + + lines = msg.split('\n') + tail = lines.pop() + + ## If there's a partial line already buffered then add whatever new + ## stuff we have and flush it out. + if me._buf: + me._buf.write(lines[0]) + me._flush() + + ## Write out any other complete lines. + for line in lines: + me._writeln(line) + + ## If there's a proper partial line, then start a new buffer. + if tail: + me._buf = StringIO() + me._buf.write(tail) + + def close(me): + """If there's any partial line buffered, flush it out.""" + me._flush() + +###-------------------------------------------------------------------------- +### Implementations. + +class FileOutput (BasicOutputDriver): + """Output driver for writing stuff to a file.""" + def __init__(me, file = SYS.stdout, *args, **kw): + """Constructor: send output to FILE (default is stdout).""" + super(FileOutput, me).__init__(*args, **kw) + me._file = file + def _write(me, text): + """Output protocol: write TEXT to the ouptut file.""" + me._file.write(text) + +class RemoteOutput (FileOutput, BasicLineOutputDriver): + """Output driver for decorating lines with `INFO' tags.""" + def _writeln(me, line): + """Line output protocol: write a complete line with an `INFO' tag.""" + me._write('INFO %s\n' % line) + +###-------------------------------------------------------------------------- +### Context. + +class DelegatingOutput (BasicOutputDriver): + """Fake output driver which delegates to some other driver.""" + + def __init__(me, default = None): + """Constructor: send output to DEFAULT.""" + me._fluid = U.Fluid(target = default) + + @CTX.contextmanager + def redirect_to(me, target): + """Temporarily redirect output to TARGET, closing it when finished.""" + try: + with me._fluid.bind(target = target): + yield + finally: + target.close() + + ## Delegating methods. + def write(me, msg): me._fluid.target.write(msg) + def writeln(me, msg): me._fluid.target.writeln(msg) + def close(me): me._fluid.target.close() + def header(me, **kw): me._fluid.target.header(**kw) + + ## Delegating properties. + @property + def headerp(me): return me._fluid.target.headerp + +## The selected output driver. Set this with `output_to'. +OUT = DelegatingOutput() + +def PRINT(msg = ''): + """Write the MSG as a line to the current output.""" + OUT.writeln(msg) + +###----- That's all, folks -------------------------------------------------- diff --git a/service.py b/service.py new file mode 100644 index 0000000..5153539 --- /dev/null +++ b/service.py @@ -0,0 +1,382 @@ +### -*-python-*- +### +### Services +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +from __future__ import with_statement + +import os as OS +import re as RX +import subprocess as SUB + +from auto import HOME +import backend as B +import config as CONF; CFG = CONF.CFG +import hash as H +import util as U + +###-------------------------------------------------------------------------- +### Protocol. +### +### A service is a thing for which a user might have an account, with a login +### name and password. The service protocol is fairly straightforward: a +### password can be set to a particular value using `setpasswd' (which +### handles details of hashing and so on), or cleared (i.e., preventing +### logins using a password) using `clearpasswd'. Services also present +### `friendly' names, used by the user interface. +### +### A service may be local or remote. Local services are implemented in +### terms of a backend and hashing scheme. Information about a particular +### user of a service is maintained in an `account' object which keeps track +### of the backend record and hashing scheme; the service protocol operations +### are handed off to the account. Accounts provide additional protocol for +### clients which are willing to restrict themselves to the use of local +### services. +### +### A remote service doesn't have local knowledge of the password database: +### instead, it simply sends commands corresponding to the service protocol +### operations to some external service which is expected to act on them. +### The implementation here uses SSH, and the remote end is expected to be +### provided by another instance of `chpwd', but that needn't be the case: +### the protocol is very simple. + +UnknownUser = B.UnknownUser + +class IncorrectPassword (Exception): + """ + A failed password check is reported via an exception. + + This is /not/ an `ExpectedError', since we anticipate that whoever called + `check' will have made their own arrangements to deal with the failure in + some more useful way. + """ + pass + +class BasicService (object): + """ + A simple base class for services. + """ + + def __init__(me, friendly, *args, **kw): + super(BasicService, me).__init__(*args) + me.friendly = friendly + me.meta = kw + +###-------------------------------------------------------------------------- +### Local services. + +class Account (object): + """ + An account represents information about a user of a particular service. + + From here, we can implement the service protocol operations, and also check + passwords. + + Users are expected to acquire account objects via the `lookup' method of a + `LocalService' or similar. + """ + + def __init__(me, svc, rec): + """ + Create a new account, for the service SVC, holding the user record REC. + """ + me._svc = svc + me._rec = rec + me._hash = svc.hash + + def check(me, passwd): + """ + Check the password PASSWD against the information we have. If the + password is correct, return normally; otherwise, raise + `IncorrectPassword'. + """ + if not me._hash.check(me._rec, me._rec.passwd, passwd): + raise IncorrectPassword + + def clearpasswd(me): + """Service protocol: clear the user's password.""" + if me._hash.NULL is None: + raise U.ExpectedError, (400, "Can't clear this password") + me._rec.passwd = me._hash.NULL + me._rec.write() + + def setpasswd(me, passwd): + """Service protocol: set the user's password to PASSWD.""" + passwd = me._hash.hash(me._rec, passwd) + me._rec.passwd = passwd + me._rec.write() + +class LocalService (BasicService): + """ + A local service has immediate knowledge of a hashing scheme and a password + storage backend. (Knowing connection details for a remote database server + is enough to qualify for being a `local' service. The important bit is + that the hashed passwords are exposed to us.) + + The service protocol is implemented via an `Account', acquired through the + `find' method. Mainly for the benefit of the `Account' class, the + service's hashing scheme is exposed in the `hash' attribute. + """ + + def __init__(me, backend, hash, *args, **kw): + """ + Create a new local service with a FRIENDLY name, using the given BACKEND + and HASH scheme. + """ + super(LocalService, me).__init__(*args, **kw) + me._be = backend + me.hash = hash + + def find(me, user): + """Find the named USER, returning an `Account' object.""" + rec = me._be.lookup(user) + return Account(me, rec) + + def setpasswd(me, user, passwd): + """Service protcol: set USER's password to PASSWD.""" + me.find(user).setpasswd(passwd) + + def clearpasswd(me, user): + """Service protocol: clear USER's password, preventing logins.""" + me.find(user).clearpasswd() + +CONF.export('LocalService') + +###-------------------------------------------------------------------------- +### Remote services. + +class BasicRemoteService (BasicService): + """ + A remote service transmits the simple service protocol operations to some + remote system, which presumably is better able to implement them than we + are. This is useful if, for example, the password file isn't available to + us, or we don't have (or can't be allowed to have) access to the database + tables containing password hashes, or must synchronize updates with some + remote process. It can also be useful to integrate with services which + don't present a conventional password file. + + This class provides common machinery for communicating with various kinds + of remote service. Specific subclasses are provided for transporting + requests through SSH and GNU Userv; others can be added easily in local + configuration. + """ + + def _run(me, cmd, input = None): + """ + This is the core of the remote service machinery. It issues a command + and parses the response. It will generate strings of informational + output from the command; error responses cause appropriate exceptions to + be raised. + + The command is determined by passing the CMD argument to the `_mkcmd' + method, which a subclass must implement; it should return a list of + command-line arguments suitable for `subprocess.Popen'. The INPUT is a + string to make available on the command's stdin; if None, then no input + is provided to the command. The `_describe' method must provide a + description of the remote service for use in timeout messages. + + We expect output on stdout in a simple line-based format. The first + whitespace-separated token on each line is a type code: `OK' means the + command completed successfully; `INFO' means the rest of the line is some + useful (and expected) information; and `ERR' means an error occurred: the + next token is an HTTP integer status code, and the remainder is a + human-readable message. + """ + + ## Run the command and collect its output and status. + with timeout(30, "waiting for remote service %s" % me._describe()): + proc = SUB.Popen(me._mkcmd(cmd), + stdin = input is not None and SUB.PIPE or None, + stdout = SUB.PIPE, stderr = SUB.PIPE) + out, err = proc.communicate(input) + st = proc.wait() + + ## If the program failed then report this: it obviously didn't work + ## properly. + if st or err: + raise U.ExpectedError, ( + 500, 'Remote service error: %r (rc = %d)' % (err, st)) + + ## Split a word off the front of a string; return the word and the + ## remaining string. + def nextword(line): + ww = line.split(None, 1) + n = len(ww) + if not n: return None + elif n == 1: return ww[0], '' + else: return ww + + ## Work through the lines, parsing them. + win = False + for line in out.splitlines(): + type, rest = nextword(line) + if type == 'ERR': + code, msg = nextword(rest) + raise U.ExpectedError, (int(code), msg) + elif type == 'INFO': + yield rest + elif type == 'OK': + win = True + else: + raise U.ExpectedError, \ + (500, 'Incomprehensible reply from remote service: %r' % line) + + ## If we didn't get any kind of verdict then something weird has + ## happened. + if not win: + raise U.ExpectedError, (500, 'No reply from remote service') + + def _run_noout(me, cmd, input = None): + """Like `_run', but expect no output.""" + for _ in me._run(cmd, input): + raise U.ExpectedError, (500, 'Unexpected output from remote service') + +class SSHRemoteService (BasicRemoteService): + """ + A remote service transported over SSH. + + The remote service is given commands of the form + + `set SERVICE USER' + Set USER's password for SERVICE to the password provided on the next + line of standard input. + + `clear SERVICE USER' + Clear the USER's password for SERVICE. + + Arguments are form-url-encoded, since SSH doesn't preserve token boundaries + in its argument list. + + It is expected that the remote user has an `.ssh/authorized_keys' file + entry for us specifying a program to be run; the above commands will be + left available to this program in the environment variable + `SSH_ORIGINAL_COMMAND'. + """ + + def __init__(me, remote, name, *args, **kw): + """ + Initialize an SSH remote service, contacting the SSH user REMOTE + (probably of the form `LOGIN@HOSTNAME') and referring to the service + NAME. + """ + super(SSHRemoteService, me).__init__(*args, **kw) + me._remote = remote + me._name = name + + def _describe(me): + """Description of the remote service.""" + return "`%s' via SSH to `%s'" % (me._name, me._remote), + + def _mkcmd(me, cmd): + """Format a command for SSH. Mainly escaping arguments.""" + return ['ssh', me._remote, ' '.join(map(urlencode, cmd))] + + def setpasswd(me, user, passwd): + """Service protocol: set the USER's password to PASSWD.""" + me._run_noout(['set', me._name, user], passwd + '\n') + + def clearpasswd(me, user): + """Service protocol: clear the USER's password.""" + me._run_noout(['clear', me._name, user]) + +CONF.export('SSHRemoteService') + +class CommandRemoteService (BasicRemoteService): + """ + A remote service transported over a standard Unix command. + + This is left rather generic. We need to know some command lists SET and + CLEAR containing the relevant service names and arguments. These are + simply executed, after simple placeholder substitution. + + The SET command should read a password as its first line on stdin, and set + that as the user's new password. The CLEAR command should simply prevent + the user from logging in with a password. On success, the commands should + print a line `OK' to standard output, and on any kind of anticipated + failure, they should print `ERR' followed by an HTTP status code and a + message; in either case, the program should exit with status zero. In + disastrous cases, it's acceptable to print an error message to stderr + and/or exit with a nonzero status. + + The placeholders are as follows. + + `%u' the user's name + `%%' a single `%' character + """ + + R_PAT = RX.compile('%(.)') + + def __init__(me, set, clear, *args, **kw): + """ + Initialize the command remote service. + """ + super(CommandRemoteService, me).__init__(*args, **kw) + me._set = set + me._clear = clear + me._map = dict(u = user) + + def _subst(me, c): + """Return the substitution for the placeholder `%C'.""" + return me._map.get(c, c) + + def _mkcmd(me, cmd): + """Construct the command to be executed, by substituting placeholders.""" + return [me.R_PAT.sub(lambda m: me._subst(m.group(1))) for arg in cmd] + + def setpasswd(me, user, passwd): + """Service protocol: set the USER's password to PASSWD.""" + me._run_noout(me._set, passwd + '\n') + + def clearpasswd(me, user): + """Service protocol: clear the USER's password.""" + me._run_noout(me._clear) + +CONF.export('CommandRemoteService') + +###-------------------------------------------------------------------------- +### Services registry. + +## The registry of services. +SERVICES = {} +CONF.export('SERVICES') + +## Set some default configuration. +CONF.DEFAULTS.update( + + ## The master database, as a pair (MODNAME, MODARGS). + DB = ('sqlite3', [OS.path.join(HOME, 'chpwd.db')]), + + ## The hash to use for our master password database. + HASH = H.CryptHash('md5')) + +## Post-configuration hook: add the master service. +@CONF.hook +def add_master_service(): + dbmod, dbargs = CFG.DB + SERVICES['master'] = \ + LocalService(B.DatabaseBackend(dbmod, dbargs, + 'users', 'user', 'passwd'), + CFG.HASH, + friendly = 'Password changing service') + +###----- That's all, folks -------------------------------------------------- diff --git a/subcommand.py b/subcommand.py new file mode 100644 index 0000000..b285915 --- /dev/null +++ b/subcommand.py @@ -0,0 +1,403 @@ +### -*-python-*- +### +### Subcommand dispatch +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +from __future__ import with_statement + +import optparse as OP +from cStringIO import StringIO +import sys as SYS + +from output import OUT +import util as U + +### We've built enough infrastructure now: it's time to move on to user +### interface stuff. +### +### Everything is done in terms of `subcommands'. A subcommand has a name, a +### set of `contexts' in which it's active (see below), a description (for +### help), a function, and a bunch of parameters. There are a few different +### kinds of parameters, but the basic idea is that they have names and +### values. When we invoke a subcommand, we'll pass the parameter values as +### keyword arguments to the function. +### +### We have a fair number of different interfaces to provide: there's an +### administration interface for adding and removing new users and accounts; +### there's a GNU Userv interface for local users to change their passwords; +### there's an SSH interface for remote users and for acting as a remote +### service; and there's a CGI interface. To make life a little more +### confusing, sets of commands don't map one-to-one with these various +### interfaces: for example, the remote-user SSH interface is (basically) the +### same as the Userv interface, and the CGI interface offers two distinct +### command sets depending on whether the user has authenticated. +### +### We call these various command sets `contexts'. To be useful, a +### subcommand must be active within at least one context. Command lookup +### takes place with a specific context in mind, and command names only need +### be unique within a particular context. Commands from a different context +### are simply unavailable. +### +### When it comes to parameters, we have simple positional arguments, and +### fancy options. Positional arguments are called this because on the +### command line they're only distinguished by their order. Like Lisp +### functions, a subcommand has some of mandatory formal arguments, followed +### by some optional arguments, and finally maybe a `rest' argument which +### gobbles up any remaining actual arguments as a list. To make things more +### fun, we also have options, which conform to the usual Unix command-line +### conventions. +### +### Finally, there's a global set of options, always read from the command +### line, which affects stuff like which configuration file to use, and can +### also be useful in testing and debugging. + +###-------------------------------------------------------------------------- +### Parameters. + +## The global options. This will carry the option values once they've been +## parsed. +OPTS = None + +class Parameter (object): + """ + Base class for parameters. + + Currently only stores the parameter's name, which does double duty as the + name of the handler function's keyword argument which will receive this + parameter's value, and the parameter name in the CGI interface from which + the value is read. + """ + def __init__(me, name): + me.name = name + +class Opt (Parameter): + """ + An option, i.e., one which is presented as an option-flag in command-line + interfaces. + + The SHORT and LONG strings are the option flags for this parameter. The + SHORT string should be a single `-' followed by a single character (usually + a letter. The LONG string should be a pair `--' followed by a name + (usually words, joined with hyphens). + + The HELP is presented to the user as a description of the option. + + The ARGNAME may be either `None' to indicate that this is a simple boolean + switch (the value passed to the handler function will be `True' or + `False'), or a string (conventionally in uppercase, used as a metasyntactic + variable in the generated usage synopsis) to indicate that the option takes + a general string argument (passed literally to the handler function). + """ + def __init__(me, name, short, long, help, argname = None): + Parameter.__init__(me, name) + me.short = short + me.long = long + me.help = help + me.argname = argname + +class Arg (Parameter): + """ + A (positional) argument. Nothing much to do here. + + The parameter name, converted to upper case, is used as a metasyntactic + variable in the generated usage synopsis. + """ + pass + +###-------------------------------------------------------------------------- +### Subcommands. + +class Subcommand (object): + """ + A subcommand object. + + Many interesting things about the subcommand are made available as + attributes. + + `name' + The subcommand name. Used to look the command up (see + the `lookup_subcommand' method of `SubcommandOptionParser'), and in + usage and help messages. + + `contexts' + A set (coerced from any iterable provided to the constructor) of + contexts in which this subcommand is available. + + `desc' + A description of the subcommand, provided if the user requests + detailed help. + + `func' + The handler function, invoked to actually carry out the subcommand. + + `opts' + A list of `Opt' objects, used to build the option parser. + + `params', `oparams', `rparam' + `Arg' objects for the positional parameters. `params' is a list of + mandatory parameters; `oparams' is a list of optional parameters; and + `rparam' is either an `Arg' for the `rest' parameter, or `None' if + there is no `rest' parameter. + """ + + def __init__(me, name, contexts, desc, func, opts = [], + params = [], oparams = [], rparam = None): + """ + Initialize a subcommand object. The constructors arguments are used to + initialize attributes on the object; see the class docstring for details. + """ + me.name = name + me.contexts = set(contexts) + me.desc = desc + me.opts = opts + me.params = params + me.oparams = oparams + me.rparam = rparam + me.func = func + + def usage(me): + """Generate a suitable usage summary for the subcommand.""" + + ## Cache the summary in an attribute. + try: return me._usage + except AttributeError: pass + + ## Gather up a list of switches and options with arguments. + u = [] + sw = [] + for o in me.opts: + if o.argname: + if o.short: u.append('[%s %s]' % (o.short, o.argname.upper())) + else: u.append('%s=%s' % (o.long, o.argname.upper())) + else: + if o.short: sw.append(o.short[1]) + else: u.append(o.long) + + ## Generate the usage message. + me._usage = ' '.join( + [me.name] + # The command name. + (sw and ['[-%s]' % ''.join(sorted(sw))] or []) + + # Switches, in order. + sorted(u) + # Options with arguments, and + # options without short names. + [p.name.upper() for p in me.params] + + # Required arguments, in order. + ['[%s]' % p.name.upper() for p in me.oparams] + + # Optional arguments, in order. + (me.rparam and ['[%s ...]' % me.rparam.name.upper()] or [])) + # The `rest' argument, if present. + + ## And return it. + return me._usage + + def mkoptparse(me): + """ + Make and return an `OptionParser' object for this subcommand. + + This is used for dispatching through a command-line interface, and for + generating subcommand-specific help. + """ + op = OP.OptionParser(usage = 'usage: %%prog %s' % me.usage(), + description = me.desc) + for o in me.opts: + op.add_option(o.short, o.long, dest = o.name, help = o.help, + action = o.argname and 'store' or 'store_true', + metavar = o.argname) + return op + + def cmdline(me, args): + """ + Invoke the subcommand given a list ARGS of command-line arguments. + """ + + ## Parse any options. + op = me.mkoptparse() + opts, args = op.parse_args(args) + + ## Count up the remaining positional arguments supplied, and how many + ## mandatory and optional arguments we want. + na = len(args) + np = len(me.params) + nop = len(me.oparams) + + ## Complain if there's a mismatch. + if na < np or (not me.rparam and na > np + nop): + raise U.ExpectedError, (400, 'Wrong number of arguments') + + ## Now we want to gather the parameters into a dictionary. + kw = {} + + ## First, work through the various options. The option parser tends to + ## define attributes for omitted options with the value `None': we leave + ## this out of the keywords dictionary so that the subcommand can provide + ## its own default values. + for o in me.opts: + try: v = getattr(opts, o.name) + except AttributeError: pass + else: + if v is not None: kw[o.name] = v + + ## Next, assign values from positional arguments to the corresponding + ## parameters. + for a, p in zip(args, me.params + me.oparams): + kw[p.name] = a + + ## If we have a `rest' parameter then set it to any arguments which + ## haven't yet been consumed. + if me.rparam: + kw[me.rparam.name] = na > np + nop and args[np + nop:] or [] + + ## Call the handler function. + me.func(**kw) + +###-------------------------------------------------------------------------- +### Option parsing with subcommands. + +class SubcommandOptionParser (OP.OptionParser, object): + """ + A subclass of `OptionParser' with some additional knowledge about + subcommands. + + The current context is maintained in the `context' attribute, which can be + freely assigned by the client. The initial value is chosen as the first in + the CONTEXTS list, which is otherwise only used to set up the `help' + command. + """ + + def __init__(me, usage = '%prog [-OPTIONS] COMMAND [ARGS ...]', + contexts = ['cli'], commands = [], *args, **kw): + """ + Constructor for the options parser. As for the superclass, but with an + additional argument CONTEXTS used for initializing the `help' command. + """ + super(SubcommandOptionParser, me).__init__(usage = usage, *args, **kw) + me._cmds = commands + + ## We must turn of the `interspersed arguments' feature: otherwise we'll + ## eat the subcommand's arguments. + me.disable_interspersed_args() + me.context = list(contexts)[0] + + ## Provide a default `help' command. + me._cmds = {} + me.addsubcmd(Subcommand( + 'help', contexts, + func = me.cmd_help, + desc = 'Show help for %prog, or for the COMMANDs.', + rparam = Arg('commands'))) + for sub in commands: me.addsubcmd(sub) + + def addsubcmd(me, sub): + """Add a subcommand to the main map.""" + for c in sub.contexts: + me._cmds[sub.name, c] = sub + + def print_help(me, file = None, *args, **kw): + """ + Print a help message. This augments the superclass behaviour by printing + synopses for the available subcommands. + """ + if file is None: file = SYS.stdout + super(SubcommandOptionParser, me).print_help(file = file, *args, **kw) + file.write('\nCommands:\n') + for sub in sorted(set(me._cmds.values()), key = lambda c: c.name): + if sub.desc is None or me.context not in sub.contexts: continue + file.write('\t%s\n' % sub.usage()) + + def cmd_help(me, commands = []): + """ + A default `help' command. With arguments, print help about those; + otherwise just print help on the main program, as for `--help'. + """ + s = StringIO() + if not commands: + me.print_help(file = s) + else: + sep = '' + for name in commands: + s.write(sep) + sep = '\n' + c = me.lookup_subcommand(name) + c.mkoptparse().print_help(file = s) + OUT.write(s.getvalue()) + + def lookup_subcommand(me, name, exactp = False, context = None): + """ + Find the subcommand with the given NAME in the CONTEXT (default the + current context). Unless EXACTP, accept a command for which NAME is an + unambiguous prefix. Return the subcommand object, or raise an + appropriate `ExpectedError'. + """ + + if context is None: context = me.context + + ## See if we can find an exact match. + try: c = me._cmds[name, context] + except KeyError: pass + else: return c + + ## No. Maybe we'll find a prefix match. + match = [] + if not exactp: + for c in set(me._cmds.values()): + if context in c.contexts and \ + c.name.startswith(name): + match.append(c) + + ## See what we came up with. + if len(match) == 0: + raise U.ExpectedError, (404, "Unknown command `%s'" % name) + elif len(match) > 1: + raise U.ExpectedError, ( + 404, + ("Ambiguous command `%s': could be any of %s" % + (name, ', '.join("`%s'" % c.name for c in match)))) + else: + return match[0] + + def dispatch(me, context, args): + """ + Invoke the appropriate subcommand, indicated by ARGS, within the CONTEXT. + """ + global OPTS + if not args: raise U.ExpectedError, (400, "Missing command") + me.context = context + c = me.lookup_subcommand(args[0]) + c.cmdline(args[1:]) + +###-------------------------------------------------------------------------- +### Registry of subcommands. + +## Our list of commands. We'll attach this to the options parser when we're +## ready to roll. +COMMANDS = [] + +def subcommand(name, contexts, desc, cls = Subcommand, + opts = [], params = [], oparams = [], rparam = None): + """Decorator for defining subcommands.""" + def _(func): + COMMANDS.append(cls(name, contexts, desc, func, + opts, params, oparams, rparam)) + return _ + +###----- That's all, folks -------------------------------------------------- diff --git a/util.py b/util.py new file mode 100644 index 0000000..49bd11f --- /dev/null +++ b/util.py @@ -0,0 +1,582 @@ +### -*-python-*- +### +### Miscellaneous utilities +### +### (c) 2013 Mark Wooding +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of Chopwood: a password-changing service. +### +### Chopwood is free software; you can redistribute it and/or modify +### it under the terms of the GNU Affero General Public License as +### published by the Free Software Foundation; either version 3 of the +### License, or (at your option) any later version. +### +### Chopwood is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU Affero General Public License for more details. +### +### You should have received a copy of the GNU Affero General Public +### License along with Chopwood; if not, see +### . + +from __future__ import with_statement + +import base64 as BN +import contextlib as CTX +import fcntl as F +import os as OS +import re as RX +import signal as SIG +import sys as SYS +import time as T + +try: import threading as TH +except ImportError: import dummy_threading as TH + +###-------------------------------------------------------------------------- +### Some basics. + +def identity(x): + """The identity function: returns its argument.""" + return x + +def constantly(x): + """The function which always returns X.""" + return lambda: x + +class struct (object): + """A simple object for storing data in attributes.""" + DEFAULTS = {} + def __init__(me, *args, **kw): + cls = me.__class__ + for k, v in kw.iteritems(): setattr(me, k, v) + try: + slots = cls.__slots__ + except AttributeError: + if args: raise ValueError, 'no slots defined' + else: + if len(args) > len(slots): raise ValueError, 'too many arguments' + for k, v in zip(slots, args): setattr(me, k, v) + for k in slots: + if hasattr(me, k): continue + try: setattr(me, k, cls.DEFAULTS[k]) + except KeyError: raise ValueError, "no value for `%s'" % k + +class Tag (object): + """An object whose only purpose is to be distinct from other objects.""" + def __init__(me, name): + me._name = name + def __repr__(me): + return '#<%s %r>' % (type(me).__name__, me._name) + +class DictExpanderClass (type): + """ + Metaclass for classes with autogenerated members. + + If the class body defines a dictionary `__extra__' then the key/value pairs + in this dictionary are promoted into attributes of the class. This is much + easier -- and safer -- than fiddling about with `locals'. + """ + def __new__(cls, name, supers, dict): + try: + ex = dict['__extra__'] + except KeyError: + pass + else: + for k, v in ex.iteritems(): + dict[k] = v + del dict['__extra__'] + return super(DictExpanderClass, cls).__new__(cls, name, supers, dict) + +class ExpectedError (Exception): + """ + A (concrete) base class for various errors we expect to encounter. + + The `msg' attribute carries a human-readable message explaining what the + problem actually is. The really important bit, though, is the `code' + attribute, which carries an HTTP status code to be reported to the user + agent, if we're running through CGI. + """ + def __init__(me, code, msg): + me.code = code + me.msg = msg + def __str__(me): + return '%s (%d)' % (me.msg, me.code) + +def register(dict, name): + """A decorator: add the decorated function to DICT, under the key NAME.""" + def _(func): + dict[name] = func + return func + return _ + +class StringSubst (object): + """ + A string substitution. Initialize with a dictionary mapping source strings + to target strings. The object is callable, and maps strings in the obvious + way. + """ + def __init__(me, map): + me._map = map + me._rx = RX.compile('|'.join(RX.escape(s) for s in map)) + def __call__(me, s): + return me._rx.sub(lambda m: me._map[m.group(0)], s) + +def readline(what, file = SYS.stdin): + """Read a single line from FILE (default stdin) and return it.""" + try: line = SYS.stdin.readline() + except IOError, e: raise ExpectedError, (500, str(e)) + if not line.endswith('\n'): + raise ExpectedError, (500, "Failed to read %s" % what) + return line[:-1] + +class EscapeHatch (BaseException): + """Exception used by the `Escape' context manager""" + def __init__(me): pass + +class Escape (object): + """ + A context manager. Executes its body until completion or the `Escape' + object itself is invoked as a function. Other exceptions propagate + normally. + """ + def __init__(me): + me.exc = EscapeHatch() + def __call__(me): + raise me.exc + def __enter__(me): + return me + def __exit__(me, exty, exval, extb): + return exval is me.exc + +class Fluid (object): + """ + Stores `fluid' variables which can be temporarily bound to new values, and + later restored. + + A caller may use the object's attributes for storing arbitrary values + (though storing a `bind' value would be silly). The `bind' method provides + a context manager which binds attributes to other values during its + execution. This works even with multiple threads. + """ + + ## We maintain two stores for variables. One is a global store, `_g'; the + ## other is a thread-local store `_t'. We look for a variable first in the + ## thread-local store, and then if necessary in the global store. Binding + ## works by remembering the old state of the variable on entry, setting it + ## in the thread-local store (always), and then restoring the old state on + ## exit. + + ## A special marker for unbound variables. If a variable is bound to a + ## value, rebound temporarily with `bind', and then deleted, we must + ## pretend that it's not there, and then restore it again afterwards. We + ## use this tag to mark variables which have been deleted while they're + ## rebound. + UNBOUND = Tag('unbound-variable') + + def __init__(me, **kw): + """Create a new set of fluid variables, initialized from the keywords.""" + me.__dict__.update(_g = struct(), + _t = TH.local()) + for k, v in kw.iteritems(): + setattr(me._g, k, v) + + def __getattr__(me, k): + """Return the current value stored with K, or raise AttributeError.""" + try: v = getattr(me._t, k) + except AttributeError: v = getattr(me._g, k) + if v is Fluid.UNBOUND: raise AttributeError, k + return v + + def __setattr__(me, k, v): + """Associate the value V with the variable K.""" + if hasattr(me._t, k): setattr(me._t, k, v) + else: setattr(me._g, k, v) + + def __delattr__(me, k): + """ + Forget about the variable K, so that attempts to read it result in an + AttributeError. + """ + if hasattr(me._t, k): setattr(me._t, k, Fluid.UNBOUND) + else: delattr(me._g, k) + + def __dir__(me): + """Return a list of the currently known variables.""" + seen = set() + keys = [] + for s in [me._t, me._g]: + for k in dir(s): + if k in seen: continue + seen.add(k) + if getattr(s, k) is not Fluid.UNBOUND: keys.append(k) + return keys + + @CTX.contextmanager + def bind(me, **kw): + """ + A context manager: bind values to variables according to the keywords KW, + and execute the body; when the body exits, restore the rebound variables + to their previous values. + """ + + ## A list of things to do when we finish. + unwind = [] + + def _delattr(k): + ## Remove K from the thread-local store. Only it might already have + ## been deleted, so be careful. + try: delattr(me._t, k) + except AttributeError: pass + + def stash(k): + ## Stash a function for restoring the old state of K. We do this here + ## rather than inline only because Python's scoping rules are crazy and + ## we need to ensure that all of the necessary variables are + ## lambda-bound. + try: ov = getattr(me._t, k) + except AttributeError: unwind.append(lambda: _delattr(k)) + else: unwind.append(lambda: setattr(me._t, k, ov)) + + ## Rebind the variables. + for k, v in kw.iteritems(): + stash(k) + setattr(me._t, k, v) + + ## Run the body, and restore. + try: yield me + finally: + for f in unwind: f() + +class Cleanup (object): + """ + A context manager for stacking other context managers. + + By itself, it does nothing. Attach other context managers with `enter' or + loose cleanup functions with `add'. On exit, contexts are left and + cleanups performed in reverse order. + """ + def __init__(me): + me._cleanups = [] + def __enter__(me): + return me + def __exit__(me, exty, exval, extb): + trap = False + for c in reversed(me._cleanups): + if c(exty, exval, extb): trap = True + return trap + def enter(me, ctx): + v = ctx.__enter__() + me._cleanups.append(ctx.__exit__) + return v + def add(me, func): + me._cleanups.append(lambda exty, exval, extb: func()) + +###-------------------------------------------------------------------------- +### Encodings. + +class Encoding (object): + """ + A pairing of injective encoding on binary strings, with its appropriate + partial inverse. + + The two functions are available in the `encode' and `decode' attributes. + See also the `ENCODINGS' dictionary. + """ + def __init__(me, encode, decode): + me.encode = encode + me.decode = decode + +ENCODINGS = { + 'base64': Encoding(lambda s: BN.b64encode(s), + lambda s: BN.b64decode(s)), + 'base32': Encoding(lambda s: BN.b32encode(s).lower(), + lambda s: BN.b32decode(s, casefold = True)), + 'hex': Encoding(lambda s: BN.b16encode(s).lower(), + lambda s: BN.b16decode(s, casefold = True)), + None: Encoding(identity, identity) +} + +###-------------------------------------------------------------------------- +### Time and timeouts. + +def update_time(): + """ + Reset our idea of the current time, as kept in the global variable `NOW'. + """ + global NOW + NOW = int(T.time()) +update_time() + +class Alarm (Exception): + """ + Exception used internally by the `timeout' context manager. + + If you're very unlucky, you might get one of these at top level. + """ + pass + +class Timeout (ExpectedError): + """ + Report a timeout, from the `timeout' context manager. + """ + def __init__(me, what): + ExpectedError.__init__(me, 500, "Timeout %s" % what) + +## Set `DEADLINE' to be the absolute time of the next alarm. We'll keep this +## up to date in `timeout'. +delta, _ = SIG.getitimer(SIG.ITIMER_REAL) +if delta == 0: DEADLINE = None +else: DEADLINE = NOW + delta + +def _alarm(sig, tb): + """If we receive `SIGALRM', raise the alarm.""" + raise Alarm +SIG.signal(SIG.SIGALRM, _alarm) + +@CTX.contextmanager +def timeout(delta, what): + """ + A context manager which interrupts execution of its body after DELTA + seconds, if it doesn't finish before then. + + If execution is interrupted, a `Timeout' exception is raised, carrying WHY + (a gerund phrase) as part of its message. + """ + + global DEADLINE + when = NOW + delta + if DEADLINE is not None and when >= DEADLINE: + yield + update_time() + else: + od = DEADLINE + try: + DEADLINE = when + SIG.setitimer(SIG.ITIMER_REAL, delta) + yield + except Alarm: + raise Timeout, what + finally: + update_time() + DEADLINE = od + if od is None: SIG.setitimer(SIG.ITIMER_REAL, 0) + else: SIG.setitimer(SIG.ITIMER_REAL, DEADLINE - NOW) + +###-------------------------------------------------------------------------- +### File locking. + +@CTX.contextmanager +def lockfile(lock, t = None): + """ + Acquire an exclusive lock on a named file LOCK while executing the body. + + If T is zero, fail immediately if the lock can't be acquired; if T is none, + then wait forever if necessary; otherwise give up after T seconds. + """ + fd = -1 + try: + fd = OS.open(lock, OS.O_WRONLY | OS.O_CREAT, 0600) + if timeout is None: + F.lockf(fd, F.LOCK_EX) + elif timeout == 0: + F.lockf(fd, F.LOCK_EX | F.LOCK_NB) + else: + with timeout(t, "waiting for lock file `%s'" % lock): + F.lockf(fd, F.LOCK_EX) + yield None + finally: + if fd != -1: OS.close(fd) + +###-------------------------------------------------------------------------- +### Database utilities. + +### Python's database API is dreadful: it exposes far too many +### implementation-specific details to the programmer, who may well want to +### write code which works against many different databases. +### +### One particularly frustrating problem is the variability of placeholder +### syntax in SQL statements: there's no universal convention, just a number +### of possible syntaxes, at least one of which will be implemented (and some +### of which are mutually incompatible). Because not doing this invites all +### sorts of misery such as SQL injection vulnerabilties, we introduce a +### simple abstraction. A database parameter-type object keeps track of one +### particular convention, providing the correct placeholders to be inserted +### into the SQL command string, and the corresponding arguments, in whatever +### way is necessary. +### +### The protocol is fairly simple. An object of the appropriate class is +### instantiated for each SQL statement, providing it with a dictionary +### mapping placeholder names to their values. The object's `sub' method is +### called for each placeholder found in the statement, with a match object +### as an argument; the match object picks out the name of the placeholder in +### question in group 1, and the method returns a piece of syntax appropriate +### to the database backend. Finally, the collected arguments are made +### available, in whatever format is required, in the object's `args' +### attribute. + +## Turn simple Unix not-quite-glob patterns into SQL `LIKE' patterns. +## Match using: x LIKE y ESCAPE '\\' +globtolike = StringSubst({ + '\\*': '*', '%': '\\%', '*': '%', + '\\?': '?', '_': '\\_', '?': '_' +}) + +class LinearParam (object): + """ + Abstract parent class for `linear' parameter conventions. + + A linear convention is one where the arguments are supplied as a list, and + placeholders are either all identical (with semantics `insert the next + argument'), or identify their argument by its position within the list. + """ + def __init__(me, kw): + me._i = 0 + me.args = [] + me._kw = kw + def sub(me, match): + name = match.group(1) + me.args.append(me._kw[name]) + marker = me._format() + me._i += 1 + return marker +class QmarkParam (LinearParam): + def _format(me): return '?' +class NumericParam (LinearParam): + def _format(me): return ':%d' % me._i +class FormatParam (LinearParam): + def _format(me): return '%s' + +class DictParam (object): + """ + Abstract parent class for `dictionary' parameter conventions. + + A dictionary convention is one where the arguments are provided as a + dictionary, and placeholders contain a key name identifying the + corresponding value in that dictionary. + """ + def __init__(me, kw): + me.args = kw + def sub(me, match): + name = match.group(1) + return me._format(name) +def NamedParam (object): + def _format(me, name): return ':%s' % name +def PyFormatParam (object): + def _format(me, name): return '%%(%s)s' % name + +### Since we're doing a bunch of work to paper over idiosyncratic placeholder +### syntax, we might as well also sort out other problems. The `DB_FIXUPS' +### dictionary maps database module names to functions which might need to do +### clever stuff at connection setup time. + +DB_FIXUPS = {} + +@register(DB_FIXUPS, 'sqlite3') +def fixup_sqlite3(db): + """ + Unfortunately, SQLite learnt about FOREIGN KEY constraints late, and so + doesn't enforce them unless explicitly told to. + """ + c = db.cursor() + c.execute("PRAGMA foreign_keys = ON") + +class SimpleDBConnection (object): + """ + Represents a database connection, while trying to hide the differences + between various kinds of database backends. + """ + + __metaclass__ = DictExpanderClass + + ## A map from placeholder convention names to classes implementing them. + PLACECLS = { + 'qmark': QmarkParam, + 'numeric': NumericParam, + 'named': NamedParam, + 'format': FormatParam, + 'pyformat': PyFormatParam + } + + ## A pattern for our own placeholder syntax. + R_PLACE = RX.compile(r'\$(\w+)') + + def __init__(me, modname, modargs): + """ + Make a new database connection, using the module MODNAME, and passing its + `connect' function the MODARGS -- which may be either a list or a + dictionary. + """ + + ## Get the module, and create a connection. + mod = __import__(modname) + if isinstance(modargs, dict): me._db = mod.connect(**modargs) + else: me._db = mod.connect(*modargs) + + ## Apply any necessary fixups. + try: fixup = DB_FIXUPS[modname] + except KeyError: pass + else: fixup(me._db) + + ## Grab hold of other interesting things. + me.Error = mod.Error + me.Warning = mod.Warning + me._placecls = me.PLACECLS[mod.paramstyle] + + def execute(me, command, **kw): + """ + Execute the SQL COMMAND. The keyword arguments are used to provide + values corresponding to `$NAME' placeholders in the COMMAND. + + Return the receiver, so that iterator protocol is convenient. + """ + me._cur = me._db.cursor() + plc = me._placecls(kw) + subst = me.R_PLACE.sub(plc.sub, command) + ##PRINT('*** %s : %r' % (subst, plc.args)) + me._cur.execute(subst, plc.args) + return me + + def __iter__(me): + """Iterator protocol: simply return the receiver.""" + return me + def next(me): + """Iterator protocol: return the next row from the current query.""" + row = me.fetchone() + if row is None: raise StopIteration + return row + + def __enter__(me): + """ + Context protocol: begin a transaction. + """ + ##PRINT('<<< BEGIN') + return + def __exit__(me, exty, exval, tb): + """Context protocol: commit or roll back a transaction.""" + if exty: + ##PRINT('>*> ROLLBACK') + me.rollback() + else: + ##PRINT('>>> COMMIT') + me.commit() + + ## Import a number of methods from the underlying connection. + __extra__ = {} + for _name in ['fetchone', 'fetchmany', 'fetchall']: + def _(name, extra): + extra[name] = lambda me, *args, **kw: \ + getattr(me._cur, name)(*args, **kw) + _(_name, __extra__) + for _name in ['commit', 'rollback']: + def _(name, extra): + extra[name] = lambda me, *args, **kw: \ + getattr(me._db, name)(*args, **kw) + _(_name, __extra__) + del _name, _ + +###----- That's all, folks -------------------------------------------------- diff --git a/wrapper.fhtml b/wrapper.fhtml new file mode 100644 index 0000000..425e05f --- /dev/null +++ b/wrapper.fhtml @@ -0,0 +1,54 @@ +~1[ + +~]~ + + + + + ~={title}H + + + + + + +~={payload}@?~ + +

+ Chopwood, version ~={version}H: + copyright © 2012 Mark Wooding +
+ This is free + software. You + can download + the source code. +
+ + + +~ +~1[~]~ -- [mdw]