Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Go

on:
push:
branches: [master, main]
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
check-latest: true

- name: Verify formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "These files need gofmt:"
echo "$unformatted"
exit 1
fi

- name: Vet
run: go vet ./...

- name: Unit tests
run: go test -race -coverprofile=coverage.out ./...

- name: Coverage summary
run: go tool cover -func=coverage.out | tail -1

# The conformance suite creates and deletes real records, so it only runs
# when a test zone is configured. Forks and PRs from forks skip it.
conformance:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
check-latest: true

- name: libdns conformance suite
env:
NJALLA_API_TOKEN: ${{ secrets.NJALLA_API_TOKEN }}
NJALLA_TEST_ZONE: ${{ secrets.NJALLA_TEST_ZONE }}
run: |
if [ -z "$NJALLA_API_TOKEN" ] || [ -z "$NJALLA_TEST_ZONE" ]; then
echo "NJALLA_API_TOKEN / NJALLA_TEST_ZONE not configured; skipping."
exit 0
fi
cd libdnstest
go test -v ./...
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Test binaries and coverage output
*.test
coverage.out

# Local credentials for the conformance suite
.env
138 changes: 109 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,41 @@ Njalla for [`libdns`](https://github.com/libdns/libdns)

[![Go Reference](https://pkg.go.dev/badge/github.com/libdns/njalla?status.svg)](https://pkg.go.dev/github.com/libdns/njalla)

This package implements the [libdns interfaces](https://github.com/libdns/libdns) for [Njalla](https://njal.la/), allowing you to manage DNS records.
Manage [Njalla](https://njal.la) DNS records through the libdns interfaces.
Implements `RecordGetter`, `RecordAppender`, `RecordSetter`, `RecordDeleter`,
and `ZoneLister`.

## Configuration
## Install

To use this provider, you'll need to obtain an API token from Njalla. You can generate an API token from your Njalla account settings.
```sh
go get github.com/libdns/njalla
```

## Authentication

Create a token under [Settings → API](https://njal.la/settings/api/):

```go
provider := njalla.Provider{APIToken: "your-njalla-api-token"}
```

Grant the token the methods you call:

| libdns method | Njalla API methods |
| --------------- | ----------------------------------------------------------- |
| `GetRecords` | `list-records` |
| `AppendRecords` | `add-record` |
| `SetRecords` | `list-records`, `add-record`, `edit-record`, `remove-record` |
| `DeleteRecords` | `remove-record` |
| `ListZones` | `list-domains` |

Njalla's token editor offers an **ACME** option for the DNS-01 challenge. It
grants the four record methods, scoped to TXT records under `_acme-challenge`.
That scope also narrows `list-records` to the same prefix, so `GetRecords`
returns nothing outside it. `DeleteRecords` never lists, so challenge cleanup
keeps working.

## Example
## Usage

```go
package main
Expand All @@ -25,48 +53,100 @@ import (
)

func main() {
// Create the provider
provider := njalla.Provider{
APIToken: "your-njalla-api-token",
}

// Define your zone
zone := "example.com"
provider := njalla.Provider{APIToken: "your-njalla-api-token"}
ctx := context.Background()
zone := "example.com."

// Add a record
records, err := provider.AppendRecords(context.TODO(), zone, []libdns.Record{
added, err := provider.AppendRecords(ctx, zone, []libdns.Record{
libdns.Address{
Name: "test",
IP: netip.MustParseAddr("192.0.2.1"),
TTL: time.Hour,
},
})
if err != nil {
fmt.Printf("Error adding record: %v\n", err)
fmt.Printf("adding record: %v\n", err)
return
}
fmt.Printf("Records added: %v\n", records)

// Get all records
allRecords, err := provider.GetRecords(context.TODO(), zone)
records, err := provider.GetRecords(ctx, zone)
if err != nil {
fmt.Printf("Error getting records: %v\n", err)
fmt.Printf("getting records: %v\n", err)
return
}
fmt.Printf("All records: %v\n", allRecords)
fmt.Printf("%d records in %s\n", len(records), zone)

// Delete records
deletedRecords, err := provider.DeleteRecords(context.TODO(), zone, records)
if err != nil {
fmt.Printf("Error deleting records: %v\n", err)
return
if _, err := provider.DeleteRecords(ctx, zone, added); err != nil {
fmt.Printf("deleting records: %v\n", err)
}
fmt.Printf("Deleted records: %v\n", deletedRecords)
}
```

## Caveats
## Record types

A, AAAA, CAA, CNAME, MX, NS, TXT, and HTTPS map to their libdns types. Anything
else, including Njalla's `Redirect` and `Dynamic` pseudo-types, arrives as
`libdns.RR`. You can pass any type as input.

## Limitations

Njalla refuses these records. The provider checks each one before sending the
request, so the error names the cause.

| Record | Constraint |
| ------ | --------------------------------------- |
| SRV | Zone apex only |
| SVCB | Unsupported |
| MX | Preference 0 |
| TXT | Empty values, and values containing `"` |

**SRV.** Njalla validates the last two labels of the name, so it takes
`_sip._tcp` and rejects the RFC 2782 form `_sip._tcp.voice`. Reordering to
`voice._sip._tcp` passes validation and publishes an invalid SRV location, so
the provider sends the name unchanged.

**SVCB.** libdns names a SVCB record `_scheme.name`, and Njalla rejects
underscore labels for this type. HTTPS carries no such prefix and works.

## Behaviour

**Trailing dots.** Njalla stores hostname values as you send them and matches
them byte for byte, so the provider passes them through untouched in both
directions. Deleting a record tries both spellings.

**TXT encoding.** Values round trip as sent, including spaces, semicolons,
non-ASCII text, and lengths past 255 bytes. The provider adds no escaping or
chunking.

**TTL.** A TTL of 0 leaves the field unset, and Njalla applies 10800 seconds.
Pass a sub-second duration to ask for 0, as [`libdns.RR`](https://pkg.go.dev/github.com/libdns/libdns#RR)
describes.

**Underscore names.** Njalla refuses them for A, AAAA, and MX. TXT, CNAME, and
SRV accept them.

**Atomicity.** Njalla has no batch endpoint, so `AppendRecords`, `SetRecords`,
and `DeleteRecords` apply one change at a time. When one returns an error, it
also returns the records it already changed.

**DNSSEC.** `GetRecords` omits DNSSEC records, and `SetRecords` does not handle
them.

## Testing

Unit tests need no credentials:

```sh
go test ./...
```

`libdnstest/` is a separate module running the shared libdns conformance suite
against a real zone. It creates and deletes records, so point it at a zone you
can afford to modify.

```sh
cd libdnstest
NJALLA_API_TOKEN=your-token NJALLA_TEST_ZONE=example.com. go test -v ./...
```

- This provider is compatible with libdns v1.1.0 and follows the updated interfaces that use the new Record type system.
- The following record types are fully supported: A, AAAA, CNAME, TXT, MX, SRV
- Other record types are supported using the generic RR structure.
The suite skips SRV and SVCB for the reasons under [Limitations](#limitations).
Loading
Loading