tsdmg

package module
v0.0.0-...-bda8ed4 Latest Latest
Warning

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

Go to latest
Published: Feb 11, 2026 License: MIT Imports: 12 Imported by: 0

README

Tailscale Domain Mgmt. Gateway (tsdmg)

Go Report Card Documentation GitHub issues license

A tsnet based service for managing custom domains in your Tailnet, along with libraries to enable your Tailscale nodes to manage DNS records, and retrieve public (Let's Encrypt) TLS certificates at runtime.

Why Do I Need This?

Running a tsdmg service in your Tailnet enables several use-cases not possible out-of-the-box with Tailscale:

  • Custom domains for your Tailscale nodes e.g. <node>.yourdomain.com
  • Allow Tailscale nodes to retrieve public (Let's Encrypt) TLS certificates for custom domains
  • Allow Tailscale nodes to manage your domains/subdomains arbitrarily

My motivation to build this is that I wanted all my internal services and web applications (not on the Internet, accesible only via Tailscale) to be reachable via my custom domain (<node>.services.adrianosela.com), and I wanted them to serve TLS/HTTPS using public (Let's Encrypt) certificates.

Really I wish I could just delegate the services.adrianosela.com zone to Tailscale, and have them do this for me (but it's not possible as of Feb 2026).

How Does it Work?

Essentially:

  • Using Tailscale ACLs, you define which Tailscale sources (nodes, users, groups) can manage which subdomains (e.g. node "webapp" can manage "webapp.yourdomain.com")
  • You provision the tsdmg service with credentials for your DNS provider (e.g. Cloudflare, Google, GoDaddy, etc...)
  • Your Tailscale nodes can request domains to be created/updated/deleted against the tsdmg service via HTTP
  • The tsdmg service will use incoming requests' Tailscale identity to authenticate and authorize (based on Tailscale ACLs) domain management requests

Service Usage

Run the tsdmg service as shown in ./cmd/server/main.go:

tsdmg, err := service.New(ctx, tsClient, dnsProvider)
if err != nil {
    log.Fatalf("failed to initialize tsdmg service: %v", err)
}

if err = tsdmg.ServeHTTP(ln); err != nil {
    log.Fatalf("failed to serve HTTP over tsnet listener: %v", err)
}

The dns.Provider interface is implemented for all major DNS providers by https://github.com/libdns e.g.:

Running the Server with Docker

The tsdmg server is available as a Docker image:

docker run ghcr.io/adrianosela/tsdmg -h
Example: Running with Cloudflare
docker run -it ghcr.io/adrianosela/tsdmg \
  -domain=yourdomain.com \
  -dns-provider=cloudflare \
  -cloudflare-api-token=$TSDMG_CLOUDFLARE_API_TOKEN \
  -ts-authkey=$TSDMG_TS_AUTHKEY

See the Makefile tsdmg-docker target for a complete working example.

Configuration Options
Flag Description Required Default
-addr Address to listen on No :80
-hostname Hostname to use for Tailscale machine No tsdmg
-ts-authkey Tailscale auth key Yes -
-dns-provider Which DNS provider to use (one of aws, gcp, azure, cloudflare, godaddy) Yes -
-domain Domain management allowlist (repeatable) No -
-node-reg-domain Domain in which to create A/AAAA records for registering Tailscale nodes (repeatable, must be subset of -domain if both set) No -
-cloudflare-api-token Cloudflare API Token If dns-provider=cloudflare -
-aws-access-key-id AWS Access Key ID If dns-provider=aws (or use profile) -
-aws-secret-access-key AWS Secret Access Key If dns-provider=aws (or use profile) -
-aws-profile AWS Profile If dns-provider=aws (or use keys) -
-gcp-project Google Cloud Project ID If dns-provider=gcp -
-gcp-svc-acct-json Google Cloud Service Account JSON If dns-provider=gcp -
-azure-subscription-id Azure Subscription ID If dns-provider=azure -
-azure-resource-group-name Azure Resource Group Name If dns-provider=azure -
-azure-client-id Azure Client ID If dns-provider=azure -
-azure-client-secret Azure Client Secret If dns-provider=azure -
-azure-tenant-id Azure Tenant ID If dns-provider=azure -
-godaddy-api-token GoDaddy API Token If dns-provider=godaddy -

Client Usage

This package includes a tsdmg client capable of requesting, caching, and refreshing public (Let's Encrypt) TLS certificates, by leveraging a tsdmg server.

serverURL := "http://tsdmg" // my server's node name is tsdmg

opts := []tsdmg.Option{
	// My laptop is already running the Tailscale desktop
	// client, so the tsdmg server is already reachable by
	// node-name i.e. http://tsdmg. Not setting this option
	// will attempt to initialize a new Tailscale node.
	tsdmg.WithSkipTailscaleNode(true),
}

client, err := tsdmg.NewClient(ctx, serverURL, opts...)
if err != nil {
	log.Fatalf("failed to initialize client: %v", err)
}
defer client.Close()

created, err := client.CreateRecords(ctx, recordsToCreate...)
// check error

deleted, err := client.DeleteRecords(ctx, recordsToDelete...)
// check error

Certificate Manager Usage

With an initialized tsdmg.Client client:

NOTE: import "github.com/adrianosela/tsdmg/pkg/tsautocert"

certCommonName := "macbook.tsdmg.net"

opts := []tsautocert.Option{
	// Cache certificates in the filesystem to
	// avoid hitting the Let's Encrypt rate limit.
	tsautocert.WithCertificateCache(autocert.DirCache("./certcache")),
}

certManager, err := tsautocert.NewCertificateManager(
	ctx,
	client,
	certCommonName,
	opts...,
)
if err != nil {
	log.Fatalf("failed to initialize certificate manager: %v", err)
}
defer certManager.Close()

if err := certManager.WaitForInitialCert(ctx); err != nil {
	log.Fatalf("failed to wait for initial certificate: %v", err)
}

ln, err := net.Listen("tcp", ":443")
if err != nil {
	log.Fatalf("failed to start tcp listener: %v", err)
}

// Configure TLS listener to get certificate using tsdmg client.
ln = tls.NewListener(ln, &tls.Config{GetCertificate: certManager.GetCertificate})
defer ln.Close()

err = http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Hello World!"))
}))
if err != nil {
	log.Fatalf("failed to serve HTTP: %v", err)
}

Tailscale ACLs Example

To allow ANY node to retrieve TLS certificates for <node>.<your-custom-domain> (e.g. your-macbook.yourdomain.com), you can add a grant in your ACL as follows:

The ${node} will be replaced with the tsdmg client node's name by the tsdmg server prior to evaluation.

	"grants": [
		{
			"src": ["*"],
			"dst": ["*"],
			"ip":  ["*"],

			"app": {
				"tsdmg.net/dns/v1": [
					{
						"TXT": ["_acme-challenge.${node}.yourdomain.com"],
					},
				],
			},
		},
	],

Say you also want your client to have the ability to create A and AAAA records (e.g. for its own tailnet private IP or any other IP):

	"grants": [
		{
			"src": ["*"],
			"dst": ["*"],
			"ip":  ["*"],

			"app": {
				"tsdmg.net/dns/v1": [
					{
						"TXT":  ["_acme-challenge.${node}.yourdomain.com"],
						"A":    ["${node}.yourdomain.com"],
						"AAAA": ["${node}.yourdomain.com"],
					},
				],
			},
		},
	],

TODOs:

  • Better project structure e.g. internal, not everything as pkg

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Client

type Client interface {
	// CreateRecords creates the requested DNS records via the tsdmg server.
	CreateRecords(context.Context, ...types.Record) ([]types.Record, error)

	// DeleteRecords deletes the requested DNS records via the tsdmg server.
	DeleteRecords(context.Context, ...types.Record) ([]types.Record, error)

	// Register requests the tsdmg server to create A and AAAA records for this
	// node's Tailscale private IPs. Domain configuration lives server side. That
	// is, the tsdmg server decides which domains to create records in, but all
	// records will be of the form ${node}.${domain}.
	Register(context.Context) ([]types.Record, error)

	// Close closes the client gracefully.
	Close() error
}

Client represents a tsdmg client capable of managing DNS records by requesting them via the tsdmg server. Aside from arbitrary record creation, the client can request to "register" itself meaning that the tsdmg server will create A and AAAA records of the form ${node}.${domain} on its behalf. The ${domain}s are configured on the tsdmg server.

func NewClient

func NewClient(ctx context.Context, serverURL string, opts ...Option) (Client, error)

NewClient returns a new Client for a tsdmg server at serverURL with the given options. Note that serverURL must include scheme (http/s).

type Option

type Option func(*config)

Option represents a configuration option for initializing a client.

func WithLogger

func WithLogger(logger logger.Logger) Option

WithLogger is a configuration option to configure a logger. If this option is not set a log/slog logger is used with a JSON handler.

func WithSkipTailscaleNode

func WithSkipTailscaleNode(skipTailscaleNode bool) Option

WithSkipTailscaleNode is a configuration option to signal that the client is an existing Tailscale node, with networking already set-up. When this option is set, the client will skip initializing an underlying tsnet Tailscale node, and instead will rely on the machine's networking allowing it to reach the certsnet server a.k.a. acme proxy.

func WithTailscaleNode

func WithTailscaleNode(tailscaleClient *local.Client) Option

WithTailscaleNode is a configuration option to pass an already initialized Tailscale (tsnet) client to be used for making requests to the certnet server a.k.a. acme proxy.

If WithTailscaleNode and WithSkipTailscaleNode are BOTH unset, the client will attempt to initialize a new, ephemeral, tsnet Tailscale node. For that to succeed, TS_AUTHKEY env must be set.

Jump to

Keyboard shortcuts

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