lancert

package
v0.57.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 9, 2026 License: Apache-2.0 Imports: 27 Imported by: 0

Documentation

Overview

Package lancert holds the publicly trusted certificate of a homelab's LAN name under its kombify.me base (homelab identity target §5, §14 decision 3): one certificate for lan.<base>.kombify.me only, with which the Authority's LAN enrollment listener serves the LAN name the installed Hub PWA opens from home.

The node generates the certificate key and its ACME account key and keeps both in Owner custody; it runs its own ACME client against a second issuer (ZeroSSL or Google Trust Services with external account binding, until kombify.me is on the Public Suffix List). kombify Me publishes only the DNS-01 value and, as the LAN name's one DNS-only record, the node's private site address. Neither key, the CSR's key nor the EAB key is printed, logged or written to a receipt.

Index

Constants

View Source
const (
	// LANLabel is the label of the LAN name under the homelab's domain.
	LANLabel = "lan"
)

Variables

View Source
var ErrAddressNotPublished = errors.New("lancert: the LAN address is not published")

ErrAddressNotPublished reports that kombify Me did not publish the LAN name's address (or the node had none to publish). Nothing was ordered: a caller may retry soon without touching the ACME issuer.

View Source
var Issuers = map[string]string{
	"zerossl":             "https://acme.zerossl.com/v2/DV90",
	"gts":                 "https://dv.acme-v02.api.pki.goog/directory",
	"letsencrypt-staging": "https://acme-staging-v02.api.letsencrypt.org/directory",
}

Issuer directories. ZeroSSL and Google Trust Services need external account binding. Let's Encrypt production is not offered: its 50 certificates per week for the registered domain kombify.me are shared by every homelab until the Public Suffix List entry takes effect (target §14 decision 3). Let's Encrypt staging serves journeys only; its chain is not trusted.

View Source
var LAN = Profile{Custody: custodyDirName, Names: func(domain string) []string { return []string{LANName(domain)} }, PublishAddress: true, /* contains filtered or unexported fields */}

LAN is the lan.<domain> profile (the zero Issuer profile).

View Source
var Public = Profile{
	Custody: "public-tls",
	Names:   func(domain string) []string { return []string{"*." + domain, domain} },
	// contains filtered or unexported fields
}

Public is the public-names profile.

Functions

func AuthoritativeTXT

func AuthoritativeTXT(ctx context.Context, name, value string) error

AuthoritativeTXT waits until every authoritative name server of the zone answers value for name, querying them directly: a shared recursive resolver asked too early caches the missing answer.

func Covers

func Covers(workspace, host string, now time.Time) bool

Covers reports whether the installed key pair matches and its certificate is valid for host for at least another hour.

func CoversProfile

func CoversProfile(workspace string, profile Profile, host string, now time.Time) bool

CoversProfile is Covers for a profile.

func CustodyDir

func CustodyDir(workspace string) string

CustodyDir is the Owner custody directory of the LAN certificate.

func CustodyDirOf

func CustodyDirOf(workspace string, p Profile) string

func LANName

func LANName(domain string) string

LANName is the LAN name of a homelab domain.

func LiveDir

func LiveDir(workspace string) string

LiveDir holds the certificate and its key; the Basement core mounts it.

func LiveDirOf

func LiveDirOf(workspace string, p Profile) string

LiveDirOf and CustodyDirOf expose a profile's directories.

func NeedsRenewal

func NeedsRenewal(workspace, domain string, now time.Time) bool

NeedsRenewal reports a missing certificate, one that does not cover the domain's names, and one within 30 days of expiry.

func NeedsRenewalProfile

func NeedsRenewalProfile(workspace string, profile Profile, domain string, now time.Time) bool

NeedsRenewalProfile is NeedsRenewal for a profile.

func PublishAddress

func PublishAddress(ctx context.Context, workspace, domain string, publisher Publisher) (netip.Addr, error)

PublishAddress publishes the node site address for lan.<domain> through kombify Me before any ACME order: without that record the base's proxied wildcard answers the LAN name with Cloudflare addresses. A router that drops private answers (DNS rebinding protection) still leaves the Hub on the away flow. Every failure wraps ErrAddressNotPublished.

func Remove

func Remove(workspace string) error

Remove deletes the certificate, its key and the ACME account key under the custody lock. The live directory itself stays: the Basement core mounts it.

func RemoveConfined

func RemoveConfined(dir string, names ...string) error

RemoveConfined removes the named files under dir, confined to dir; a missing file or directory is no error.

func RemoveProfile

func RemoveProfile(workspace string, profile Profile) error

RemoveProfile is Remove for a profile.

func Renew

func Renew(ctx context.Context, workspace, domain string, publisher Publisher, keyUID, keyGID int) (bool, error)

Renew renews an issued LAN certificate within 30 days of its expiry with the account in custody (no external account binding is needed again), and otherwise republishes the LAN name's address: Me removes that record with every tunnel teardown, and the site address can move. A node that never issued one has nothing to renew or publish.

func RenewProfile

func RenewProfile(ctx context.Context, workspace string, profile Profile, domain string, publisher Publisher, keyUID, keyGID int) (bool, error)

RenewProfile is Renew for a profile.

func SiteAddress

func SiteAddress(workspace string) (netip.Addr, error)

SiteAddress is the address lan.<domain> must resolve to: the node site address the owner-signed LAN resolver custody binds, which the LAN enrollment listener (9443) is published on and announces. Only an RFC 1918 or IPv6 ULA address is answered (narrower than Me, which also admits 100.64.0.0/10); a public, loopback, link-local, IPv4-mapped or zoned address never leaves the node.

func WriteConfined

func WriteConfined(dir, name string, raw []byte, mode os.FileMode, uid, gid int) error

WriteConfined writes name under dir atomically (a new temporary file beside it, fsync, rename), confined to dir. A uid of -1 keeps the writer's owner.

Types

type Cloudflare

type Cloudflare struct {
	Token string
	// API overrides the Cloudflare API base (tests).
	API    string
	Client *http.Client
	// contains filtered or unexported fields
}

Cloudflare answers DNS-01 for an own domain in the owner's Cloudflare zone with the owner's API token (a token limited to that zone with Zone:Read, which zoneOf needs to look the zone up by name, and DNS:Edit). It publishes only the challenge TXT record under _acme-challenge.<name> and removes exactly the record it created; it never touches an address record, and the token is never logged or returned in an error.

func (*Cloudflare) RemoveChallenge

func (c *Cloudflare) RemoveChallenge(ctx context.Context, identifier, value string) error

RemoveChallenge deletes the record SetChallenge created for the value.

func (*Cloudflare) SetChallenge

func (c *Cloudflare) SetChallenge(ctx context.Context, identifier, value string) error

SetChallenge publishes one DNS-01 value as a TXT record.

func (*Cloudflare) SetLANAddress

func (c *Cloudflare) SetLANAddress(context.Context, string, netip.Addr) error

SetLANAddress is not offered: an own domain's address records are the owner's.

type Issuer

type Issuer struct {
	// Profile is the certificate to obtain; the zero value is the LAN name.
	Profile   Profile
	Workspace string
	Domain    string
	// Directory is the ACME directory URL of the issuer.
	Directory string
	// EAB is required for a new account at ZeroSSL and Google Trust Services.
	EAB *acme.ExternalAccountBinding
	// Contact is the optional ACME account contact address; Google Trust
	// Services and ZeroSSL refuse an account without one.
	Contact   string
	Publisher Publisher
	// KeyUID and KeyGID own the certificate key file: the core server's
	// account, which serves the LAN enrollment listener.
	KeyUID, KeyGID int
	// Propagated waits until the authoritative servers answer the value.
	Propagated func(ctx context.Context, name, value string) error
}

Issuer obtains the LAN certificate.

func (Issuer) Issue

func (i Issuer) Issue(ctx context.Context) (State, error)

Issue publishes the LAN name's address, registers or reuses the node's ACME account, proves control of the LAN name lan.<domain> (and nothing else: the key lives in the core, so it must not be able to impersonate the identity origin or any service) through the publisher and installs the certificate. Nothing is ordered unless Me read the address back. One issuance runs at a time.

type Profile

type Profile struct {
	// Custody is the directory under .stackkit/custody that holds the ACME
	// account and the live directory.
	Custody string
	// Names are the certificate's DNS names for a domain.
	Names func(domain string) []string
	// PublishAddress publishes the node's private site address for the LAN name.
	PublishAddress bool
	// contains filtered or unexported fields
}

Profile selects which certificate an Issuer obtains and where it lives. The LAN profile is the lan.<domain> certificate the Authority's LAN listener serves. The Public profile is the certificate of a public identity origin's router: *.<domain> and <domain> (id.<name>, base.<name>, ...), obtained by DNS-01 through kombify Me (a kombify.me name) or the owner's DNS provider (an own domain); the Basement core router mounts its live directory read-only.

type Publisher

type Publisher interface {
	SetChallenge(ctx context.Context, identifier, value string) error
	RemoveChallenge(ctx context.Context, identifier, value string) error
	SetLANAddress(ctx context.Context, name string, address netip.Addr) error
}

Publisher is kombify Me's side of the LAN name: it publishes and removes one DNS-01 value, and publishes the node's private address as the DNS-only record of the LAN name, answering nil only after Me's read-back shows that name, DNS-only, with exactly that address.

type Source

type Source struct {
	Workspace string
	// contains filtered or unexported fields
}

Source serves the installed certificate to a TLS listener, reloading it when the file changes.

func (*Source) Certificate

func (s *Source) Certificate() *tls.Certificate

Certificate answers the installed key pair, or nil when none is installed.

type State

type State struct {
	Names             []string `json:"names"`
	Issuer            string   `json:"issuer"`
	NotBefore         string   `json:"notBefore"`
	NotAfter          string   `json:"notAfter"`
	SerialHex         string   `json:"serialHex"`
	CertificateSHA256 string   `json:"certificateSha256"`
}

State is the public identity of the installed certificate.

func Load

func Load(workspace string) (State, *x509.Certificate, error)

Load parses the installed certificate; os.ErrNotExist when none is.

func LoadPublic

func LoadPublic(workspace string) (State, *x509.Certificate, error)

LoadPublic parses the installed public-names certificate.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL