chiark / gitweb /
Initial commit.
authorMark Wooding <mdw@distorted.org.uk>
Thu, 7 Mar 2013 18:47:57 +0000 (18:47 +0000)
committerMark Wooding <mdw@distorted.org.uk>
Fri, 8 Mar 2013 02:37:03 +0000 (02:37 +0000)
35 files changed:
.gitignore [new file with mode: 0644]
.skelrc [new file with mode: 0644]
AGPLv3 [new file with mode: 0644]
Makefile [new file with mode: 0644]
about.fhtml [new file with mode: 0644]
agpl.py [new file with mode: 0644]
backend.py [new file with mode: 0644]
cgi.py [new file with mode: 0644]
chpwd [new file with mode: 0755]
chpwd.css [new file with mode: 0644]
chpwd.js [new file with mode: 0644]
cmd-admin.py [new file with mode: 0644]
cmd-cgi.py [new file with mode: 0644]
cmd-remote.py [new file with mode: 0644]
cmd-user.py [new file with mode: 0644]
cmdutil.py [new file with mode: 0644]
config.py [new file with mode: 0644]
cookies.fhtml [new file with mode: 0644]
crypto.py [new file with mode: 0644]
dbmaint.py [new file with mode: 0644]
error.fhtml [new file with mode: 0644]
exception.fhtml [new file with mode: 0644]
format.py [new file with mode: 0644]
get-version [new file with mode: 0755]
hash.py [new file with mode: 0644]
httpauth.py [new file with mode: 0644]
list.fhtml [new file with mode: 0644]
login.fhtml [new file with mode: 0644]
operate.fhtml [new file with mode: 0644]
operation.py [new file with mode: 0644]
output.py [new file with mode: 0644]
service.py [new file with mode: 0644]
subcommand.py [new file with mode: 0644]
util.py [new file with mode: 0644]
wrapper.fhtml [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..3d55bb2
--- /dev/null
@@ -0,0 +1,10 @@
+auto.py
+auto-*.py
+RELEASE
+
+chpwd.db
+chpwd.conf
+
+static/
+*.pyc
+*.new
diff --git a/.skelrc b/.skelrc
new file mode 100644 (file)
index 0000000..fbcaea9
--- /dev/null
+++ b/.skelrc
@@ -0,0 +1,9 @@
+;;; -*-emacs-lisp-*-
+
+(setq skel-alist
+      (append
+       '((author . "Mark Wooding")
+        (licence-text . "[[agpl]]")
+        (full-title . "Chopwood: a password-changing service")
+        (program . "Chopwood"))
+       skel-alist))
diff --git a/AGPLv3 b/AGPLv3
new file mode 100644 (file)
index 0000000..dba13ed
--- /dev/null
+++ b/AGPLv3
@@ -0,0 +1,661 @@
+                    GNU AFFERO GENERAL PUBLIC LICENSE
+                       Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <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/>.
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..9cedd7f
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,118 @@
+### -*-makefile-*-
+###
+### Build and setup script
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/about.fhtml b/about.fhtml
new file mode 100644 (file)
index 0000000..9579700
--- /dev/null
@@ -0,0 +1,57 @@
+~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
+&lt;<a href="http://www.gnu.org/licenses/">http://www.gnu.org/licenses/</a>&gt;.
+
+~1[<!------- That's all, folks ------------------------------------------>~]~
diff --git a/agpl.py b/agpl.py
new file mode 100644 (file)
index 0000000..b89330c
--- /dev/null
+++ b/agpl.py
@@ -0,0 +1,112 @@
+### -*-python-*-
+###
+### GNU Affero General Public License compliance
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/backend.py b/backend.py
new file mode 100644 (file)
index 0000000..1725d7d
--- /dev/null
@@ -0,0 +1,309 @@
+### -*-python-*-
+###
+### Password backends
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/cgi.py b/cgi.py
new file mode 100644 (file)
index 0000000..f69646c
--- /dev/null
+++ b/cgi.py
@@ -0,0 +1,603 @@
+### -*-python-*-
+###
+### CGI machinery
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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({
+  "`": '&lsquo;',
+  "'": '&rsquo;',
+  "``": '&ldquo;',
+  "''": '&rdquo;',
+  "--": '&ndash;',
+  "---": '&mdash;'
+})
+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 &copy; 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 --------------------------------------------------
diff --git a/chpwd b/chpwd
new file mode 100755 (executable)
index 0000000..5517274
--- /dev/null
+++ b/chpwd
@@ -0,0 +1,272 @@
+#! /usr/bin/python
+###
+### Password management
+###
+### (c) 2012 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/chpwd.css b/chpwd.css
new file mode 100644 (file)
index 0000000..64b98be
--- /dev/null
+++ b/chpwd.css
@@ -0,0 +1,100 @@
+/* -*-css-*-
+ *
+ * Style sheet for Chopwood
+ *
+ * (c) 2013 Mark Wooding
+ */
+
+/*----- Licensing notice --------------------------------------------------*
+ *
+ * This file is part of Chopwood: a password-changing service.
+ *
+ * Chopwood is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * Chopwood is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with Chopwood; if not, see
+ * <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 -------------------------------------------------*/
diff --git a/chpwd.js b/chpwd.js
new file mode 100644 (file)
index 0000000..9ad74ad
--- /dev/null
+++ b/chpwd.js
@@ -0,0 +1,148 @@
+/* -*-js-*-
+ *
+ * Common JavaScript code for Chopwood
+ *
+ * (c) 2013 Mark Wooding
+ */
+
+/*----- Licensing notice --------------------------------------------------*
+ *
+ * This file is part of Chopwood: a password-changing service.
+ *
+ * Chopwood is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * Chopwood is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with Chopwood; if not, see
+ * <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 -------------------------------------------------*/
diff --git a/cmd-admin.py b/cmd-admin.py
new file mode 100644 (file)
index 0000000..dc52461
--- /dev/null
@@ -0,0 +1,172 @@
+### -*-python-*-
+###
+### Administrative commands
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/cmd-cgi.py b/cmd-cgi.py
new file mode 100644 (file)
index 0000000..b06ad6a
--- /dev/null
@@ -0,0 +1,188 @@
+### -*-python-*-
+###
+### CGI commands
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/cmd-remote.py b/cmd-remote.py
new file mode 100644 (file)
index 0000000..b140329
--- /dev/null
@@ -0,0 +1,47 @@
+### -*-python-*-
+###
+### Remote service commands
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/cmd-user.py b/cmd-user.py
new file mode 100644 (file)
index 0000000..671ab20
--- /dev/null
@@ -0,0 +1,116 @@
+### -*-python-*-
+###
+### User commands
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/cmdutil.py b/cmdutil.py
new file mode 100644 (file)
index 0000000..a20b8dd
--- /dev/null
@@ -0,0 +1,300 @@
+### -*-python-*-
+###
+### Utilities for the various commands
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/config.py b/config.py
new file mode 100644 (file)
index 0000000..2c13232
--- /dev/null
+++ b/config.py
@@ -0,0 +1,91 @@
+### -*-python-*-
+###
+### Configuration handling
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/cookies.fhtml b/cookies.fhtml
new file mode 100644 (file)
index 0000000..cfc340b
--- /dev/null
@@ -0,0 +1,103 @@
+~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&rsquo;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 &lsquo;<tt>.</tt>&rsquo;.
+
+<dl>
+<dt>Datestamp
+<dd>The time at which the cookie was issued, as a simple count of (non-leap)
+seconds since 1974&ndash;01&ndash;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 ------------------------------------------>~]~
diff --git a/crypto.py b/crypto.py
new file mode 100644 (file)
index 0000000..749ac2a
--- /dev/null
+++ b/crypto.py
@@ -0,0 +1,82 @@
+### -*-python-*-
+###
+### Cryptographic primitives
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/dbmaint.py b/dbmaint.py
new file mode 100644 (file)
index 0000000..e9082c8
--- /dev/null
@@ -0,0 +1,92 @@
+### -*-python-*-
+###
+### Database maintenance
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/error.fhtml b/error.fhtml
new file mode 100644 (file)
index 0000000..c602dc5
--- /dev/null
@@ -0,0 +1,31 @@
+~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 ------------------------------------------>~]~
diff --git a/exception.fhtml b/exception.fhtml
new file mode 100644 (file)
index 0000000..5d72429
--- /dev/null
@@ -0,0 +1,62 @@
+~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 ------------------------------------------>~]~
diff --git a/format.py b/format.py
new file mode 100644 (file)
index 0000000..231f922
--- /dev/null
+++ b/format.py
@@ -0,0 +1,1332 @@
+### -*-python-*-
+###
+### String formatting, with bells, whistles, and gongs
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/get-version b/get-version
new file mode 100755 (executable)
index 0000000..a166923
--- /dev/null
@@ -0,0 +1,10 @@
+#! /bin/sh -e
+
+if [ -d .git ] && version=$(git describe --abbrev=4 2>/dev/null); then
+  case "$(git diff-index --name-only HEAD)" in ?*) version=$version+ ;; esac
+elif [ -f RELEASE ]; then
+  version=$(cat RELEASE)
+else
+  version=UNKNOWN-VERSION
+fi
+echo "$version"
diff --git a/hash.py b/hash.py
new file mode 100644 (file)
index 0000000..0b3142e
--- /dev/null
+++ b/hash.py
@@ -0,0 +1,337 @@
+### -*-python-*-
+###
+### Password hashing schemes
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/httpauth.py b/httpauth.py
new file mode 100644 (file)
index 0000000..22648dd
--- /dev/null
@@ -0,0 +1,267 @@
+### -*-python-*-
+###
+### HTTP authentication
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/list.fhtml b/list.fhtml
new file mode 100644 (file)
index 0000000..92d2c2c
--- /dev/null
@@ -0,0 +1,127 @@
+~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 ------------------------------------------>~]~
diff --git a/login.fhtml b/login.fhtml
new file mode 100644 (file)
index 0000000..44d3d1e
--- /dev/null
@@ -0,0 +1,47 @@
+~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 ------------------------------------------>~]~
diff --git a/operate.fhtml b/operate.fhtml
new file mode 100644 (file)
index 0000000..64d09a8
--- /dev/null
@@ -0,0 +1,48 @@
+~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 &ndash; ~={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 ------------------------------------------>~]~
diff --git a/operation.py b/operation.py
new file mode 100644 (file)
index 0000000..1184e3d
--- /dev/null
@@ -0,0 +1,320 @@
+### -*-python-*-
+###
+### Operations and policy switch
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/output.py b/output.py
new file mode 100644 (file)
index 0000000..b849985
--- /dev/null
+++ b/output.py
@@ -0,0 +1,209 @@
+### -*-python-*-
+###
+### Output machinery
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/service.py b/service.py
new file mode 100644 (file)
index 0000000..5153539
--- /dev/null
@@ -0,0 +1,382 @@
+### -*-python-*-
+###
+### Services
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/subcommand.py b/subcommand.py
new file mode 100644 (file)
index 0000000..b285915
--- /dev/null
@@ -0,0 +1,403 @@
+### -*-python-*-
+###
+### Subcommand dispatch
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/util.py b/util.py
new file mode 100644 (file)
index 0000000..49bd11f
--- /dev/null
+++ b/util.py
@@ -0,0 +1,582 @@
+### -*-python-*-
+###
+### Miscellaneous utilities
+###
+### (c) 2013 Mark Wooding
+###
+
+###----- Licensing notice ---------------------------------------------------
+###
+### This file is part of Chopwood: a password-changing service.
+###
+### Chopwood is free software; you can redistribute it and/or modify
+### it under the terms of the GNU Affero General Public License as
+### published by the Free Software Foundation; either version 3 of the
+### License, or (at your option) any later version.
+###
+### Chopwood is distributed in the hope that it will be useful,
+### but WITHOUT ANY WARRANTY; without even the implied warranty of
+### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+### GNU Affero General Public License for more details.
+###
+### You should have received a copy of the GNU Affero General Public
+### License along with Chopwood; if not, see
+### <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 --------------------------------------------------
diff --git a/wrapper.fhtml b/wrapper.fhtml
new file mode 100644 (file)
index 0000000..425e05f
--- /dev/null
@@ -0,0 +1,54 @@
+~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 &copy; 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 ------------------------------------------>~]~