DNS Zone Parsers with LPeg in Lua

Parse BIND9 zone files with LPeg in Lua.

tl;dr

To parse DNS zone files (RFC 1035 #5.1) with LPeg:

local lpeg = require('lpeg')

local LOWER = lpeg.R('az')
local UPPER = lpeg.R('AZ')
local LETTER = LOWER + UPPER
local DIGIT = lpeg.R('09')

local NEWLINE = lpeg.P('\n')
local INLINESPACE = lpeg.S('\t ')
local ALLSPACE = NEWLINE + INLINESPACE
local INLINESEP = INLINESPACE ^ 1
local LINESEP = (ALLSPACE ^ 0) * NEWLINE * (ALLSPACE ^ 0)

local INTEGER = DIGIT ^ 1

local DOLLAR = lpeg.P('$')
local DIRECTIVE = DOLLAR * lpeg.C(LETTER ^ 0)

local UNDERSCORE = lpeg.P('_')
local HYPHEN = lpeg.P('-')
local DOT = lpeg.P('.')
local AT = lpeg.P('@')
local STAR = lpeg.P('*')

-- Match a domain
local FullyQualified, Relative, DomainPart = lpeg.V('FullyQualified'), lpeg.V('Relative'), lpeg.V('DomainPart')
local Domain = lpeg.P{
    'Domain',
    Domain = lpeg.C(AT + FullyQualified + Relative),
    FullyQualified = Relative ^ -1 * DOT,
    Relative = (DomainPart + STAR) * (DOT * DomainPart) ^ 0,
    DomainPart = (LETTER + DIGIT + HYPHEN + UNDERSCORE) ^ 1,
}

-- Match a record
local SEMICOLON = lpeg.P(';')
local COMMENT = SEMICOLON * (lpeg.P(1) - NEWLINE) ^ 0
local OPEN = lpeg.P('(')
local CLOSE = lpeg.P(')')
local Name, TTL, Class, Type, RDATA = lpeg.V('Name'), lpeg.V('TTL'), lpeg.V('Class'), lpeg.V('Type'), lpeg.V('RDATA')
local NONEND = lpeg.P(1) - NEWLINE - SEMICOLON - OPEN
local Line = NONEND ^ 0 * (OPEN * (lpeg.P(1) - CLOSE) ^ 0 * CLOSE) ^ -1
local Directive = DIRECTIVE * Line
local Record = lpeg.P{
    'Record',
    Record = lpeg.Ct(
                Name * (
                        Class * TTL ^ -1 +
                        TTL * Class ^ -1
                       ) ^ -1 * Type * RDATA
             ),
    Name = lpeg.Cg(Domain ^ -1, 'name') * INLINESEP,
    TTL = lpeg.Cg(INTEGER, 'ttl') * INLINESEP,
    Class = lpeg.Cg(lpeg.P('IN') + lpeg.P('CH') + lpeg.P('HS'), 'class') * INLINESEP,
    Type = lpeg.Cg(LETTER ^ 1, 'type') * INLINESEP,
    RDATA = lpeg.Cg(Line, 'data')
}
local Directive = lpeg.Ct(lpeg.Cg(DIRECTIVE * Line, 'directive'))
local Entry = lpeg.V('Entry')
local Empty = lpeg.V('Empty')
local Entries = lpeg.V('Entries')
local Zone = lpeg.P{
    'Zone',
    Zone = Entries,
    Entries = lpeg.Ct(
                Entry ^ -1 * (NEWLINE * Entry) ^ 0
              ),
    Empty = INLINESEP ^ -1,
    Entry = (Directive + Record) ^ -1 * Empty * (COMMENT ^ -1),
}

local function encode(zone)
    local result = ''
    for _, e in ipairs(zone) do
        if e.directive then
            result = result .. e.directive .. '\n'
        else
            local line = ''
            line = line .. e.name .. '\t'
            if e.ttl then line = line .. e.ttl .. '\t' end
            if e.class then line = line .. e.class .. '\t' end
            line = line .. e.type .. '\t'
            line = line .. e.data .. '\n'
            result = result .. line
        end
    end
    return result
end

return {
    Zone = Zone,
    Domain = Domain,
    encode = encode,
    decode = function(content) return lpeg.match(Zone, content) end
}

Usage

local zone = require('zone')
local inspect = require('inspect')

local result = zone.decode[[
; COMMENT1
.                        3600000      NS    A.ROOT-SERVERS.NET. ; COMMENT2
A.ROOT-SERVERS.NET.      3600000      A     198.41.0.4
A.ROOT-SERVERS.NET.      3600000      AAAA  2001:503:ba3e::2:30
.                        3600000      NS    B.ROOT-SERVERS.NET.
B.ROOT-SERVERS.NET.      3600000      A     192.228.79.201
B.ROOT-SERVERS.NET.      3600000      AAAA  2001:500:84::b
.                        3600000      NS    C.ROOT-SERVERS.NET.
C.ROOT-SERVERS.NET.      3600000      A     192.33.4.12
C.ROOT-SERVERS.NET.      3600000      AAAA  2001:500:2::c
; COMMENT3
                                      A     1.1.1.1
TEST0.TEST.                           A     1.1.1.1
CLASS0.TEST.         IN               A     1.1.1.1
CLASS1.TEST.         66  IN           A     1.1.1.1
CLASS2.TEST.         IN  33           A     1.1.1.1
]]

print(inspect(result))

--[[
Output:
{ {
    data = "A.ROOT-SERVERS.NET. ",
    name = ".",
    ttl = "3600000",
    type = "NS"
  }, {
    data = "198.41.0.4",
    name = "A.ROOT-SERVERS.NET.",
    ttl = "3600000",
    type = "A"
  }, {
    data = "2001:503:ba3e::2:30",
    name = "A.ROOT-SERVERS.NET.",
    ttl = "3600000",
    type = "AAAA"
  }, {
    data = "B.ROOT-SERVERS.NET.",
    name = ".",
    ttl = "3600000",
    type = "NS"
  }, {
    data = "192.228.79.201",
    name = "B.ROOT-SERVERS.NET.",
    ttl = "3600000",
    type = "A"
  }, {
    data = "2001:500:84::b",
    name = "B.ROOT-SERVERS.NET.",
    ttl = "3600000",
    type = "AAAA"
  }, {
    data = "C.ROOT-SERVERS.NET.",
    name = ".",
    ttl = "3600000",
    type = "NS"
  }, {
    data = "192.33.4.12",
    name = "C.ROOT-SERVERS.NET.",
    ttl = "3600000",
    type = "A"
  }, {
    data = "2001:500:2::c",
    name = "C.ROOT-SERVERS.NET.",
    ttl = "3600000",
    type = "AAAA"
  }, {
    data = "1.1.1.1",
    name = "",
    type = "A"
  }, {
    data = "1.1.1.1",
    name = "TEST0.TEST.",
    type = "A"
  }, {
    class = "IN",
    data = "1.1.1.1",
    name = "CLASS0.TEST.",
    type = "A"
  }, {
    class = "IN",
    data = "1.1.1.1",
    name = "CLASS1.TEST.",
    ttl = "66",
    type = "A"
  }, {
    class = "IN",
    data = "1.1.1.1",
    name = "CLASS2.TEST.",
    ttl = "33",
    type = "A"
  } }
]]