From: Mark Wooding Date: Thu, 7 Mar 2013 18:47:57 +0000 (+0000) Subject: Initial commit. X-Git-Tag: 1.0.0~36 X-Git-Url: https://git.distorted.org.uk/~mdw/chopwood/commitdiff_plain/a2916c0635fec5b45ad742904db9f5769b48f53d Initial commit. --- a2916c0635fec5b45ad742904db9f5769b48f53d 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[~]~