FENO for libdns
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
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.
| 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.
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.
- Names are relative to the zone, as libdns requires:
@(or"") is the apex,wwwiswww.kunde.no. A fully-qualified name with a trailing dot (www.kunde.no.) is made relative before it is sent; FENO would otherwise createwww.kunde.no.kunde.no. - Record types: the FENO public API accepts
A,AAAA,CNAME,TXT,MX,SRVandCAA. Writing anything else (NS,PTR,HTTPS/SVCB, …) fails withfeno.ErrUnsupportedTypebefore any API call. (Consequently Caddy's ECH publishing, which needsHTTPSrecords, is not supported.)GetRecordsreturns unknown types aslibdns.RR. - Record ids. Every record returned by
GetRecords,AppendRecordsandSetRecordscarries FENO's numeric record id inProviderData(anint).DeleteRecordsuses it when present — oneDELETEper 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 onlyresults[0].RR(), which has noProviderData. The listing needsdns:readoracme:write; withacme:writealone 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:
0means "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,MXandSRVrecords are returned fully qualified (trailing dot) and accepted with or without one. - CAA records are sent in FENO's split form (
flags,tag∈issue/issuewild/iodef, and the bare CA domain or iodef URL asvalue) and read back aslibdns.CAAwhether FENO returns that form or a presentation triple (0 issue letsencrypt.org) invalue. SetRecordsis not atomic. FENO has no batch endpoint; records are replaced in place (PUTby id), created, and surplus ones deleted, one call each. On error the zone may be partially updated (the error is never alibdns.AtomicErr).SetRecordsandDeleteRecordsare serialised per zone within a process.- Rate limits: 120 requests/min and 5000/h per key. A
429is retried once afterRetry-After(up to 30 s, honouring the context);502/503/504and connection errors on idempotent calls are retried once after 2 s. Everything else is returned as*feno.APIErrorwith FENO'sCode(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 theX-Request-Idto quote to support. - Propagation: FENO is not the authoritative nameserver — a
2xxmeans 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.
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.
MIT — see LICENSE. Copyright 2026 Erik Nilsen / FENO.