--- /dev/null
+auto.py
+auto-*.py
+RELEASE
+
+chpwd.db
+chpwd.conf
+
+static/
+*.pyc
+*.new
--- /dev/null
+;;; -*-emacs-lisp-*-
+
+(setq skel-alist
+ (append
+ '((author . "Mark Wooding")
+ (licence-text . "[[agpl]]")
+ (full-title . "Chopwood: a password-changing service")
+ (program . "Chopwood"))
+ skel-alist))
--- /dev/null
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ 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.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ 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 <http://www.gnu.org/licenses/>.
+
+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
+<http://www.gnu.org/licenses/>.
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+## 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 --------------------------------------------------
--- /dev/null
+~1[<!-- -*-html-*-
+ --
+ -- Credits page
+ --
+ -- (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
+ -- <http://www.gnu.org/licenses/>.
+ -->~]~
+
+<h1>About this program</h1>
+
+<p>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.
+
+<h2>Source code</h2>
+
+<p>Chopwood is free software. You
+can <a href="~={script}H/~={package}H-~={version}H.tar.gz">download the
+code running on this server</a>.
+
+<h2>Licence</h2>
+
+<p>Chopwood is free software; you can redistribute it and/or modify it under
+the terms of the
+<a href="http://www.gnu.org/licenses/agpl-3.0.html">GNU Affero General
+Public License</a> as published by the Free Software Foundation; either
+version 3 of the License, or (at your option) any later version.
+
+<p>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.
+
+<p>You should have received a copy of the GNU Affero General Public
+License along with Chopwood; if not, see
+<<a href="http://www.gnu.org/licenses/">http://www.gnu.org/licenses/</a>>.
+
+~1[<!------- That's all, folks ------------------------------------------>~]~
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+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 --------------------------------------------------
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+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 --------------------------------------------------
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+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 `<head>' element is written, and a `<body>' 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("""\
+<!DOCTYPE HTML PUBLIC '-//W3C//DTD HTML 4.01//EN'
+ 'http://www.w3c.org/TR/html4/strict.dtd'>
+<html>
+<head>
+ <title>%(title)s</title>
+ <link rel=stylesheet type='text/css' media=screen href='%(style)s'>
+ <meta http-equiv='Content-Script-Type' content='text/javascript'>
+ <script type='text/javascript' src='%(script)s'></script>
+</head>""" % dict(title = html_quotify(title),
+ style = static('chpwd.css'),
+ script = static('chpwd.js')))
+
+ ## Write the body.
+ PRINT('<body>')
+ yield None
+ PRINT('''\
+
+<div class=credits>
+ <a href="%(about)s">Chopwood</a>, version %(version)s:
+ copyright © 2012 Mark Wooding
+</div>
+
+</body>
+</html>''' % 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("""\
+<html>
+<head><title>No, sorry, it's moved again.</title></head>
+<body><p>I'm <a href="%s">over here</a> now.<body>
+</html>""" % 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("""\
+<h2>Exception</h2>
+<pre>%s</pre>""" % html_quotify(
+ '\n'.join(TB.format_exception_only(exty, exval))))
+
+ ## Format a traceback so we can find out what has gone wrong.
+ PRINT("""\
+<h2>Traceback</h2>
+<ol>""")
+ for file, line, func, text in TB.extract_tb(extb, 20):
+ PRINT("<li><b>%s</b>:%d (<b>%s</b>)" % (
+ htmlescape(file), line, htmlescape(func)))
+ if text is not None:
+ PRINT("<br><tt>%s</tt>" % htmlescape(text))
+ PRINT("</ol>")
+
+ ## Format various useful tables.
+ def fmt_dict(d):
+ fmt_kvlist(d.iteritems())
+ def fmt_kvlist(l):
+ for k, v in sorted(l):
+ PRINT("<tr><th align=right valign=top>%s<td><tt>%s</tt>" % (
+ htmlescape(k), htmlescape(v)))
+ def fmt_list(l):
+ for i in l:
+ PRINT("<tr><tt>%s</tt>" % htmlescape(i))
+
+ PRINT("""\
+<h2>Parameters</h2>""")
+ for what, thing, how in [('Query', PARAM, fmt_kvlist),
+ ('Cookies', COOKIE, fmt_dict),
+ ('Path', PATH, fmt_list),
+ ('Environment', ENV, fmt_dict)]:
+ PRINT("<h3>%s</h3>\n<table>" % what)
+ how(thing)
+ PRINT("</table>")
+
+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("<div class=exception>")
+ cgi_error_guts()
+ PRINT("</div>\n</body></html>")
+ else:
+ with html("chpwd internal error", status = 500):
+ PRINT("<h1>chpwd internal error</h1>")
+ 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 --------------------------------------------------
--- /dev/null
+#! /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
+### <http://www.gnu.org/licenses/>.
+
+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
+<http://www.gnu.org/licenses/agpl-3.0.html>. 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 --------------------------------------------------
--- /dev/null
+/* -*-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
+ * <http://www.gnu.org/licenses/>.
+ */
+
+/*----- 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 -------------------------------------------------*/
--- /dev/null
+/* -*-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
+ * <http://www.gnu.org/licenses/>.
+ */
+
+/*----- 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 -------------------------------------------------*/
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+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 --------------------------------------------------
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+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("""\
+<html>
+<head><title>Chopwood: filler text</title></head>
+<body>
+<h1>Failure expected soon
+<p>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 --------------------------------------------------
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+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 --------------------------------------------------
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+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 --------------------------------------------------
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+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 "<unknown service `%s'>" % 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 --------------------------------------------------
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+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 --------------------------------------------------
--- /dev/null
+~1[<!-- -*-html-*-
+ --
+ -- Information about cookies
+ --
+ -- (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
+ -- <http://www.gnu.org/licenses/>.
+ -->~]~
+
+<h1>Why and how Chopwood uses cookies</h1>
+
+<h2>Which cookies does Chopwood actually store?</h2>
+
+<p>Chopwood uses only one cookie, named <b>chpwd-token</b>. 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).
+
+<h2>What do you need this cookie for?</h2>
+
+<p>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.
+
+<p>For example, if we used <b>GET</b> 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.
+
+<p>We could avoid this problem by using <b>POST</b> requests everywhere, but
+that causes other trouble. In particular, you'd get that annoying
+<blockquote>
+ 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.
+</blockquote>
+message whenever you hit the reload button.
+
+<h2>What's in this cookie?</h2>
+
+<p>If you actually look at the cookie, you find that it looks something like
+this:
+<blockquote>
+ <tt>1357322139.HFsD16dOh1jjdhXdO%24gkjQ.eBcBNYFhi6sKpGuahfr7yQDzqOJuYZZexJbVug9ultU.mdw</tt>
+</blockquote>
+(Did I say something about long and ugly?) It consists of four pieces
+separated by dots ‘<tt>.</tt>’.
+
+<dl>
+<dt>Datestamp
+<dd>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).
+
+<dt>Nonce
+<dd>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
+<a href='http://en.wikipedia.org/wiki/Cross-site_request_forgery'><em>cross-site
+request forgery</em></a> attacks.
+
+<dt>Tag
+<dd>This is a cryptographic check that the other parts of the token haven't
+been modfied by an attacker.
+
+<dt>User name
+<dd>Your user name, in plain text.
+</dl>
+
+<h2>How do I know you're not using this as part of some hideous behavioural
+advertising scheme?</h2>
+
+<p>That's tricky. I could tell you that this program is
+<a href='http://www.gnu.org/philosophy/free-sw.html'>free software</a>, and
+that you can <a href='chpwd'>download its source code</a> and check for
+yourself.
+
+<p>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.
+
+<p>So, really, it comes down to trust. Sorry.
+
+~1[<!------- That's all, folks ------------------------------------------>~]~
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+###--------------------------------------------------------------------------
+### 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 --------------------------------------------------
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+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 --------------------------------------------------
--- /dev/null
+~1[<!-- -*-html-*-
+ --
+ -- Report an expected error
+ --
+ -- (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
+ -- <http://www.gnu.org/licenses/>.
+ -->~]~
+
+<h1>Chopwood: error</h1>
+
+<p><b>~={error.msg}:H</b>
+
+~1[<!------- That's all, folks ------------------------------------------>~]~
--- /dev/null
+~1[<!-- -*-html-*-
+ --
+ -- Error report body
+ --
+ -- (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
+ -- <http://www.gnu.org/licenses/>.
+ -->~]~
+
+~={toplevel}:[~
+<div class=exception>~%~;~
+<h1>Chopwood: internal error</h1>
+<p>(That means a bug. Please report it.)~2%~]~
+
+<h2>Exception</h2>
+<pre>
+~={exception}{~H~^~%~}~
+</pre>
+
+<h2>Traceback</h2>
+<ol>~={traceback}:{
+<li><b>~H</b>:~D (<b>~H</b>)~@[~% <br><tt>~H</tt>~]~}
+</ol>
+
+<h2>Parameters</h2>
+<h3>Query</h3>
+<table>~
+~={PARAM}:{~%<tr><th align=right valign=top>~H<td><tt>~H</tt>~}
+</table>
+<h3>Cookies</h3>
+<table>~
+~={COOKIE}:{~%<tr><th align=right valign=top>~H<td><tt>~H</tt>~}
+</table>
+<h3>Path</h3>
+<table>~
+~={PATH}{~%<tt>~H</tt>~}
+</table>
+<h3>Environment</h3>
+<table>~
+~={ENV}:{~%<tr><th align=right valign=top>~H<td><tt>~H</tt>~}
+</table>~
+
+~={toplevel}:[~2%</div>~%</body>~%</html>~;~]~
+
+~1[<!------- That's all, folks ------------------------------------------>~]~
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+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 --------------------------------------------------
--- /dev/null
+#! /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"
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+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 --------------------------------------------------
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+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, '<unknown error %s>' % 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 --------------------------------------------------
--- /dev/null
+~1[<!-- -*-html-*-
+ --
+ -- Main account listing and password changing form
+ --
+ -- (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
+ -- <http://www.gnu.org/licenses/>.
+ -->~]~
+
+<h1>Chopwood: accounts list</h1>
+
+<form method=POST action='~={script}H'>
+
+<div class=expand-outer>
+<div class=expand-inner>
+<table class=expand>
+ <tr><td>
+ <h2><label for=acct-list accesskey=a>Your <u>a</u>ccounts</label></h2>
+ <tr class=expand><td>
+ <select multiple id=acct-list name=services>~={accts}{
+ <option value=~={@.service}H>~
+ ~={@.service}H: ~={@.friendly}:H~={@.alias}@[ (~H)~]~*~}
+ </select>
+</table>
+<script type'text/javascript'><!--
+ function check_accounts() {
+ if (elt('acct-list').selectedIndex == -1)
+ return 'No accounts selected.';
+ return null;
+ }
+ FORMS.acct = {
+ elts: ['list'],
+ check: function () { return null; }
+ }
+--></script>
+</div>
+
+<div class=expand-reference>
+
+<h2>Set a new password</h2>
+<table>
+<tr>
+ <td class=label>
+ <label for=set-first accesskey=p>New <u>p</u>assword:</label>
+ <td>
+ <input id=set-first type=password name=first>
+<tr>
+ <td class=label>
+ <label for=set-second>Confirm password:</label>
+ <td>
+ <input id=set-second type=password name=second>
+ <td>
+ <button type=submit accesskey=s id=set-submit
+ name=%act value=set><u>S</u>et</button>
+<tr>
+ <td colspan=3 class=whinge id=set-whinge align=center>OK
+</table>
+<script type'text/javascript'><!--
+ function check_partial_passwd() {
+ if (elt('set-first').value != '' ||
+ elt('set-second').value != '')
+ return 'Password at least partially entered.';
+ return null;
+ }
+ FORMS.set = {
+ elts: ['first', 'second'],
+ check: function () {
+ var w;
+ if ((w = check_accounts()) !== null)
+ return w;
+ else if (elt('set-first').value == "")
+ return 'Empty password not permitted.';
+ else if (elt('set-first').value != elt('set-second').value)
+ return 'Passwords don\'t match.';
+ return null;
+ }
+ }
+--></script>
+
+<h2>Generate a new password</h2>
+<button type=submit id=reset-submit accesskey=g
+ name=%act value=reset><u>G</u>enerate</button>
+<span class=whinge id=reset-whinge>OK</span>
+<script type='text/javascript'><!--
+ FORMS.reset = {
+ check: function () {
+ return check_accounts() || check_partial_passwd();
+ }
+ }
+--></script>
+
+<h2>Clear the existing passwords</h2>
+<button type=submit id=clear-submit accesskey=c
+ name=%act value=clear><u>C</u>lear</button>
+<span class=whinge id=clear-whinge>OK</span>
+<script type='text/javascript'><!--
+ FORMS.clear = {
+ check: function () {
+ return check_accounts() || check_partial_passwd();
+ }
+ }
+--></script>
+
+</div>
+</div>
+
+<input type=hidden name=%nonce value='~={nonce}H'>
+</form>
+
+~1[<!------- That's all, folks ------------------------------------------>~]~
--- /dev/null
+~1[<!-- -*-html-*-
+ --
+ -- Login page
+ --
+ -- (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
+ -- <http://www.gnu.org/licenses/>.
+ -->~]~
+
+<h1>Chopwood: login</h1>
+
+~={why}@[<p class='whinge wrong'>~:H~2%~]~
+
+<form method=POST action='~={script}H/auth'>
+<table>
+<tr>
+ <td class=label><label for=login-u accesskey=l><u>L</u>ogin:</label>
+ <td><input id=login-u name=u>
+<tr>
+ <td class=label><label for=login-pw>Password:</label>
+ <td><input id=login-pw name=pw type=password>
+ <td><button type=submit accesskey=o>L<u>o</u>gin</button>
+</table>
+</form>
+
+<p>Logging in will set a short-lived cookie in your browser. If this
+worries you, you might like to read about
+<a href='~={static}H/cookies.html'>why and how Chopwood uses cookies<a>.
+
+~1[<!------- That's all, folks ------------------------------------------>~]~
--- /dev/null
+~1[<!-- -*-html-*-
+ --
+ -- Outcome of some password-changing operation
+ --
+ -- (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
+ -- <http://www.gnu.org/licenses/>.
+ -->~]~
+
+<h1>Chopwood: ~={what}:H – ~={outcome.rc}[~
+ successful~;~
+ partially successful~;~
+ FAILED~;~
+ no services specified: nothing to do!~:;~
+ unknown status code ~={outcome.rc}D~]~
+</h1>
+
+~={info}{~
+<h2>Information</h2>
+<p><table>~@{
+<tr><th align=left>~={@.desc}:H<td>~={@.value}H~*~}
+</table>~2%~}~
+
+<h2>Results</h2>
+<p><table>~={results}{
+<tr>~
+<th align=left>~={@.svc.friendly}:H~
+<td>~={@.error}:[OK~={@.result}[: ~H~]~;FAILED: ~={@.error.msg}:H~]~*~}
+</table>
+
+~1[<!------- That's all, folks ------------------------------------------>~]~
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+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 --------------------------------------------------
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+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 --------------------------------------------------
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+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 --------------------------------------------------
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+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 --------------------------------------------------
--- /dev/null
+### -*-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
+### <http://www.gnu.org/licenses/>.
+
+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 --------------------------------------------------
--- /dev/null
+~1[<!-- -*-html-*-
+ --
+ -- Common HTML wrapper
+ --
+ -- (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
+ -- <http://www.gnu.org/licenses/>.
+ -->~]~
+
+<!DOCTYPE HTML PUBLIC '-//W3C//DTD HTML 4.01//EN'
+ 'http://www.w3c.org/TR/html4/strict.dtd'>
+<html>
+<head>
+ <title>~={title}H</title>
+ <link rel=stylesheet type='text/css' media=screen
+ href='~={static}H/chpwd.css'>
+ <script type='text/javascript' src='~={static}H/chpwd.js'></script>
+</head>
+
+<body>
+
+~={payload}@?~
+
+<div class=credits>
+ <a href="~={static}H/about.html">Chopwood</a>, version ~={version}H:
+ copyright © 2012 Mark Wooding
+ <br>
+ This is <a href="http://www.gnu.org/licenses/agpl-3.0.html">free
+ software</a>. You
+ can <a href="~={script}H/~={package}H-~={version}H.tar.gz">download
+ the source code</a>.
+</div>
+
+</body>
+</html>
+~
+~1[<!------- That's all, folks ------------------------------------------>~]~