Skip to content

Repository files navigation

FENO for libdns

Go Reference CI

This package implements the libdns interfaces for FENO, a Norwegian .no registrar, using the FENO public API (https://api.feno.no/v1). It lets libdns-based tools — CertMagic, Caddy (via a dns.providers.feno module), dynamic-DNS clients, your own Go programs — read and manipulate the DNS records of .no zones hosted on FENO's nameservers (ns1.feno.no / ns2.feno.no).

It implements RecordGetter, RecordAppender, RecordSetter, RecordDeleter and ZoneLister, returns the typed libdns structs (libdns.Address, libdns.TXT, …), is stdlib-only apart from libdns itself, and is safe for concurrent use. Module path:

github.com/MrErikCodes/libdns-feno

Getting an API key

When FENO's public API is enabled on your account, keys are created in the FENO dashboard under API keys (two-factor authentication must be enrolled; the key is shown exactly once and looks like feno_live_…). Pick the scopes the key needs — a :write scope never implies the matching :read:

What you want to do Scopes on the key Methods that work
Certificates only (ACME DNS-01, wildcards included) acme:write AppendRecords + DeleteRecords (by id, or by name/value — that is what CertMagic/Caddy send) — TXT at _acme-challenge* only. GetRecords/SetRecords work too, but the API returns a listing filtered to _acme-challenge* TXT records; the rest of the zone stays invisible
Full DNS management dns:read + dns:write Everything: GetRecords, AppendRecords, SetRecords, DeleteRecords (by id or by match)
Enumerate zones domains:read (in addition) ListZones

Recommendation: use an acme:write key for anything that only issues certificates. It can write TXT records at _acme-challenge / _acme-challenge.<label> and nothing else, so a leaked CI or server credential cannot touch your MX or A records. Use dns:write only when the same key also manages ordinary DNS (e.g. Caddy's global dns option, dynamic DNS).

The domain must be registered at FENO and delegated to FENO's nameservers; otherwise the API answers 400 NOT_FENO_NS.

Configuration

Field JSON (for Caddy and friends) Description
APIKey api_key Required. The FENO public API key (feno_live_…).
APIBase api_base Optional. API base URL, default https://api.feno.no/v1. For tests/staging.
HTTPClient Optional *http.Client; default has a 30 s timeout.

The zero value is usable as soon as APIKey is set — there is no provisioning step.

Usage

package main

import (
	"context"
	"fmt"
	"os"
	"time"

	feno "github.com/MrErikCodes/libdns-feno"
	"github.com/libdns/libdns"
)

func main() {
	p := &feno.Provider{APIKey: os.Getenv("FENO_API_KEY")}
	ctx := context.Background()
	zone := "kunde.no." // libdns zones are fully qualified

	// Publish an ACME challenge (needs acme:write or dns:write).
	created, err := p.AppendRecords(ctx, zone, []libdns.Record{
		libdns.TXT{Name: "_acme-challenge", Text: "Xf3k9Lm2pQr7…", TTL: 60 * time.Second},
	})
	if err != nil {
		panic(err)
	}
	fmt.Println("record id:", created[0].(libdns.TXT).ProviderData)

	// ...validate with the CA, then remove exactly that record (no read scope needed).
	if _, err := p.DeleteRecords(ctx, zone, created); err != nil {
		panic(err)
	}

	// Read the whole zone (needs dns:read).
	recs, err := p.GetRecords(ctx, zone)
	if err != nil {
		panic(err)
	}
	for _, r := range recs {
		rr := r.RR()
		fmt.Println(rr.Name, rr.TTL, rr.Type, rr.Data)
	}
}

Environment variables are a convention of the calling program, not of this package; FENO_API_KEY is what FENO's other clients use.

Semantics and caveats

  • Names are relative to the zone, as libdns requires: @ (or "") is the apex, www is www.kunde.no. A fully-qualified name with a trailing dot (www.kunde.no.) is made relative before it is sent; FENO would otherwise create www.kunde.no.kunde.no.
  • Record types: the FENO public API accepts A, AAAA, CNAME, TXT, MX, SRV and CAA. Writing anything else (NS, PTR, HTTPS/SVCB, …) fails with feno.ErrUnsupportedType before any API call. (Consequently Caddy's ECH publishing, which needs HTTPS records, is not supported.) GetRecords returns unknown types as libdns.RR.
  • Record ids. Every record returned by GetRecords, AppendRecords and SetRecords carries FENO's numeric record id in ProviderData (an int). DeleteRecords uses it when present — one DELETE per record, no listing. Records without an id are matched against a listing of the zone with libdns' rules (name must match; an empty type, a zero TTL or an empty value is a wildcard). CertMagic — and so Caddy — always takes this second path: its solver keeps only results[0].RR(), which has no ProviderData. The listing needs dns:read or acme:write; with acme:write alone FENO returns only the _acme-challenge* TXT records, which is all such a key could delete anyway, and a key with neither gets an error that says so. During a wildcard issuance two TXT values live at _acme-challenge; matching by value (or deleting by id) removes only the one you published.
  • TTL: 0 means "provider default", which FENO applies as 3600 s — except for TXT records, where this package sends 60 s so challenge records do not linger. Set an explicit TTL to override either. FENO's floor is 15 s; a smaller non-zero TTL is raised to 15 s rather than rejected.
  • Targets of CNAME, MX and SRV records are returned fully qualified (trailing dot) and accepted with or without one.
  • CAA records are sent in FENO's split form (flags, tagissue/issuewild/iodef, and the bare CA domain or iodef URL as value) and read back as libdns.CAA whether FENO returns that form or a presentation triple (0 issue letsencrypt.org) in value.
  • SetRecords is not atomic. FENO has no batch endpoint; records are replaced in place (PUT by id), created, and surplus ones deleted, one call each. On error the zone may be partially updated (the error is never a libdns.AtomicErr). SetRecords and DeleteRecords are serialised per zone within a process.
  • Rate limits: 120 requests/min and 5000/h per key. A 429 is retried once after Retry-After (up to 30 s, honouring the context); 502/503/504 and connection errors on idempotent calls are retried once after 2 s. Everything else is returned as *feno.APIError with FENO's Code (INSUFFICIENT_SCOPE, ACME_SCOPE_VIOLATION, NOT_FENO_NS, DOMAIN_NOT_MANAGED, DNS_RECORD_NOT_FOUND, INVALID_RECORD_TYPE, RATE_LIMITED, …), the HTTP status, FENO's message and the X-Request-Id to quote to support.
  • Propagation: FENO is not the authoritative nameserver — a 2xx means the write was accepted by FENO's DNS backend, not that every resolver sees it yet. Keep your ACME client's propagation checks on and give it ~30–60 s.

Testing

go vet ./...
go test -race ./...

The test suite runs entirely against an in-process net/http/httptest mock of the FENO API (bearer auth, scopes, the acme:write confinement and its filtered listing, the error envelope, record ids, cursor pagination, 429 + Retry-After). No network access or FENO account is needed.

Licence

MIT — see LICENSE. Copyright 2026 Erik Nilsen / FENO.

About

libdns provider for the FENO (feno.no) DNS API

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages