diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6c7e909 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +.pre-commit-config.yaml linguist-generated +.github/workflows/build.yml linguist-generated +.github/workflows/check-pre-commit-hooks.yml linguist-generated +.github/workflows/tests.yml linguist-generated +.gitattributes linguist-generated +treefmt.toml linguist-generated diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..0a2b1bd --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,91 @@ +# This file is automatically generated from Nix configuration. Do not edit directly. + +concurrency: + cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 + - name: Build static site + run: nix build .#admin2136 --print-build-logs -o result-site + - name: Build Docker image + run: nix build .#docker-image --print-build-logs + - name: Upload Docker image as artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + if-no-files-found: error + name: docker-image-${{ matrix.arch }} + path: result + strategy: + matrix: + include: + - arch: x86_64 + runner: ubuntu-26.04 + - arch: aarch64 + runner: ubuntu-26.04-arm + docker: + if: github.event_name == 'push' || github.event_name == 'pull_request' + needs: + - build + runs-on: ubuntu-latest + steps: + - name: Download Docker images + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + path: artifacts + pattern: docker-image-* + - env: + REGISTRY_PASSWORD: ${{ secrets.registry_password || secrets.GITHUB_TOKEN }} + REGISTRY_USER: ${{ vars.REGISTRY_USER }} + TAG: ${{ github.head_ref || github.ref_name || 'latest' }} + name: Push multi-arch Docker manifest to registry + run: | + if [[ "$GITHUB_REF_NAME" =~ v[0-9]+\.[0-9]+\.[0-9]+ ]]; then + registry=registry.famedly.net/docker-oss + else + registry=registry.famedly.net/docker-nightly + fi + + echo "$REGISTRY_PASSWORD" \ + | podman login registry.famedly.net -u "$REGISTRY_USER" --password-stdin + + image="$registry/admin2136" + # Branch names may contain slashes, which are not valid in + # Docker tags. + tag="${TAG//\//-}" + + # Combine the per-arch images into a multi-arch manifest. + # Every `podman load` overwrites `admin2136:latest`, so retag + # each image with an arch suffix before loading the next. + podman manifest create admin2136-multiarch + for arch in x86_64 aarch64; do + podman load < "artifacts/docker-image-$arch/result" + podman tag admin2136:latest "admin2136:$arch" + podman manifest add admin2136-multiarch "containers-storage:localhost/admin2136:$arch" + done + + # `--all` pushes the per-arch images along with the + # manifest (by digest only, so no arch-specific tags show + # up in the registry). Publish under both the branch/tag + # name and the commit SHA. + podman manifest push --all admin2136-multiarch "docker://$image:$tag" + podman manifest push --all admin2136-multiarch "docker://$image:$GITHUB_SHA" +name: Build +"on": + merge_group: {} + pull_request: + branches: + - '**' + types: + - opened + - reopened + - synchronize + - ready_for_review + push: + branches: + - main + tags: + - v* diff --git a/.github/workflows/check-pre-commit-hooks.yml b/.github/workflows/check-pre-commit-hooks.yml new file mode 100644 index 0000000..6d5141f --- /dev/null +++ b/.github/workflows/check-pre-commit-hooks.yml @@ -0,0 +1,27 @@ +# This file is automatically generated from Nix configuration. Do not edit directly. + +concurrency: + cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} +jobs: + prek: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 + - env: + TREEFMT_NO_CACHE: "1" + name: Run pre-commit hooks + run: prek --all-files --show-diff-on-failure + shell: nix develop .#standards --command bash {0} +name: Make sure all pre-commit hooks pass +"on": + merge_group: {} + pull_request: + branches: + - '**' + types: + - opened + - reopened + - synchronize + - ready_for_review diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3b62b40 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,25 @@ +# This file is automatically generated from Nix configuration. Do not edit directly. + +concurrency: + cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} +jobs: + smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 + - name: Run tests + run: node test/smoke.mjs + shell: nix develop --command bash {0} +name: Run tests +"on": + merge_group: {} + pull_request: + branches: + - '**' + types: + - opened + - reopened + - synchronize + - ready_for_review diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9989a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +result +result-* +.devenv/ +.direnv/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5200449 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,66 @@ +repos: +- hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-illegal-windows-names + - id: end-of-file-fixer + - id: fix-byte-order-marker + - id: check-json + - id: check-json5 + - id: check-toml + - id: check-vcs-permalinks + - id: check-xml + - args: + - --fix=lf + id: mixed-line-ending + - id: check-symlinks + - id: destroyed-symlinks + - id: check-merge-conflict + - id: detect-private-key + - id: check-shebang-scripts-are-executable + - id: check-executables-have-shebangs + repo: builtin +- hooks: + - args: + - --write-changes + - --force-exclude + description: Check the repository for spelling mistakes + entry: typos + id: typos + language: system + name: typos + types: + - text + - description: Ensure that files set up with the filegen module are up-to-date + entry: filegen-apply-script + id: filegen + language: system + name: filegen + pass_filenames: false + - description: Format *all* files + entry: treefmt + id: treefmt + language: system + name: treefmt + require_serial: true + repo: local +- hooks: + - args: + - follow + description: Ensure that flake inputs are recursively de-duplicated + entry: flake-edit + files: + glob: '{flake.nix,flake.lock}' + id: flake-follows + language: system + name: flake-follows + pass_filenames: false + repo: local +- hooks: + - description: Check licensing info for REUSE compliance + entry: reuse lint + id: reuse + language: system + name: reuse + pass_filenames: false + repo: local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..841f740 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2026 Famedly GmbH (info@famedly.com) +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +# admin2136 is a fully static site, so serving it is delegated to the +# upstream Alpine-based static-web-server image; only the site files are +# added on top. static-web-server serves /public on port 80 by default. +FROM ghcr.io/static-web-server/static-web-server:2-alpine + +COPY index.html style.css /public/ +COPY js /public/js diff --git a/LICENSES/AGPL-3.0-or-later.txt b/LICENSES/AGPL-3.0-or-later.txt new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSES/AGPL-3.0-or-later.txt @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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 +. diff --git a/README.md b/README.md index aff4104..a910a80 100644 --- a/README.md +++ b/README.md @@ -1 +1,40 @@ + + # admin2136 (AXFR / RFC 2136 zone editor) + +admin2136 is a standalone web frontend for DNS zone management via AXFR and RFC +2136 dynamic updates, authenticated with TSIG. + +The browser cannot speak to port 53 directly, so all DNS messages travel through +a DoWS (DNS over WebSocket) proxy such as +[dows](https://github.com/famedly/dows). + +Editing is supported for A, AAAA, CNAME, TXT and SSHFP records; other record +types are shown read-only (unknown types as raw RFC 3597 `\# len hex` data). + +## Run + +The site is fully static and can be served by any web server. A Docker image +that layers the site on top of the existing upstream Alpine-based +[static-web-server](https://static-web-server.net/) image can be built with the +nix flake: + +```console +$ nix build .#docker-image +$ docker load < result +$ docker run --rm -p 8080:80 admin2136 +``` + +The site is then available at `http://localhost:8080`. + +## Tests + +The DOM-independent modules are covered by smoke tests: + +```console +$ node test/smoke.mjs +``` diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000..271e34f --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2026 Famedly GmbH (info@famedly.com) +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +version = 1 + +# Generated files +[[annotations]] +path = [ + "flake.lock", + ".gitignore", + ".gitattributes", + ".pre-commit-config.yaml", + ".github/workflows/*.yml", + "treefmt.toml", +] +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 Famedly GmbH (info@famedly.com)" +SPDX-License-Identifier = "AGPL-3.0-or-later" diff --git a/_typos.toml b/_typos.toml new file mode 100644 index 0000000..14d4151 --- /dev/null +++ b/_typos.toml @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2026 Famedly GmbH (info@famedly.com) +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +[default.extend-words] +dows = "dows" # DNS over WebSocket diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..5d9fa77 --- /dev/null +++ b/flake.lock @@ -0,0 +1,198 @@ +{ + "nodes": { + "devshell": { + "inputs": { + "nixpkgs": [ + "famedly-engineering-standards", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1768818222, + "narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=", + "owner": "numtide", + "repo": "devshell", + "rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, + "famedly-engineering-standards": { + "inputs": { + "devshell": "devshell", + "flake-parts": "flake-parts", + "github-actions-nix": "github-actions-nix", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay", + "treefmt": "treefmt", + "wrappers": "wrappers" + }, + "locked": { + "lastModified": 1784628414, + "narHash": "sha256-JRCYkrZG8CyHJfde9zqHfdWQQWY5Y6AXcUHixREZXt8=", + "owner": "famedly", + "repo": "engineering-standards", + "rev": "26b385aba416cad9e85927899276d050e700b5cb", + "type": "github" + }, + "original": { + "owner": "famedly", + "repo": "engineering-standards", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1778716662, + "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "github-actions-nix": { + "inputs": { + "flake-parts": [ + "famedly-engineering-standards", + "flake-parts" + ], + "nixpkgs": [ + "famedly-engineering-standards", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1773808042, + "narHash": "sha256-97K9g40SdtDVWslJG8ytpiC3+qgeQzftsZheGQ8qoGY=", + "owner": "synapdeck", + "repo": "github-actions-nix", + "rev": "805a4f69856e0f3d0bcd8ae916f1625f22b0578c", + "type": "github" + }, + "original": { + "owner": "synapdeck", + "repo": "github-actions-nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1779622335, + "narHash": "sha256-6lZN0CPBbeK9UVgFQ8EV4t2qSVQPhhlerFBGZRiz73Y=", + "rev": "705e9929918b43bd7b715dc0a878ac870449bb03", + "type": "tarball", + "url": "https://releases.nixos.org/nixos/26.05/nixos-26.05beta1.705e9929918b/nixexprs.tar.xz?lastModified=1779622335&rev=705e9929918b43bd7b715dc0a878ac870449bb03" + }, + "original": { + "type": "tarball", + "url": "https://channels.nixos.org/nixos-26.05/nixexprs.tar.xz" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1777168982, + "narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "root": { + "inputs": { + "famedly-engineering-standards": "famedly-engineering-standards", + "flake-parts": [ + "famedly-engineering-standards", + "flake-parts" + ], + "nixpkgs": [ + "famedly-engineering-standards", + "nixpkgs" + ] + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "famedly-engineering-standards", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1779851998, + "narHash": "sha256-UkkMh3bX9QW4Luqkm98nUaOqKWrU6i65mUnph3WeSSw=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "6cddd512fa2bf7231f098d3a2f92f6e4cff71e0a", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "treefmt": { + "inputs": { + "nixpkgs": [ + "famedly-engineering-standards", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1780220602, + "narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "db947814a175b7ca6ded66e21383d938df01c227", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + }, + "wrappers": { + "inputs": { + "nixpkgs": [ + "famedly-engineering-standards", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1780661205, + "narHash": "sha256-3F5DixT3Gk91lBI9E+TGMm0ko5HrRbDiL23di16TJGA=", + "owner": "BirdeeHub", + "repo": "nix-wrapper-modules", + "rev": "8dd304c3582ddd339217e1cc5fb53f50acb63c2d", + "type": "github" + }, + "original": { + "owner": "BirdeeHub", + "repo": "nix-wrapper-modules", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..a57d5e7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: 2026 Famedly GmbH (info@famedly.com) +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +{ + description = "AXFR / RFC 2136 zone editor"; + + inputs = { + famedly-engineering-standards.url = "github:famedly/engineering-standards"; + + nixpkgs.follows = "famedly-engineering-standards/nixpkgs"; + flake-parts.follows = "famedly-engineering-standards/flake-parts"; + }; + + outputs = + { famedly-engineering-standards, flake-parts, ... }@inputs: + flake-parts.lib.mkFlake { inherit inputs; } { + imports = [ + famedly-engineering-standards.flakeModules.default + + ./nix/package.nix + ./nix/workflows/build.nix + ./nix/workflows/tests.nix + ]; + + systems = famedly-engineering-standards.lib.famedlySystems; + + perSystem = + { config, pkgs, ... }: + { + # This is a plain JavaScript project without a build step, so + # the standards devshell is all we need -- plus Node.js to run + # the smoke tests. + devShells.default = config.devShells.standards; + devshells.standards.devshell.packages = [ pkgs.nodejs ]; + + famedly.standards = { + nix.projects."." = { }; + }; + + # Hard-wrap markdown prose at 80 columns. mdformat runs + # through the treefmt pre-commit hook, so violations are + # fixed automatically. + treefmt = { + programs.mdformat = { + enable = true; + settings.wrap = 80; + }; + + # Refer to the binary by name so that the generated + # treefmt.toml does not contain nix store paths. The package + # is put on PATH through the prek wrapper and the devshell. + settings.formatter.mdformat.command = "mdformat"; + }; + + # Check licensing information (SPDX headers and REUSE.toml) + # against the REUSE specification. This runs in CI through + # the check-pre-commit-hooks workflow. + prek-pre-commit = { + package.runtimePkgs = [ pkgs.reuse ]; + + workspaces.".".repos = [ + { + repo = "local"; + hooks = [ + { + id = "reuse"; + name = "reuse"; + description = "Check licensing info for REUSE compliance"; + pass_filenames = false; + + entry = "reuse lint"; + language = "system"; + } + ]; + } + ]; + }; + }; + }; +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..5c2dc79 --- /dev/null +++ b/index.html @@ -0,0 +1,109 @@ + + + + + +admin2136 + + + + + + +
+
+ admin2136 + AXFR / RFC 2136 zone editor +
+ / +
+ + +
+ + + + +
+ +
+
+ + + + + + +
+
+
+ + + + +
+

Edit record

+ +
+ + +
+ +
+ + +
+
+
+
+ + +

admin2136

+

A web frontend for DNS zone management via AXFR and RFC 2136 + dynamic updates, authenticated with TSIG, transported with DNS + over WebSocket.

+

Source available at https://github.com/famedly/admin2136/commit/{{ git_commit_hash }}

+ +
+ +
+
+ + + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..71485ea --- /dev/null +++ b/js/app.js @@ -0,0 +1,539 @@ +// SPDX-FileCopyrightText: 2026 Famedly GmbH (info@famedly.com) +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// admin2136 application glue: login, record list, editing via RFC 2136. + +import { DowsConnection } from "./dows.js"; +import { axfr, sendUpdate } from "./dnsops.js"; +import { parseTsigSpec } from "./tsig.js"; +import { ZoneStore } from "./zonestore.js"; +import { VirtualList } from "./vlist.js"; +import { rdataToText, textToRdata, soaSerial } from "./rdata.js"; +import { + typeToName, + nameToType, + TYPE_A, + TYPE_AAAA, + TYPE_CNAME, + TYPE_TXT, + TYPE_SSHFP, + TYPE_SOA, + CLASS_IN, + CLASS_NONE, +} from "./dnswire.js"; + +const $ = (id) => document.getElementById(id); + +const EDITABLE_TYPES = new Set([ + TYPE_A, + TYPE_AAAA, + TYPE_CNAME, + TYPE_TXT, + TYPE_SSHFP, +]); +const ROW_HEIGHT = 30; +const SETTINGS_KEY = "admin2136-settings"; + +let conn = null; // DowsConnection +let store = null; // ZoneStore +let tsigKey = null; // parsed TSIG key or null +let order = new Uint32Array(0); // display order -> arena record index +let view = null; // null = show all of `order`, else filtered record indices +let vlist = null; // VirtualList +let editRec = null; // record index being edited, or null for "add" +let settings = { proxy: "", ns: "" }; + +// ---- helpers ---- + +function setStatus(text, cls = "") { + const el = $("status-bar"); + el.textContent = text; + el.className = cls; +} + +function displayName(rel) { + return rel === "" ? "@" : rel; +} + +// Map a DoWS proxy WebSocket URL to the corresponding HTTP(S) URL where the +// proxy serves its info page, or null if it is not a valid ws:// / wss:// URL. +function proxyHttpUrl(wsUrl) { + let u; + try { + u = new URL(wsUrl); + } catch (e) { + return null; + } + if (u.protocol === "ws:") u.protocol = "http:"; + else if (u.protocol === "wss:") u.protocol = "https:"; + else return null; + return u.href; +} + +// Rebuild the indirection array used for display: it starts as the identity +// sequence 0..n over the arena records and is sorted with the store's +// comparator (revname, type, ttl). Rows never move in the arena; only this +// array determines what the list shows where. Must be called after every +// store mutation (load, add, edit, delete). +function rebuildOrder() { + const n = store ? store.count : 0; + order = new Uint32Array(n); + for (let i = 0; i < n; i++) order[i] = i; + order.sort((a, b) => store.compareRecords(a, b)); +} + +async function ensureConn() { + if (conn && conn.connected) return; + conn = new DowsConnection(settings.proxy, settings.ns); + await conn.connect(); +} + +// Transfer the zone into a fresh store; the current store is only replaced +// on success so a failed reload does not lose the working copy. +async function transferZone(progress) { + await ensureConn(); + const fresh = new ZoneStore(store ? store.zone : $("zone").value.trim()); + let n = 0; + await axfr(conn, fresh.zoneFqdn, tsigKey, (rr) => { + fresh.add(fresh.relativize(rr.name), rr.type, rr.ttl, rr.rdata); + if (rr.type === TYPE_SOA) fresh.serial = soaSerial(rr.rdata); + // Report the very first record immediately (it signals that the server + // accepted the request), then every 1024 records. The serial is known + // from the first record on, since an AXFR starts with the SOA. + if (progress && (++n === 1 || (n & 1023) === 0)) progress(n, fresh.serial); + }); + store = fresh; +} + +function updateMeta() { + $("zone-meta").textContent = + "serial " + (store.serial ?? "?") + " · " + store.count + " records"; +} + +function refreshList() { + vlist.setCount(view ? view.length : order.length); +} + +// ---- record list ---- + +function buildRow(el) { + el.classList.add("record-grid"); + const mk = (cls) => { + const s = document.createElement("span"); + s.className = cls; + el.appendChild(s); + return s; + }; + const c = { + name: mk("c-name"), + ttl: mk("c-ttl"), + type: mk("c-type"), + data: mk("c-data"), + actions: mk("c-actions"), + }; + c.edit = document.createElement("button"); + c.edit.className = "row-edit"; + c.edit.textContent = "✎"; + c.edit.title = "Edit record"; + c.edit.addEventListener("click", () => openEditor(el._rec)); + c.del = document.createElement("button"); + c.del.className = "row-del"; + c.del.textContent = "✕"; + c.del.title = "Delete record"; + c.del.addEventListener("click", () => deleteRecord(el._rec)); + c.actions.append(c.edit, c.del); + el._c = c; +} + +function renderRow(i, el) { + if (!el._c) buildRow(el); + // Translate the DOM row index to an arena record index via the sorted + // indirection array (or the filtered subset of it). + const rec = view ? view[i] : order[i]; + el._rec = rec; + const r = store.get(rec); + const c = el._c; + const data = rdataToText(r.type, r.rdata); + c.name.textContent = displayName(r.name); + c.ttl.textContent = r.ttl; + c.type.textContent = typeToName(r.type); + c.data.textContent = data; + c.data.title = data; + const editable = EDITABLE_TYPES.has(r.type); + c.edit.disabled = !editable; + c.del.disabled = r.type === TYPE_SOA; + el.classList.toggle("readonly", !editable); +} + +function applyFilter() { + const q = $("filter").value.trim().toLowerCase(); + if (!q) { + view = null; + } else { + // Filter in display order so `view` stays sorted as well. + const v = []; + for (const idx of order) { + const r = store.get(idx); + if ( + displayName(r.name).toLowerCase().includes(q) || + typeToName(r.type).toLowerCase().includes(q) || + rdataToText(r.type, r.rdata).toLowerCase().includes(q) + ) { + v.push(idx); + } + } + view = v; + } + refreshList(); +} + +// ---- login ---- + +// Keep the "About proxy" link under the proxy URL field in sync with the +// input; without a valid ws(s):// URL the link is hidden (an without +// href is not focusable, and CSS hides it while reserving its space). +function updateProxyAboutLink() { + const link = $("proxy-about-link"); + const url = proxyHttpUrl($("proxy-url").value.trim()); + if (url) link.href = url; + else link.removeAttribute("href"); +} + +$("proxy-url").addEventListener("input", updateProxyAboutLink); + +function validateNameserver() { + const input = $("nameserver"); + let msg = "Expected host[:port]"; + try { + let url = new URL("test://" + input.value); + if (decodeURIComponent(url.host) == input.value) { + msg = ""; + } + } catch (e) { + // ignore the error + } + input.setCustomValidity(msg); +} + +$("nameserver").addEventListener("input", validateNameserver); + +function restoreSettings() { + try { + const s = JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}"); + if (s.proxy) $("proxy-url").value = s.proxy; + if (s.ns) $("nameserver").value = s.ns; + if (s.zone) $("zone").value = s.zone; + } catch (e) { + /* ignore */ + } +} + +$("login-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const status = $("login-status"); + const btn = $("login-btn"); + const proxy = $("proxy-url").value.trim(); + const ns = $("nameserver").value.trim(); + const zone = $("zone").value.trim().toLowerCase().replace(/\.$/, ""); + const tsigSpec = $("tsig").value.trim(); + + try { + btn.disabled = true; + status.textContent = ""; + if (tsigSpec && !(crypto && crypto.subtle)) { + throw new Error( + "Web Crypto unavailable; serve this page via https:// or from localhost", + ); + } + tsigKey = tsigSpec ? parseTsigSpec(tsigSpec) : null; + settings = { proxy, ns }; + localStorage.setItem(SETTINGS_KEY, JSON.stringify({ proxy, ns, zone })); + + status.textContent = "Connecting to " + ns + "…"; + if (conn) conn.close(); + conn = new DowsConnection(proxy, ns); + await conn.connect(); + + status.textContent = "Transferring zone…"; + store = new ZoneStore(zone); + + // The login conceptually ends with the first successful DNS message: + // switch to the zone view right away and let the rest of the AXFR + // report its progress in the bottom status bar, like a reload does. + let inZoneView = false; + try { + await transferZone((n, serial) => { + if (!inZoneView) { + inZoneView = true; + enterZoneView(); + } + if (serial !== null) $("zone-meta").textContent = "serial " + serial; + setStatus("Transferring zone… " + n + " records"); + }); + } catch (err) { + if (!inZoneView) throw err; // login itself failed + setStatus("✗ zone transfer failed: " + err.message, "error"); + return; + } + if (!inZoneView) enterZoneView(); + rebuildOrder(); + updateMeta(); + refreshList(); + setStatus("Zone loaded via AXFR (" + store.count + " records).", "ok"); + } catch (err) { + status.textContent = "✗ " + err.message; + if (conn) { + conn.close(); + conn = null; + } + } finally { + btn.disabled = false; + } +}); + +// Toggle between the login form and the zone view without touching any +// state (connection, store, form inputs); used both by login and by +// history navigation. +function showZoneView() { + $("login-view").hidden = true; + $("zone-view").hidden = false; + $("toolbar").classList.add("zone-active"); +} + +function showLoginView() { + $("zone-view").hidden = true; + $("login-view").hidden = false; + $("toolbar").classList.remove("zone-active"); +} + +// Switch from the login form to the (possibly still loading) zone view. +function enterZoneView() { + showZoneView(); + $("zone-name").textContent = store.zoneFqdn; + $("zone-meta").textContent = ""; + $("filter").value = ""; + view = null; + if (!vlist) vlist = new VirtualList($("record-list"), ROW_HEIGHT, renderRow); + rebuildOrder(); + refreshList(); + if (history.state?.view != "zone") { + // Make the zone view a history entry so the browser back button returns + // to the login form (and forward returns here). + history.pushState({ view: "zone" }, ""); + } +} + +// ---- history navigation ---- + +const LOGIN_FIELDS = ["proxy-url", "nameserver", "zone", "tsig"]; + +// Login form input values captured when the user navigates back from the +// zone view; navigating forward restores exactly this snapshot. +let loginSnapshot = null; + +window.addEventListener("popstate", (e) => { + if (e.state && e.state.view === "zone") { + // Forward to the zone view. The connection and store are kept alive + // while on the login screen, so nothing needs to be reloaded. + if (!store) { + // No session behind this entry (it belongs to a previous page load); + // bounce off it so the back button keeps working past it. + history.forward(); + return; + } + if (loginSnapshot) { + for (const id of LOGIN_FIELDS) $(id).value = loginSnapshot[id]; + updateProxyAboutLink(); + validateNameserver(); + } + showZoneView(); + } else { + // Back to the login form: keep all inputs as they are, but remember + // them so forward can undo any edits made in the meantime. + if ($("login-view").hidden) { + loginSnapshot = {}; + for (const id of LOGIN_FIELDS) loginSnapshot[id] = $(id).value; + } + $("login-status").textContent = ""; + showLoginView(); + } +}); + +// ---- refresh ---- + +$("refresh-btn").addEventListener("click", async () => { + const btn = $("refresh-btn"); + try { + btn.disabled = true; + setStatus("Reloading zone via AXFR…"); + // TODO(IXFR): request an incremental transfer based on store.serial. + await transferZone((n) => setStatus("Reloading zone… " + n + " records")); + rebuildOrder(); + applyFilter(); + updateMeta(); + setStatus( + "Zone reloaded (" + + store.count + + " records, serial " + + (store.serial ?? "?") + + ").", + "ok", + ); + } catch (err) { + setStatus("✗ reload failed: " + err.message, "error"); + } finally { + btn.disabled = false; + } +}); + +// ---- filtering ---- + +let filterTimer = null; +$("filter").addEventListener("input", () => { + clearTimeout(filterTimer); + filterTimer = setTimeout(applyFilter, 150); +}); + +// ---- editing ---- + +function openEditor(rec) { + editRec = rec; + $("edit-status").textContent = ""; + if (rec === null) { + $("edit-title").textContent = "Add record"; + $("edit-name").value = ""; + $("edit-ttl").value = "3600"; + $("edit-type").value = "A"; + $("edit-data").value = ""; + } else { + const r = store.get(rec); + $("edit-title").textContent = "Edit record"; + $("edit-name").value = displayName(r.name); + $("edit-ttl").value = r.ttl; + $("edit-type").value = typeToName(r.type); + $("edit-data").value = rdataToText(r.type, r.rdata); + } + $("edit-dialog").showModal(); +} + +$("add-btn").addEventListener("click", () => openEditor(null)); +$("edit-cancel").addEventListener("click", () => $("edit-dialog").close()); + +$("edit-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const status = $("edit-status"); + const btn = $("edit-save"); + try { + btn.disabled = true; + status.textContent = ""; + + const rawName = $("edit-name").value.trim(); + const nameRel = + rawName === "" || rawName === "@" + ? "" + : store.relativize(store.absolutize(rawName)); + const ttl = parseInt($("edit-ttl").value, 10); + if (!Number.isInteger(ttl) || ttl < 0 || ttl > 0x7fffffff) { + throw new Error("invalid TTL"); + } + const type = nameToType($("edit-type").value); + if (type === null) throw new Error("unknown record type"); + const rdata = textToRdata(type, $("edit-data").value, (n) => + store.absolutize(n), + ); + const nameAbs = store.absolutize(nameRel); + + const updates = []; + if (editRec !== null) { + const old = store.get(editRec); + updates.push({ + name: store.absolutize(old.name), + type: old.type, + cls: CLASS_NONE, + ttl: 0, + rdata: old.rdata, + }); + } + updates.push({ name: nameAbs, type, cls: CLASS_IN, ttl, rdata }); + + status.textContent = "Sending update…"; + await ensureConn(); + await sendUpdate(conn, store.zoneFqdn, tsigKey, updates); + + if (editRec !== null) store.replace(editRec, nameRel, type, ttl, rdata); + else store.add(nameRel, type, ttl, rdata); + + $("edit-dialog").close(); + rebuildOrder(); + applyFilter(); + updateMeta(); + setStatus( + (editRec !== null ? "Updated " : "Added ") + + displayName(nameRel) + + " " + + typeToName(type) + + " record.", + "ok", + ); + } catch (err) { + status.textContent = "✗ " + err.message; + } finally { + btn.disabled = false; + } +}); + +async function deleteRecord(rec) { + const r = store.get(rec); + const what = displayName(r.name) + " " + typeToName(r.type); + if ( + !confirm("Delete " + what + " record?\n\n" + rdataToText(r.type, r.rdata)) + ) + return; + try { + setStatus("Deleting " + what + "…"); + await ensureConn(); + await sendUpdate(conn, store.zoneFqdn, tsigKey, [ + { + name: store.absolutize(r.name), + type: r.type, + cls: CLASS_NONE, + ttl: 0, + rdata: r.rdata, + }, + ]); + store.remove(rec); + rebuildOrder(); + applyFilter(); + updateMeta(); + setStatus("Deleted " + what + " record.", "ok"); + } catch (err) { + setStatus("✗ delete failed: " + err.message, "error"); + } +} + +// ---- about ---- + +$("about-btn").addEventListener("click", () => { + // Once logged in, link to the info page of the connected proxy; the + // connection URL includes the nameserver endpoint path segment. + const url = $("login-view").hidden && conn ? proxyHttpUrl(conn.url) : null; + $("about-proxy").hidden = url === null; + if (url) $("about-proxy-link").href = url; + $("about-dialog").showModal(); +}); +$("about-close").addEventListener("click", () => $("about-dialog").close()); + +// ---- init ---- + +// Absence of history state marks the login view, which the page always +// starts on. A (re)load can land in an entry carrying zone state from a +// previous session; navigate back to the login entry, since that session +// no longer exists. On a bfcache restore the store survives, so the zone +// view stays usable. +window.addEventListener("pageshow", () => { + if (history.state && !store) history.back(); +}); +restoreSettings(); +updateProxyAboutLink(); +validateNameserver(); diff --git a/js/dnsops.js b/js/dnsops.js new file mode 100644 index 0000000..b216a03 --- /dev/null +++ b/js/dnsops.js @@ -0,0 +1,211 @@ +// SPDX-FileCopyrightText: 2026 Famedly GmbH (info@famedly.com) +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// High-level DNS operations over a DoWS connection: AXFR and RFC 2136 UPDATE. +// +// TODO(IXFR): for incremental reloads, send an IXFR query (type 251) with the +// current SOA in the authority section and apply the delta envelopes to the +// ZoneStore instead of rebuilding it. The transport and TSIG layers already +// support this; only the envelope interpretation is missing. + +import { + Writer, + buildQuery, + parseMessage, + TYPE_SOA, + TYPE_RRSIG, + TYPE_NSEC, + TYPE_NSEC3, + TYPE_AXFR, + TYPE_TSIG, + CLASS_IN, + OPCODE_UPDATE, +} from "./dnswire.js"; +import { TsigSession, tsigErrorName } from "./tsig.js"; + +const RCODES = { + 0: "NOERROR", + 1: "FORMERR", + 2: "SERVFAIL", + 3: "NXDOMAIN", + 4: "NOTIMP", + 5: "REFUSED", + 6: "YXDOMAIN", + 7: "YXRRSET", + 8: "NXRRSET", + 9: "NOTAUTH", + 10: "NOTZONE", +}; + +export function rcodeName(rc) { + return RCODES[rc] || "RCODE" + rc; +} + +// RRtypes that are dropped when received: they are never stored in the +// client state nor shown in the UI. +export const IGNORED_RRTYPES = new Set([TYPE_RRSIG, TYPE_NSEC, TYPE_NSEC3]); + +function randId() { + return crypto.getRandomValues(new Uint16Array(1))[0]; +} + +function errorDetail(resp) { + const tsig = resp.additional.find((rr) => rr.type === TYPE_TSIG); + if (!tsig) return ""; + const name = tsigErrorName(tsig.rdata); + return name ? " (" + name + ")" : ""; +} + +// Stream a full zone transfer; `onRecord` is called for every record of the +// zone exactly once (the trailing terminator SOA is suppressed). +// Returns the number of records seen. +export async function axfr(conn, zone, tsigKey, onRecord) { + const id = randId(); + const tsig = tsigKey ? new TsigSession(tsigKey) : null; + let msg = buildQuery(id, zone, TYPE_AXFR); + if (tsig) msg = await tsig.sign(msg); + conn.send(msg); + + console.log( + "[axfr] query sent: id=" + + id + + " zone=" + + zone + + (tsigKey ? " (TSIG-signed)" : " (unsigned)"), + ); + + let soaSeen = 0; + let count = 0; + let frameNo = 0; + let lastSigned = true; + while (soaSeen < 2) { + const raw = await conn.recv(120000); + frameNo++; + const resp = parseMessage(raw); + console.log( + "[axfr] reply #" + + frameNo + + ": " + + raw.length + + " bytes," + + " id=" + + resp.id + + " (expected " + + id + + ")," + + " rcode=" + + rcodeName(resp.rcode) + + "," + + " qd=" + + resp.questions.length + + " an=" + + resp.answers.length + + " ns=" + + resp.authority.length + + " ar=" + + resp.additional.length, + ); + if (resp.id !== id) { + // stray message; not ours -- keep waiting + console.warn( + "[axfr] reply #" + + frameNo + + " ignored due to ID mismatch; still waiting for id=" + + id, + ); + continue; + } + if (resp.rcode !== 0) { + throw new Error( + "AXFR refused: " + rcodeName(resp.rcode) + errorDetail(resp), + ); + } + if (tsig) { + lastSigned = (await tsig.verify(raw)).signed; + if (!lastSigned) { + console.log("[axfr] reply #" + frameNo + " carries no TSIG (deferred)"); + } + } + let progressed = false; + let ignored = 0; + for (const rr of resp.answers) { + if (rr.type === TYPE_TSIG) continue; + if (IGNORED_RRTYPES.has(rr.type)) { + ignored++; + progressed = true; // consumed from the stream, just not stored + continue; + } + if (rr.type === TYPE_SOA) { + soaSeen++; + console.log( + "[axfr] reply #" + + frameNo + + ": SOA record (soaSeen=" + + soaSeen + + ")" + + (soaSeen === 2 ? " -> transfer complete" : ""), + ); + if (soaSeen === 2) break; // terminator SOA, do not report again + } else if (soaSeen === 0) { + throw new Error("AXFR stream did not start with SOA"); + } + onRecord(rr); + count++; + progressed = true; + } + console.log( + "[axfr] reply #" + + frameNo + + " processed: " + + count + + " records total, " + + ignored + + " ignored, soaSeen=" + + soaSeen, + ); + if (!progressed && soaSeen < 2) { + throw new Error("AXFR stream stalled (empty response message)"); + } + } + // RFC 8945 5.3.1: the last message of the transfer must carry a TSIG. + if (tsig && !lastSigned) { + throw new Error("AXFR ended with an unsigned message (missing TSIG)"); + } + console.log("[axfr] done: " + count + " records"); + return count; +} + +// Build an RFC 2136 UPDATE message. `updates` entries: +// { name (absolute), type, cls, ttl, rdata (Uint8Array) } +export function buildUpdateMessage(id, zone, updates) { + const w = new Writer(128); + w.u16(id).u16(OPCODE_UPDATE << 11); + w.u16(1).u16(0).u16(updates.length).u16(0); + w.name(zone).u16(TYPE_SOA).u16(CLASS_IN); // zone section + for (const u of updates) { + w.name(u.name).u16(u.type).u16(u.cls).u32(u.ttl); + w.u16(u.rdata.length).bytes(u.rdata); + } + return w.done(); +} + +export async function sendUpdate(conn, zone, tsigKey, updates) { + const id = randId(); + const tsig = tsigKey ? new TsigSession(tsigKey) : null; + let msg = buildUpdateMessage(id, zone, updates); + if (tsig) msg = await tsig.sign(msg); + conn.send(msg); + for (;;) { + const raw = await conn.recv(); + const resp = parseMessage(raw); + if (resp.id !== id) continue; + if (resp.rcode !== 0) { + throw new Error( + "update failed: " + rcodeName(resp.rcode) + errorDetail(resp), + ); + } + if (tsig) await tsig.verify(raw); + return; + } +} diff --git a/js/dnswire.js b/js/dnswire.js new file mode 100644 index 0000000..cd6a20c --- /dev/null +++ b/js/dnswire.js @@ -0,0 +1,281 @@ +// SPDX-FileCopyrightText: 2026 Famedly GmbH (info@famedly.com) +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// DNS wire format primitives: encoding, parsing, name (de)compression. + +export const TYPE_A = 1; +export const TYPE_NS = 2; +export const TYPE_CNAME = 5; +export const TYPE_SOA = 6; +export const TYPE_PTR = 12; +export const TYPE_MX = 15; +export const TYPE_TXT = 16; +export const TYPE_AAAA = 28; +export const TYPE_SSHFP = 44; +export const TYPE_RRSIG = 46; +export const TYPE_NSEC = 47; +export const TYPE_NSEC3 = 50; +export const TYPE_TSIG = 250; +export const TYPE_IXFR = 251; +export const TYPE_AXFR = 252; + +export const CLASS_IN = 1; +export const CLASS_NONE = 254; +export const CLASS_ANY = 255; + +export const OPCODE_QUERY = 0; +export const OPCODE_UPDATE = 5; + +const TYPE_NAMES = { + 1: "A", + 2: "NS", + 5: "CNAME", + 6: "SOA", + 12: "PTR", + 15: "MX", + 16: "TXT", + 28: "AAAA", + 33: "SRV", + 35: "NAPTR", + 43: "DS", + 44: "SSHFP", + 46: "RRSIG", + 47: "NSEC", + 48: "DNSKEY", + 50: "NSEC3", + 51: "NSEC3PARAM", + 52: "TLSA", + 59: "CDS", + 60: "CDNSKEY", + 64: "SVCB", + 65: "HTTPS", + 99: "SPF", + 250: "TSIG", + 257: "CAA", +}; + +export function typeToName(t) { + return TYPE_NAMES[t] || "TYPE" + t; +} + +export function nameToType(s) { + s = s.trim().toUpperCase(); + for (const [num, name] of Object.entries(TYPE_NAMES)) { + if (name === s) return Number(num); + } + const m = /^TYPE(\d+)$/.exec(s); + if (m) return Number(m[1]); + return null; +} + +const te = new TextEncoder(); +const td = new TextDecoder(); + +// Growable big-endian byte writer. +export class Writer { + constructor(cap = 512) { + this.dv = new DataView(new ArrayBuffer(cap)); + this.n = 0; + } + + _ensure(extra) { + if (this.n + extra <= this.dv.byteLength) return; + let cap = this.dv.byteLength * 2; + while (cap < this.n + extra) cap *= 2; + const buf = new ArrayBuffer(cap); + new Uint8Array(buf).set(new Uint8Array(this.dv.buffer, 0, this.n)); + this.dv = new DataView(buf); + } + + u8(v) { + this._ensure(1); + this.dv.setUint8(this.n, v); + this.n += 1; + return this; + } + + u16(v) { + this._ensure(2); + this.dv.setUint16(this.n, v); + this.n += 2; + return this; + } + + u32(v) { + this._ensure(4); + this.dv.setUint32(this.n, v); + this.n += 4; + return this; + } + + u48(v) { + this.u16(Math.floor(v / 0x100000000)); + this.u32(v % 0x100000000); + return this; + } + + bytes(b) { + this._ensure(b.length); + new Uint8Array(this.dv.buffer, this.n, b.length).set(b); + this.n += b.length; + return this; + } + + name(s) { + return this.bytes(nameToWire(s)); + } + + done() { + return new Uint8Array(this.dv.buffer.slice(0, this.n)); + } +} + +// Text name ("www.example.com." or "www.example.com") to uncompressed wire labels. +export function nameToWire(name) { + if (name === "." || name === "") return Uint8Array.of(0); + if (name.endsWith(".")) name = name.slice(0, -1); + const w = new Writer(name.length + 2); + for (const label of name.split(".")) { + const b = te.encode(label); + if (b.length === 0) throw new Error('empty label in name "' + name + '"'); + if (b.length > 63) + throw new Error('label longer than 63 bytes: "' + label + '"'); + w.u8(b.length).bytes(b); + } + w.u8(0); + const out = w.done(); + if (out.length > 255) + throw new Error('name longer than 255 bytes: "' + name + '"'); + return out; +} + +// Read a (possibly compressed) name at `off` in `msg`. +// Returns [nameText (absolute, with trailing dot), offset after the name]. +export function readName(msg, off) { + const labels = []; + let end = -1; + let jumps = 0; + for (;;) { + if (off >= msg.length) throw new Error("truncated name"); + const len = msg[off]; + if (len === 0) { + if (end < 0) end = off + 1; + break; + } + if ((len & 0xc0) === 0xc0) { + if (off + 1 >= msg.length) + throw new Error("truncated compression pointer"); + if (end < 0) end = off + 2; + if (++jumps > 128) throw new Error("compression pointer loop"); + off = ((len & 0x3f) << 8) | msg[off + 1]; + continue; + } + if ((len & 0xc0) !== 0) + throw new Error("unsupported label type 0x" + (len & 0xc0).toString(16)); + if (off + 1 + len > msg.length) throw new Error("truncated label"); + labels.push(td.decode(msg.subarray(off + 1, off + 1 + len))); + off += 1 + len; + } + return [labels.length ? labels.join(".") + "." : ".", end]; +} + +export function buildQuery(id, qname, qtype, qclass = CLASS_IN) { + const w = new Writer(64); + w.u16(id).u16(0).u16(1).u16(0).u16(0).u16(0); + w.name(qname).u16(qtype).u16(qclass); + return w.done(); +} + +// Decompress rdata into a self-contained buffer. Types whose rdata may +// legally contain compressed names get their names expanded so the copy +// stays valid outside of the original message. +function extractRdata(msg, type, off, rdlen) { + switch (type) { + case TYPE_NS: + case TYPE_CNAME: + case TYPE_PTR: { + const [n] = readName(msg, off); + return nameToWire(n); + } + case TYPE_MX: { + const w = new Writer(rdlen + 4); + w.bytes(msg.subarray(off, off + 2)); + const [n] = readName(msg, off + 2); + return w.name(n).done(); + } + case TYPE_SOA: { + const [mname, o1] = readName(msg, off); + const [rname, o2] = readName(msg, o1); + const w = new Writer(rdlen + 8); + return w + .name(mname) + .name(rname) + .bytes(msg.subarray(o2, off + rdlen)) + .done(); + } + default: + return msg.slice(off, off + rdlen); + } +} + +export function parseMessage(msg) { + if (msg.length < 12) throw new Error("DNS message shorter than header"); + const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength); + const id = dv.getUint16(0); + const flags = dv.getUint16(2); + const counts = [ + dv.getUint16(4), + dv.getUint16(6), + dv.getUint16(8), + dv.getUint16(10), + ]; + let off = 12; + + const questions = []; + for (let i = 0; i < counts[0]; i++) { + const [name, e] = readName(msg, off); + off = e; + if (off + 4 > msg.length) throw new Error("truncated question"); + questions.push({ + name, + type: dv.getUint16(off), + cls: dv.getUint16(off + 2), + }); + off += 4; + } + + const sections = [[], [], []]; + for (let s = 0; s < 3; s++) { + for (let i = 0; i < counts[s + 1]; i++) { + const [name, e] = readName(msg, off); + off = e; + if (off + 10 > msg.length) throw new Error("truncated resource record"); + const type = dv.getUint16(off); + const cls = dv.getUint16(off + 2); + const ttl = dv.getUint32(off + 4); + const rdlen = dv.getUint16(off + 8); + off += 10; + if (off + rdlen > msg.length) throw new Error("truncated rdata"); + sections[s].push({ + name, + type, + cls, + ttl, + rdata: extractRdata(msg, type, off, rdlen), + }); + off += rdlen; + } + } + + return { + id, + flags, + opcode: (flags >>> 11) & 0xf, + rcode: flags & 0xf, + questions, + answers: sections[0], + authority: sections[1], + additional: sections[2], + }; +} diff --git a/js/dows.js b/js/dows.js new file mode 100644 index 0000000..c56976d --- /dev/null +++ b/js/dows.js @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2026 Famedly GmbH (info@famedly.com) +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// DoWS (DNS over WebSocket) transport. +// +// The nameserver (with optional port) is appended as the last path component +// of the proxy URL. Each DNS message travels in exactly one WebSocket frame, +// in both directions -- there is *no* 16-bit length prefix as in DNS over TCP; +// the WebSocket framing delimits messages. + +export class DowsConnection { + constructor(proxyUrl, nameserver) { + this.url = proxyUrl.replace(/\/*$/, "/") + encodeURIComponent(nameserver); + this.ws = null; + this.closed = false; + this.closeReason = null; + this.queue = []; // received frames not yet consumed + this.waiters = []; // pending recv() promises + } + + get connected() { + return this.ws !== null && !this.closed; + } + + connect() { + return new Promise((resolve, reject) => { + let ws; + try { + ws = new WebSocket(this.url); + } catch (e) { + reject(new Error('invalid WebSocket URL "' + this.url + '"')); + return; + } + ws.binaryType = "arraybuffer"; + let settled = false; + ws.onopen = () => { + settled = true; + this.ws = ws; + resolve(); + }; + ws.onclose = (ev) => { + this.closed = true; + this.closeReason = ev.reason || "code " + ev.code; + this._rejectWaiters( + new Error("connection closed (" + this.closeReason + ")"), + ); + if (!settled) { + settled = true; + reject(new Error("could not connect to " + this.url)); + } + }; + ws.onmessage = (ev) => { + if (typeof ev.data === "string") { + console.warn("[dows] received unexpected TEXT frame:", ev.data); + return; + } + const data = new Uint8Array(ev.data); + const w = this.waiters.shift(); + console.log( + "[dows] frame received:", + data.length, + "bytes,", + w ? "delivered to waiter" : "queued (no waiter pending)", + ); + if (w) w.resolve(data); + else this.queue.push(data); + }; + }); + } + + send(bytes) { + if (!this.connected) throw new Error("not connected"); + console.log("[dows] frame sent:", bytes.length, "bytes"); + this.ws.send(bytes); + } + + // Receive the next DNS message (one WebSocket frame). + recv(timeoutMs = 30000) { + if (this.queue.length) return Promise.resolve(this.queue.shift()); + if (this.closed) { + return Promise.reject( + new Error("connection closed (" + this.closeReason + ")"), + ); + } + return new Promise((resolve, reject) => { + const w = {}; + const timer = setTimeout(() => { + const i = this.waiters.indexOf(w); + if (i >= 0) this.waiters.splice(i, 1); + reject(new Error("timeout waiting for DNS response")); + }, timeoutMs); + w.resolve = (v) => { + clearTimeout(timer); + resolve(v); + }; + w.reject = (e) => { + clearTimeout(timer); + reject(e); + }; + this.waiters.push(w); + }); + } + + _rejectWaiters(err) { + const ws = this.waiters; + this.waiters = []; + for (const w of ws) w.reject(err); + } + + close() { + if (this.ws && !this.closed) this.ws.close(); + } +} diff --git a/js/rdata.js b/js/rdata.js new file mode 100644 index 0000000..e7708e5 --- /dev/null +++ b/js/rdata.js @@ -0,0 +1,351 @@ +// SPDX-FileCopyrightText: 2026 Famedly GmbH (info@famedly.com) +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Conversion between rdata wire format and presentation text. +// +// Fully supported (editable): A, AAAA, CNAME, TXT. +// Displayed nicely (read-only): SOA, NS, PTR, MX. +// Everything else is shown as RFC 3597 generic data: \# . + +import { + nameToWire, + readName, + typeToName, + TYPE_A, + TYPE_NS, + TYPE_CNAME, + TYPE_SOA, + TYPE_PTR, + TYPE_MX, + TYPE_TXT, + TYPE_AAAA, + TYPE_SSHFP, +} from "./dnswire.js"; + +const te = new TextEncoder(); + +export function rdataToText(type, rdata) { + try { + switch (type) { + case TYPE_A: + if (rdata.length === 4) return rdata.join("."); + break; + case TYPE_AAAA: + if (rdata.length === 16) return formatIpv6(rdata); + break; + case TYPE_CNAME: + case TYPE_NS: + case TYPE_PTR: + return readName(rdata, 0)[0]; + case TYPE_MX: { + const pref = (rdata[0] << 8) | rdata[1]; + return pref + " " + readName(rdata, 2)[0]; + } + case TYPE_SOA: + return formatSoa(rdata); + case TYPE_TXT: + return formatTxt(rdata); + case TYPE_SSHFP: + return formatSshfp(rdata); + } + } catch (e) { + // fall through to generic representation + } + return genericToText(rdata); +} + +export function textToRdata(type, text, absolutize) { + const t = text.trim(); + if (t.startsWith("\\#")) return parseGeneric(t); // RFC 3597 escape hatch + switch (type) { + case TYPE_A: + return parseIpv4(t); + case TYPE_AAAA: + return parseIpv6(t); + case TYPE_CNAME: { + if (t === "") throw new Error("CNAME target must not be empty"); + return nameToWire(absolutize(t)); + } + case TYPE_TXT: + return parseTxt(text); + case TYPE_SSHFP: + return parseSshfp(t); + } + throw new Error( + "editing " + + typeToName(type) + + ' records is not supported (use "\\# " for raw rdata)', + ); +} + +export function soaSerial(rdata) { + const [, o1] = readName(rdata, 0); + const [, o2] = readName(rdata, o1); + if (o2 + 20 > rdata.length) return null; + return ( + ((rdata[o2] << 24) | + (rdata[o2 + 1] << 16) | + (rdata[o2 + 2] << 8) | + rdata[o2 + 3]) >>> + 0 + ); +} + +function formatSoa(rdata) { + const [mname, o1] = readName(rdata, 0); + const [rname, o2] = readName(rdata, o1); + if (o2 + 20 > rdata.length) throw new Error("short SOA"); + const u32 = (o) => + ((rdata[o] << 24) | + (rdata[o + 1] << 16) | + (rdata[o + 2] << 8) | + rdata[o + 3]) >>> + 0; + return ( + mname + + " " + + rname + + " " + + u32(o2) + + " " + + u32(o2 + 4) + + " " + + u32(o2 + 8) + + " " + + u32(o2 + 12) + + " " + + u32(o2 + 16) + ); +} + +// --- IPv4 --- + +export function parseIpv4(s) { + const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s.trim()); + if (!m) throw new Error('invalid IPv4 address "' + s + '"'); + const b = new Uint8Array(4); + for (let i = 0; i < 4; i++) { + const v = Number(m[i + 1]); + if (v > 255) throw new Error('invalid IPv4 address "' + s + '"'); + b[i] = v; + } + return b; +} + +// --- IPv6 --- + +export function formatIpv6(b) { + const g = []; + for (let i = 0; i < 16; i += 2) g.push((b[i] << 8) | b[i + 1]); + // Find the longest run of zero groups (length >= 2) for "::". + let best = -1, + bestLen = 0, + cur = -1; + for (let i = 0; i < 8; i++) { + if (g[i] === 0) { + if (cur < 0) cur = i; + if (i - cur + 1 > bestLen) { + best = cur; + bestLen = i - cur + 1; + } + } else { + cur = -1; + } + } + const hex = (x) => x.toString(16); + if (bestLen < 2) return g.map(hex).join(":"); + return ( + g.slice(0, best).map(hex).join(":") + + "::" + + g + .slice(best + bestLen) + .map(hex) + .join(":") + ); +} + +export function parseIpv6(s) { + s = s.trim().toLowerCase(); + if (s === "") throw new Error("empty IPv6 address"); + let head = s, + tail = null; + const dc = s.indexOf("::"); + if (dc >= 0) { + head = s.slice(0, dc); + tail = s.slice(dc + 2); + if (tail.includes("::")) + throw new Error('invalid IPv6 address "' + s + '"'); + } + const parseGroups = (part) => { + if (part === "") return []; + const out = []; + for (const grp of part.split(":")) { + if (grp.includes(".")) { + const v4 = parseIpv4(grp); // embedded IPv4, e.g. ::ffff:1.2.3.4 + out.push((v4[0] << 8) | v4[1], (v4[2] << 8) | v4[3]); + } else { + if (!/^[0-9a-f]{1,4}$/.test(grp)) + throw new Error('invalid IPv6 address "' + s + '"'); + out.push(parseInt(grp, 16)); + } + } + return out; + }; + const h = parseGroups(head); + const t = tail === null ? [] : parseGroups(tail); + let groups; + if (tail !== null) { + if (h.length + t.length > 7) + throw new Error('invalid IPv6 address "' + s + '"'); + groups = h.concat(new Array(8 - h.length - t.length).fill(0), t); + } else { + if (h.length !== 8) throw new Error('invalid IPv6 address "' + s + '"'); + groups = h; + } + const b = new Uint8Array(16); + for (let i = 0; i < 8; i++) { + b[2 * i] = groups[i] >>> 8; + b[2 * i + 1] = groups[i] & 0xff; + } + return b; +} + +// --- TXT --- + +function formatTxt(rdata) { + const parts = []; + let o = 0; + while (o < rdata.length) { + const len = rdata[o++]; + if (o + len > rdata.length) throw new Error("malformed TXT rdata"); + parts.push(quoteString(rdata.subarray(o, o + len))); + o += len; + } + return parts.length ? parts.join(" ") : '""'; +} + +function quoteString(b) { + let s = '"'; + for (const c of b) { + if (c === 0x22 || c === 0x5c) + s += "\\" + String.fromCharCode(c); // " and \ + else if (c < 0x20 || c > 0x7e) s += "\\" + String(c).padStart(3, "0"); + else s += String.fromCharCode(c); + } + return s + '"'; +} + +// Accepts either master-file style quoted strings ("foo" "bar", with \" \\ +// and \DDD escapes) or, when the input does not start with a quote, raw text +// that is split into 255-byte chunks automatically. +export function parseTxt(text) { + const strings = []; + const t = text.trim(); + if (t.startsWith('"')) { + let i = 0; + while (i < t.length) { + while (i < t.length && (t[i] === " " || t[i] === "\t")) i++; + if (i >= t.length) break; + if (t[i] !== '"') throw new Error("expected opening quote in TXT data"); + i++; + const bytes = []; + while (i < t.length && t[i] !== '"') { + if (t[i] === "\\") { + i++; + if (i >= t.length) throw new Error("dangling backslash in TXT data"); + if (/[0-9]/.test(t[i])) { + const dec = t.slice(i, i + 3); + if (!/^[0-9]{3}$/.test(dec)) + throw new Error("invalid \\DDD escape in TXT data"); + const v = parseInt(dec, 10); + if (v > 255) throw new Error("invalid \\DDD escape in TXT data"); + bytes.push(v); + i += 3; + } else { + bytes.push(...te.encode(t[i])); + i++; + } + } else { + bytes.push(...te.encode(t[i])); + i++; + } + } + if (t[i] !== '"') throw new Error("unterminated string in TXT data"); + i++; + if (bytes.length > 255) throw new Error("TXT string exceeds 255 bytes"); + strings.push(Uint8Array.from(bytes)); + } + } else { + const b = te.encode(t); + for (let i = 0; i < b.length; i += 255) + strings.push(b.subarray(i, i + 255)); + } + if (strings.length === 0) strings.push(new Uint8Array(0)); + + let total = 0; + for (const s of strings) total += 1 + s.length; + const out = new Uint8Array(total); + let o = 0; + for (const s of strings) { + out[o++] = s.length; + out.set(s, o); + o += s.length; + } + return out; +} + +// --- SSHFP (RFC 4255) --- + +// Presentation format: +function formatSshfp(rdata) { + if (rdata.length < 3) throw new Error("malformed SSHFP rdata"); + let hex = ""; + for (let i = 2; i < rdata.length; i++) + hex += rdata[i].toString(16).padStart(2, "0"); + return rdata[0] + " " + rdata[1] + " " + hex; +} + +export function parseSshfp(text) { + const m = /^(\d+)\s+(\d+)\s+([0-9a-fA-F\s]+)$/.exec(text.trim()); + if (!m) { + throw new Error( + 'invalid SSHFP data (expected " ")', + ); + } + const algo = Number(m[1]); + const fptype = Number(m[2]); + if (algo > 255) throw new Error("SSHFP algorithm must be 0-255"); + if (fptype > 255) throw new Error("SSHFP fingerprint type must be 0-255"); + const hex = m[3].replace(/\s+/g, "").toLowerCase(); + if (hex.length === 0 || hex.length % 2 !== 0) { + throw new Error("SSHFP fingerprint must be an even number of hex digits"); + } + const b = new Uint8Array(2 + hex.length / 2); + b[0] = algo; + b[1] = fptype; + for (let i = 0; i < hex.length / 2; i++) { + b[2 + i] = parseInt(hex.slice(2 * i, 2 * i + 2), 16); + } + return b; +} + +// --- RFC 3597 generic rdata --- + +export function genericToText(rdata) { + let hex = ""; + for (const b of rdata) hex += b.toString(16).padStart(2, "0"); + return "\\# " + rdata.length + (rdata.length ? " " + hex : ""); +} + +export function parseGeneric(text) { + const m = /^\\#\s+(\d+)\s*([0-9a-fA-F\s]*)$/.exec(text.trim()); + if (!m) throw new Error('invalid generic rdata (expected "\\# ")'); + const len = Number(m[1]); + const hex = m[2].replace(/\s+/g, ""); + if (hex.length !== len * 2) throw new Error("generic rdata length mismatch"); + const b = new Uint8Array(len); + for (let i = 0; i < len; i++) + b[i] = parseInt(hex.slice(2 * i, 2 * i + 2), 16); + return b; +} diff --git a/js/tsig.js b/js/tsig.js new file mode 100644 index 0000000..755570f --- /dev/null +++ b/js/tsig.js @@ -0,0 +1,351 @@ +// SPDX-FileCopyrightText: 2026 Famedly GmbH (info@famedly.com) +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// TSIG (RFC 8945) request signing and response verification using Web +// Crypto HMAC. +// +// A TsigSession covers one signed request and its response(s): it keeps +// the request MAC and, for multi-message answers such as AXFR, the running +// MAC so every received message is authenticated (RFC 8945 Section 5.3.1 +// and 5.4). + +import { + Writer, + nameToWire, + readName, + TYPE_TSIG, + CLASS_ANY, +} from "./dnswire.js"; + +const ALGORITHMS = { + "hmac-sha1": "SHA-1", + "hmac-sha256": "SHA-256", + "hmac-sha384": "SHA-384", + "hmac-sha512": "SHA-512", +}; + +const HASH_BYTES = { + "SHA-1": 20, + "SHA-256": 32, + "SHA-384": 48, + "SHA-512": 64, +}; + +export const TSIG_ERRORS = { + 16: "BADSIG", + 17: "BADKEY", + 18: "BADTIME", + 22: "BADTRUNC", +}; + +// Parse "hmac-sha256:keyname:base64secret" (same order as `nsupdate -y`). +export function parseTsigSpec(spec) { + const parts = spec.split(":"); + if (parts.length < 3) { + throw new Error( + 'TSIG secret must be given as "hmac-sha256:keyname:base64secret"', + ); + } + const algo = parts[0].trim().toLowerCase(); + const name = parts[1].trim().toLowerCase().replace(/\.$/, ""); + const secretB64 = parts.slice(2).join(":").trim(); + if (!name) throw new Error("TSIG key name is empty"); + if (!(algo in ALGORITHMS)) { + throw new Error( + 'unsupported TSIG algorithm "' + + algo + + '" (supported: ' + + Object.keys(ALGORITHMS).join(", ") + + ")", + ); + } + let secret; + try { + secret = b64decode(secretB64); + } catch (e) { + throw new Error("TSIG secret is not valid base64"); + } + if (secret.length === 0) throw new Error("TSIG secret is empty"); + return { name, algo, hash: ALGORITHMS[algo], secret, cryptoKey: null }; +} + +function b64decode(s) { + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +async function hmac(key, data) { + if (!key.cryptoKey) { + key.cryptoKey = await crypto.subtle.importKey( + "raw", + key.secret, + { name: "HMAC", hash: key.hash }, + false, + ["sign"], + ); + } + return new Uint8Array(await crypto.subtle.sign("HMAC", key.cryptoKey, data)); +} + +// Sign a complete DNS message: returns the new message with the TSIG RR +// appended to the additional section, plus the request MAC needed to +// verify the response. +async function signWithMac(msg, key) { + const now = Math.floor(Date.now() / 1000); + const fudge = 300; + const keyWire = nameToWire(key.name); + const algWire = nameToWire(key.algo); + + // Digest input: full message followed by the TSIG variables. + const v = new Writer(msg.length + keyWire.length + algWire.length + 20); + v.bytes(msg); + v.bytes(keyWire).u16(CLASS_ANY).u32(0); + v.bytes(algWire).u48(now).u16(fudge); + v.u16(0).u16(0); // error, other-len + const mac = await hmac(key, v.done()); + + const rd = new Writer(algWire.length + mac.length + 16); + rd.bytes(algWire).u48(now).u16(fudge); + rd.u16(mac.length).bytes(mac); + rd.u16((msg[0] << 8) | msg[1]); // original ID + rd.u16(0).u16(0); // error, other-len + const rdata = rd.done(); + + const out = new Writer(msg.length + keyWire.length + rdata.length + 10); + out.bytes(msg); + out.bytes(keyWire).u16(TYPE_TSIG).u16(CLASS_ANY).u32(0); + out.u16(rdata.length).bytes(rdata); + const signed = out.done(); + + // Bump ARCOUNT. + const arcount = ((signed[10] << 8) | signed[11]) + 1; + signed[10] = (arcount >>> 8) & 0xff; + signed[11] = arcount & 0xff; + return { signed, mac }; +} + +// Sign a complete DNS message: returns a new message with the TSIG RR +// appended to the additional section. Prefer TsigSession when the response +// should be verified as well. +export async function signMessage(msg, key) { + return (await signWithMac(msg, key)).signed; +} + +// Locate and parse the TSIG RR in a wire-format message. Returns null when +// the message carries no TSIG; throws when a TSIG RR is misplaced or +// malformed. `rrStart` is the byte offset of the TSIG RR: everything +// before it is the part of the message covered by the MAC. +function locateTsig(msg) { + if (msg.length < 12) throw new Error("DNS message shorter than header"); + const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength); + const counts = [ + dv.getUint16(4), + dv.getUint16(6), + dv.getUint16(8), + dv.getUint16(10), + ]; + let off = 12; + for (let i = 0; i < counts[0]; i++) { + off = readName(msg, off)[1] + 4; + } + const nrrs = counts[1] + counts[2] + counts[3]; + let tsig = null; + for (let i = 0; i < nrrs; i++) { + const rrStart = off; + const [owner, e] = readName(msg, off); + off = e; + if (off + 10 > msg.length) throw new Error("truncated resource record"); + const type = dv.getUint16(off); + const cls = dv.getUint16(off + 2); + const ttl = dv.getUint32(off + 4); + const rdlen = dv.getUint16(off + 8); + const rd = off + 10; + off = rd + rdlen; + if (off > msg.length) throw new Error("truncated rdata"); + if (type !== TYPE_TSIG) continue; + if (tsig) throw new Error("multiple TSIG records in message"); + if (i !== nrrs - 1 || counts[3] === 0) { + throw new Error("TSIG RR is not the last record of the message"); + } + const [algName, ao] = readName(msg, rd); + if (ao + 10 > off) throw new Error("truncated TSIG rdata"); + const time = dv.getUint16(ao) * 0x100000000 + dv.getUint32(ao + 2); + const fudge = dv.getUint16(ao + 6); + const macSize = dv.getUint16(ao + 8); + const p = ao + 10 + macSize; + if (p + 6 > off) throw new Error("truncated TSIG rdata"); + const mac = msg.slice(ao + 10, p); + const origId = dv.getUint16(p); + const error = dv.getUint16(p + 2); + const otherLen = dv.getUint16(p + 4); + if (p + 6 + otherLen > off) throw new Error("truncated TSIG rdata"); + const other = msg.slice(p + 6, p + 6 + otherLen); + tsig = { + rrStart, + keyName: owner, + cls, + ttl, + algName, + time, + fudge, + mac, + origId, + error, + other, + }; + } + return tsig; +} + +function macEqual(a, b) { + if (a.length !== b.length) return false; + let d = 0; + for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i]; + return d === 0; +} + +// One signed request/response exchange (RFC 8945). sign() the outgoing +// message once, then verify() every received message in order. For +// multi-message answers (AXFR) up to 99 unsigned messages between signed +// ones are tolerated; they are folded into the running digest and are +// authenticated by the next signed message. +export class TsigSession { + constructor(key) { + this.key = key; + this.requestMac = null; // MAC of the signed request + this.priorMac = null; // running MAC: last TSIG MAC seen + this.pending = []; // unsigned messages since the last TSIG + this.unsigned = 0; // count of `pending` + this.signedSeen = 0; // verified TSIG records so far + } + + async sign(msg) { + const { signed, mac } = await signWithMac(msg, this.key); + this.requestMac = mac; + this.priorMac = mac; + return signed; + } + + // Verify one response message. Returns { signed }: false means the + // message carried no TSIG and its authentication is deferred to the next + // signed message in the stream. Throws on any verification failure. + async verify(raw) { + if (!this.requestMac) { + throw new Error("TsigSession.verify() called before sign()"); + } + const t = locateTsig(raw); + if (!t) { + if (this.signedSeen === 0) { + throw new Error("response is not TSIG-signed"); + } + if (++this.unsigned > 99) { + throw new Error("more than 99 unsigned messages in TSIG-signed stream"); + } + this.pending.push(raw); + return { signed: false }; + } + + const keyName = t.keyName.toLowerCase().replace(/\.$/, ""); + if (keyName !== this.key.name) { + throw new Error('response TSIG uses unexpected key "' + keyName + '"'); + } + const algName = t.algName.toLowerCase().replace(/\.$/, ""); + if (algName !== this.key.algo) { + throw new Error( + 'response TSIG uses unexpected algorithm "' + algName + '"', + ); + } + if (t.cls !== CLASS_ANY || t.ttl !== 0) { + throw new Error("malformed TSIG RR (class/TTL)"); + } + + const errName = + t.error === 0 ? null : TSIG_ERRORS[t.error] || "TSIG error " + t.error; + if (t.mac.length === 0) { + // Unsigned error response (RFC 8945 Section 5.3.2): it cannot be + // authenticated, but surface the server's reason. + throw new Error( + "server rejected TSIG: " + (errName || "unsigned TSIG in response"), + ); + } + const hashLen = HASH_BYTES[this.key.hash]; + if (t.mac.length > hashLen) { + throw new Error("TSIG MAC longer than " + this.key.algo + " output"); + } + if (t.mac.length < Math.max(10, hashLen / 2)) { + throw new Error("TSIG MAC shorter than allowed truncation"); + } + + // The digested form of the message: TSIG RR removed, ARCOUNT + // decremented, original ID restored (RFC 8945 Section 4.3.2). + const dm = raw.slice(0, t.rrStart); + dm[0] = (t.origId >>> 8) & 0xff; + dm[1] = t.origId & 0xff; + const arcount = ((dm[10] << 8) | dm[11]) - 1; + if (arcount < 0) throw new Error("TSIG RR not counted in ARCOUNT"); + dm[10] = (arcount >>> 8) & 0xff; + dm[11] = arcount & 0xff; + + const w = new Writer(raw.length + 64); + w.u16(this.priorMac.length).bytes(this.priorMac); + for (const m of this.pending) w.bytes(m); + w.bytes(dm); + if (this.signedSeen === 0) { + // First signed response digests the full TSIG variables... + w.bytes(nameToWire(keyName)).u16(CLASS_ANY).u32(0); + w.bytes(nameToWire(algName)).u48(t.time).u16(t.fudge); + w.u16(t.error).u16(t.other.length).bytes(t.other); + } else { + // ...subsequent ones only the timers (RFC 8945 Section 5.3.1). + w.u48(t.time).u16(t.fudge); + } + const full = await hmac(this.key, w.done()); + if (!macEqual(full.subarray(0, t.mac.length), t.mac)) { + throw new Error( + "response TSIG MAC verification failed" + + (errName ? " (server reported " + errName + ")" : ""), + ); + } + + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - t.time) > t.fudge) { + throw new Error( + "response TSIG time out of range (server " + + t.time + + ", client " + + now + + ", fudge " + + t.fudge + + "); check clock synchronization", + ); + } + if (errName) { + throw new Error("server reported TSIG error: " + errName); + } + + this.priorMac = t.mac; + this.pending = []; + this.unsigned = 0; + this.signedSeen++; + return { signed: true }; + } +} + +// Extract the TSIG error code from a TSIG RR's rdata; returns a text like +// "BADKEY" or null if the record carries no error. +export function tsigErrorName(rdata) { + try { + const [, o] = readName(rdata, 0); // algorithm name + // time(6) fudge(2) macsize(2) mac(n) origid(2) error(2) + const macSize = (rdata[o + 8] << 8) | rdata[o + 9]; + const errOff = o + 10 + macSize + 2; + const err = (rdata[errOff] << 8) | rdata[errOff + 1]; + if (!err) return null; + return TSIG_ERRORS[err] || "TSIG error " + err; + } catch (e) { + return null; + } +} diff --git a/js/vlist.js b/js/vlist.js new file mode 100644 index 0000000..8f74d34 --- /dev/null +++ b/js/vlist.js @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2026 Famedly GmbH (info@famedly.com) +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Minimal virtual list: renders only the visible slice of a large row set +// into a small, reused pool of DOM elements. Rows have a fixed height. + +export class VirtualList { + constructor(viewport, rowHeight, renderRow) { + this.viewport = viewport; // scrollable element + this.rowHeight = rowHeight; + this.renderRow = renderRow; // (index, rowElement) => void + this.count = 0; + this.pool = []; + + this.inner = document.createElement("div"); + this.inner.className = "vlist-inner"; + viewport.appendChild(this.inner); + + viewport.addEventListener("scroll", () => this._update(false)); + if (typeof ResizeObserver !== "undefined") { + new ResizeObserver(() => this._update(false)).observe(viewport); + } + } + + setCount(n) { + this.count = n; + this.inner.style.height = n * this.rowHeight + "px"; + this._update(true); + } + + refresh() { + this._update(true); + } + + _ensurePool(n) { + while (this.pool.length < n) { + const el = document.createElement("div"); + el.className = "vlist-row"; + el.style.height = this.rowHeight + "px"; + el._idx = -1; + this.inner.appendChild(el); + this.pool.push(el); + } + } + + _update(force) { + const { scrollTop, clientHeight } = this.viewport; + const overscan = 4; + const first = Math.max( + 0, + Math.floor(scrollTop / this.rowHeight) - overscan, + ); + const last = Math.min( + this.count, + Math.ceil((scrollTop + clientHeight) / this.rowHeight) + overscan, + ); + const needed = Math.max(0, last - first); + this._ensurePool(needed); + for (let j = 0; j < this.pool.length; j++) { + const el = this.pool[j]; + if (j < needed) { + const idx = first + j; + el.style.display = ""; + el.style.top = idx * this.rowHeight + "px"; + if (force || el._idx !== idx) { + el._idx = idx; + this.renderRow(idx, el); + } + } else if (el._idx !== -1) { + el.style.display = "none"; + el._idx = -1; + } + } + } +} diff --git a/js/zonestore.js b/js/zonestore.js new file mode 100644 index 0000000..7c71338 --- /dev/null +++ b/js/zonestore.js @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: 2026 Famedly GmbH (info@famedly.com) +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Compact in-memory zone representation. +// +// All records live in a single growable Uint8Array arena; a Uint32Array holds +// one offset per record. Owner names are stored with the zone suffix stripped +// ('' = zone apex, trailing dot = out-of-zone absolute name) and rdata is kept +// in raw (uncompressed) wire format. Per-record layout in the arena: +// +// u8 name length +// .. name (UTF-8, relative to zone) +// u16 rrtype +// u32 ttl +// u16 rdata length +// .. rdata (wire format) +// +// The class is not stored: zones transferred via AXFR are single-class (IN). +// Replacing or removing records leaves garbage in the arena; it is reclaimed +// on the next zone reload, which is fine for an editing session. + +const te = new TextEncoder(); +const td = new TextDecoder(); + +const DOT = 0x2e; // '.' + +function lowerByte(b) { + return b >= 0x41 && b <= 0x5a ? b + 0x20 : b; +} + +// Compare two relative DNS names label-wise from the rightmost label +// ("reversed name"), case-insensitively. '' (the apex) sorts first. Reads +// directly from arena bytes at [oa, oa+na) / [ob, ob+nb) -- no decoding or +// splitting -- to keep the display-order sort allocation-free. +function compareRevNameBytes(arena, oa, na, ob, nb) { + if (na === 0 && nb === 0) return 0; + if (na === 0) return -1; + if (nb === 0) return 1; + // Absolute (out-of-zone) names carry a trailing dot; ignore it so it does + // not sort before real labels (equivalent to dropping the trailing empty + // element that String.split('.') would produce). + const ea = oa + na - (arena[oa + na - 1] === DOT ? 1 : 0); + const eb = ob + nb - (arena[ob + nb - 1] === DOT ? 1 : 0); + let ia = ea - 1; + let ib = eb - 1; + for (;;) { + let sa = ia; + while (sa >= oa && arena[sa] !== DOT) sa--; + let sb = ib; + while (sb >= ob && arena[sb] !== DOT) sb--; + let pa = sa + 1; + let pb = sb + 1; + while (pa <= ia && pb <= ib) { + const ca = lowerByte(arena[pa]); + const cb = lowerByte(arena[pb]); + if (ca !== cb) return ca - cb; + pa++; + pb++; + } + if (pa <= ia) return 1; // b's label is a proper prefix of a's -> a > b + if (pb <= ib) return -1; // a's label is a proper prefix of b's -> a < b + ia = sa - 1; + ib = sb - 1; + const aDone = ia < oa; + const bDone = ib < ob; + if (aDone && bDone) return 0; + if (aDone) return -1; // shared suffix: fewer labels first + if (bDone) return 1; + } +} + +export class ZoneStore { + constructor(zone) { + this.zone = zone.toLowerCase().replace(/\.+$/, ""); + this.zoneFqdn = this.zone === "" ? "." : this.zone + "."; + this.arena = new Uint8Array(1 << 16); + this.arenaLen = 0; + this.offsets = new Uint32Array(1024); + this.count = 0; + this.serial = null; // SOA serial, tracked by the app + } + + clear() { + this.arenaLen = 0; + this.count = 0; + this.serial = null; + } + + // 'www.example.com.' -> 'www'; apex -> ''; out-of-zone names are kept + // absolute (trailing dot). + relativize(fqdn) { + const lower = fqdn.toLowerCase(); + if (lower === this.zoneFqdn) return ""; + const suffix = "." + this.zoneFqdn; + if (lower.endsWith(suffix)) + return fqdn.slice(0, fqdn.length - suffix.length); + return fqdn; + } + + absolutize(rel) { + if (rel === "" || rel === "@") return this.zoneFqdn; + if (rel.endsWith(".")) return rel; + return rel + "." + this.zoneFqdn; + } + + _growArena(need) { + if (this.arenaLen + need <= this.arena.length) return; + let cap = this.arena.length * 2; + while (cap < this.arenaLen + need) cap *= 2; + const b = new Uint8Array(cap); + b.set(this.arena.subarray(0, this.arenaLen)); + this.arena = b; + } + + _encode(nameRel, type, ttl, rdata) { + const nb = te.encode(nameRel); + if (nb.length > 255) throw new Error("name too long"); + if (rdata.length > 0xffff) throw new Error("rdata too long"); + this._growArena(1 + nb.length + 8 + rdata.length); + const a = this.arena; + let o = this.arenaLen; + a[o++] = nb.length; + a.set(nb, o); + o += nb.length; + a[o++] = (type >>> 8) & 0xff; + a[o++] = type & 0xff; + a[o++] = (ttl >>> 24) & 0xff; + a[o++] = (ttl >>> 16) & 0xff; + a[o++] = (ttl >>> 8) & 0xff; + a[o++] = ttl & 0xff; + a[o++] = (rdata.length >>> 8) & 0xff; + a[o++] = rdata.length & 0xff; + a.set(rdata, o); + o += rdata.length; + const off = this.arenaLen; + this.arenaLen = o; + return off; + } + + add(nameRel, type, ttl, rdata) { + if (this.count === this.offsets.length) { + const b = new Uint32Array(this.offsets.length * 2); + b.set(this.offsets); + this.offsets = b; + } + this.offsets[this.count] = this._encode(nameRel, type, ttl, rdata); + return this.count++; + } + + replace(i, nameRel, type, ttl, rdata) { + if (i < 0 || i >= this.count) throw new Error("record index out of range"); + this.offsets[i] = this._encode(nameRel, type, ttl, rdata); + } + + remove(i) { + if (i < 0 || i >= this.count) throw new Error("record index out of range"); + this.offsets.copyWithin(i, i + 1, this.count); + this.count--; + } + + // Display-order comparator for two record indices, reading straight from + // the arena: sorts by (revname, type, ttl). revname is the DNS name + // compared alphabetically with its labels reversed, so names group by + // subtree; the apex ('', shown as '@') sorts before all other names. + // + // Reads bytes directly out of the arena (no this.get(), no string + // decoding/splitting) since this runs O(n log n) times per rebuildOrder() + // and allocation was the dominant cost for large zones. + compareRecords(ia, ib) { + const a = this.arena; + const oa = this.offsets[ia]; + const ob = this.offsets[ib]; + const nlenA = a[oa]; + const nlenB = a[ob]; + const c = compareRevNameBytes(a, oa + 1, nlenA, ob + 1, nlenB); + if (c !== 0) return c; + let pa = oa + 1 + nlenA; + let pb = ob + 1 + nlenB; + const typeA = (a[pa] << 8) | a[pa + 1]; + const typeB = (a[pb] << 8) | a[pb + 1]; + if (typeA !== typeB) return typeA - typeB; + pa += 2; + pb += 2; + const ttlA = + ((a[pa] << 24) | (a[pa + 1] << 16) | (a[pa + 2] << 8) | a[pa + 3]) >>> 0; + const ttlB = + ((a[pb] << 24) | (a[pb + 1] << 16) | (a[pb + 2] << 8) | a[pb + 3]) >>> 0; + return ttlA - ttlB; + } + + // Decode record i. `rdata` is a view into the arena: valid until the store + // is cleared, but stays intact across add/replace/remove. + get(i) { + if (i < 0 || i >= this.count) throw new Error("record index out of range"); + const a = this.arena; + let o = this.offsets[i]; + const nlen = a[o++]; + const name = td.decode(a.subarray(o, o + nlen)); + o += nlen; + const type = (a[o] << 8) | a[o + 1]; + const ttl = + ((a[o + 2] << 24) | (a[o + 3] << 16) | (a[o + 4] << 8) | a[o + 5]) >>> 0; + const rdlen = (a[o + 6] << 8) | a[o + 7]; + o += 8; + return { name, type, ttl, rdata: a.subarray(o, o + rdlen) }; + } +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..9111563 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,92 @@ +# SPDX-FileCopyrightText: 2026 Famedly GmbH (info@famedly.com) +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +{ inputs, ... }: +{ + perSystem = + { config, pkgs, ... }: + let + rev = inputs.self.rev; + + # The existing upstream Alpine-based static-web-server image; + # static-web-server is deliberately not built with nix. Pinned by + # digest, so update imageDigest and hash together (e.g. with + # nix-prefetch-docker). + static-web-server-image = pkgs.dockerTools.pullImage { + imageName = "ghcr.io/static-web-server/static-web-server"; + finalImageTag = "2-alpine"; + imageDigest = "sha256:9ddec0e5fb0320cbba2ded016fc96179c5a891feec1b2049a331bc190c08008f"; + hash = "sha256-LrLfLRcqOJKfTudCbvx4TcG/Q7cVVlXOiW2NOuj/+2M="; + os = "linux"; + }; + in + { + packages = { + admin2136 = pkgs.stdenvNoCC.mkDerivation { + nativeBuildInputs = [ pkgs.mustache-go ]; + data = builtins.toJSON { git_commit_hash = rev; }; + passAsFile = [ "data" ]; + + pname = "admin2136"; + version = "0.1.0"; + + src = pkgs.lib.fileset.toSource { + root = ../.; + fileset = pkgs.lib.fileset.unions [ + ../style.css + ../js + ]; + }; + + dontBuild = true; + + installPhase = '' + runHook preInstall + + mkdir -p $out + mustache "$dataPath" ${../index.html} > $out/index.html + cp -r style.css js $out/ + + runHook postInstall + ''; + + meta.description = "AXFR / RFC 2136 zone editor"; + }; + + default = config.packages.admin2136; + + docker-image = pkgs.dockerTools.buildLayeredImage { + name = "admin2136"; + tag = "latest"; + + fromImage = static-web-server-image; + + # Add the site as a real directory (not a /nix/store + # symlink), so the image stays nothing but the upstream image + # plus one layer of static files. + fakeRootCommands = '' + mkdir -p ./public + cp -r ${config.packages.admin2136}/. ./public/ + ''; + + # buildLayeredImage does not inherit the base image + # configuration, so restate it (see docker/alpine/Dockerfile + # and docker/alpine/entrypoint.sh upstream), pointing the + # server at the site directory. + config = { + User = "sws:sws"; + WorkingDir = "/home/sws"; + Entrypoint = [ "/usr/local/bin/entrypoint.sh" ]; + Cmd = [ "static-web-server" ]; + Env = [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + "SERVER_ROOT=/public" + ]; + ExposedPorts."80/tcp" = { }; + StopSignal = "SIGQUIT"; + }; + }; + }; + }; +} diff --git a/nix/workflows/build.nix b/nix/workflows/build.nix new file mode 100644 index 0000000..bd64763 --- /dev/null +++ b/nix/workflows/build.nix @@ -0,0 +1,145 @@ +# SPDX-FileCopyrightText: 2026 Famedly GmbH (info@famedly.com) +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +{ config, ... }: +let + allowed-actions = config.famedly.standards.allowed-action-versions; + + # Not (yet) part of the engineering standards' allow-list + # (`standards/allowed-github-actions.toml`), so we pin them here the + # same way: by full commit SHA. + # + # rev = "v7.0.1" + upload-artifact = "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"; + # rev = "v8.0.1" + download-artifact = "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"; +in +{ + perSystem.githubActions.workflows.build = { + name = "Build"; + + on.pullRequest = { + branches = [ "**" ]; + types = [ + "opened" + "reopened" + "synchronize" + "ready_for_review" + ]; + }; + on.mergeGroup = { }; + on.push = { + branches = [ "main" ]; + tags = [ "v*" ]; + }; + + concurrency = { + group = "\${{ github.workflow }}-\${{ github.ref }}"; + cancelInProgress = true; + }; + + jobs.build = { + strategy.matrix.include = [ + { + arch = "x86_64"; + runner = "ubuntu-26.04"; + } + { + arch = "aarch64"; + runner = "ubuntu-26.04-arm"; + } + ]; + + steps = [ + { uses = allowed-actions."actions/checkout".uses; } + { uses = allowed-actions."cachix/install-nix-action".uses; } + + { + name = "Build static site"; + run = "nix build .#admin2136 --print-build-logs -o result-site"; + } + + { + # The image is the existing upstream Alpine-based + # static-web-server image with the site files layered on top; + # static-web-server itself is not built with nix. + name = "Build Docker image"; + run = "nix build .#docker-image --print-build-logs"; + } + + { + name = "Upload Docker image as artifact"; + uses = upload-artifact; + with_ = { + name = "docker-image-\${{ matrix.arch }}"; + path = "result"; + if-no-files-found = "error"; + }; + } + ]; + }; + + # Push images for all builds: version tags go to the release + # registry (docker-oss), everything else (PRs, main) goes to the + # nightly registry (docker-nightly). The per-architecture images + # are combined into a single multi-arch manifest. + jobs.docker = { + runsOn = "ubuntu-latest"; + if_ = "github.event_name == 'push' || github.event_name == 'pull_request'"; + needs = [ "build" ]; + + steps = [ + { + name = "Download Docker images"; + uses = download-artifact; + with_ = { + pattern = "docker-image-*"; + path = "artifacts"; + }; + } + + { + name = "Push multi-arch Docker manifest to registry"; + env = { + REGISTRY_USER = "\${{ vars.REGISTRY_USER }}"; + REGISTRY_PASSWORD = "\${{ secrets.registry_password || secrets.GITHUB_TOKEN }}"; + TAG = "\${{ github.head_ref || github.ref_name || 'latest' }}"; + }; + run = '' + if [[ "$GITHUB_REF_NAME" =~ v[0-9]+\.[0-9]+\.[0-9]+ ]]; then + registry=registry.famedly.net/docker-oss + else + registry=registry.famedly.net/docker-nightly + fi + + echo "$REGISTRY_PASSWORD" \ + | podman login registry.famedly.net -u "$REGISTRY_USER" --password-stdin + + image="$registry/admin2136" + # Branch names may contain slashes, which are not valid in + # Docker tags. + tag="''${TAG//\//-}" + + # Combine the per-arch images into a multi-arch manifest. + # Every `podman load` overwrites `admin2136:latest`, so retag + # each image with an arch suffix before loading the next. + podman manifest create admin2136-multiarch + for arch in x86_64 aarch64; do + podman load < "artifacts/docker-image-$arch/result" + podman tag admin2136:latest "admin2136:$arch" + podman manifest add admin2136-multiarch "containers-storage:localhost/admin2136:$arch" + done + + # `--all` pushes the per-arch images along with the + # manifest (by digest only, so no arch-specific tags show + # up in the registry). Publish under both the branch/tag + # name and the commit SHA. + podman manifest push --all admin2136-multiarch "docker://$image:$tag" + podman manifest push --all admin2136-multiarch "docker://$image:$GITHUB_SHA" + ''; + } + ]; + }; + }; +} diff --git a/nix/workflows/tests.nix b/nix/workflows/tests.nix new file mode 100644 index 0000000..85d254a --- /dev/null +++ b/nix/workflows/tests.nix @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: 2026 Famedly GmbH (info@famedly.com) +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +{ config, ... }: +let + allowed-actions = config.famedly.standards.allowed-action-versions; +in +{ + perSystem.githubActions.workflows.tests = { + name = "Run tests"; + + on.pullRequest = { + branches = [ "**" ]; + types = [ + "opened" + "reopened" + "synchronize" + "ready_for_review" + ]; + }; + on.mergeGroup = { }; + + concurrency = { + group = "\${{ github.workflow }}-\${{ github.ref }}"; + cancelInProgress = true; + }; + + jobs.smoke = { + runsOn = "ubuntu-latest"; + + steps = [ + { uses = allowed-actions."actions/checkout".uses; } + { uses = allowed-actions."cachix/install-nix-action".uses; } + + { + name = "Run tests"; + shell = "nix develop --command bash {0}"; + run = "node test/smoke.mjs"; + } + ]; + }; + }; +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..67dcd9c --- /dev/null +++ b/style.css @@ -0,0 +1,366 @@ +/* + * SPDX-FileCopyrightText: 2026 Famedly GmbH (info@famedly.com) + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +:root { + --bg: #14171c; + --bg-alt: #1c2027; + --bg-hover: #242a33; + --border: #303743; + --fg: #d7dde6; + --fg-dim: #8792a2; + --accent: #4d9fff; + --accent-fg: #ffffff; + --danger: #ff6b6b; + --ok: #5fd38a; + --mono: + ui-monospace, "SF Mono", "Cascadia Mono", Consolas, Menlo, monospace; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--fg); + font: + 14px/1.4 system-ui, + sans-serif; +} + +body { + display: flex; + flex-direction: column; +} + +button { + font: inherit; + color: var(--fg); + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 12px; + cursor: pointer; +} +button:hover:not(:disabled) { + background: var(--bg-hover); +} +button:disabled { + opacity: 0.4; + cursor: default; +} +button[type="submit"] { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-fg); +} +button[type="submit"]:hover:not(:disabled) { + filter: brightness(1.1); + background: var(--accent); +} + +input, +select, +textarea { + font: inherit; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 8px; + width: 100%; +} +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: var(--accent); +} +textarea { + font-family: var(--mono); + resize: vertical; +} + +label { + display: block; + margin-bottom: 12px; + color: var(--fg-dim); + font-size: 13px; +} +label > input, +label > select, +label > textarea { + margin-top: 4px; +} + +.status { + margin-top: 10px; + color: var(--fg-dim); + white-space: pre-wrap; +} + +/* ---- login ---- */ + +#login-view { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} +/* The display:flex above would override the UA's [hidden] rule. */ +#login-view[hidden] { + display: none; +} +#login-form { + /* margin:auto centers, but unlike align-items it never clips the top + of the form on small viewports when the view scrolls */ + margin: auto; + width: 420px; + max-width: 100%; + background: var(--bg-alt); + border: 1px solid var(--border); + border-radius: 12px; + padding: 28px; +} +/* Shift the button down a bit: the 8px added above are taken from the + status line's margin below, so the form height stays the same. */ +#login-form button[type="submit"] { + width: 100%; + padding: 8px; + margin-top: 8px; +} +#login-status { + margin-top: 2px; +} +#login-form input { + font-family: var(--mono); +} +#login-form label { + margin-bottom: 24px; +} +#login-form label.has-sublink { + margin-bottom: 4px; +} +.proxy-about { + font-size: 12px; + line-height: 16px; + margin: 0 0 6px; + text-align: right; +} +.proxy-about a:not([href]) { + visibility: hidden; +} + +a { + color: var(--accent); +} + +/* ---- zone view ---- */ + +#zone-view { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} +#zone-view[hidden] { + display: none; +} + +#toolbar { + display: flex; + gap: 8px; + align-items: center; + padding: 10px 14px; + background: var(--bg-alt); + border-bottom: 1px solid var(--border); +} +#brand { + display: flex; + flex-direction: column; + line-height: 1.25; + white-space: nowrap; +} +#brand-name { + font-weight: 600; + font-family: var(--mono); +} +#brand-sub { + font-size: 11px; + color: var(--fg-dim); +} +/* Zone controls only appear once a zone is loaded; the About button (and + the brand) stay visible on the login screen too. */ +#toolbar:not(.zone-active) .zone-only { + display: none; +} +#toolbar:not(.zone-active) #about-btn { + margin-left: auto; +} +/* Tall slash separating the app title from the zone title. */ +#brand-sep { + font-size: 28px; + font-weight: 300; + line-height: 1; + color: var(--fg-dim); + user-select: none; +} +#zone-title { + /* Two rows (zone name over record count / serial), matching #brand. */ + display: flex; + flex-direction: column; + line-height: 1.25; + flex: 0 1 auto; + margin-right: auto; + white-space: nowrap; + overflow: hidden; +} +#zone-name { + font-weight: 600; + font-family: var(--mono); +} +#zone-meta { + font-size: 11px; + color: var(--fg-dim); +} +#filter { + width: 260px; +} + +.record-grid { + display: grid; + grid-template-columns: minmax(140px, 1fr) 76px 96px minmax(200px, 3fr) 76px; + gap: 10px; + align-items: center; + padding: 0 14px; +} + +#list-header { + height: 30px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fg-dim); + border-bottom: 1px solid var(--border); + background: var(--bg-alt); +} + +#record-list { + flex: 1; + overflow-y: auto; + position: relative; +} +.vlist-inner { + position: relative; + width: 100%; +} +.vlist-row { + position: absolute; + left: 0; + right: 0; + border-bottom: 1px solid var(--border); + font-family: var(--mono); + font-size: 13px; +} +.vlist-row:hover { + background: var(--bg-hover); +} +.vlist-row > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.vlist-row .c-name { + text-align: right; +} +.vlist-row .c-ttl { + color: var(--fg-dim); + text-align: right; +} +.vlist-row .c-type { + color: var(--accent); +} +.vlist-row.readonly .c-type { + color: var(--fg-dim); +} +.vlist-row .c-actions { + display: flex; + gap: 4px; + justify-content: flex-end; + overflow: visible; +} +.vlist-row .c-actions button { + padding: 1px 8px; + font-size: 13px; + line-height: 1.5; + background: transparent; + border-color: transparent; +} +.vlist-row .c-actions button:hover:not(:disabled) { + background: var(--bg); + border-color: var(--border); +} +.vlist-row .row-del:hover:not(:disabled) { + color: var(--danger); +} + +#status-bar { + padding: 6px 14px; + font-size: 12px; + color: var(--fg-dim); + background: var(--bg-alt); + border-top: 1px solid var(--border); + min-height: 28px; + white-space: pre-wrap; +} +#status-bar.error { + color: var(--danger); +} +#status-bar.ok { + color: var(--ok); +} + +/* ---- edit dialog ---- */ + +dialog { + background: var(--bg-alt); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 12px; + padding: 24px; + width: 460px; + max-width: calc(100vw - 32px); +} +dialog::backdrop { + background: rgba(0, 0, 0, 0.55); +} +dialog h2 { + margin: 0 0 18px; + font-size: 18px; +} +dialog input, +dialog textarea { + font-family: var(--mono); +} +.field-row { + display: flex; + gap: 12px; +} +.field-row > label { + flex: 1; +} +.dialog-buttons { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 6px; +} diff --git a/test/smoke.mjs b/test/smoke.mjs new file mode 100644 index 0000000..f28d7ed --- /dev/null +++ b/test/smoke.mjs @@ -0,0 +1,532 @@ +// SPDX-FileCopyrightText: 2026 Famedly GmbH (info@famedly.com) +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Smoke tests for the DOM-independent modules. +// Run with: deno run test/smoke.mjs (or: node test/smoke.mjs) + +import { + Writer, + nameToWire, + readName, + buildQuery, + parseMessage, + typeToName, + nameToType, + TYPE_A, + TYPE_CNAME, + TYPE_SOA, + TYPE_TXT, + TYPE_AAAA, + TYPE_SSHFP, + TYPE_TSIG, + CLASS_IN, + CLASS_ANY, + CLASS_NONE, +} from "../js/dnswire.js"; +import { parseTsigSpec, signMessage, TsigSession } from "../js/tsig.js"; +import { buildUpdateMessage, IGNORED_RRTYPES } from "../js/dnsops.js"; +import { ZoneStore } from "../js/zonestore.js"; +import { + rdataToText, + textToRdata, + parseIpv4, + parseIpv6, + formatIpv6, + parseTxt, + genericToText, + parseGeneric, + soaSerial, +} from "../js/rdata.js"; + +let failures = 0; +function check(what, cond) { + if (!cond) { + failures++; + console.error("FAIL:", what); + } else { + console.log("ok:", what); + } +} +function eq(what, a, b) { + check( + what + " (" + JSON.stringify(a) + " == " + JSON.stringify(b) + ")", + JSON.stringify(a) === JSON.stringify(b), + ); +} +const hex = (u8) => + Array.from(u8, (b) => b.toString(16).padStart(2, "0")).join(""); + +// --- names --- +eq("nameToWire root", hex(nameToWire(".")), "00"); +eq( + "nameToWire www.example.com.", + hex(nameToWire("www.example.com.")), + "03777777076578616d706c6503636f6d00", +); +eq( + "readName roundtrip", + readName(nameToWire("a.b.example.org."), 0)[0], + "a.b.example.org.", +); + +// compression pointer: name at offset 2 pointing back to offset 12 +{ + const msg = new Uint8Array(32); + msg.set(nameToWire("example.com."), 12); + msg[2] = 0x03; + msg[3] = 0x77; + msg[4] = 0x77; + msg[5] = 0x77; // "www" + msg[6] = 0xc0; + msg[7] = 12; // pointer to offset 12 + const [n, end] = readName(msg, 2); + eq("compressed name", n, "www.example.com."); + eq("compressed name end offset", end, 8); +} + +// --- query build/parse --- +{ + const q = buildQuery(0x1234, "example.com.", TYPE_A); + const p = parseMessage(q); + eq("query id", p.id, 0x1234); + eq("query qname", p.questions[0].name, "example.com."); + eq("query qtype", p.questions[0].type, TYPE_A); +} + +// --- full message with answer parse, incl. compressed CNAME rdata --- +{ + const w = new Writer(); + w.u16(1).u16(0x8400).u16(1).u16(1).u16(0).u16(0); + w.name("example.com.").u16(TYPE_CNAME).u16(CLASS_IN); // question at offset 12 + w.name("alias.example.com.").u16(TYPE_CNAME).u16(CLASS_IN).u32(300); + w.u16(2).u8(0xc0).u8(12); // rdata: pointer to "example.com." in question + const p = parseMessage(w.done()); + eq("answer name", p.answers[0].name, "alias.example.com."); + eq( + "answer rdata decompressed", + readName(p.answers[0].rdata, 0)[0], + "example.com.", + ); +} + +// --- rdata text conversions --- +eq("A text", rdataToText(TYPE_A, Uint8Array.of(192, 0, 2, 1)), "192.0.2.1"); +eq("A parse", hex(textToRdata(TYPE_A, "192.0.2.1")), "c0000201"); +eq("AAAA roundtrip", formatIpv6(parseIpv6("2001:db8::1")), "2001:db8::1"); +eq( + "AAAA full form", + formatIpv6(parseIpv6("2001:0db8:0000:0000:0000:0000:0000:0001")), + "2001:db8::1", +); +eq("AAAA all zero", formatIpv6(parseIpv6("::")), "::"); +eq( + "AAAA embedded v4", + hex(parseIpv6("::ffff:192.0.2.1")), + "00000000000000000000ffffc0000201", +); +eq( + "TXT quoted parse", + hex(parseTxt('"hi" "yo"')), + "0268690279 6f".replace(/\s/g, ""), +); +eq("TXT display", rdataToText(TYPE_TXT, parseTxt('"a\\"b"')), '"a\\"b"'); +eq("TXT raw split", parseTxt("x".repeat(300)).length, 2 + 300); +eq( + "generic roundtrip", + hex(parseGeneric(genericToText(Uint8Array.of(1, 2, 0xff)))), + "0102ff", +); +eq( + "unknown type display", + rdataToText(999, Uint8Array.of(0xde, 0xad)), + "\\# 2 dead", +); +eq("unknown type name", typeToName(999), "TYPE999"); +eq("nameToType TYPE999", nameToType("TYPE999"), 999); +check("RRSIG is ignored", IGNORED_RRTYPES.has(nameToType("RRSIG"))); +check("NSEC is ignored", IGNORED_RRTYPES.has(nameToType("NSEC"))); +check("NSEC3 is ignored", IGNORED_RRTYPES.has(nameToType("NSEC3"))); + +// SSHFP +eq("SSHFP type number", TYPE_SSHFP, 44); +eq("SSHFP parse", hex(textToRdata(TYPE_SSHFP, "4 2 aabbCCdd")), "0402aabbccdd"); +eq( + "SSHFP display", + rdataToText(TYPE_SSHFP, Uint8Array.of(4, 2, 0xaa, 0xbb, 0xcc, 0xdd)), + "4 2 aabbccdd", +); +eq( + "SSHFP roundtrip", + rdataToText(TYPE_SSHFP, textToRdata(TYPE_SSHFP, "1 1 0123456789abcdef")), + "1 1 0123456789abcdef", +); +check( + "SSHFP rejects odd hex", + (() => { + try { + textToRdata(TYPE_SSHFP, "4 2 abc"); + return false; + } catch (e) { + return true; + } + })(), +); + +// CNAME parse with relative name +{ + const abs = (n) => (n.endsWith(".") ? n : n + ".example.com."); + eq( + "CNAME relative", + readName(textToRdata(TYPE_CNAME, "www", abs), 0)[0], + "www.example.com.", + ); + eq( + "CNAME absolute", + readName(textToRdata(TYPE_CNAME, "other.net.", abs), 0)[0], + "other.net.", + ); +} + +// --- SOA --- +{ + const w = new Writer(); + w.name("ns1.example.com.").name("hostmaster.example.com."); + w.u32(2024011501).u32(7200).u32(3600).u32(1209600).u32(300); + const soa = w.done(); + eq("soa serial", soaSerial(soa), 2024011501); + check( + "soa text contains serial", + rdataToText(TYPE_SOA, soa).includes("2024011501"), + ); +} + +// --- zone store --- +{ + const st = new ZoneStore("example.com"); + eq("relativize apex", st.relativize("EXAMPLE.com."), ""); + eq("relativize sub", st.relativize("www.example.com."), "www"); + eq("relativize foreign", st.relativize("other.net."), "other.net."); + eq("absolutize @", st.absolutize("@"), "example.com."); + eq("absolutize rel", st.absolutize("www"), "www.example.com."); + eq("absolutize abs", st.absolutize("other.net."), "other.net."); + + const i0 = st.add("www", TYPE_A, 300, Uint8Array.of(192, 0, 2, 1)); + const i1 = st.add("", TYPE_TXT, 60, parseTxt('"hello"')); + eq("store count", st.count, 2); + const r0 = st.get(i0); + eq("store get name", r0.name, "www"); + eq("store get type", r0.type, TYPE_A); + eq("store get ttl", r0.ttl, 300); + eq("store get rdata", hex(r0.rdata), "c0000201"); + st.replace(i0, "www", TYPE_A, 600, Uint8Array.of(192, 0, 2, 2)); + eq("store replace", st.get(i0).ttl, 600); + st.remove(i0); + eq("store remove count", st.count, 1); + eq("store remove shifts", st.get(0).type, TYPE_TXT); + + // stress: many records, arena growth + for (let i = 0; i < 50000; i++) { + st.add( + "host-" + i, + TYPE_A, + 300, + Uint8Array.of(10, i >> 16, (i >> 8) & 0xff, i & 0xff), + ); + } + eq("store stress count", st.count, 50001); + eq("store stress spot check", st.get(25000).name, "host-" + 24999); +} + +// --- update message --- +{ + const upd = buildUpdateMessage(7, "example.com.", [ + { + name: "www.example.com.", + type: TYPE_A, + cls: CLASS_NONE, + ttl: 0, + rdata: Uint8Array.of(192, 0, 2, 1), + }, + { + name: "www.example.com.", + type: TYPE_A, + cls: CLASS_IN, + ttl: 300, + rdata: Uint8Array.of(192, 0, 2, 2), + }, + ]); + const p = parseMessage(upd); + eq("update opcode", p.opcode, 5); + eq("update zone", p.questions[0].name, "example.com."); + eq("update zone type", p.questions[0].type, TYPE_SOA); + // prcount=0, upcount=2: update RRs occupy the "authority" section slot + eq("update count", p.authority.length, 2); + eq("update delete class", p.authority[0].cls, CLASS_NONE); + eq("update add class", p.authority[1].cls, CLASS_IN); +} + +// --- TSIG signing --- +{ + const key = parseTsigSpec("hmac-sha256:KeyName:" + btoa("secret-bytes")); + eq("tsig key name", key.name, "keyname"); + const q = buildQuery(42, "example.com.", TYPE_SOA); + const signed = await signMessage(q, key); + const p = parseMessage(signed); + eq("tsig arcount", p.additional.length, 1); + const tsig = p.additional[0]; + eq("tsig rr type", tsig.type, TYPE_TSIG); + eq("tsig rr class", tsig.cls, CLASS_ANY); + eq("tsig rr name", tsig.name, "keyname."); + const [alg, o] = readName(tsig.rdata, 0); + eq("tsig algorithm", alg, "hmac-sha256."); + const macSize = (tsig.rdata[o + 8] << 8) | tsig.rdata[o + 9]; + eq("tsig mac size", macSize, 32); + const origId = + (tsig.rdata[o + 10 + macSize] << 8) | tsig.rdata[o + 10 + macSize + 1]; + eq("tsig original id", origId, 42); + + // independently recompute the MAC over message + variables + const v = new Writer(); + v.bytes(q); + v.bytes(nameToWire("keyname")).u16(CLASS_ANY).u32(0); + v.bytes(nameToWire("hmac-sha256")); + const timeHi = (tsig.rdata[o] << 8) | tsig.rdata[o + 1]; + const timeLo = + ((tsig.rdata[o + 2] << 24) | + (tsig.rdata[o + 3] << 16) | + (tsig.rdata[o + 4] << 8) | + tsig.rdata[o + 5]) >>> + 0; + v.u16(timeHi).u32(timeLo); + v.u16((tsig.rdata[o + 6] << 8) | tsig.rdata[o + 7]); // fudge + v.u16(0).u16(0); + const ck = await crypto.subtle.importKey( + "raw", + key.secret, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const expected = new Uint8Array( + await crypto.subtle.sign("HMAC", ck, v.done()), + ); + eq( + "tsig mac matches", + hex(tsig.rdata.subarray(o + 10, o + 10 + macSize)), + hex(expected), + ); +} + +// --- TSIG response verification --- +{ + const key = parseTsigSpec("hmac-sha256:keyname:" + btoa("secret-bytes")); + + // Server-side signing of a response, per RFC 8945 4.3 / 5.3.1. + const hmacRaw = async (data) => { + const ck = await crypto.subtle.importKey( + "raw", + key.secret, + { name: "HMAC", hash: key.hash }, + false, + ["sign"], + ); + return new Uint8Array(await crypto.subtle.sign("HMAC", ck, data)); + }; + const serverSign = async (msg, priorMac, opts = {}) => { + const { + first = true, + unsigned = [], // unsigned messages since the last TSIG + time = Math.floor(Date.now() / 1000), + fudge = 300, + error = 0, + keyName = key.name, + } = opts; + const keyWire = nameToWire(keyName); + const algWire = nameToWire(key.algo); + const v = new Writer(); + v.u16(priorMac.length).bytes(priorMac); + for (const u of unsigned) v.bytes(u); + v.bytes(msg); + if (first) { + v.bytes(keyWire).u16(CLASS_ANY).u32(0); + v.bytes(algWire).u48(time).u16(fudge); + v.u16(error).u16(0); + } else { + v.u48(time).u16(fudge); + } + const mac = await hmacRaw(v.done()); + const rd = new Writer(); + rd.bytes(algWire).u48(time).u16(fudge); + rd.u16(mac.length).bytes(mac); + rd.u16((msg[0] << 8) | msg[1]) + .u16(error) + .u16(0); + const rdata = rd.done(); + const out = new Writer(); + out.bytes(msg); + out.bytes(keyWire).u16(TYPE_TSIG).u16(CLASS_ANY).u32(0); + out.u16(rdata.length).bytes(rdata); + const signed = out.done(); + signed[11] += 1; // bump ARCOUNT (test messages have ARCOUNT < 255) + return signed; + }; + const throws = async (what, fn, substr) => { + try { + await fn(); + check(what + " (throws)", false); + } catch (e) { + check( + what + ' ("' + e.message + '" contains "' + substr + '")', + e.message.includes(substr), + ); + } + }; + const mkResp = () => { + const r = buildQuery(7, "example.com.", TYPE_SOA); + r[2] |= 0x80; // QR + return r; + }; + + const session = new TsigSession(key); + await session.sign(buildQuery(7, "example.com.", TYPE_SOA)); + + // Missing TSIG on the first response is fatal. + await throws( + "tsig verify rejects unsigned response", + () => session.verify(mkResp()), + "not TSIG-signed", + ); + + // Valid first response verifies; a tampered copy does not. + const resp1 = await serverSign(mkResp(), session.requestMac); + const tampered = resp1.slice(); + tampered[13] ^= 0x01; // flip a bit in the qname + await throws( + "tsig verify detects tampering", + () => session.verify(tampered), + "MAC verification failed", + ); + eq("tsig verify first response", (await session.verify(resp1)).signed, true); + + // Signed continuation (running MAC, timers-only variables). + const resp2 = await serverSign(mkResp(), session.priorMac, { first: false }); + eq("tsig verify continuation", (await session.verify(resp2)).signed, true); + + // Unsigned intermediate messages are covered by the next signed one. + const gap1 = mkResp(); + const gap2 = mkResp(); + eq( + "tsig unsigned intermediate 1", + (await session.verify(gap1)).signed, + false, + ); + eq( + "tsig unsigned intermediate 2", + (await session.verify(gap2)).signed, + false, + ); + const resp3 = await serverSign(mkResp(), session.priorMac, { + first: false, + unsigned: [gap1, gap2], + }); + eq("tsig verify after gap", (await session.verify(resp3)).signed, true); + + // A continuation that omits the pending unsigned messages must fail. + eq("tsig unsigned gap", (await session.verify(mkResp())).signed, false); + const bad = await serverSign(mkResp(), session.priorMac, { first: false }); + await throws( + "tsig verify detects dropped messages", + () => session.verify(bad), + "MAC verification failed", + ); + + // Unsigned TSIG error response (MAC size 0) surfaces the error code. + { + const s = new TsigSession(key); + await s.sign(buildQuery(7, "example.com.", TYPE_SOA)); + const msg = mkResp(); + const rd = new Writer(); + rd.bytes(nameToWire(key.algo)).u48(0).u16(0); + rd.u16(0); // mac size 0 + rd.u16(7).u16(17).u16(0); // orig id, error BADKEY, other-len + const rdata = rd.done(); + const out = new Writer(); + out.bytes(msg); + out.bytes(nameToWire(key.name)).u16(TYPE_TSIG).u16(CLASS_ANY).u32(0); + out.u16(rdata.length).bytes(rdata); + const err = out.done(); + err[11] += 1; + await throws("tsig unsigned error response", () => s.verify(err), "BADKEY"); + } + + // Wrong key name and stale timestamps are rejected. + { + const s = new TsigSession(key); + await s.sign(buildQuery(7, "example.com.", TYPE_SOA)); + const wrongKey = await serverSign(mkResp(), s.requestMac, { + keyName: "otherkey", + }); + await throws( + "tsig verify rejects wrong key", + () => s.verify(wrongKey), + "unexpected key", + ); + const stale = await serverSign(mkResp(), s.requestMac, { + time: Math.floor(Date.now() / 1000) - 4000, + }); + await throws( + "tsig verify rejects stale time", + () => s.verify(stale), + "time out of range", + ); + } +} + +// --- display order (indirection array sorted by revname, type, ttl) --- +{ + const st = new ZoneStore("example.com"); + const soa = (() => { + const w = new Writer(); + w.name("ns1.example.com.").name("host.example.com."); + w.u32(1).u32(2).u32(3).u32(4).u32(5); + return w.done(); + })(); + const ip = Uint8Array.of(192, 0, 2, 1); + // insertion order deliberately scrambled + const recs = [ + st.add("b.www", TYPE_A, 300, ip), // rev: www.b + st.add("mail", TYPE_A, 300, ip), // rev: mail + st.add("", TYPE_SOA, 300, soa), // apex, first + st.add("www", TYPE_TXT, 60, parseTxt('"x"')), // rev: www, TXT(16) > A(1) + st.add("www", TYPE_A, 100, ip), // rev: www, ttl 100 < 300 + st.add("a.www", TYPE_A, 300, ip), // rev: www.a + st.add("www", TYPE_A, 300, ip), // rev: www, ttl 300 + st.add("", TYPE_TXT, 60, parseTxt('"apex"')), // apex, TXT after SOA(6) + ]; + const order = new Uint32Array(st.count); + for (let i = 0; i < st.count; i++) order[i] = i; + order.sort((a, b) => st.compareRecords(a, b)); + const got = Array.from(order, (i) => { + const r = st.get(i); + return ( + (r.name === "" ? "@" : r.name) + "/" + typeToName(r.type) + "/" + r.ttl + ); + }); + eq("display order", got, [ + "@/SOA/300", + "@/TXT/60", + "mail/A/300", + "www/A/100", + "www/A/300", + "www/TXT/60", + "a.www/A/300", + "b.www/A/300", + ]); +} + +if (failures) { + console.error("\n" + failures + " failure(s)"); + throw new Error(failures + " test failure(s)"); +} +console.log("\nall tests passed"); diff --git a/treefmt.toml b/treefmt.toml new file mode 100644 index 0000000..7535044 --- /dev/null +++ b/treefmt.toml @@ -0,0 +1,39 @@ +allowMissingFormatter = false +excludes = [ + "*.lock", + "*.patch", + "package-lock.json", + "go.mod", + "go.sum", + ".gitattributes", + ".gitignore", + ".gitmodules", + ".hgignore", + ".svnignore", + "LICENSE", + ".pre-commit-config.yaml", + ".github/workflows/build.yml", + ".github/workflows/check-pre-commit-hooks.yml", + ".github/workflows/tests.yml", + ".gitattributes", + "treefmt.toml", +] +walk = "git" + +[formatter.mdformat] +command = "mdformat" +excludes = [] +includes = ["*.md"] +options = ["--wrap", "80"] + +[formatter.nixfmt] +command = "nixfmt" +excludes = [] +includes = ["*.nix"] +options = ["--strict"] + +[formatter.rustfmt] +command = "rustfmt" +excludes = [] +includes = ["*.rs"] +options = ["--config", "skip_children=true", "--edition", "2024"]