--- /dev/null
+#!/usr/bin/perl
+# An example script to generate files for the PowerDNS LMDB high performance
+# backend
+
+use LMDB_File 0.04 qw( :dbflags :envflags :cursor_op :writeflags );
+
+use strict;
+use warnings;
+
+my $HOME = "/var/tmp/lmdb";
+
+mkdir $HOME unless -d $HOME;
+my $env = LMDB::Env->new( $HOME, {
+ mapsize => 100*1024*1024*1024,
+ maxdbs => 3,
+});
+my $txn = LMDB::Txn->new( $env, 0 );
+my $dns_zone = $txn->OpenDB( 'zone', MDB_CREATE );
+
+my $zone = 'example.com';
+my $zone_id = 1;
+my $zone_ttl = 300;
+my $soa_entry = "ns.$zone. hostmaster.$zone. 2012021101 86400 7200 604800 86400";
+# XXX $zone length MUST be less than 500 bytes
+$dns_zone->put( scalar reverse(lc $zone), join("\t", $zone_id, $zone_ttl, $soa_entry) );
+
+my $dns_data = $txn->OpenDB( 'data', MDB_CREATE | MDB_DUPSORT );
+my $dns_extended_data = $txn->OpenDB( 'extended_data', MDB_CREATE );
+my @entries = (
+ # host type data
+ [ $zone, 'NS', "ns.$zone" ],
+ # MX/SRV put priority <space> data
+ [ $zone, 'MX', "10 mail.hotmail.com" ],
+ # No SOA records
+ [ "test.$zone", 'A', '1.2.3.4' ],
+ [ "text.$zone", 'TXT', "test\n123" ],
+ [ "longtext.$zone", 'TXT', "A" x 550 ],
+
+);
+
+my $extended_ref = 0;
+for my $row (@entries) {
+ my ($host, $type, $data) = @$row;
+
+ # Don't ever allow these characters as they break powerdns
+ $data =~ tr/"\\//d;
+
+ if( $type eq 'TXT' ) {
+ $data =~ s/([^ -~])/sprintf '\\%03d', ord $1/eg;
+ }
+
+ my $key = join( "\t", scalar reverse(lc $host), $type ); # XXX must be less than 500 bytes
+ my $val = join( "\t", $zone_id, $zone_ttl, $data);
+ if( length $val > 500 ) {
+ $dns_data->put( $key, "REF\t" . ++$extended_ref );
+ $dns_extended_data->put( $extended_ref, $val );
+ # Extended data record storage as DUPSORT can only store up to 500 bytes of data unfortunately
+ } else {
+ $dns_data->put( $key, $val );
+ }
+}
+
+$txn->commit;
--- /dev/null
+/*
+ * LMDBBackend - a high performance LMDB based backend for PowerDNS written by
+ * Mark Zealey, 2013
+ *
+ * This was originally going to be a backend using BerkeleyDB 5 for high
+ * performance DNS over massive (millions of zones) databases. However,
+ * BerkeleyDB had a number of issues to do with locking, contention and
+ * corruption which made it unsuitable for use. Instead, we use LMDB to perform
+ * very fast lookups.
+ *
+ * See the documentation for more details, and lmdb-example.pl for an example
+ * script which generates a simple zone.
+ */
+
+#include <pdns/utility.hh>
+#include <pdns/dnsbackend.hh>
+#include <pdns/dns.hh>
+#include <pdns/dnspacket.hh>
+#include <pdns/pdnsexception.hh>
+#include <pdns/logger.hh>
+#include <signal.h>
+#include "lmdbbackend.hh"
+#include <pdns/arguments.hh>
+
+#if 0
+#define DEBUGLOG(msg) L<<Logger::Error<<msg
+#else
+#define DEBUGLOG(msg) do {} while(0)
+#endif
+
+LMDBBackend::LMDBBackend(const string &suffix)
+{
+ setArgPrefix("lmdb"+suffix);
+ open_db();
+}
+
+void LMDBBackend::open_db() {
+ L<<Logger::Error<<"Loading LMDB database " << getArg("datapath") << endl;
+
+ string path = getArg("datapath");
+ int rc;
+ int major, minor, patch;
+
+ string verstring( mdb_version( &major, &minor, &patch ) );
+ if( MDB_VERINT( major, minor, patch ) < MDB_VERINT( 0, 9, 8 ) )
+ throw PDNSException( "LMDB Library version too old (" + verstring + "). Needs to be 0.9.8 or greater" );
+
+ if( rc = mdb_env_create(&env) )
+ throw PDNSException("Couldn't open LMDB database " + path + ": mdb_env_create() returned " + mdb_strerror(rc));
+
+ if( rc = mdb_env_set_maxdbs( env, 3 ) )
+ throw PDNSException("Couldn't open LMDB database " + path + ": mdb_env_set_maxdbs() returned " + mdb_strerror(rc));
+
+ if( rc = mdb_env_open(env, path.c_str(), MDB_RDONLY, 0) )
+ throw PDNSException("Couldn't open LMDB database " + path + ": mdb_env_open() returned " + mdb_strerror(rc));
+
+ if( rc = mdb_txn_begin(env, NULL, MDB_RDONLY, &txn) )
+ throw PDNSException("Couldn't start LMDB txn " + path + ": mdb_txn_begin() returned " + mdb_strerror(rc));
+
+ if( rc = mdb_dbi_open(txn, "zone", 0, &zone_db) )
+ throw PDNSException("Couldn't open LMDB zone database " + path + ": mdb_dbi_open() returned " + mdb_strerror(rc));
+ if( rc = mdb_cursor_open(txn, zone_db, &zone_cursor) )
+ throw PDNSException("Couldn't open cursor on LMDB zone database " + path + ": mdb_cursor_open() returned " + mdb_strerror(rc));
+
+ if( rc = mdb_dbi_open(txn, "data", MDB_DUPSORT, &data_db) )
+ throw PDNSException("Couldn't open LMDB data database " + path + ": mdb_dbi_open() returned " + mdb_strerror(rc));
+ if( rc = mdb_cursor_open(txn, data_db, &data_cursor) )
+ throw PDNSException("Couldn't open cursor on LMDB data database " + path + ": mdb_cursor_open() returned " + mdb_strerror(rc));
+
+ if( rc = mdb_dbi_open(txn, "extended_data", 0, &data_extended_db) )
+ throw PDNSException("Couldn't open LMDB extended_data database " + path + ": mdb_dbi_open() returned " + mdb_strerror(rc));
+ if( rc = mdb_cursor_open(txn, data_extended_db, &data_extended_cursor) )
+ throw PDNSException("Couldn't open cursor on LMDB data_extended database " + path + ": mdb_cursor_open() returned " + mdb_strerror(rc));
+
+}
+
+void LMDBBackend::close_db() {
+ L<<Logger::Error<<"Closing LMDB database"<< endl;
+
+ mdb_cursor_close(data_cursor);
+ mdb_cursor_close(zone_cursor);
+ mdb_cursor_close(data_extended_cursor);
+ mdb_dbi_close(env, data_db);
+ mdb_dbi_close(env, zone_db);
+ mdb_dbi_close(env, data_extended_db);
+ mdb_txn_abort(txn);
+ mdb_env_close(env);
+}
+
+LMDBBackend::~LMDBBackend()
+{
+ close_db();
+}
+
+void LMDBBackend::reload() {
+ close_db();
+ open_db();
+}
+
+// Get the zone name and value of the requested zone (reversed) OR the entry
+// just before where it should have been
+bool LMDBBackend::getAuthZone( string &rev_zone )
+{
+ MDB_val key, data;
+ // XXX can do this just using char *
+
+ string orig = rev_zone;
+ key.mv_data = (char *)rev_zone.c_str();
+ key.mv_size = rev_zone.length();
+
+ // Release our transaction and cursors in order to get latest data
+ mdb_txn_reset( txn );
+ mdb_txn_renew( txn );
+ mdb_cursor_renew( txn, zone_cursor );
+ mdb_cursor_renew( txn, data_cursor );
+ mdb_cursor_renew( txn, data_extended_cursor );
+
+ // Find the nearest record, or the last record if none
+ if( mdb_cursor_get(zone_cursor, &key, &data, MDB_SET_RANGE) )
+ mdb_cursor_get(zone_cursor, &key, &data, MDB_LAST);
+
+ rev_zone.assign( (const char *)key.mv_data, key.mv_size );
+
+ DEBUGLOG("Auth key: " << rev_zone <<endl);
+
+ /* Only skip this bit if we got an exact hit on the SOA. otherwise we have
+ * to go back to the previous record */
+ if( orig.compare( rev_zone ) != 0 ) {
+ /* Skip back 1 entry to what should be a substring of what was searched
+ * for (or a totally different entry) */
+ if( mdb_cursor_get(zone_cursor, &key, &data, MDB_PREV) ) {
+ // At beginning of database; therefore didnt actually hit the
+ // record. Return false
+ return false;
+ }
+
+ rev_zone.assign( (const char *)key.mv_data, key.mv_size );
+ }
+
+ return true;
+}
+
+bool LMDBBackend::getAuthData( SOAData &soa, DNSPacket *p )
+{
+ MDB_val key, value;
+ if( mdb_cursor_get(zone_cursor, &key, &value, MDB_GET_CURRENT) )
+ return false;
+
+ string data( (const char *)value.mv_data, value.mv_size );
+ DEBUGLOG("Auth record data " << data<<endl);
+
+// XXX do this in C too
+ vector<string>parts;
+ stringtok(parts,data,"\t");
+
+ if(parts.size() != 3 )
+ throw PDNSException("Invalid record in zone table: " + data );
+
+ fillSOAData( parts[2], soa );
+
+ soa.domain_id = atoi( parts[0].c_str() );
+ soa.ttl = atoi( parts[1].c_str() );
+
+ soa.scopeMask = 0;
+ soa.db = this;
+
+ return true;
+}
+
+// Called to start an AXFR then ->get() is called. Return true if the domain exists
+bool LMDBBackend::list(const string &target, int zoneId, bool include_disabled) {
+ DEBUGLOG("list() requested for " <<target << endl);
+ d_first = true;
+ d_origdomain = target;
+ d_domain_id = zoneId;
+ d_curqtype = QType::AXFR;
+
+ // getSOA will have been called first to ensure the domain exists so if
+ // that's the case then there's no reason we can't AXFR it.
+
+ return true;
+}
+
+void LMDBBackend::lookup(const QType &type, const string &inQdomain, DNSPacket *p, int zoneId)
+{
+ DEBUGLOG("lookup: " <<inQdomain << " " << type.getName() << endl);
+
+ d_first = true;
+ d_origdomain = inQdomain;
+ d_curqtype = type;
+}
+
+inline bool LMDBBackend::get_finished()
+{
+ d_origdomain = "";
+
+ return false;
+}
+
+bool LMDBBackend::get(DNSResourceRecord &rr)
+{
+ MDB_val key, value;
+ bool is_axfr = (d_curqtype == QType::AXFR);
+ bool is_full_key = ( ! is_axfr && d_curqtype != QType::ANY );
+
+ DEBUGLOG("get : " <<d_origdomain<< endl);
+ if( !d_origdomain.length() )
+ return false;
+
+ DEBUGLOG("Starting Q " << d_first<< endl);
+
+ if( d_first ) {
+ d_first = false;
+
+ // Reverse the query string
+ string lowerq = toLower( d_origdomain );
+ d_querykey = string( lowerq.rbegin(), lowerq.rend() );
+ d_searchkey = d_querykey;
+
+ // For normal queries ensure that we are only trying to get the exact
+ // record and also try to specify the type too to make negatives a lot
+ // quicker
+ if( ! is_axfr ) {
+ d_searchkey += "\t";
+
+ // Search by query type too to easily exclude anything that doesn't
+ // belong to us
+ if( is_full_key )
+ d_searchkey += d_curqtype.getName();
+ }
+
+ key.mv_size = d_searchkey.length();
+ key.mv_data = (char *)d_searchkey.c_str();
+ if( mdb_cursor_get(data_cursor, &key, &value, is_full_key ? MDB_SET_KEY : MDB_SET_RANGE ) )
+ return get_finished();
+ } else {
+next_record:
+ key.mv_size = 0;
+ if( mdb_cursor_get(data_cursor, &key, &value, is_full_key ? MDB_NEXT_DUP : MDB_NEXT ) )
+ return get_finished();
+ }
+
+ // Some buggy versions of lmdb will do this. Should be caught in opendb above though.
+ if( key.mv_size == 0 ) {
+ DEBUGLOG("No key returned. Error" << endl);
+ return get_finished();
+ }
+
+ string cur_value((const char *)value.mv_data, value.mv_size);
+ string cur_key((const char *)key.mv_data, key.mv_size);
+
+ DEBUGLOG("querykey: " << d_querykey << "; cur_key: " <<cur_key<< "; cur_value: " << cur_value << endl);
+
+ vector<string> keyparts, valparts;
+
+ stringtok(keyparts,cur_key,"\t");
+ stringtok(valparts,cur_value,"\t");
+
+ if( valparts.size() == 2 && valparts[0] == "REF" ) {
+ MDB_val extended_key, extended_val;
+
+ // XXX parse into an int and have extended table as MDB_INTEGER to have
+ // a bit better performance/smaller space?
+ extended_key.mv_data = (char *)valparts[1].c_str();
+ extended_key.mv_size = valparts[1].length();
+
+ if( int rc = mdb_cursor_get( data_extended_cursor, &extended_key, &extended_val, MDB_SET_KEY ) )
+ throw PDNSException("Record " + cur_key + " references extended record " + cur_value + " but this doesn't exist: " + mdb_strerror( rc ));
+
+ cur_value.assign((const char *)extended_val.mv_data, extended_val.mv_size);
+ valparts.clear();
+ stringtok(valparts, cur_value, "\t");
+ }
+
+ if( keyparts.size() != 2 || valparts.size() != 3 )
+ throw PDNSException("Invalid record in record table: key: '" + cur_key + "'; value: "+ cur_value);
+
+ string compare_string = cur_key.substr(0, d_searchkey.length());
+ DEBUGLOG( "searchkey: " << d_searchkey << "; compare: " << compare_string << ";" << endl);
+
+ // If we're onto records not beginning with this search prefix, then we
+ // must be past the end
+ if( compare_string.compare( d_searchkey ) )
+ return get_finished();
+
+ int domain_id = atoi( valparts[0].c_str() );
+
+ // If we are doing an AXFR and the record fetched has been outside of our domain then end the transfer
+ if( is_axfr ) {
+ // Check it's not a subdomain ie belongs to this record
+ if( domain_id != d_domain_id )
+ goto next_record;
+
+ // If it's under the main domain then append the . to the comparison to
+ // ensure items outside our zone don't enter
+ if( keyparts[0].length() > d_querykey.length() ) {
+ string test = d_querykey;
+ test.append(".");
+
+ compare_string = cur_key.substr(0, d_querykey.length() + 1);
+
+ DEBUGLOG("test: " << test << "; compare: " << compare_string << ";" << endl);
+
+ if( test.compare( compare_string ) )
+ goto next_record;
+ }
+
+ // We need to maintain query casing so strip off domain (less dot) and append originial query
+ string sub = keyparts[0].substr( d_origdomain.length(), string::npos );
+ rr.qname = string( sub.rbegin(), sub.rend() ) + d_origdomain;
+ } else
+ rr.qname = d_origdomain; // use cached and original casing
+
+ DEBUGLOG("Found record: " <<cur_key << ": "<<valparts.size() << endl);
+
+ DEBUGLOG("pass! " << rr.qname << ";" << endl);
+ rr.qtype = keyparts[1];
+
+ /* Filter records to only match query type */
+ if( d_curqtype != QType::ANY && !is_axfr && rr.qtype != d_curqtype )
+ goto next_record;
+
+ DEBUGLOG("Correct record type" << endl);
+ rr.auth = 1;
+
+ rr.domain_id = domain_id;
+ rr.ttl = atoi( valparts[1].c_str() );
+
+ if( rr.qtype.getCode() != QType::MX && rr.qtype.getCode() != QType::SRV )
+ rr.content = valparts[2];
+ else {
+ // split out priority field
+ string::size_type pos = valparts[2].find_first_of(" ", 0);
+
+ rr.priority = atoi( valparts[2].substr(0, pos).c_str() );
+ rr.content = valparts[2].substr(pos+1, valparts[2].length());
+ }
+
+ return true;
+}
+
+class LMDBFactory : public BackendFactory
+{
+public:
+ LMDBFactory() : BackendFactory("lmdb") {}
+ void declareArguments(const string &suffix="")
+ {
+ declare(suffix,"datapath","Path to the directory containing the lmdb files","/etc/pdns/data");
+ }
+ DNSBackend *make(const string &suffix="")
+ {
+ return new LMDBBackend(suffix);
+ }
+};
+
+/* THIRD PART */
+
+class LMDBLoader
+{
+public:
+ LMDBLoader()
+ {
+ BackendMakers().report(new LMDBFactory);
+
+ L<<Logger::Info<<" [LMDBBackend] This is the LMDBBackend version ("__DATE__", "__TIME__") reporting"<<endl;
+ }
+};
+
+static LMDBLoader lmdbLoader;
+
</sect2>
</sect1>
+ <sect1 id="lmdbbackend"><title>LMDB (high performance) backend</title>
+ <para>
+ <table>
+ <title>LMDB backend capabilities</title>
+ <tgroup cols="2">
+ <tbody>
+ <row><entry>Native</entry><entry>Yes</entry></row>
+ <row><entry>Master</entry><entry>No</entry></row>
+ <row><entry>Slave</entry><entry>No</entry></row>
+ <row><entry>Superslave</entry><entry>No</entry></row>
+ <row><entry>Autoserial</entry><entry>No</entry></row>
+ <row><entry>DNSSEC</entry><entry>No</entry></row>
+ <row><entry>Module name</entry><entry>lmdb</entry></row>
+ <row><entry>Launch</entry><entry>lmdb</entry></row>
+ </tbody>
+ </tgroup>
+ </table>
+ </para>
+ <para>
+ Based on the <ulink url="http://symas.com/mdb/">LMDB key-value
+ database</ulink>, the LMDB backend turns powerdns into a very high
+ performance and DDOS-resilient authoritative DNS server. Testing on a
+ 32-core server shows the ability to answer up to 400,000 queries per second
+ with instant startup and real-time updates independent of database size.
+ </para>
+ <para>
+ <variablelist>
+ <varlistentry>
+ <term>lmdb-datapath=</term>
+ <listitem>
+ <para>
+ Location of the database to load
+ </para>
+ </listitem>
+ </varlistentry>
+ </variablelist>
+ </para>
+ <sect2>
+ <title>Operation</title>
+ <para>
+ Unlike other backends LMDB does not require any special configuration.
+ New or updated zones are available the next query after the update
+ transaction is committed. If the underlying database is removed or
+ recreated then the reload command should be sent through to powerdns to
+ get it to close and reopen the database.
+ </para>
+ </sect2>
+ <sect2><title>Database Format</title>
+ <para>
+ A full example script for generating a database can be found in
+ pdns/modules/lmdbbackend/lmdb-example.pl. Basically the database
+ environment is comprised of three databases to store the data:
+ </para>
+ <sect3><title>zone database</title>
+ <para>
+ Each key in the zone database is the reversed lower-cased name of
+ the zone without
+ leading or trailing dots (ie for example.com the key would be moc.elpmaxe).
+ </para>
+ <para>
+ Each value in the database must contain the following data (tab-separated):
+ <variablelist>
+ <varlistentry>
+ <term>Zone ID</term>
+ <listitem>
+ <para>The Zone's unique integer ID in ASCII (32-bit)</para>
+ </listitem>
+ </varlistentry>
+ <varlistentry>
+ <term>TTL</term>
+ <listitem>
+ <para>The TTL for the zone's SOA record</para>
+ </listitem>
+ </varlistentry>
+ <varlistentry>
+ <term>SOA data</term>
+ <listitem>
+ <para>space-separated SOA data eg
+ <screen>
+ ns.foo.com. hostmaster.foo.com. <serial> <refresh> <retry> <expire> <minimum>
+ </screen>
+ If refresh, retry, expire or minimum are not specified then the powerdns defaults will be used
+ </para>
+ </listitem>
+ </varlistentry>
+ </variablelist>
+ </para>
+ </sect3>
+ <sect3 id="lmdb-data"><title>data database</title>
+ <para>
+ This database is required to have been created with the MDB_DUPSORT flag enabled. It stores the records for each domain.
+ Each key must contain the following data (tab-separated):
+ <variablelist>
+ <varlistentry>
+ <term>Record name</term>
+ <listitem>
+ <para>The reversed lower-cased name of the record and zone without leading or trailing dots</para>
+ </listitem>
+ </varlistentry>
+ <varlistentry>
+ <term>Record type</term>
+ <listitem>
+ <para>The type of record A, NS, PTR etc. SOA is not allowed as it is automatically created from the zone database records.</para>
+ </listitem>
+ </varlistentry>
+ </variablelist>
+ </para>
+ <para>
+ The value for each entry must contain the following data
+ (tab-separated). If the length of this record is greater than the
+ LMDB limit of 510 bytes (for DUPSORT databases) an entry of "REF"
+ followed by the tab character and a unique 32-bit ASCII integer
+ which contains a reference into <xref linkend="lmdb-extended-data" />.
+ <variablelist>
+ <varlistentry>
+ <term>Zone ID</term>
+ <listitem>
+ <para>The Zone's unique integer ID in ASCII (32-bit)</para>
+ </listitem>
+ </varlistentry>
+ <varlistentry>
+ <term>TTL</term>
+ <listitem>
+ <para>The TTL for the SOA record</para>
+ </listitem>
+ </varlistentry>
+ <varlistentry>
+ <term>Record data</term>
+ <listitem>
+ <para>
+ The record's data entry. For MX/SRV records the
+ priority is the first field and space-separated from the rest
+ of the data. Care must be taken to escape the data
+ appropriately for PowerDNS. As in the Pipe backend " and \
+ characters are not allowed and any it is advised that any
+ characters outside of ASCII 32-126 are escaped using the \
+ character.
+ </para>
+ </listitem>
+ </varlistentry>
+ </variablelist>
+ </para>
+ </sect3>
+ <sect3 id="lmdb-extended-data"><title>extended_data database</title>
+ <para>
+ If the length of the value that you wish to insert into <xref
+ linkend="lmdb-data" /> is longer than 510 bytes you need to create the
+ REF entry as described above linked in to this table. The value is a
+ unique 32-bit integer value formatted in ASCII and the value is the
+ exact same format as it would have been in <xref linkend="lmdb-data" />
+ but can be however long you require.
+ </para>
+ </sect3>
+
+ <sect3><title>Example database structure</title>
+ <para>
+ (as output by the pdns/modules/lmdbbackend/lmdb-example.pl example script and shown by pdns/modules/lmdbbackend/dumpdb.pl)
+ <screen>
+ # perl dumpdb.pl /var/tmp/lmdb zone
+ key: moc.elpmaxe; value: 1 300 ns.example.com. hostmaster.example.com. 2012021101 86400 7200 604800 86400
+ # perl dumpdb.pl /var/tmp/lmdb data
+ key: moc.elpmaxe MX; value: 1 300 10 mail.hotmail.com
+ key: moc.elpmaxe NS; value: 1 300 ns.example.com
+ key: moc.elpmaxe.tset A; value: 1 300 1.2.3.4
+ key: moc.elpmaxe.txet TXT; value: 1 300 test\010123
+ key: moc.elpmaxe.txetgnol TXT; value: REF 1
+ # perl dumpdb.pl /var/tmp/lmdb extended_data
+ key: 1; value: 1 300 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+ </screen>
+ </para>
+ </sect3>
+ </sect2>
+ </sect1>
+
<sect1 id="odbc">
<title>ODBC backend</title>
<para>