BREAKING CHANGE: move Uncloud API socket to /run/uncloud/api/uncloud.sock, activate it by systemd socket unit

This commit is contained in:
Pasha Sviderski committed 2026-09-17 15:23:38 +10:00
1 parent c65f352250
commit f68c9859d9
24 files changed
+338 -105

No files matched your search

+7 -3
View File
@@ -166,7 +166,9 @@ $ uc machine init --name oracle-vm ubuntu@152.67.101.197
✓ uncloudd binary installed: /usr/local/bin/uncloudd
⏳ Downloading uninstall script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/uninstall.sh
✓ uncloud-uninstall script installed: /usr/local/bin/uncloud-uninstall
✓ Systemd unit file created: /etc/systemd/system/uncloud.socket
✓ Systemd unit file created: /etc/systemd/system/uncloud.service
Created symlink /etc/systemd/system/sockets.target.wants/uncloud.socket → /etc/systemd/system/uncloud.socket.
Created symlink /etc/systemd/system/multi-user.target.wants/uncloud.service → /etc/systemd/system/uncloud.service.
⏳ Starting Uncloud machine daemon (uncloud.service)...
✓ Uncloud machine daemon started.
@@ -208,7 +210,9 @@ $ uc machine add --name hetzner-server root@5.223.45.199
✓ uncloudd binary installed: /usr/local/bin/uncloudd
⏳ Downloading uninstall script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/uninstall.sh
✓ uncloud-uninstall script installed: /usr/local/bin/uncloud-uninstall
✓ Systemd unit file created: /etc/systemd/system/uncloud.socket
✓ Systemd unit file created: /etc/systemd/system/uncloud.service
Created symlink /etc/systemd/system/sockets.target.wants/uncloud.socket → /etc/systemd/system/uncloud.socket.
Created symlink /etc/systemd/system/multi-user.target.wants/uncloud.service → /etc/systemd/system/uncloud.service.
⏳ Starting Uncloud machine daemon (uncloud.service)...
✓ Uncloud machine daemon started.
@@ -319,9 +323,9 @@ I'm grateful to the following projects that inspired Uncloud's design and implem
* [Tailscale](https://tailscale.com/) — for pioneering the vision of decentralised flat mesh networking with an amazing
user experience that feels like magic.
* [Talos Linux](https://github.com/siderolabs/talos)
and [KubeSpan](https://www.talos.dev/v1.10/talos-guides/network/kubespan/) — for the machine API design using
[grpc-proxy](https://github.com/siderolabs/grpc-proxy) and for its elegant approach to secure WireGuard-based overlay
networking with zero configuration.
and [KubeSpan](https://www.talos.dev/v1.10/talos-guides/network/kubespan/) — for inspiring Uncloud's API routing
design with [grpc-proxy](https://github.com/siderolabs/grpc-proxy) and for their elegant approach to secure
WireGuard-based overlay networking with zero configuration.
* [Docker Swarm Classic](https://github.com/docker-archive/classicswarm) and
[Rancher 1.x](http://rancher-com-website-main-elb-elb-1798790864.us-west-2.elb.amazonaws.com/docs/rancher/v1.6/en/)
— for showing the power of simplicity and pragmatism in container orchestration and that not every problem needs the
+3 -3
View File
@@ -22,7 +22,7 @@ const (
// ModuleID is the Caddy module ID for Uncloud storage.
ModuleID = "caddy.storage.uncloud"
// DefaultSocketPath is the default path to the Uncloud API socket.
DefaultSocketPath = "/run/uncloud/uncloud.sock"
DefaultSocketPath = "/run/uncloud/api/uncloud.sock"
// DefaultLockTTL is the default duration of a distributed lock lease.
DefaultLockTTL = 20 * time.Second
// lockCleanupTimeout bounds how long an unloaded module waits for active lock operations when cleaning up.
@@ -37,7 +37,7 @@ func init() {
// Storage implements a Caddy storage backend that uses an Uncloud cluster to store assets such as TLS certificates.
type Storage struct {
// Socket is the path to the Uncloud API socket.
// Defaults to /run/uncloud/uncloud.sock when not set.
// Defaults to /run/uncloud/api/uncloud.sock when not set.
Socket string `json:"socket,omitempty"`
// LockTTL is the duration of a distributed lock after which it expires if not renewed. Locks renew automatically
// until unlocked. If an instance crashes or cannot renew, expiry allows another instance to acquire the stale lock.
@@ -154,7 +154,7 @@ func (s *Storage) CertMagicStorage() (certmagic.Storage, error) {
//
// {
// storage uncloud {
// socket /run/uncloud/uncloud.sock
// socket /run/uncloud/api/uncloud.sock
// lock_ttl 20s
// }
// }
+1 -1
View File
@@ -192,7 +192,7 @@ func add(ctx context.Context, uncli *cli.CLI, remoteMachine *cli.RemoteMachine,
// Deploy a Caddy service container to the added machine. If caddy service is already deployed on other machines,
// use the deployed image version.
// NOTE: We use the cluster client to inspect and scale the Caddy service because the newly added machine may have
// issues accessing the Machine API of existing machines in the cluster.
// issues accessing the API of existing machines in the cluster.
// See the issue for more details: https://github.com/psviderski/uncloud/issues/65.
caddyImage := ""
caddySvc, err := clusterClient.InspectService(ctx, client.CaddyServiceName)
+3 -2
View File
@@ -88,10 +88,11 @@ func main() {
configPath := fs.ExpandHomeDir(opts.configPath)
// Make uc connect via the local Unix socket when running on a cluster machine.
if opts.connect == "" {
if !fs.Exists(configPath) && fs.Exists(machine.DefaultUncloudSockPath) {
if !fs.Exists(configPath) && fs.Exists(machine.DefaultClusterAPISockPath) {
conn = &config.MachineConnection{
Unix: machine.DefaultUncloudSockPath,
Unix: machine.DefaultClusterAPISockPath,
}
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ func newDialStdioCommand() *cobra.Command {
},
}
cmd.Flags().StringVar(&socketPath, "socket", machine.DefaultUncloudSockPath,
cmd.Flags().StringVar(&socketPath, "socket", machine.DefaultClusterAPISockPath,
"Path to the Uncloud API socket")
return cmd
+1 -1
View File
@@ -60,7 +60,7 @@ func (c *WireGuardConnector) Connect(ctx context.Context) (*grpc.ClientConn, err
}
endpoint := netip.AddrPortFrom(endpointAddr, DefaultEndpointPort)
machineManagementIP := network.ManagementIP(machine.PublicKey)
machineAPIAddr := net.JoinHostPort(machineManagementIP.String(), strconv.Itoa(constants.MachineAPIPort))
machineAPIAddr := net.JoinHostPort(machineManagementIP.String(), strconv.Itoa(constants.UncloudAPIPort))
tunCfg := &Config{
LocalAddress: c.user.ManagementIP(),
+1 -1
View File
@@ -603,7 +603,7 @@ func provisionOrConnectRemoteMachine(
if remoteMachine.User != rootUser {
// provisionMachine has just added the user to the uncloud group. Any SSH ControlMaster left over from
// a previous uc invocation (e.g. a failed uc command against the uninitialised machine) still holds
// the old user groups and would deny access to /run/uncloud/uncloud.sock. Close the current session
// the old user groups and would deny access to /run/uncloud/api/uncloud.sock. Close the current session
// if it exists so the next session picks up the new groups.
conn.CloseControlMaster(ctx)
}
+2 -2
View File
@@ -70,9 +70,9 @@ func TestMachineConnection_String(t *testing.T) {
{
name: "unix connection",
conn: MachineConnection{
Unix: "/run/uncloud/uncloud.sock",
Unix: "/run/uncloud/api/uncloud.sock",
},
want: "unix:///run/uncloud/uncloud.sock",
want: "unix:///run/uncloud/api/uncloud.sock",
},
{
name: "no connection",
+61 -1
View File
@@ -2,24 +2,50 @@ package daemon
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"github.com/coreos/go-systemd/activation"
systemd "github.com/coreos/go-systemd/daemon"
"github.com/psviderski/uncloud/internal/machine"
)
const systemdSocketUnit = "uncloud.socket"
type Daemon struct {
machine *machine.Machine
}
func New(dataDir string) (*Daemon, error) {
listeners, err := activation.ListenersWithNames()
if err != nil {
return nil, fmt.Errorf("get systemd-activated sockets: %w", err)
}
listener, err := selectActivatedListener(listeners)
if err != nil {
return nil, err
}
if listener != nil {
slog.Info("Using systemd-activated API socket.", "addr", listener.Addr().String())
}
config := &machine.Config{
DataDir: dataDir,
ClusterAPISockPath: machine.DefaultClusterAPISockPath,
ClusterAPIListener: listener,
}
mach, err := machine.NewMachine(config)
if err != nil {
return nil, fmt.Errorf("init machine: %w", err)
initErr := fmt.Errorf("init machine: %w", err)
if listener != nil {
if closeErr := listener.Close(); closeErr != nil {
initErr = errors.Join(initErr, fmt.Errorf("close systemd-activated socket: %w", closeErr))
}
}
return nil, initErr
}
return &Daemon{
@@ -27,6 +53,40 @@ func New(dataDir string) (*Daemon, error) {
}, nil
}
// selectActivatedListener returns the systemd activated listener. If systemd supplied multiple listeners,
// it returns the one associated with systemdSocketUnit.
func selectActivatedListener(listeners map[string][]net.Listener) (net.Listener, error) {
var activated []net.Listener
for _, named := range listeners {
activated = append(activated, named...)
}
if len(activated) == 0 {
return nil, nil
}
if len(activated) == 1 {
return activated[0], nil
}
named, ok := listeners[systemdSocketUnit]
if !ok || len(named) != 1 {
return nil, fmt.Errorf("expected one systemd-activated socket named '%s', received %d",
systemdSocketUnit, len(named))
}
// Close all other unused listeners.
for name, unused := range listeners {
if name == systemdSocketUnit {
continue
}
for _, l := range unused {
_ = l.Close()
}
}
return named[0], nil
}
func (d *Daemon) Run(ctx context.Context) error {
slog.Info("Starting machine.")
+71
View File
@@ -0,0 +1,71 @@
package daemon
import (
"net"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSelectActivatedListener(t *testing.T) {
t.Run("no activated listeners", func(t *testing.T) {
listener, err := selectActivatedListener(nil)
require.NoError(t, err)
assert.Nil(t, listener)
})
t.Run("only listener regardless of name", func(t *testing.T) {
activated := testListener(t)
listener, err := selectActivatedListener(map[string][]net.Listener{"another.socket": {activated}})
require.NoError(t, err)
assert.Same(t, activated, listener)
})
t.Run("named listener when multiple", func(t *testing.T) {
activated := testListener(t)
extra := testListener(t)
listener, err := selectActivatedListener(map[string][]net.Listener{
systemdSocketUnit: {activated},
"another.socket": {extra},
})
require.NoError(t, err)
assert.Same(t, activated, listener)
assert.Error(t, extra.Close())
})
t.Run("multiple listeners without expected name", func(t *testing.T) {
first := testListener(t)
second := testListener(t)
listener, err := selectActivatedListener(map[string][]net.Listener{
"first.socket": {first},
"second.socket": {second},
})
require.ErrorContains(t, err, "expected one systemd-activated socket")
assert.Nil(t, listener)
})
t.Run("multiple listeners under expected name", func(t *testing.T) {
first := testListener(t)
second := testListener(t)
listener, err := selectActivatedListener(map[string][]net.Listener{
systemdSocketUnit: {first, second},
})
require.ErrorContains(t, err, "expected one systemd-activated socket")
assert.Nil(t, listener)
})
}
func testListener(t *testing.T) net.Listener {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = listener.Close() })
return listener
}
+1 -1
View File
@@ -176,7 +176,7 @@ func (cc *clusterController) Run(ctx context.Context) error {
// Start the network API server before waiting for the store sync so the machine is reachable on the mesh
// during the sync and can serve requests that don't depend on the store.
// Assume the management IP can't be changed when the network is running.
apiAddr := net.JoinHostPort(cc.state.Network.ManagementIP.String(), strconv.Itoa(constants.MachineAPIPort))
apiAddr := net.JoinHostPort(cc.state.Network.ManagementIP.String(), strconv.Itoa(constants.UncloudAPIPort))
listener, err := net.Listen("tcp", apiAddr)
if err != nil {
return fmt.Errorf("listen API port: %w", err)
+4 -3
View File
@@ -1,8 +1,9 @@
package constants
const (
// MachineAPIPort is the port for the Machine API service on the management WireGuard network.
MachineAPIPort = 51000
// UnregistryPort is the port for the embedded container registry listening on the machine IP.
// UncloudAPIPort is the TCP port on which each machine serves the Uncloud API over the management WireGuard
// network.
UncloudAPIPort = 51000
// UnregistryPort is the TCP port for the embedded container registry listening on the machine IP.
UnregistryPort = 51500
)
+2 -2
View File
@@ -48,12 +48,12 @@ func ConfigureIptablesChains(machineIP netip.Addr, wgPort int) error {
}
}
// Allow cluster machines to access Machine API via the management IPv6 WireGuard network.
// Allow cluster machines to access the Uncloud API via the management IPv6 WireGuard network.
acceptMachineAPIRule := []string{
"-i", network.WireGuardInterfaceName,
"-s", "fdcc::/16",
"-p", "tcp",
"--dport", strconv.Itoa(constants.MachineAPIPort),
"--dport", strconv.Itoa(constants.UncloudAPIPort),
"-j", "ACCEPT",
}
// Allow Corrosion gossip traffic from cluster machines via the management IPv6 WireGuard network.
+105 -55
View File
@@ -58,8 +58,12 @@ import (
)
const (
DefaultMachineSockPath = "/run/uncloud/machine.sock"
DefaultUncloudSockPath = "/run/uncloud/uncloud.sock"
// DefaultMachineAPISockPath is the default path to the Unix socket for API requests handled directly by this
// machine.
DefaultMachineAPISockPath = "/run/uncloud/machine.sock"
// DefaultClusterAPISockPath is the default path to the Unix socket for the client-facing API that routes requests
// across the cluster.
DefaultClusterAPISockPath = "/run/uncloud/api/uncloud.sock"
DefaultSockGroup = "uncloud"
// DefaultCaddyAdminSockPath is the default path to the Caddy admin socket for validating the generated Caddy
// reverse proxy configuration.
@@ -71,8 +75,14 @@ const (
type Config struct {
// DataDir is the directory where the machine stores its persistent state. Default is /var/lib/uncloud.
DataDir string
MachineSockPath string
UncloudSockPath string
// MachineAPISockPath is the path to the Unix socket for API requests handled directly by this machine.
MachineAPISockPath string
// ClusterAPISockPath is the path to the Unix socket for the client-facing API that routes requests across the
// cluster.
ClusterAPISockPath string
// ClusterAPIListener is an optional pre-bound listener for the client-facing API that routes requests across the
// cluster. If set, Run takes ownership of it and closes it on shutdown or startup failure.
ClusterAPIListener net.Listener
CorrosionDataDir string
// CorrosionRunDir is the runtime directory for the corrosion service.
@@ -104,11 +114,11 @@ func (c *Config) SetDefaults() (*Config, error) {
if cfg.DataDir == "" {
cfg.DataDir = "/var/lib/uncloud"
}
if cfg.MachineSockPath == "" {
cfg.MachineSockPath = DefaultMachineSockPath
if cfg.MachineAPISockPath == "" {
cfg.MachineAPISockPath = DefaultMachineAPISockPath
}
if cfg.UncloudSockPath == "" {
cfg.UncloudSockPath = DefaultUncloudSockPath
if cfg.ClusterAPISockPath == "" && cfg.ClusterAPIListener == nil {
cfg.ClusterAPISockPath = DefaultClusterAPISockPath
}
if cfg.DockerClient == nil {
@@ -165,7 +175,7 @@ type Machine struct {
config Config
state *State
// started is closed when the machine is ready to serve requests on the local API server.
// started is closed when the machine and cluster API servers are ready to serve requests.
started chan struct{}
// initialised is closed when the machine is configured as a member of a cluster.
initialised chan struct{}
@@ -186,15 +196,14 @@ type Machine struct {
// dockerService provides high-level operations for managing Docker containers.
dockerService *machinedocker.Service
dockerServer *machinedocker.Server
// localMachineServer is the gRPC server for the machine API listening on the local Unix socket.
localMachineServer *grpc.Server
// machineAPIServer handles API requests directly on this machine.
machineAPIServer *grpc.Server
// proxyDirector manages routing of gRPC requests between local and remote machine API servers.
// proxyDirector routes API requests to local or remote machines.
proxyDirector *apiproxy.Director
// localProxyServer is the gRPC proxy server for the machine API listening on the local Unix socket.
// It proxies requests to the local or remote machine API servers depending on the request targets
// and aggregates responses.
localProxyServer *grpc.Server
// clusterAPIServer is the client-facing gRPC server that routes API requests across the cluster. It sends requests
// to one or more local or remote machines and aggregates their responses.
clusterAPIServer *grpc.Server
// mu protects the Machine from concurrent reads and writes.
mu sync.RWMutex
@@ -269,10 +278,10 @@ func NewMachine(config *Config) (*Machine, error) {
}
dockerService := machinedocker.NewService(config.DockerClient, db)
// Init a local gRPC proxy server that proxies requests to the local or remote machine API servers.
// Init the client-facing API server that routes requests to local or remote machines.
mapper := apiproxy.NewCorrosionMapper(corroStore)
proxyDirector := apiproxy.NewDirector(config.MachineSockPath, constants.MachineAPIPort, mapper)
localProxyServer := grpc.NewServer(
proxyDirector := apiproxy.NewDirector(config.MachineAPISockPath, constants.UncloudAPIPort, mapper)
clusterAPIServer := grpc.NewServer(
grpc.ForceServerCodecV2(proxy.Codec()),
grpc.UnaryInterceptor(grpcversion.ServerUnaryInterceptor),
grpc.StreamInterceptor(grpcversion.ServerStreamInterceptor),
@@ -291,7 +300,7 @@ func NewMachine(config *Config) (*Machine, error) {
store: corroStore,
cluster: c,
dockerService: dockerService,
localProxyServer: localProxyServer,
clusterAPIServer: clusterAPIServer,
proxyDirector: proxyDirector,
}
@@ -316,7 +325,7 @@ func NewMachine(config *Config) (*Machine, error) {
caddyStorageServer := caddystorage.NewServer(caddyStore)
leaseServer := distlockgrpc.NewServer(distlock.NewMemoryStore())
m.localMachineServer = newGRPCServer(m, c, m.dockerServer, caddyServer, caddyStorageServer, leaseServer)
m.machineAPIServer = newGRPCServer(m, c, m.dockerServer, caddyServer, caddyStorageServer, leaseServer)
if m.Initialised() {
close(m.initialised)
@@ -343,7 +352,7 @@ func newGRPCServer(
return s
}
// Started returns a channel that is closed when the machine is ready to serve requests on the local API server.
// Started returns a channel that is closed when the machine and cluster API servers are ready to serve requests.
func (m *Machine) Started() <-chan struct{} {
return m.started
}
@@ -395,21 +404,54 @@ func (m *Machine) Run(ctx context.Context) error {
// Create a cancellable context for the Run method to allow stopping the machine gracefully.
ctx, m.stop = context.WithCancel(ctx)
// Take over the systemd-activated socket for the cluster API if provided, otherwise bind to the configured
// Unix socket path.
clusterAPIListener := m.config.ClusterAPIListener
clusterAPISockPath := m.config.ClusterAPISockPath
if clusterAPIListener != nil {
if clusterAPIListener.Addr().Network() == "unix" {
clusterAPISockPath = clusterAPIListener.Addr().String()
} else {
clusterAPISockPath = ""
}
defer clusterAPIListener.Close()
}
sockGID, err := socketGID()
if err != nil {
return err
}
if err = prepareUnixSocketDirectory(m.config.MachineAPISockPath, sockGID); err != nil {
return fmt.Errorf("prepare machine API Unix socket directory: %w", err)
}
// The cluster API socket activated by systemd may have been created with root group ownership.
// Apply the intended access permissions.
if clusterAPISockPath != "" {
if err = prepareUnixSocketDirectory(clusterAPISockPath, sockGID); err != nil {
return fmt.Errorf("prepare cluster API Unix socket directory: %w", err)
}
}
// Docker dependency is essential for the machine to function. Block until it's ready.
if err := docker.WaitDaemonReady(ctx, m.config.DockerClient); err != nil {
return fmt.Errorf("wait for Docker daemon: %w", err)
}
defer m.config.DockerClient.Close()
// Bind the local API listeners before starting the dependencies (e.g. corrosion) to not deal with the teardown
// Bind the API listeners before starting the dependencies (e.g. corrosion) to not deal with the teardown
// on failure.
machineListener, err := listenUnixSocket(m.config.MachineSockPath)
machineAPIListener, err := sockets.NewUnixSocket(m.config.MachineAPISockPath, sockGID)
if err != nil {
return fmt.Errorf("listen machine API unix socket %q: %w", m.config.MachineSockPath, err)
return fmt.Errorf("listen machine API Unix socket %q: %w", m.config.MachineAPISockPath, err)
}
proxyListener, err := listenUnixSocket(m.config.UncloudSockPath)
defer machineAPIListener.Close()
if clusterAPIListener == nil {
clusterAPIListener, err = sockets.NewUnixSocket(clusterAPISockPath, sockGID)
if err != nil {
return fmt.Errorf("listen API proxy unix socket %q: %w", m.config.UncloudSockPath, err)
return fmt.Errorf("listen cluster API Unix socket %q: %w", clusterAPISockPath, err)
}
defer clusterAPIListener.Close()
}
// Configure and start the corrosion service on the loopback if the machine is not initialised as a cluster
@@ -437,20 +479,20 @@ func (m *Machine) Run(ctx context.Context) error {
// Use an errgroup to coordinate error handling and graceful shutdown of multiple machine components.
errGroup, ctx := errgroup.WithContext(ctx)
// Start the local machine API server.
// Start the API server that handles requests directly on this machine.
errGroup.Go(func() error {
slog.Info("Starting local machine API server.", "path", m.config.MachineSockPath)
if err := m.localMachineServer.Serve(machineListener); err != nil {
return fmt.Errorf("local machine API server failed: %w", err)
slog.Info("Starting machine API server.", "path", m.config.MachineAPISockPath)
if err := m.machineAPIServer.Serve(machineAPIListener); err != nil {
return fmt.Errorf("machine API server failed: %w", err)
}
return nil
})
// Start the local API proxy server.
// Start the client-facing API server that routes requests across the cluster.
errGroup.Go(func() error {
slog.Info("Starting local API proxy server.", "path", m.config.UncloudSockPath)
if err := m.localProxyServer.Serve(proxyListener); err != nil {
return fmt.Errorf("local API proxy server failed: %w", err)
slog.Info("Starting cluster API server.", "path", clusterAPIListener.Addr().String())
if err := m.clusterAPIServer.Serve(clusterAPIListener); err != nil {
return fmt.Errorf("cluster API server failed: %w", err)
}
return nil
})
@@ -478,7 +520,7 @@ func (m *Machine) Run(ctx context.Context) error {
slog.Info("Starting cluster controller.")
// Update the proxy director's local address to the machine's management IP address, allowing
// the proxy to identify which requests should be proxied to the local machine API server.
// the proxy to identify which requests should be handled by the local API server.
m.proxyDirector.UpdateLocalAddress(m.state.Network.ManagementIP.String())
proxyServer := grpc.NewServer(
grpc.ForceServerCodecV2(proxy.Codec()),
@@ -577,17 +619,17 @@ func (m *Machine) Run(ctx context.Context) error {
// Shutdown goroutine.
errGroup.Go(func() error {
<-ctx.Done()
slog.Info("Stopping local machine API server.")
slog.Info("Stopping machine API server.")
// TODO: implement timeout for graceful shutdown.
m.localMachineServer.GracefulStop()
slog.Info("Local machine API server stopped.")
m.machineAPIServer.GracefulStop()
slog.Info("Machine API server stopped.")
slog.Info("Stopping local API proxy server.")
slog.Info("Stopping cluster API server.")
// TODO: implement timeout for graceful shutdown.
m.localProxyServer.GracefulStop()
m.clusterAPIServer.GracefulStop()
// Close the proxy director to close all backend connections.
m.proxyDirector.Close()
slog.Info("Local API proxy server stopped.")
slog.Info("Cluster API server stopped.")
return nil
})
@@ -616,38 +658,46 @@ func (m *Machine) Run(ctx context.Context) error {
return err
}
// listenUnixSocket creates a new Unix socket listener with the specified path. The socket file is created with 0660
// access mode and uncloud group if the group is found, otherwise it falls back to the root group.
func listenUnixSocket(path string) (net.Listener, error) {
// socketGID returns the uncloud group ID, or the root group ID if the uncloud group does not exist.
func socketGID() (int, error) {
gid := 0 // Fall back to the root group if the uncloud group is not found.
group, err := user.LookupGroup(DefaultSockGroup)
if err != nil {
//goland:noinspection GoTypeAssertionOnErrors
if _, ok := err.(user.UnknownGroupError); ok {
slog.Info(
"Specified group not found, using root group for the API socket.",
"group", DefaultSockGroup, "path", path,
"Specified group not found, using root group for API sockets.",
"group", DefaultSockGroup,
)
} else {
return nil, fmt.Errorf("lookup %q group ID (GID): %w", DefaultSockGroup, err)
return 0, fmt.Errorf("lookup %q group ID (GID): %w", DefaultSockGroup, err)
}
} else {
gid, err = strconv.Atoi(group.Gid)
if err != nil {
return nil, fmt.Errorf("parse %q group ID (GID) %q: %w", DefaultSockGroup, group.Gid, err)
return 0, fmt.Errorf("parse %q group ID (GID) %q: %w", DefaultSockGroup, group.Gid, err)
}
}
// Ensure the parent directory exists and has the correct group permissions.
return gid, nil
}
// prepareUnixSocketDirectory ensures the socket's parent directory has the intended group and permissions.
func prepareUnixSocketDirectory(path string, gid int) error {
parent, _ := filepath.Split(path)
if err = os.MkdirAll(parent, 0o750); err != nil {
return nil, fmt.Errorf("create directory %q: %w", parent, err)
if err := os.MkdirAll(parent, 0o750); err != nil {
return fmt.Errorf("create directory %q: %w", parent, err)
}
if err = os.Chown(parent, -1, gid); err != nil {
return nil, fmt.Errorf("chown directory %q: %w", parent, err)
if err := os.Chown(parent, -1, gid); err != nil {
return fmt.Errorf("chown directory %q: %w", parent, err)
}
// MkdirAll preserves the mode of an existing directory. A Docker container may have created the bind-mount source
// first, so apply the intended mode explicitly.
if err := os.Chmod(parent, 0o750); err != nil {
return fmt.Errorf("chmod directory %q: %w", parent, err)
}
return sockets.NewUnixSocket(path, gid)
return nil
}
func (m *Machine) configureCorrosion() error {
+4 -4
View File
@@ -35,7 +35,7 @@ func (cfg *SSHConnectorConfig) Destination() string {
return dst
}
// SSHConnector establishes a connection to the machine API through an SSH tunnel to the machine.
// SSHConnector establishes a connection to the Uncloud API through an SSH tunnel to the machine.
type SSHConnector struct {
config SSHConnectorConfig
client *ssh.Client
@@ -69,7 +69,7 @@ func (c *SSHConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
sockPath := c.config.SockPath
if sockPath == "" {
sockPath = machine.DefaultUncloudSockPath
sockPath = machine.DefaultClusterAPISockPath
}
conn, err := grpc.NewClient(
"unix://"+sockPath,
@@ -83,7 +83,7 @@ func (c *SSHConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
conn, dErr := c.client.DialContext(ctx, "unix", addr)
if dErr != nil {
return nil, fmt.Errorf(
"connect to machine API socket '%s' through SSH tunnel (is uncloud.service running "+
"connect to Uncloud API socket '%s' through SSH tunnel (is uncloud.service running "+
"on the remote machine and does the SSH user '%s' have permissions to access the socket?):"+
" %w",
addr, c.client.User(), dErr,
@@ -94,7 +94,7 @@ func (c *SSHConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
),
)
if err != nil {
return nil, fmt.Errorf("create machine API client: %w", err)
return nil, fmt.Errorf("create Uncloud API client: %w", err)
}
return conn, nil
}
+2 -2
View File
@@ -19,7 +19,7 @@ import (
"google.golang.org/grpc/credentials/insecure"
)
// SSHCLIConnector establishes a connection to the machine API by executing SSH CLI
// SSHCLIConnector establishes a connection to the Uncloud API by executing SSH CLI
// and running `uncloudd dial-stdio` on the remote machine.
type SSHCLIConnector struct {
config SSHConnectorConfig
@@ -104,7 +104,7 @@ func (c *SSHCLIConnector) Connect(ctx context.Context) (*grpc.ClientConn, error)
}),
)
if err != nil {
return nil, fmt.Errorf("create machine API client: %w", err)
return nil, fmt.Errorf("create Uncloud API client: %w", err)
}
return grpcConn, nil
+2 -2
View File
@@ -13,7 +13,7 @@ import (
"google.golang.org/grpc/credentials/insecure"
)
// TCPConnector establishes a connection to the machine API through a direct TCP connection to an API endpoint.
// TCPConnector establishes a connection to the Uncloud API through a direct TCP connection.
type TCPConnector struct {
apiAddr netip.AddrPort
}
@@ -40,7 +40,7 @@ func (c *TCPConnector) Connect(_ context.Context) (*grpc.ClientConn, error) {
grpc.WithStreamInterceptor(grpcversion.ClientStreamInterceptor),
)
if err != nil {
return nil, fmt.Errorf("create machine API client: %w", err)
return nil, fmt.Errorf("create Uncloud API client: %w", err)
}
return conn, nil
}
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"google.golang.org/grpc/credentials/insecure"
)
// UnixConnector establishes a connection to the machine API through a unix domain socket.
// UnixConnector establishes a connection to the Uncloud API through a Unix domain socket.
type UnixConnector struct {
socketPath string
}
@@ -31,7 +31,7 @@ func (c *UnixConnector) Connect(_ context.Context) (*grpc.ClientConn, error) {
grpc.WithStreamInterceptor(grpcversion.ClientStreamInterceptor),
)
if err != nil {
return nil, fmt.Errorf("create machine API client: %w", err)
return nil, fmt.Errorf("create Uncloud API client: %w", err)
}
return conn, nil
}
+3 -3
View File
@@ -35,9 +35,9 @@ echo "Docker in Docker is ready."
echo "Loading corrosion image from /images/corrosion.tar..."
docker load < /images/corrosion.tar
# Make machine API accessible from the host via port publishing.
echo "Proxying Uncloud API port 51000/tcp to Unix socket /run/uncloud/uncloud.sock..."
socat TCP-LISTEN:51000,reuseaddr,fork,bind="$(hostname -i)" UNIX-CONNECT:/run/uncloud/uncloud.sock &
# Make the Uncloud API accessible from the host via port publishing.
echo "Proxying Uncloud API port 51000/tcp to Unix socket /run/uncloud/api/uncloud.sock..."
socat TCP-LISTEN:51000,reuseaddr,fork,bind="$(hostname -i)" UNIX-CONNECT:/run/uncloud/api/uncloud.sock &
# Execute the passed command and wait for it while maintaining signal handling.
"$@" &
+24 -3
View File
@@ -277,11 +277,33 @@ install_uncloud_binaries() {
}
install_uncloud_systemd() {
local uncloud_socket_path="${INSTALL_SYSTEMD_DIR}/uncloud.socket"
local uncloud_service_path="${INSTALL_SYSTEMD_DIR}/uncloud.service"
mkdir -p "${INSTALL_SYSTEMD_DIR}"
cat > "${uncloud_socket_path}" << EOF
[Unit]
Description=Uncloud API socket
Before=docker.service
[Socket]
ListenStream=/run/uncloud/api/uncloud.sock
SocketUser=root
SocketGroup=uncloud
SocketMode=0660
DirectoryMode=0750
[Install]
WantedBy=sockets.target
EOF
log "✓ Systemd unit file created: ${uncloud_socket_path}"
cat > "${uncloud_service_path}" << EOF
[Unit]
Description=Uncloud machine daemon
After=network-online.target docker.service
Requires=uncloud.socket
After=network-online.target uncloud.socket docker.service
Wants=network-online.target
[Service]
@@ -306,12 +328,11 @@ WantedBy=multi-user.target
EOF
log "✓ Systemd unit file created: ${uncloud_service_path}"
if [[ "${INSTALL_ONLY}" != "true" ]]; then
# Reload systemd to recognize the new or updated unit file.
systemctl daemon-reload
fi
systemctl enable uncloud.service
systemctl enable uncloud.socket uncloud.service
}
start_uncloud() {
+3
View File
@@ -57,14 +57,17 @@ fi
log "⏳ Stopping systemd services..."
systemctl stop uncloud.service || log "uncloud.service not running or doesn't exist."
systemctl stop uncloud.socket || log "uncloud.socket not running or doesn't exist."
# TODO: remove uncloud-corrosion.service handling in 0.22 once pre-0.20 systemd installs are gone.
systemctl stop uncloud-corrosion.service || log "uncloud-corrosion.service not running or doesn't exist."
systemctl disable uncloud.service || log "uncloud.service already disabled or doesn't exist."
systemctl disable uncloud.socket || log "uncloud.socket already disabled or doesn't exist."
systemctl disable uncloud-corrosion.service || log "uncloud-corrosion.service already disabled or doesn't exist."
log "✓ Systemd services stopped."
log "⏳ Removing systemd service files..."
rm -fv "${INSTALL_SYSTEMD_DIR}/uncloud.service"
rm -fv "${INSTALL_SYSTEMD_DIR}/uncloud.socket"
# TODO: remove uncloud-corrosion.service handling in 0.22 once pre-0.20 systemd installs are gone.
rm -fv "${INSTALL_SYSTEMD_DIR}/uncloud-corrosion.service"
systemctl daemon-reload
@@ -142,7 +142,9 @@ WARNING: Access to the remote API on a privileged Docker daemon is equivalent
✓ uncloudd binary installed: /usr/local/bin/uncloudd
⏳ Downloading uninstall script: https://raw.githubusercontent.com/psviderski/uncloud/refs/heads/main/scripts/uninstall.sh
✓ uncloud-uninstall script installed: /usr/local/bin/uncloud-uninstall
✓ Systemd unit file created: /etc/systemd/system/uncloud.socket
✓ Systemd unit file created: /etc/systemd/system/uncloud.service
Created symlink /etc/systemd/system/sockets.target.wants/uncloud.socket → /etc/systemd/system/uncloud.socket.
Created symlink /etc/systemd/system/multi-user.target.wants/uncloud.service → /etc/systemd/system/uncloud.service.
⏳ Starting Uncloud machine daemon (uncloud.service)...
✓ Uncloud machine daemon started.
@@ -393,9 +395,11 @@ The following actions will be performed:
Do you want to proceed with uninstallation? [y/N] y
⏳ Stopping systemd services...
Removed /etc/systemd/system/multi-user.target.wants/uncloud.service.
Removed /etc/systemd/system/sockets.target.wants/uncloud.socket.
✓ Systemd services stopped.
⏳ Removing systemd service files...
removed '/etc/systemd/system/uncloud.service'
removed '/etc/systemd/system/uncloud.socket'
✓ Systemd service files removed.
⏳ Removing binaries...
removed '/usr/local/bin/uncloudd'
@@ -17,9 +17,9 @@ cluster. It has a name and a list of connection details for the machines in that
A context is not the same thing as a cluster. It is your local view of a cluster: which machines you can connect through
and in what order to try them. Different people or environments may need to reach the same cluster in different ways.
You can also manually create multiple contexts for the same cluster. For example, one that connects through
a machine with a public IP when you're not in the office, and another that connects through a private machine on the
office network when you're on-site to reduce latency. You can switch between them depending on where you are.
You can also manually create multiple contexts for the same cluster. For example, one that connects through a machine
with a public IP when you're not in the office, and another that connects through a private machine on the office
network when you're on-site to reduce latency. You can switch between them depending on where you are.
### Managing contexts
@@ -46,11 +46,14 @@ When you run a `uc` command, it determines which cluster to connect to using thi
Once the context is resolved, `uc` tries each connection in the context's `connections` list in order until one
succeeds.
## User permissions on the machine
## API socket access
When `uc` connects to a machine over SSH, it communicates with the Uncloud daemon through the Unix socket
`/run/uncloud/uncloud.sock` on that machine. The daemon restricts access to the socket to the `root` user and members
of the `uncloud` Linux group. This means your SSH user must be either `root` or a member of the `uncloud` group.
The Uncloud daemon exposes the API through the Unix socket `/run/uncloud/api/uncloud.sock` on each machine. It restricts
access to the `root` user and members of the `uncloud` Linux group.
### User access
When `uc` connects to a machine over SSH, the SSH user must be either `root` or a member of the `uncloud` group.
In most cases you don't need to set this up manually. When you initialise or add a machine with a non-root user,
`uc machine init` and `uc machine add` automatically add that user to the `uncloud` group during installation.
@@ -67,6 +70,21 @@ user, close any long-running SSH connections to the machine (for example, SSH Co
The same requirement applies when running `uc` locally on a cluster machine with a `unix://` connection. The local user
must be `root` or a member of the `uncloud` group.
### Container access
If a container needs to talk to the Uncloud API over the socket, mount `/run/uncloud/api` as a read-only directory. Do
not mount the socket file directly. The directory mount lets the container see a replacement socket after the daemon
restarts.
:::warning Full cluster access
Mounting the Uncloud API socket gives the container the same cluster-wide privileges as a local `uc` client. A process
with access can manage workloads across the cluster and may be able to gain root access to cluster machines through the
workloads it creates. Treat the Uncloud API socket like the Docker socket. Only mount it into containers that you fully
trust.
:::
## Global flags and environment variables
These flags are available on every `uc` command. They can also be set with an environment variable. The flag takes
@@ -95,11 +113,11 @@ uc --connect ssh://root@203.0.113.1 ls
# Go's built-in SSH library (no SSH config support, useful when the system ssh is not available)
uc --connect ssh+go://root@203.0.113.1 ls
# Direct connection to machine gRPC API over TCP (for advanced users with custom setups)
# Direct connection to the Uncloud API over TCP (for advanced users with custom setups)
uc --connect tcp://[fdcc:4439:f545:3ca:5d17:66e5:7c96:40bd]:51000 ls
# Direct connection to machine gRPC API over a Unix socket (for running uc locally on a cluster machine)
uc --connect unix:///run/uncloud/uncloud.sock ls
# Direct connection to the Uncloud API over a Unix socket (for running uc locally on a cluster machine)
uc --connect unix:///run/uncloud/api/uncloud.sock ls
```
:::info
+3 -3
View File
@@ -69,11 +69,11 @@ next one. You can change the default connection with an interactive command
Every connection must have exactly one connection type attribute:
| Attribute | Format | Description |
|-----------|-----------------------------|------------------------------------------------------------------------------------------------------------------------------------|
|-----------|---------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
| `ssh` | `user@host[:port]` | Connect using the system `ssh` command with full SSH config support (default for new connections added with `uc machine init/add`) |
| `ssh_go` | `user@host[:port]` | Connect using Go's built-in SSH library (no SSH config support) |
| `tcp` | `host:port` | Connect directly to the machine gRPC API over TCP (for advanced users with custom setups) |
| `unix` | `/run/uncloud/uncloud.sock` | Connect directly to the machine gRPC API over a Unix socket (for running `uc` locally on the cluster machines) |
| `tcp` | `host:port` | Connect directly to the Uncloud API over TCP (for advanced users with custom setups) |
| `unix` | `/run/uncloud/api/uncloud.sock` | Connect directly to the Uncloud API over a Unix socket (for running `uc` locally on a cluster machine) |
A connection can also have these optional attributes: