chiark / gitweb /
zone.lisp: New utility for hashing files.
[zone] / zone.lisp
index 633e0d4978fe1f92ed55463949520789aca3f872..177ded6b292c7601a2befb06f1135906cb43c3d0 100644 (file)
--- a/zone.lisp
+++ b/zone.lisp
@@ -111,6 +111,110 @@ (defun iso-date (&optional time &key datep timep (sep #\ ))
       (when timep
        (format s "~2,'0D:~2,'0D:~2,'0D" hr min sec)))))
 
+(deftype octet () '(unsigned-byte 8))
+(deftype octet-vector (&optional n) `(array octet (,n)))
+
+(defun decode-hex (hex &key (start 0) end)
+  "Decode a hexadecimal-encoded string, returning a vector of octets."
+  (let* ((end (or end (length hex)))
+        (len (- end start))
+        (raw (make-array (floor len 2) :element-type 'octet)))
+    (unless (evenp len)
+      (error "Invalid hex string `~A' (odd length)" hex))
+    (do ((i start (+ i 2)))
+       ((>= i end) raw)
+      (let ((high (digit-char-p (char hex i) 16))
+           (low (digit-char-p (char hex (1+ i)) 16)))
+       (unless (and high low)
+         (error "Invalid hex string `~A' (bad digit)" hex))
+       (setf (aref raw (/ (- i start) 2)) (+ (* 16 high) low))))))
+
+(defun slurp-file (file &optional (element-type 'character))
+  "Read and return the contents of FILE as a vector."
+  (with-open-file (in file :element-type element-type)
+    (let ((buf (make-array 1024 :element-type element-type))
+         (pos 0))
+      (loop
+       (let ((end (read-sequence buf in :start pos)))
+         (when (< end (length buf))
+           (return (adjust-array buf end)))
+         (setf pos end
+               buf (adjust-array buf (* 2 pos))))))))
+
+(defmacro defenum (name (&key export) &body values)
+  "Set up symbol properties for manifest constants.
+
+   The VALUES are a list of (TAG VALUE) pairs. Each TAG is a symbol; we set
+   the NAME property on TAG to VALUE, and export TAG.  There are also handy
+   hash-tables mapping in the forward and reverse directions, in the name
+   symbol's `enum-forward' and `enum-reverse' properties."
+  `(eval-when (:compile-toplevel :load-toplevel :execute)
+     ,(let*/gensyms (export)
+       (with-gensyms (forward reverse valtmp)
+         `(let ((,forward (make-hash-table))
+                (,reverse (make-hash-table)))
+            (when ,export (export ',name))
+            ,@(mapcar (lambda (item)
+                        (destructuring-bind (tag value) item
+                          (let ((constant
+                                 (intern (concatenate 'string
+                                                      (symbol-name name)
+                                                      "/"
+                                                      (symbol-name tag)))))
+                            `(let ((,valtmp ,value))
+                               (when ,export
+                                 (export ',constant)
+                                 (when (eq (symbol-package ',tag) *package*)
+                                   (export ',tag)))
+                               (defconstant ,constant ,valtmp)
+                               (setf (get ',tag ',name) ,value
+                                     (gethash ',tag ,forward) ,valtmp
+                                     (gethash ,valtmp ,reverse) ',tag)))))
+                      values)
+            (setf (get ',name 'enum-forward) ,forward
+                  (get ',name 'enum-reverse) ,reverse))))))
+
+(defun lookup-enum (name tag &key min max)
+  "Look up a TAG in an enumeration.
+
+   If TAG is a symbol, check its NAME property; if it's a fixnum then take it
+   as it is.  Make sure that it's between MIN and MAX, if they're not nil."
+  (let ((value (etypecase tag
+               (fixnum tag)
+               (symbol (or (get tag name)
+                           (error "~S is not a known ~A" tag name))))))
+    (unless (and (or (null min) (<= min value))
+               (or (null max) (<= value max)))
+      (error "Value ~S out of range for ~A" value name))
+    value))
+
+(defun reverse-enum (name value)
+  "Reverse-lookup of a VALUE in enumeration NAME.
+
+   If a tag for the VALUE is found, return it and `t'; otherwise return VALUE
+   unchanged and `nil'."
+  (multiple-value-bind (tag foundp) (gethash value (get name 'enum-reverse))
+    (if foundp
+       (values tag t)
+       (values value nil))))
+
+(defun mapenum (func name)
+  "Call FUNC on TAG/VALUE pairs from the enumeration called NAME."
+  (maphash func (get name 'enum-forward)))
+
+(defun hash-file (hash file context)
+  "Hash the FILE using the OpenSSL HASH function, returning an octet string.
+
+   CONTEXT is a temporary-files context."
+  (let ((temp (temporary-file context "hash")))
+    (run-program (list "openssl" "dgst" (concatenate 'string "-" hash))
+                :input file :output temp)
+    (with-open-file (in temp)
+      (let ((line (read-line in)))
+       (assert (and (>= (length line) 9)
+                    (string= line "(stdin)= " :end1 9)))
+       (decode-hex line :start 9)))))
+
 ;;;--------------------------------------------------------------------------
 ;;; Zone types.
 
@@ -125,6 +229,10 @@ (defstruct (soa (:predicate soap))
   min-ttl
   serial)
 
+(export 'zone-text-name)
+(defun zone-text-name (zone)
+  (princ-to-string (zone-name zone)))
+
 (export 'mx)
 (defstruct (mx (:predicate mxp))
   "Mail-exchange record information."
@@ -259,32 +367,31 @@ (defmacro preferred-subnet-case (&body clauses)
              clauses)))
 
 (export 'zone-parse-host)
-(defun zone-parse-host (f zname)
-  "Parse a host name F.
-
-   If F ends in a dot then it's considered absolute; otherwise it's relative
-   to ZNAME."
-  (setf f (stringify f))
-  (cond ((string= f "@") (stringify zname))
-       ((and (plusp (length f))
-             (char= (char f (1- (length f))) #\.))
-        (string-downcase (subseq f 0 (1- (length f)))))
-       (t (string-downcase (concatenate 'string f "."
-                                        (stringify zname))))))
-
-(export 'zone-make-name)
-(defun zone-make-name (prefix zone-name)
-  "Compute a full domain name from a PREFIX and a ZONE-NAME.
-
-   If the PREFIX ends with `.' then it's absolute already; otherwise, append
-   the ZONE-NAME, separated with a `.'.  If PREFIX is nil, or `@', then
-   return the ZONE-NAME only."
-  (if (or (not prefix) (string= prefix "@"))
-      zone-name
-      (let ((len (length prefix)))
-       (if (or (zerop len) (char/= (char prefix (1- len)) #\.))
-           (join-strings #\. (list prefix zone-name))
-           prefix))))
+(defun zone-parse-host (form &optional tail)
+  "Parse a host name FORM from a value in a zone form.
+
+   The underlying parsing is done using `parse-domain-name'.  Here, we
+   interpret various kinds of Lisp object specially.  In particular: `nil'
+   refers to the TAIL zone (just like a plain `@'); and a symbol is downcased
+   before use."
+  (let ((name (etypecase form
+               (null (make-domain-name :labels nil :absolutep nil))
+               (domain-name form)
+               (symbol (parse-domain-name (string-downcase form)))
+               (string (parse-domain-name form)))))
+    (if (null tail) name
+       (domain-name-concat name tail))))
+
+(export 'zone-records-sorted)
+(defun zone-records-sorted (zone)
+  "Return the ZONE's records, in a pleasant sorted order."
+  (sort (copy-seq (zone-records zone))
+       (lambda (zr-a zr-b)
+         (multiple-value-bind (precp follp)
+             (domain-name< (zr-name zr-a) (zr-name zr-b))
+           (cond (precp t)
+                 (follp nil)
+                 (t (string< (zr-type zr-a) (zr-type zr-b))))))))
 
 ;;;--------------------------------------------------------------------------
 ;;; Serial numbering.
@@ -377,9 +484,9 @@ (defun zone-process-records (rec ttl func)
                                   top))
                         ((listp r)
                          (dolist (name (listify (car r)))
-                           (collect (make-zone-subdomain :name name
-                                                         :ttl ttl
-                                                         :records (cdr r))
+                           (collect (make-zone-subdomain
+                                     :name (zone-parse-host name)
+                                     :ttl ttl :records (cdr r))
                                     sub)))
                         (t
                          (error "Unexpected record form ~A" (car r))))))))
@@ -393,24 +500,25 @@ (defun zone-process-records (rec ttl func)
             (multiple-value-bind (top sub) (sift rec ttl)
               (if (and dom (null top) sub)
                   (let ((preferred
-                         (or (find-if (lambda (s)
-                                        (some #'zone-preferred-subnet-p
-                                              (listify (zs-name s))))
-                                      sub)
+                         (or (find-if
+                              (lambda (s)
+                                (let ((ll (domain-name-labels (zs-name s))))
+                                  (and (consp ll) (null (cdr ll))
+                                       (zone-preferred-subnet-p (car ll)))))
+                              sub)
                              (car sub))))
                     (when preferred
                       (process (zs-records preferred)
                                dom
                                (zs-ttl preferred))))
-                  (let ((name (and dom
-                                   (string-downcase
-                                    (join-strings #\. (reverse dom))))))
+                  (let ((name dom))
                     (dolist (zr top)
                       (setf (zr-name zr) name)
                       (funcall func zr))))
               (dolist (s sub)
                 (process (zs-records s)
-                         (cons (zs-name s) dom)
+                         (if (null dom) (zs-name s)
+                             (domain-name-concat dom (zs-name s)))
                          (zs-ttl s))))))
 
     ;; Process the records we're given with no prefix.
@@ -427,19 +535,21 @@ (defun zone-parse-head (head)
    though a singleton NAME needn't be a list.  Returns the default TTL and an
    soa structure representing the zone head."
   (destructuring-bind
-      (zname
+      (raw-zname
        &key
        (source *default-zone-source*)
        (admin (or *default-zone-admin*
-                 (format nil "hostmaster@~A" zname)))
+                 (format nil "hostmaster@~A" raw-zname)))
        (refresh *default-zone-refresh*)
        (retry *default-zone-retry*)
        (expire *default-zone-expire*)
        (min-ttl *default-zone-min-ttl*)
        (ttl min-ttl)
-       (serial (make-zone-serial zname)))
+       (serial (make-zone-serial raw-zname))
+       &aux
+       (zname (zone-parse-host raw-zname root-domain)))
       (listify head)
-    (values (string-downcase zname)
+    (values zname
            (timespec-seconds ttl)
            (make-soa :admin admin
                      :source (zone-parse-host source zname)
@@ -484,6 +594,7 @@ (defmacro defzoneparse (types (name data list
 
    These (except MAKE-PTR-P, which defaults to nil) default to the above
    arguments (even if you didn't accept the arguments)."
+
   (setf types (listify types))
   (let* ((type (car types))
         (func (intern (format nil "ZONE-PARSE/~:@(~A~)" type))))
@@ -495,7 +606,8 @@ (defmacro defzoneparse (types (name data list
           (defun ,func (,prefix ,zname ,data ,ttl ,col)
             ,@doc
             ,@decls
-            (let ((,name (zone-make-name ,prefix ,zname)))
+            (let ((,name (if (null ,prefix) ,zname
+                             (domain-name-concat ,prefix ,zname))))
               (flet ((,list (&key ((:name ,tname) ,name)
                                   ((:type ,ttype) ,type)
                                   ((:data ,tdata) ,data)
@@ -522,7 +634,7 @@ (defun zone-parse-records (zname ttl records)
             (let ((func (or (get (zr-type zr) 'zone-parse)
                             (error "No parser for record ~A."
                                    (zr-type zr))))
-                  (name (and (zr-name zr) (stringify (zr-name zr)))))
+                  (name (and (zr-name zr) (zr-name zr))))
               (funcall func name zname (zr-data zr) (zr-ttl zr) rec))))
       (zone-process-records records ttl #'parse-record))))
 
@@ -546,10 +658,11 @@ (defun zone-parse (zf)
 
 (export 'zone-create)
 (defun zone-create (zf)
-  "Zone construction function.  Given a zone form ZF, construct the zone and
-   add it to the table."
+  "Zone construction function.
+
+   Given a zone form ZF, construct the zone and add it to the table."
   (let* ((zone (zone-parse zf))
-        (name (zone-name zone)))
+        (name (zone-text-name zone)))
     (setf (zone-find name) zone)
     name))
 
@@ -573,7 +686,8 @@ (defmacro defrevzone (head &body zf)
     (with-gensyms (ipn)
       `(dolist (,ipn (net-parse-to-ipnets ',nets ,family))
         (let ((*address-family* (ipnet-family ,ipn)))
-          (zone-create `((,(reverse-domain ,ipn ,prefix-bits)
+          (zone-create `((,(format nil "~A." (reverse-domain ,ipn
+                                                             ,prefix-bits))
                            ,@',(loop for (k v) on args by #'cddr
                                      unless (member k
                                                     '(:family :prefix-bits))
@@ -604,6 +718,101 @@ (defun zone-set-address (rec addrspec &rest args
     (do-host (addr addrspec :family family)
       (apply rec :type (ipaddr-rrtype addr) :data addr key-args))))
 
+;;;--------------------------------------------------------------------------
+;;; Building raw record vectors.
+
+(defvar *record-vector* nil
+  "The record vector under construction.")
+
+(defun rec-ensure (n)
+  "Ensure that at least N octets are spare in the current record."
+  (let ((want (+ n (fill-pointer *record-vector*)))
+       (have (array-dimension *record-vector* 0)))
+    (unless (<= want have)
+      (adjust-array *record-vector*
+                   (do ((new (* 2 have) (* 2 new)))
+                       ((<= want new) new))))))
+
+(export 'rec-octet-vector)
+(defun rec-octet-vector (vector &key (start 0) end)
+  "Copy (part of) the VECTOR to the output."
+  (let* ((end (or end (length vector)))
+        (len (- end start)))
+    (rec-ensure len)
+    (do ((i start (1+ i)))
+       ((>= i end))
+      (vector-push (aref vector i) *record-vector*))))
+
+(export 'rec-byte)
+(defun rec-byte (octets value)
+  "Append an unsigned byte, OCTETS octets wide, with VALUE, to the record."
+  (rec-ensure octets)
+  (do ((i (1- octets) (1- i)))
+      ((minusp i))
+    (vector-push (ldb (byte 8 (* 8 i)) value) *record-vector*)))
+
+(export 'rec-u8)
+(defun rec-u8 (value)
+  "Append an 8-bit VALUE to the current record."
+  (rec-byte 1 value))
+
+(export 'rec-u16)
+(defun rec-u16 (value)
+  "Append a 16-bit VALUE to the current record."
+  (rec-byte 2 value))
+
+(export 'rec-u32)
+(defun rec-u32 (value)
+  "Append a 32-bit VALUE to the current record."
+  (rec-byte 4 value))
+
+(export 'rec-raw-string)
+(defun rec-raw-string (s &key (start 0) end)
+  "Append (a (substring of) a raw string S to the current record.
+
+   No arrangement is made for reporting the length of the string.  That must
+   be done by the caller, if necessary."
+  (setf-default end (length s))
+  (rec-ensure (- end start))
+  (do ((i start (1+ i)))
+      ((>= i end))
+    (vector-push (char-code (char s i)) *record-vector*)))
+
+(export 'rec-string)
+(defun rec-string (s &key (start 0) end (max 255))
+  (let* ((end (or end (length s)))
+        (len (- end start)))
+    (unless (<= len max)
+      (error "String `~A' too long" (subseq s start end)))
+    (rec-u8 (- end start))
+    (rec-raw-string s :start start :end end)))
+
+(export 'rec-name)
+(defun rec-name (name)
+  "Append a domain NAME.
+
+   No attempt is made to perform compression of the name."
+  (dolist (label (reverse (domain-name-labels name)))
+    (rec-string label :max 63))
+  (rec-u8 0))
+
+(export 'build-record)
+(defmacro build-record (&body body)
+  "Build a raw record, and return it as a vector of octets."
+  `(let ((*record-vector* (make-array 256
+                                     :element-type '(unsigned-byte 8)
+                                     :fill-pointer 0
+                                     :adjustable t)))
+     ,@body
+     (copy-seq *record-vector*)))
+
+(export 'zone-record-rrdata)
+(defgeneric zone-record-rrdata (type zr)
+  (:documentation "Emit (using the `build-record' protocol) RRDATA for ZR.
+
+   The TYPE is a keyword naming the record type.  Return the numeric RRTYPE
+   code."))
+
 ;;;--------------------------------------------------------------------------
 ;;; Zone record parsers.
 
@@ -611,10 +820,18 @@ (defzoneparse :a (name data rec)
   ":a IPADDR"
   (zone-set-address #'rec data :make-ptr-p t :family :ipv4))
 
+(defmethod zone-record-rrdata ((type (eql :a)) zr)
+  (rec-u32 (ipaddr-addr (zr-data zr)))
+  1)
+
 (defzoneparse :aaaa (name data rec)
   ":aaaa IPADDR"
   (zone-set-address #'rec data :make-ptr-p t :family :ipv6))
 
+(defmethod zone-record-rrdata ((type (eql :aaaa)) zr)
+  (rec-byte 16 (ipaddr-addr (zr-data zr)))
+  28)
+
 (defzoneparse :addr (name data rec)
   ":addr IPADDR"
   (zone-set-address #'rec data :make-ptr-p t))
@@ -627,14 +844,26 @@ (defzoneparse :ptr (name data rec :zname zname)
   ":ptr HOST"
   (rec :data (zone-parse-host data zname)))
 
+(defmethod zone-record-rrdata ((type (eql :ptr)) zr)
+  (rec-name (zr-data zr))
+  12)
+
 (defzoneparse :cname (name data rec :zname zname)
   ":cname HOST"
   (rec :data (zone-parse-host data zname)))
 
+(defmethod zone-record-rrdata ((type (eql :cname)) zr)
+  (rec-name (zr-data zr))
+  5)
+
 (defzoneparse :txt (name data rec)
   ":txt (TEXT*)"
   (rec :data (listify data)))
 
+(defmethod zone-record-rrdata ((type (eql :txt)) zr)
+  (mapc #'rec-string (zr-data zr))
+  16)
+
 (export '*dkim-pathname-defaults*)
 (defvar *dkim-pathname-defaults*
   (make-pathname :directory '(:relative "keys")
@@ -656,13 +885,15 @@ (defzoneparse :dkim (name data rec)
                     (flush))
                   (when (plusp len)
                     (cond ((< len 64)
-                           (unless out (setf out (make-string-output-stream)))
+                           (unless out
+                             (setf out (make-string-output-stream)))
                            (write-string text out))
                           (t
                            (do ((i 0 j)
                                 (j 64 (+ j 64)))
                                ((>= i len))
-                             (push (subseq text i (min j len)) things))))))))
+                             (push (subseq text i (min j len))
+                                   things))))))))
        (do ((p plist (cddr p)))
            ((endp p))
          (emit (format nil "~(~A~)=~A;" (car p) (cadr p))))
@@ -683,15 +914,8 @@ (defzoneparse :dkim (name data rec)
       (rec :type :txt
           :data (nreverse things)))))
 
-(eval-when (:load-toplevel :execute)
-  (dolist (item '((sshfp-algorithm rsa 1)
-                 (sshfp-algorithm dsa 2)
-                 (sshfp-algorithm ecdsa 3)
-                 (sshfp-type sha-1 1)
-                 (sshfp-type sha-256 2)))
-    (destructuring-bind (prop sym val) item
-      (setf (get sym prop) val)
-      (export sym))))
+(defenum sshfp-algorithm () (:rsa 1) (:dsa 2) (:ecdsa 3))
+(defenum sshfp-type () (:sha-1 1) (:sha-256 2))
 
 (export '*sshfp-pathname-defaults*)
 (defvar *sshfp-pathname-defaults*
@@ -715,17 +939,22 @@ (defzoneparse :sshfp (name data rec)
                    (rec :data (list (parse-integer alg)
                                     (parse-integer type)
                                     fpr)))))))
-      (flet ((lookup (what prop)
-              (etypecase what
-                (fixnum what)
-                (symbol (or (get what prop)
-                            (error "~S is not a known ~A" what prop))))))
-       (dolist (item (listify data))
-         (destructuring-bind (fpr &key (alg 'rsa) (type 'sha-1))
-             (listify item)
-           (rec :data (list (lookup alg 'sshfp-algorithm)
-                            (lookup type 'sshfp-type)
-                            fpr)))))))
+      (dolist (item (listify data))
+       (destructuring-bind (fpr &key (alg 'rsa) (type 'sha-1))
+           (listify item)
+         (rec :data (list (lookup-enum alg 'sshfp-algorithm :min 0 :max 255)
+                          (lookup-enum type 'sshfp-type :min 0 :max 255)
+                          fpr))))))
+
+(defmethod zone-record-rrdata ((type (eql :sshfp)) zr)
+  (destructuring-bind (alg type fpr) (zr-data zr)
+    (rec-u8 alg)
+    (rec-u8 type)
+    (do ((i 0 (+ i 2))
+        (n (length fpr)))
+       ((>= i n))
+      (rec-u8 (parse-integer fpr :start i :end (+ i 2) :radix 16))))
+  44)
 
 (defzoneparse :mx (name data rec :zname zname)
   ":mx ((HOST :prio INT :ip IPADDR)*)"
@@ -737,6 +966,13 @@ (defzoneparse :mx (name data rec :zname zname)
        (when ip (zone-set-address #'rec ip :name host))
        (rec :data (cons host prio))))))
 
+(defmethod zone-record-rrdata ((type (eql :mx)) zr)
+  (let ((name (car (zr-data zr)))
+       (prio (cdr (zr-data zr))))
+    (rec-u16 prio)
+    (rec-name name))
+  15)
+
 (defzoneparse :ns (name data rec :zname zname)
   ":ns ((HOST :ip IPADDR)*)"
   (dolist (ns (listify data))
@@ -747,6 +983,10 @@ (defzoneparse :ns (name data rec :zname zname)
        (when ip (zone-set-address #'rec ip :name host))
        (rec :data host)))))
 
+(defmethod zone-record-rrdata ((type (eql :ns)) zr)
+  (rec-name (zr-data zr))
+  2)
+
 (defzoneparse :alias (name data rec :zname zname)
   ":alias (LABEL*)"
   (dolist (a (listify data))
@@ -755,7 +995,8 @@ (defzoneparse :alias (name data rec :zname zname)
         :data name)))
 
 (defzoneparse :srv (name data rec :zname zname)
-  ":srv (((SERVICE &key :port) (PROVIDER &key :port :prio :weight :ip)*)*)"
+  ":srv (((SERVICE &key :port :protocol)
+         (PROVIDER &key :port :prio :weight :ip)*)*)"
   (dolist (srv data)
     (destructuring-bind (servopts &rest providers) srv
       (destructuring-bind
@@ -764,7 +1005,12 @@ (defzoneparse :srv (name data rec :zname zname)
        (unless default-port
          (let ((serv (serv-by-name service protocol)))
            (setf default-port (and serv (serv-port serv)))))
-       (let ((rname (format nil "~(_~A._~A~).~A" service protocol name)))
+       (let ((rname (flet ((prepend (tag tail)
+                             (domain-name-concat
+                              (make-domain-name
+                               :labels (list (format nil "_~(~A~)" tag)))
+                              tail)))
+                      (prepend service (prepend protocol name)))))
          (dolist (prov providers)
            (destructuring-bind
                (srvname
@@ -779,6 +1025,14 @@ (defzoneparse :srv (name data rec :zname zname)
                (rec :name rname
                     :data (list prio weight port host))))))))))
 
+(defmethod zone-record-rrdata ((type (eql :srv)) zr)
+  (destructuring-bind (prio weight port host) (zr-data zr)
+    (rec-u16 prio)
+    (rec-u16 weight)
+    (rec-u16 port)
+    (rec-name host))
+  33)
+
 (defzoneparse :net (name data rec)
   ":net (NETWORK*)"
   (dolist (net (listify data))
@@ -815,11 +1069,12 @@ (defzoneparse (:rev :reverse) (name data rec)
                       (ipaddr-networkp (ipaddr-addr (zr-data zr)) ipn))
              (let* ((frag (reverse-domain-fragment (zr-data zr)
                                                    0 frag-len))
-                    (name (concatenate 'string frag "." name)))
-               (unless (gethash name seen)
+                    (name (domain-name-concat frag name))
+                    (name-string (princ-to-string name)))
+               (unless (gethash name-string seen)
                  (rec :name name :type :ptr
                       :ttl (zr-ttl zr) :data (zr-name zr))
-                 (setf (gethash name seen) t))))))))))
+                 (setf (gethash name-string seen) t))))))))))
 
 (defzoneparse :multi (name data rec :zname zname :ttl ttl)
   ":multi (((NET*) &key :start :end :family :suffix) . REC)
@@ -845,42 +1100,45 @@ (defzoneparse :multi (name data rec :zname zname :ttl ttl)
 
    Obviously, nested `:multi' records won't work well."
 
-  (destructuring-bind (nets &key start end (family *address-family*) suffix)
+  (destructuring-bind (nets
+                      &key start end ((:suffix raw-suffix))
+                      (family *address-family*))
       (listify (car data))
-    (dolist (net (listify nets))
-      (dolist (ipn (net-parse-to-ipnets net family))
-       (let* ((addr (ipnet-net ipn))
-              (width (ipaddr-width addr))
-              (comp-width (reverse-domain-component-width addr))
-              (end (round-up (or end
-                                 (ipnet-changeable-bits width
-                                                        (ipnet-mask ipn)))
-                             comp-width))
-              (start (round-down (or start (- end comp-width))
-                                 comp-width))
-              (map (ipnet-host-map ipn)))
-         (multiple-value-bind (host-step host-limit)
-             (ipnet-index-bounds map start end)
-           (do ((index 0 (+ index host-step)))
-               ((> index host-limit))
-             (let* ((addr (ipnet-index-host map index))
-                    (frag (reverse-domain-fragment addr start end))
-                    (target (concatenate 'string
-                                         (zone-make-name
-                                          (if (not suffix) frag
-                                              (concatenate 'string
-                                                           frag "." suffix))
-                                          zname)
-                                         ".")))
-               (dolist (zr (zone-parse-records (zone-make-name frag zname)
-                                               ttl
-                                               (subst target '*
-                                                      (cdr data))))
-                 (rec :name (zr-name zr)
-                      :type (zr-type zr)
-                      :data (zr-data zr)
-                      :ttl (zr-ttl zr)
-                      :make-ptr-p (zr-make-ptr-p zr)))))))))))
+    (let ((suffix (if (not raw-suffix)
+                     (make-domain-name :labels nil :absolutep nil)
+                     (zone-parse-host raw-suffix))))
+      (dolist (net (listify nets))
+       (dolist (ipn (net-parse-to-ipnets net family))
+         (let* ((addr (ipnet-net ipn))
+                (width (ipaddr-width addr))
+                (comp-width (reverse-domain-component-width addr))
+                (end (round-up (or end
+                                   (ipnet-changeable-bits width
+                                                          (ipnet-mask ipn)))
+                               comp-width))
+                (start (round-down (or start (- end comp-width))
+                                   comp-width))
+                (map (ipnet-host-map ipn)))
+           (multiple-value-bind (host-step host-limit)
+               (ipnet-index-bounds map start end)
+             (do ((index 0 (+ index host-step)))
+                 ((> index host-limit))
+               (let* ((addr (ipnet-index-host map index))
+                      (frag (reverse-domain-fragment addr start end))
+                      (target (reduce #'domain-name-concat
+                                      (list frag suffix zname)
+                                      :from-end t
+                                      :initial-value root-domain)))
+                 (dolist (zr (zone-parse-records (domain-name-concat frag
+                                                                     zname)
+                                                 ttl
+                                                 (subst target '*
+                                                        (cdr data))))
+                   (rec :name (zr-name zr)
+                        :type (zr-type zr)
+                        :data (zr-data zr)
+                        :ttl (zr-ttl zr)
+                        :make-ptr-p (zr-make-ptr-p zr))))))))))))
 
 ;;;--------------------------------------------------------------------------
 ;;; Zone file output.
@@ -895,11 +1153,59 @@ (defvar *writing-zone* nil
 (defvar *zone-output-stream* nil
   "Stream to write zone data on.")
 
-(defmethod zone-write :around (format zone stream)
-  (declare (ignore format))
+(export 'zone-write-raw-rrdata)
+(defgeneric zone-write-raw-rrdata (format zr type data)
+  (:documentation "Write an otherwise unsupported record in a given FORMAT.
+
+   ZR gives the record object, which carries the name and TTL; the TYPE is
+   the numeric RRTYPE code; and DATA is an octet vector giving the RRDATA.
+   This is used by the default `zone-write-record' method to handle record
+   types which aren't directly supported by the format driver."))
+
+(export 'zone-write-header)
+(defgeneric zone-write-header (format zone)
+  (:documentation "Emit the header for a ZONE, in a given FORMAT.
+
+   The header includes any kind of initial comment, the SOA record, and any
+   other necessary preamble.  There is no default implementation.
+
+   This is part of the protocol used by the default method on `zone-write';
+   if you override that method."))
+
+(export 'zone-write-trailer)
+(defgeneric zone-write-trailer (format zone)
+  (:documentation "Emit the header for a ZONE, in a given FORMAT.
+
+   The footer may be empty, and is so by default.
+
+   This is part of the protocol used by the default method on `zone-write';
+   if you override that method.")
+  (:method (format zone)
+    (declare (ignore format zone))
+    nil))
+
+(export 'zone-write-record)
+(defgeneric zone-write-record (format type zr)
+  (:documentation "Emit a record of the given TYPE (a keyword).
+
+   The default implementation builds the raw RRDATA and passes it to
+   `zone-write-raw-rrdata'.")
+  (:method (format type zr)
+    (let* (code
+          (data (build-record (setf code (zone-record-rrdata type zr)))))
+      (zone-write-raw-rrdata format zr code data))))
+
+(defmethod zone-write (format zone stream)
+  "This default method calls `zone-write-header', then `zone-write-record'
+   for each record in the zone, and finally `zone-write-trailer'.  While it's
+   running, `*writing-zone*' is bound to the zone object, and
+  `*zone-output-stream*' to the output stream."
   (let ((*writing-zone* zone)
        (*zone-output-stream* stream))
-    (call-next-method)))
+    (zone-write-header format zone)
+    (dolist (zr (zone-records-sorted zone))
+      (zone-write-record format (zr-type zr) zr))
+    (zone-write-trailer format zone)))
 
 (export 'zone-save)
 (defun zone-save (zones &key (format :bind))
@@ -919,26 +1225,44 @@ (defun zone-save (zones &key (format :bind))
 ;;;--------------------------------------------------------------------------
 ;;; Bind format output.
 
+(defvar *bind-last-record-name* nil
+  "The previously emitted record name.
+
+   Used for eliding record names on output.")
+
 (export 'bind-hostname)
 (defun bind-hostname (hostname)
-  (if (not hostname)
-      "@"
-      (let* ((h (string-downcase (stringify hostname)))
-            (hl (length h))
-            (r (string-downcase (zone-name *writing-zone*)))
-            (rl (length r)))
-       (cond ((string= r h) "@")
-             ((and (> hl rl)
-                   (char= (char h (- hl rl 1)) #\.)
-                   (string= h r :start1 (- hl rl)))
-              (subseq h 0 (- hl rl 1)))
-             (t (concatenate 'string h "."))))))
-
-(export 'bind-record)
-(defgeneric bind-record (type zr))
-
-(defmethod zone-write ((format (eql :bind)) zone stream)
-  (format stream "~
+  (let ((zone (domain-name-labels (zone-name *writing-zone*)))
+       (name (domain-name-labels hostname)))
+    (loop
+      (unless (and zone name (string= (car zone) (car name)))
+       (return))
+      (pop zone) (pop name))
+    (flet ((stitch (labels absolutep)
+            (format nil "~{~A~^.~}~@[.~]"
+                    (reverse (mapcar #'quotify-label labels))
+                    absolutep)))
+      (cond (zone (stitch (domain-name-labels hostname) t))
+           (name (stitch name nil))
+           (t "@")))))
+
+(export 'bind-output-hostname)
+(defun bind-output-hostname (hostname)
+  (let ((name (bind-hostname hostname)))
+    (cond ((and *bind-last-record-name*
+               (string= name *bind-last-record-name*))
+          "")
+         (t
+          (setf *bind-last-record-name* name)
+          name))))
+
+(defmethod zone-write :around ((format (eql :bind)) zone stream)
+  (declare (ignorable zone stream))
+  (let ((*bind-last-record-name* nil))
+    (call-next-method)))
+
+(defmethod zone-write-header ((format (eql :bind)) zone)
+  (format *zone-output-stream* "~
 ;;; Zone file `~(~A~)'
 ;;;   (generated ~A)
 
@@ -954,7 +1278,7 @@ (defmethod zone-write ((format (eql :bind)) zone stream)
                  (when at
                    (setf (char copy at) #\.))
                  copy)))
-      (format stream "~
+      (format *zone-output-stream* "~
 ~A~30TIN SOA~40T~A (
 ~55@A~60T ;administrator
 ~45T~10D~60T ;serial
@@ -962,55 +1286,155 @@ (defmethod zone-write ((format (eql :bind)) zone stream)
 ~45T~10D~60T ;retry
 ~45T~10D~60T ;expire
 ~45T~10D )~60T ;min-ttl~2%"
-             (bind-hostname (zone-name zone))
+             (bind-output-hostname (zone-name zone))
              (bind-hostname (soa-source soa))
              admin
              (soa-serial soa)
              (soa-refresh soa)
              (soa-retry soa)
              (soa-expire soa)
-             (soa-min-ttl soa)))
-  (dolist (zr (zone-records zone))
-    (bind-record (zr-type zr) zr)))
+             (soa-min-ttl soa))))
 
 (export 'bind-format-record)
-(defun bind-format-record (name ttl type format args)
+(defun bind-format-record (zr format &rest args)
   (format *zone-output-stream*
-         "~A~20T~@[~8D~]~30TIN ~A~40T~?~%"
-         (bind-hostname name)
-         (and (/= ttl (zone-default-ttl *writing-zone*))
-              ttl)
-         (string-upcase (symbol-name type))
+         "~A~20T~@[~8D~]~30TIN ~A~40T~?"
+         (bind-output-hostname (zr-name zr))
+         (let ((ttl (zr-ttl zr)))
+           (and (/= ttl (zone-default-ttl *writing-zone*))
+                ttl))
+         (string-upcase (symbol-name (zr-type zr)))
          format args))
 
-(export 'bind-record-type)
-(defgeneric bind-record-type (type)
-  (:method (type) type))
-
-(export 'bind-record-format-args)
-(defgeneric bind-record-format-args (type data)
-  (:method ((type (eql :a)) data) (list "~A" (ipaddr-string data)))
-  (:method ((type (eql :aaaa)) data) (list "~A" (ipaddr-string data)))
-  (:method ((type (eql :ptr)) data) (list "~A" (bind-hostname data)))
-  (:method ((type (eql :cname)) data) (list "~A" (bind-hostname data)))
-  (:method ((type (eql :ns)) data) (list "~A" (bind-hostname data)))
-  (:method ((type (eql :mx)) data)
-    (list "~2D ~A" (cdr data) (bind-hostname (car data))))
-  (:method ((type (eql :srv)) data)
-    (destructuring-bind (prio weight port host) data
-      (list "~2D ~5D ~5D ~A" prio weight port (bind-hostname host))))
-  (:method ((type (eql :sshfp)) data)
-    (cons "~2D ~2D ~A" data))
-  (:method ((type (eql :txt)) data)
-    (cons "~#[\"\"~;~S~:;(~@{~%~8T~S~} )~]"
-         (mapcar #'stringify data))))
-
-(defmethod bind-record (type zr)
-  (destructuring-bind (format &rest args)
-      (bind-record-format-args type (zr-data zr))
-    (bind-format-record (zr-name zr)
-                       (zr-ttl zr)
-                       (bind-record-type type)
-                       format args)))
+(export 'bind-write-hex)
+(defun bind-write-hex (vector remain)
+  "Output the VECTOR as hex, in Bind format.
+
+   If the length (in bytes) is less than REMAIN then it's placed on the
+   current line; otherwise the Bind line-continuation syntax is used."
+  (flet ((output-octet (octet)
+          (format *zone-output-stream* "~(~2,'0X~)" octet)))
+    (let ((len (length vector)))
+      (cond ((< len remain)
+            (dotimes (i len) (output-octet (aref vector i)))
+            (terpri *zone-output-stream*))
+           (t
+            (format *zone-output-stream* "(")
+            (let ((i 0))
+            (loop
+              (when (>= i len) (return))
+              (let ((limit (min len (+ i 64))))
+                (format *zone-output-stream* "~%~8T")
+                (loop
+                  (when (>= i limit) (return))
+                  (output-octet (aref vector i))
+                  (incf i)))))
+            (format *zone-output-stream* " )~%"))))))
+
+(defmethod zone-write-raw-rrdata ((format (eql :bind)) zr type data)
+  (format *zone-output-stream*
+         "~A~20T~@[~8D~]~30TIN TYPE~A~40T\\# ~A "
+         (bind-output-hostname (zr-name zr))
+         (let ((ttl (zr-ttl zr)))
+           (and (/= ttl (zone-default-ttl *writing-zone*))
+                ttl))
+         type
+         (length data))
+  (bind-write-hex data 12))
+
+(defmethod zone-write-record ((format (eql :bind)) (type (eql :a)) zr)
+  (bind-format-record zr "~A~%" (ipaddr-string (zr-data zr))))
+
+(defmethod zone-write-record ((format (eql :bind)) (type (eql :aaaa)) zr)
+  (bind-format-record zr "~A~%" (ipaddr-string (zr-data zr))))
+
+(defmethod zone-write-record ((format (eql :bind)) (type (eql :ptr)) zr)
+  (bind-format-record zr "~A~%" (bind-hostname (zr-data zr))))
+
+(defmethod zone-write-record ((format (eql :bind)) (type (eql :cname)) zr)
+  (bind-format-record zr "~A~%" (bind-hostname (zr-data zr))))
+
+(defmethod zone-write-record ((format (eql :bind)) (type (eql :ns)) zr)
+  (bind-format-record zr "~A~%" (bind-hostname (zr-data zr))))
+
+(defmethod zone-write-record ((format (eql :bind)) (type (eql :mx)) zr)
+  (bind-format-record zr "~2D ~A~%"
+                     (cdr (zr-data zr))
+                     (bind-hostname (car (zr-data zr)))))
+
+(defmethod zone-write-record ((format (eql :bind)) (type (eql :srv)) zr)
+  (destructuring-bind (prio weight port host) (zr-data zr)
+    (bind-format-record zr "~2D ~5D ~5D ~A~%"
+                       prio weight port (bind-hostname host))))
+
+(defmethod zone-write-record ((format (eql :bind)) (type (eql :sshfp)) zr)
+  (bind-format-record zr "~{~2D ~2D ~A~}~%" (zr-data zr)))
+
+(defmethod zone-write-record ((format (eql :bind)) (type (eql :txt)) zr)
+  (bind-format-record zr "~{~#[\"\"~;~S~:;(~@{~%~8T~S~} )~]~}~%"
+                     (zr-data zr)))
+
+;;;--------------------------------------------------------------------------
+;;; tinydns-data output format.
+
+(export 'tinydns-output)
+(defun tinydns-output (code &rest fields)
+  (format *zone-output-stream* "~C~{~@[~A~]~^:~}~%" code fields))
+
+(defmethod zone-write-raw-rrdata ((format (eql :tinydns)) zr type data)
+  (tinydns-output #\: (zr-name zr) type
+                 (with-output-to-string (out)
+                   (dotimes (i (length data))
+                     (let ((byte (aref data i)))
+                       (if (or (<= byte 32)
+                               (>= byte 127)
+                               (member byte '(#\: #\\) :key #'char-code))
+                           (format out "\\~3,'0O" byte)
+                           (write-char (code-char byte) out)))))
+                 (zr-ttl zr)))
+
+(defmethod zone-write-record ((format (eql :tinydns)) (type (eql :a)) zr)
+  (tinydns-output #\+ (zr-name zr)
+                 (ipaddr-string (zr-data zr)) (zr-ttl zr)))
+
+(defmethod zone-write-record ((format (eql :tinydns)) (type (eql :aaaa)) zr)
+  (tinydns-output #\3 (zr-name zr)
+                 (format nil "~(~32,'0X~)" (ipaddr-addr (zr-data zr)))
+                 (zr-ttl zr)))
+
+(defmethod zone-write-record ((format (eql :tinydns)) (type (eql :ptr)) zr)
+  (tinydns-output #\^ (zr-name zr) (zr-data zr) (zr-ttl zr)))
+
+(defmethod zone-write-record ((format (eql :tinydns)) (type (eql :cname)) zr)
+  (tinydns-output #\C (zr-name zr) (zr-data zr) (zr-ttl zr)))
+
+(defmethod zone-write-record ((format (eql :tinydns)) (type (eql :ns)) zr)
+  (tinydns-output #\& (zr-name zr) nil (zr-data zr) (zr-ttl zr)))
+
+(defmethod zone-write-record ((format (eql :tinydns)) (type (eql :mx)) zr)
+  (let ((name (car (zr-data zr)))
+       (prio (cdr (zr-data zr))))
+    (tinydns-output #\@ (zr-name zr) nil name prio (zr-ttl zr))))
+
+(defmethod zone-write-header ((format (eql :tinydns)) zone)
+  (format *zone-output-stream* "~
+### Zone file `~(~A~)'
+###   (generated ~A)
+~%"
+         (zone-name zone)
+         (iso-date :now :datep t :timep t))
+  (let ((soa (zone-soa zone)))
+    (tinydns-output #\Z
+                   (zone-name zone)
+                   (soa-source soa)
+                   (let* ((name (copy-seq (soa-admin soa)))
+                          (at (position #\@ name)))
+                     (when at (setf (char name at) #\.))
+                     name)
+                   (soa-serial soa)
+                   (soa-refresh soa)
+                   (soa-expire soa)
+                   (soa-min-ttl soa)
+                   (zone-default-ttl zone))))
 
 ;;;----- That's all, folks --------------------------------------------------