Skip to content

Commit c0772b8

Browse files
committed
feat: add airgapped mode to QEMU backed talos
Add new `--airgapped` flag to talos cluster create (qemu) to disable NAT in the VMs to effectively become airgapped. Signed-off-by: Mateusz Urbanek <mateusz.urbanek@siderolabs.com>
1 parent ac60a9e commit c0772b8

File tree

34 files changed

+728
-68
lines changed

34 files changed

+728
-68
lines changed

.github/workflows/ci.yaml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT.
22
#
3-
# Generated on 2025-10-16T09:22:23Z by kres 7a9d88c.
3+
# Generated on 2025-10-22T12:39:45Z by kres 46e133d.
44

55
concurrency:
66
group: ${{ github.head_ref || github.run_id }}
@@ -625,6 +625,20 @@ jobs:
625625
if: github.event_name == 'schedule'
626626
run: |
627627
make talosctl-cni-bundle
628+
- name: integration-images-list
629+
env:
630+
IMAGE_REGISTRY: registry.dev.siderolabs.io
631+
run: |
632+
make integration-images-list
633+
- name: e2e-airgapped-no-proxy
634+
env:
635+
GITHUB_STEP_NAME: ${{ github.job}}-e2e-no-proxy
636+
IMAGE_REGISTRY: registry.dev.siderolabs.io
637+
SHORT_INTEGRATION_TEST: "yes"
638+
WITH_AIRGAPPED: no-proxy
639+
WITH_CLUSTER_DISCOVERY: "false"
640+
run: |
641+
sudo -E make e2e-qemu
628642
- name: e2e-airgapped-http-proxy
629643
env:
630644
GITHUB_STEP_NAME: ${{ github.job}}-e2e-http-proxy

.github/workflows/integration-airgapped-cron.yaml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT.
22
#
3-
# Generated on 2025-10-01T17:01:09Z by kres bc281a9.
3+
# Generated on 2025-10-22T12:39:45Z by kres 46e133d.
44

55
concurrency:
66
group: ${{ github.head_ref || github.run_id }}
@@ -77,6 +77,20 @@ jobs:
7777
if: github.event_name == 'schedule'
7878
run: |
7979
make talosctl-cni-bundle
80+
- name: integration-images-list
81+
env:
82+
IMAGE_REGISTRY: registry.dev.siderolabs.io
83+
run: |
84+
make integration-images-list
85+
- name: e2e-airgapped-no-proxy
86+
env:
87+
GITHUB_STEP_NAME: ${{ github.job}}-e2e-no-proxy
88+
IMAGE_REGISTRY: registry.dev.siderolabs.io
89+
SHORT_INTEGRATION_TEST: "yes"
90+
WITH_AIRGAPPED: no-proxy
91+
WITH_CLUSTER_DISCOVERY: "false"
92+
run: |
93+
sudo -E make e2e-qemu
8094
- name: e2e-airgapped-http-proxy
8195
env:
8296
GITHUB_STEP_NAME: ${{ github.job}}-e2e-http-proxy

.kres.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -998,6 +998,19 @@ spec:
998998
- name: talosctl-cni-bundle
999999
conditions:
10001000
- only-on-schedule
1001+
- name: integration-images-list
1002+
command: integration-images-list
1003+
environment:
1004+
IMAGE_REGISTRY: registry.dev.siderolabs.io
1005+
- name: e2e-airgapped-no-proxy
1006+
command: e2e-qemu
1007+
withSudo: true
1008+
environment:
1009+
GITHUB_STEP_NAME: ${{ github.job}}-e2e-no-proxy
1010+
SHORT_INTEGRATION_TEST: yes
1011+
WITH_AIRGAPPED: no-proxy
1012+
WITH_CLUSTER_DISCOVERY: "false"
1013+
IMAGE_REGISTRY: registry.dev.siderolabs.io
10011014
- name: e2e-airgapped-http-proxy
10021015
command: e2e-qemu
10031016
withSudo: true

Makefile

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -482,10 +482,15 @@ uki-certs: talosctl ## Generate test certificates for SecureBoot/PCR Signing
482482
@$(TALOSCTL_EXECUTABLE) gen secureboot pcr
483483
@$(TALOSCTL_EXECUTABLE) gen secureboot database
484484

485-
.PHONY: cache-create
486-
cache-create: installer imager ## Generate image cache.
485+
.PHONY: integration-images-list
486+
integration-images-list: ## Generate list of integration images.
487487
@docker run --entrypoint /usr/local/bin/e2e.test registry.k8s.io/conformance:$(KUBECTL_VERSION) --list-images | \
488-
$(TALOSCTL_EXECUTABLE) images integration --installer-tag=$(IMAGE_TAG_IN) --registry-and-user=$(REGISTRY_AND_USERNAME) | \
488+
$(TALOSCTL_EXECUTABLE) images integration --installer-tag=$(IMAGE_TAG_IN) --registry-and-user=$(REGISTRY_AND_USERNAME) \
489+
> $(ARTIFACTS)/integration-images.txt
490+
491+
.PHONY: cache-create
492+
cache-create: installer imager integration-images-list ## Generate image cache.
493+
@cat $(ARTIFACTS)/integration-images.txt | \
489494
$(TALOSCTL_EXECUTABLE) images cache-create --image-cache-path=/tmp/cache.tar --images=- --force
490495
@crane push /tmp/cache.tar $(REGISTRY_AND_USERNAME)/image-cache:$(IMAGE_TAG_OUT)
491496
@$(MAKE) image-iso IMAGER_ARGS="--image-cache=$(REGISTRY_AND_USERNAME)/image-cache:$(IMAGE_TAG_OUT) --extra-kernel-arg='console=ttyS0'"

cmd/talosctl/cmd/mgmt/cluster/create/clusterops/configmaker/internal/makers/qemu.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ func (m *Qemu) AddExtraConfigBundleOpts() error {
219219

220220
// ModifyClusterRequest implements ExtraOptionsProvider.
221221
func (m *Qemu) ModifyClusterRequest() error {
222-
nameserverIPs, err := getNameserverIPs(m.EOps.Nameservers)
222+
nameserverIPs, err := getNameserverIPs(m.EOps.Nameservers, m.GatewayIPs)
223223
if err != nil {
224224
return err
225225
}
@@ -259,6 +259,11 @@ func (m *Qemu) ModifyClusterRequest() error {
259259
m.ClusterRequest.Network.PacketReorder = m.EOps.PacketReorder
260260
m.ClusterRequest.Network.PacketCorrupt = m.EOps.PacketCorrupt
261261
m.ClusterRequest.Network.Bandwidth = m.EOps.Bandwidth
262+
m.ClusterRequest.Network.Airgapped = m.EOps.Airgapped
263+
m.ClusterRequest.Network.ImageCachePath = m.EOps.ImageCachePath
264+
m.ClusterRequest.Network.ImageCacheTLSCertFile = m.EOps.ImageCacheTLSCertFile
265+
m.ClusterRequest.Network.ImageCacheTLSKeyFile = m.EOps.ImageCacheTLSKeyFile
266+
m.ClusterRequest.Network.ImageCachePort = m.EOps.ImageCachePort
262267

263268
m.ClusterRequest.KernelPath = m.EOps.NodeVmlinuzPath
264269
m.ClusterRequest.InitramfsPath = m.EOps.NodeInitramfsPath
@@ -659,9 +664,13 @@ func (m *Qemu) initJSONLogs() {
659664
m.ConfigBundleOps = slices.Concat(m.ConfigBundleOps, []bundle.Option{bundle.WithPatch([]configpatcher.Patch{configpatcher.NewStrategicMergePatch(cfg)})})
660665
}
661666

662-
func getNameserverIPs(nameservers []string) ([]netip.Addr, error) {
667+
func getNameserverIPs(nameservers []string, gatewayIPs []netip.Addr) ([]netip.Addr, error) {
663668
nameserverIPs := make([]netip.Addr, len(nameservers))
664669

670+
if len(nameservers) == 0 {
671+
return gatewayIPs, nil
672+
}
673+
665674
for i := range nameserverIPs {
666675
ip, err := netip.ParseAddr(nameservers[i])
667676
if err != nil {

cmd/talosctl/cmd/mgmt/cluster/create/clusterops/options.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ type Qemu struct {
140140
DebugShellEnabled bool
141141
WithIOMMU bool
142142
ConfigInjectionMethod string
143+
Airgapped bool
144+
ImageCachePath string
145+
ImageCacheTLSCertFile string
146+
ImageCacheTLSKeyFile string
147+
ImageCachePort uint16
143148
}
144149

145150
// GetCommon returns the default common options.
@@ -181,15 +186,16 @@ func GetQemu() Qemu {
181186
PreallocateDisks: false,
182187
BootloaderEnabled: true,
183188
UefiEnabled: true,
184-
Nameservers: []string{"8.8.8.8", "1.1.1.1", "2001:4860:4860::8888", "2606:4700:4700::1111"},
189+
Nameservers: []string{},
185190
DiskBlockSize: 512,
186191
TargetArch: runtime.GOARCH,
187192
CniBinPath: []string{filepath.Join(clustercmd.DefaultCNIDir, "bin")},
188193
CniConfDir: filepath.Join(clustercmd.DefaultCNIDir, "conf.d"),
189194
CniCacheDir: filepath.Join(clustercmd.DefaultCNIDir, "cache"),
190195
CniBundleURL: fmt.Sprintf("https://github.com/%s/talos/releases/download/%s/talosctl-cni-bundle-%s.tar.gz",
191196
images.Username, version.Trim(version.Tag), constants.ArchVariable),
192-
Disks: disks,
197+
Disks: disks,
198+
ImageCachePort: 5000,
193199
}
194200
}
195201

cmd/talosctl/cmd/mgmt/cluster/create/cmd_dev.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ func getCreateCmd() *cobra.Command {
9898
withUUIDHostnamesFlag = "with-uuid-hostnames"
9999
withSiderolinkAgentFlag = "with-siderolink"
100100
configInjectionMethodFlag = "config-injection-method"
101+
airgappedFlag = "airgapped"
102+
imageCachePathFlag = "image-cache-path"
103+
imageCacheTLSCertFileFlag = "image-cache-tls-cert-file"
104+
imageCacheTLSKeyFileFlag = "image-cache-tls-key-file"
105+
imageCachePortFlag = "image-cache-port"
101106

102107
// The following flags are the gen options - the options that are only used in machine configuration (i.e., not during the qemu/docker provisioning).
103108
// They are not applicable when no machine configuration is generated, hence mutually exclusive with the --input-dir flag.
@@ -131,6 +136,7 @@ func getCreateCmd() *cobra.Command {
131136
packetReorderFlag,
132137
packetCorruptFlag,
133138
bandwidthFlag,
139+
airgappedFlag,
134140

135141
// The following might work but need testing first.
136142
configInjectionMethodFlag,
@@ -209,7 +215,7 @@ func getCreateCmd() *cobra.Command {
209215
qemu.MarkHidden("with-debug-shell") //nolint:errcheck
210216
qemu.StringSliceVar(&qOps.ExtraUEFISearchPaths, extraUEFISearchPathsFlag, qOps.ExtraUEFISearchPaths, "additional search paths for UEFI firmware (only applies when UEFI is enabled)")
211217
qemu.StringSliceVar(&qOps.NetworkNoMasqueradeCIDRs, networkNoMasqueradeCIDRsFlag, qOps.NetworkNoMasqueradeCIDRs, "list of CIDRs to exclude from NAT")
212-
qemu.StringSliceVar(&qOps.Nameservers, nameserversFlag, qOps.Nameservers, "list of nameservers to use")
218+
qemu.StringSliceVar(&qOps.Nameservers, nameserversFlag, qOps.Nameservers, "list of nameservers to use, by default use embedded DNS forwarder")
213219
qemu.UintVar(&qOps.DiskBlockSize, diskBlockSizeFlag, qOps.DiskBlockSize, "disk block size")
214220
qemu.StringVar(&qOps.TargetArch, targetArchFlag, qOps.TargetArch, "cluster architecture")
215221
qemu.StringSliceVar(&qOps.CniBinPath, cniBinPathFlag, qOps.CniBinPath, "search path for CNI binaries")
@@ -239,6 +245,11 @@ func getCreateCmd() *cobra.Command {
239245
"enables the use of siderolink agent as configuration apply mechanism. `true` or `wireguard` enables the agent, `tunnel` enables the agent with grpc tunneling")
240246
qemu.StringVar(&qOps.ConfigInjectionMethod,
241247
configInjectionMethodFlag, qOps.ConfigInjectionMethod, "a method to inject machine config: default is HTTP server, 'metal-iso' to mount an ISO")
248+
qemu.BoolVar(&qOps.Airgapped, airgappedFlag, qOps.Airgapped, "limit VM network access to the provisioning network only")
249+
qemu.StringVar(&qOps.ImageCachePath, imageCachePathFlag, qOps.ImageCachePath, "path to image cache")
250+
qemu.StringVar(&qOps.ImageCacheTLSCertFile, imageCacheTLSCertFileFlag, qOps.ImageCacheTLSCertFile, "path to image cache TLS cert")
251+
qemu.StringVar(&qOps.ImageCacheTLSKeyFile, imageCacheTLSKeyFileFlag, qOps.ImageCacheTLSKeyFile, "path to image cache TLS key")
252+
qemu.Uint16Var(&qOps.ImageCachePort, imageCachePortFlag, qOps.ImageCachePort, "port on which to serve image cache")
242253

243254
return qemu
244255
}

cmd/talosctl/cmd/mgmt/debug/air-gapped.go

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ package debug
77
import (
88
"context"
99
"crypto/tls"
10-
stdx509 "crypto/x509"
1110
"embed"
1211
"fmt"
1312
"io"
@@ -21,10 +20,10 @@ import (
2120
"strconv"
2221
"time"
2322

24-
"github.com/siderolabs/crypto/x509"
2523
"github.com/spf13/cobra"
2624
"golang.org/x/sync/errgroup"
2725

26+
"github.com/siderolabs/talos/cmd/talosctl/pkg/mgmt/helpers"
2827
"github.com/siderolabs/talos/pkg/cli"
2928
"github.com/siderolabs/talos/pkg/machinery/config/container"
3029
"github.com/siderolabs/talos/pkg/machinery/config/encoder"
@@ -55,7 +54,7 @@ var airgappedCmd = &cobra.Command{
5554
RunE: func(cmd *cobra.Command, args []string) error {
5655
return cli.WithContext(
5756
context.Background(), func(ctx context.Context) error {
58-
caPEM, certPEM, keyPEM, err := generateSelfSignedCert()
57+
caPEM, certPEM, keyPEM, err := helpers.GenerateSelfSignedCert([]net.IP{airgappedFlags.advertisedAddress})
5958
if err != nil {
6059
return nil
6160
}
@@ -123,25 +122,6 @@ func generateConfigPatch(caPEM []byte) error {
123122
return os.WriteFile(patchFile, patchBytes, 0o644)
124123
}
125124

126-
func generateSelfSignedCert() ([]byte, []byte, []byte, error) {
127-
ca, err := x509.NewSelfSignedCertificateAuthority(x509.ECDSA(true))
128-
if err != nil {
129-
return nil, nil, nil, err
130-
}
131-
132-
serverIdentity, err := x509.NewKeyPair(ca,
133-
x509.Organization("test"),
134-
x509.CommonName("server"),
135-
x509.IPAddresses([]net.IP{airgappedFlags.advertisedAddress}),
136-
x509.ExtKeyUsage([]stdx509.ExtKeyUsage{stdx509.ExtKeyUsageServerAuth}),
137-
)
138-
if err != nil {
139-
return nil, nil, nil, err
140-
}
141-
142-
return ca.CrtPEM, serverIdentity.CrtPEM, serverIdentity.KeyPEM, nil
143-
}
144-
145125
func runHTTPServer(ctx context.Context, certPEM, keyPEM []byte) error {
146126
certificate, err := tls.X509KeyPair(certPEM, keyPEM)
147127
if err != nil {

cmd/talosctl/cmd/mgmt/dhcpd_launch.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ var dhcpdLaunchCmd = &cobra.Command{
3333
RunE: func(cmd *cobra.Command, args []string) error {
3434
var ips []net.IP
3535

36-
for _, ip := range strings.Split(dhcpdLaunchCmdFlags.addr, ",") {
36+
for ip := range strings.SplitSeq(dhcpdLaunchCmdFlags.addr, ",") {
3737
ips = append(ips, net.ParseIP(ip))
3838
}
3939

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+
5+
//go:build linux || darwin
6+
7+
package mgmt
8+
9+
import (
10+
"net"
11+
"strings"
12+
13+
"github.com/spf13/cobra"
14+
"golang.org/x/sync/errgroup"
15+
16+
"github.com/siderolabs/talos/pkg/provision/providers/vm"
17+
)
18+
19+
var dnsdLaunchCmdFlags struct {
20+
addr string
21+
resolvConf string
22+
}
23+
24+
// dnsdLaunchCmd represents the dnsd-launch command.
25+
var dnsdLaunchCmd = &cobra.Command{
26+
Use: "dnsd-launch",
27+
Short: "Internal command used by VM provisioners",
28+
Long: ``,
29+
Args: cobra.NoArgs,
30+
Hidden: true,
31+
RunE: func(cmd *cobra.Command, args []string) error {
32+
var ips []net.IP
33+
34+
for ip := range strings.SplitSeq(dnsdLaunchCmdFlags.addr, ",") {
35+
ips = append(ips, net.ParseIP(ip))
36+
}
37+
38+
var eg errgroup.Group
39+
40+
eg.Go(func() error {
41+
return vm.DNSd(ips, dnsdLaunchCmdFlags.resolvConf)
42+
})
43+
44+
return eg.Wait()
45+
},
46+
}
47+
48+
func init() {
49+
dnsdLaunchCmd.Flags().StringVar(&dnsdLaunchCmdFlags.addr, "addr", "localhost:53", "IP addresses to listen on")
50+
dnsdLaunchCmd.Flags().StringVar(&dnsdLaunchCmdFlags.resolvConf, "resolv-conf", "/etc/resolv.conf", "path to resolv file")
51+
addCommand(dnsdLaunchCmd)
52+
}

0 commit comments

Comments
 (0)