Files
uncloud/pkg/client/dns.go
T
Miek GiebenandPasha Sviderski 417e402c73 feat(dns): allow setting an externally managed cluster domain (uc dns set)
* feat: add uc dns set

This allows manual setting of the cluster's domain name. I use this to
make the local cluster admin aware that this has been done.
(and then manually update the external dns)

```
% uc dns set ctrl.u.science.ru.nl
Set cluster domain: ctrl.u.science.ru.nl
Deploy the Caddy reverse proxy service ('uc caddy deploy') to enable internet access to your services via the reserved or your custom domain.

% uc dns show
ctrl.u.science.ru.nl

% uc dns set ctrl.u.science.ru.nl
Error: domain already reserved

% uc dns release
Released cluster domain: ctrl.u.science.ru.nl
```

See: #37

Signed-off-by: Miek Gieben <miek@miek.nl>

* docs

Signed-off-by: Miek Gieben <miek@miek.nl>

* check validatity

Signed-off-by: Miek Gieben <miek@miek.nl>

* No need to call out to update anything, as set doesnt use uncloud dns

Signed-off-by: Miek Gieben <miek@miek.nl>

* Ignore domain when there is no endpoint

Prevents this error, when you know what you're doing:

    Error: failed to update DNS records pointing to caddy service: create cluster domain records in Uncloud DNS: rpc error: code = Unknown desc = Post "/domains/ctrl.u.science.ru.nl/records": unsupported protocol scheme ""

Signed-off-by: Miek Gieben <miek@miek.nl>

* from main

Signed-off-by: Miek Gieben <miek@miek.nl>

* mise proto && make cli-docs

Signed-off-by: Miek Gieben <miek@miek.nl>

* Rebase and and generate protos again

Signed-off-by: Miek Gieben <miek@miek.nl>

* Rebase and fix

Signed-off-by: Miek Gieben <miek@miek.nl>

* feat(dns): allow setting an externally managed cluster domain

---------

Signed-off-by: Miek Gieben <miek@miek.nl>
Co-authored-by: Pasha Sviderski <me@psviderski.name>
2026-09-25 16:36:01 +10:00

242 lines
6.7 KiB
Go

package client
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"sync"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/docker/compose/v2/pkg/progress"
"github.com/psviderski/uncloud/api/pb"
cliprogress "github.com/psviderski/uncloud/internal/cli/progress"
"github.com/psviderski/uncloud/internal/machine/caddyconfig"
"github.com/psviderski/uncloud/pkg/api"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// GetDomain returns the cluster domain or api.ErrNotFound if none is configured.
func (cli *Client) GetDomain(ctx context.Context) (api.ClusterDomain, error) {
domain, err := cli.ClusterClient.GetDomain(ctx, nil)
if err != nil {
if status.Code(err) == codes.NotFound {
return api.ClusterDomain{}, api.ErrNotFound
}
return api.ClusterDomain{}, err
}
return api.ClusterDomain{
Name: domain.Name,
// Older daemons (<0.21) omit Reserved and only support reserved domains.
Reserved: domain.Reserved == nil || domain.GetReserved(),
}, nil
}
var ErrNoReachableMachines = errors.New("no internet-reachable machines running service containers")
// CreateIngressRecords verifies which machines running the specified service (typically Caddy) are reachable from
// the internet, then creates DNS records for the cluster domain pointing to those machines. It tests each machine
// by sending HTTP requests to their public IPs. Only machines that respond correctly with their machine ID are included
// in the resulting DNS configuration. The domain must be reserved in Uncloud DNS.
// Returns the created DNS records or an error.
func (cli *Client) CreateIngressRecords(ctx context.Context, serviceID string) ([]*pb.DNSRecord, error) {
domain, err := cli.GetDomain(ctx)
if err != nil {
return nil, fmt.Errorf("get cluster domain: %w", err)
}
if !domain.Reserved {
return nil, status.Error(codes.FailedPrecondition, "cluster domain is not reserved in Uncloud DNS")
}
svc, err := cli.InspectService(ctx, serviceID)
if err != nil {
return nil, fmt.Errorf("inspect service '%s': %w", serviceID, err)
}
machineIDs := make(map[string]struct{}, len(svc.Containers))
for _, mc := range svc.Containers {
machineIDs[mc.MachineID] = struct{}{}
}
var wg sync.WaitGroup
reachableMachines := make(chan *pb.MachineInfo)
for id := range machineIDs {
m, err := cli.InspectMachine(ctx, id)
if err != nil {
return nil, fmt.Errorf("inspect machine '%s': %w", id, err)
}
if m.Machine.PublicIp == nil {
continue
}
wg.Go(func() {
if err = verifyCaddyReachable(ctx, m.Machine); err == nil {
reachableMachines <- m.Machine
}
})
}
go func() {
wg.Wait()
close(reachableMachines)
}()
var machines []*pb.MachineInfo
for m := range reachableMachines {
machines = append(machines, m)
}
if len(machines) == 0 {
return nil, ErrNoReachableMachines
}
req, err := getCreateDomainRecordsRequest(machines)
if err != nil {
return nil, fmt.Errorf("create CreateDomainRecordsRequest: %w", err)
}
resp, err := cli.CreateDomainRecords(ctx, req)
if err != nil {
return nil, fmt.Errorf("create cluster domain records in Uncloud DNS: %w", err)
}
return resp.Records, nil
}
func getCreateDomainRecordsRequest(machines []*pb.MachineInfo) (*pb.CreateDomainRecordsRequest, error) {
if len(machines) == 0 {
return nil, fmt.Errorf("at least one machine must be provided")
}
var ipv4IngressIPs []string
var ipv6IngressIPs []string
var errs error
for _, m := range machines {
ip, _ := m.PublicIp.ToAddr()
if ip.Is4() {
ipv4IngressIPs = append(ipv4IngressIPs, ip.String())
} else if ip.Is6() {
ipv6IngressIPs = append(ipv6IngressIPs, ip.String())
} else {
// This is just a save guard, in case some special case is ever missed.
errs = errors.Join(errs, fmt.Errorf("machine with name %s (and ID: %s) has the public IP address '%s' which is neither IPv4 nor IPv6", m.Name, m.Id, ip.String()))
}
}
if errs != nil {
return nil, errs
}
records := make([]*pb.DNSRecord, 0, 2)
if len(ipv4IngressIPs) > 0 {
records = append(records,
&pb.DNSRecord{
Name: "*",
Type: pb.DNSRecord_A,
Values: ipv4IngressIPs,
},
)
}
if len(ipv6IngressIPs) > 0 {
records = append(records,
&pb.DNSRecord{
Name: "*",
Type: pb.DNSRecord_AAAA,
Values: ipv6IngressIPs,
},
)
}
return &pb.CreateDomainRecordsRequest{
Records: records,
}, nil
}
// verifyCaddyReachable verifies that the Caddy service is reachable on the machine by its public IP.
func verifyCaddyReachable(ctx context.Context, m *pb.MachineInfo) error {
publicIP, _ := m.PublicIp.ToAddr()
pw := progress.ContextWriter(ctx)
eventID := cliprogress.MachineEventID(m.Name, publicIP.String())
pw.Event(progress.NewEvent(eventID, progress.Working, "Querying"))
verifyURL := getVerifyURL(publicIP)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, verifyURL, nil)
if err != nil {
pw.Event(progress.NewEvent(eventID, progress.Error, err.Error()))
return err
}
boff := backoff.WithContext(backoff.NewExponentialBackOff(
backoff.WithMaxInterval(1*time.Second),
backoff.WithMaxElapsedTime(5*time.Second),
), ctx)
client := &http.Client{Timeout: 3 * time.Second}
do := func() (*http.Response, error) {
return client.Do(req)
}
resp, err := backoff.RetryWithData(do, boff)
if err != nil {
e := unreachable(eventID)
e.Text = fmt.Sprintf("Failed to send HTTP request: %v", err)
pw.Event(e)
return fmt.Errorf("send HTTP request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
e := unreachable(eventID)
e.Text = fmt.Sprintf("Unexpected HTTP response status code: %d", resp.StatusCode)
pw.Event(e)
return fmt.Errorf("unexpected HTTP response status code: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
e := unreachable(eventID)
e.Text = fmt.Sprintf("Failed to read HTTP response body: %v", err)
pw.Event(e)
return fmt.Errorf("read HTTP response body: %w", err)
}
// Check the response body is the machine ID to ensure the correct Caddy container is responding.
if string(body) == m.Id {
pw.Event(progress.NewEvent(eventID, progress.Done, "Reachable"))
return nil
} else {
bodyStr := string(body)
if len(bodyStr) > 50 {
bodyStr = bodyStr[:50] + "..."
}
e := unreachable(eventID)
e.Text = fmt.Sprintf("Unexpected HTTP response body: %s", bodyStr)
pw.Event(e)
return fmt.Errorf("unexpected HTTP response body: %s", bodyStr)
}
}
func getVerifyURL(publicIP netip.Addr) string {
httpFormattedIP := net.JoinHostPort(publicIP.String(), "")
return fmt.Sprintf("http://%s%s", httpFormattedIP, caddyconfig.VerifyPath)
}
// unreachable creates a new Unreachable error event.
func unreachable(id string) progress.Event {
return progress.NewEvent(
id,
progress.Error,
"Unreachable (probably behind NAT or firewall)",
)
}