feat: implement OAuth2 device flow for machine config

Fixes #7939

See documentation in the PR for the description of the feature.

Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com>
This commit is contained in:
Andrey Smirnov 2023-11-16 21:52:31 +04:00
parent 5c8fa2a803
commit 27d208c26b
No known key found for this signature in database
GPG Key ID: FE042E3D4085A811
12 changed files with 674 additions and 10 deletions

8
go.mod
View File

@ -87,6 +87,7 @@ require (
github.com/mdlayher/genetlink v1.3.2
github.com/mdlayher/netlink v1.7.2
github.com/mdlayher/netx v0.0.0-20230430222610-7e21880baee8
github.com/mdp/qrterminal/v3 v3.2.0
github.com/nberlee/go-netstat v0.1.2
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.0-rc4
@ -140,7 +141,8 @@ require (
go.etcd.io/etcd/etcdutl/v3 v3.5.10
go.uber.org/zap v1.26.0
go4.org/netipx v0.0.0-20230824141953-6213f710f925
golang.org/x/net v0.17.0
golang.org/x/net v0.18.0
golang.org/x/oauth2 v0.14.0
golang.org/x/sync v0.5.0
golang.org/x/sys v0.14.0
golang.org/x/term v0.14.0
@ -310,10 +312,9 @@ require (
go.opentelemetry.io/otel/trace v1.19.0 // indirect
go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/crypto v0.15.0 // indirect
golang.org/x/exp v0.0.0-20230224173230-c95f2b4c22f2 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/oauth2 v0.12.0 // indirect
golang.org/x/tools v0.12.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20231022001213-2e0774f246fb // indirect
@ -329,6 +330,7 @@ require (
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect
k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect
kernel.org/pub/linux/libs/security/libcap/psx v1.2.69 // indirect
rsc.io/qr v0.2.0 // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/kustomize/api v0.13.5-0.20230601165947-6ce0bf390ce3 // indirect
sigs.k8s.io/kustomize/kyaml v0.14.3-0.20230601165947-6ce0bf390ce3 // indirect

16
go.sum
View File

@ -507,6 +507,8 @@ github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU
github.com/mdlayher/socket v0.2.1/go.mod h1:QLlNPkFR88mRUNQIzRBMfXxwKal8H7u1h3bL1CV+f0E=
github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
github.com/mdp/qrterminal/v3 v3.2.0 h1:qteQMXO3oyTK4IHwj2mWsKYYRBOp1Pj2WRYFYYNTCdk=
github.com/mdp/qrterminal/v3 v3.2.0/go.mod h1:XGGuua4Lefrl7TLEsSONiD+UEjQXJZ4mPzF+gWYIJkk=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@ -826,8 +828,8 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@ -910,8 +912,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -921,8 +923,8 @@ golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4=
golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4=
golang.org/x/oauth2 v0.14.0 h1:P0Vrf/2538nmC0H+pEQ3MNFRRnVR7RlqyVw+bvm26z0=
golang.org/x/oauth2 v0.14.0/go.mod h1:lAtNWgaWfL4cm7j2OV8TxGi9Qb7ECORx8DktCY74OwM=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -1200,6 +1202,8 @@ kernel.org/pub/linux/libs/security/libcap/cap v1.2.69/go.mod h1:Tk5Ip2TuxaWGpccL
kernel.org/pub/linux/libs/security/libcap/psx v1.2.69 h1:IdrOs1ZgwGw5CI+BH6GgVVlOt+LAXoPyh7enr8lfaXs=
kernel.org/pub/linux/libs/security/libcap/psx v1.2.69/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=

View File

@ -88,6 +88,12 @@ machine:
net/ipv6/conf/eth0.100/disable_ipv6: "1"
net.ipv6.conf.eth0/100.disable_ipv6: "1"
```
"""
[note.auth2]
title = "OAuth2 Machine Config Flow"
description = """\
Talos Linux when running on the `metal` platform can be configured to authenticate the machine configuration download using [OAuth2 device flow](https://www.talos.dev/v1.6/advanced/machine-config-oauth/).
"""
[make_deps]

View File

@ -11,6 +11,7 @@ import (
"log"
"os"
"path/filepath"
"time"
"github.com/cosi-project/runtime/pkg/state"
"github.com/siderolabs/gen/channel"
@ -25,6 +26,7 @@ import (
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime"
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/errors"
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/internal/netutils"
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/oauth2"
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url"
"github.com/siderolabs/talos/internal/pkg/meta"
"github.com/siderolabs/talos/pkg/download"
@ -79,6 +81,24 @@ func (m *Metal) Configuration(ctx context.Context, r state.State) ([]byte, error
return nil, err
}
oauth2Cfg, err := oauth2.NewConfig(procfs.ProcCmdline(), *option)
if err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to parse OAuth2 config: %w", err)
}
var extraHeaders map[string]string
// perform OAuth2 device auth flow first to acquire extra headers
if oauth2Cfg != nil {
if err = retry.Constant(constants.ConfigLoadTimeout, retry.WithUnits(30*time.Second)).RetryWithContext(ctx, func(ctx context.Context) error {
return oauth2Cfg.DeviceAuthFlow(ctx, r)
}); err != nil {
return nil, fmt.Errorf("OAuth2 device auth flow failed: %w", err)
}
extraHeaders = oauth2Cfg.ExtraHeaders()
}
return download.Download(
ctx,
*option,
@ -88,6 +108,7 @@ func (m *Metal) Configuration(ctx context.Context, r state.State) ([]byte, error
// give a timeout per attempt, max 50% of that is dedicated for URL interpolation, the rest is for the actual download
retry.WithAttemptTimeout(constants.ConfigLoadAttemptTimeout),
),
download.WithHeaders(extraHeaders),
)
}
}

View File

@ -0,0 +1,204 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
// Package oauth2 implements OAuth2 Device Flow to authenticate machine config download.
package oauth2
import (
"bytes"
"context"
"fmt"
"log"
"net/http"
"net/url"
"os"
"github.com/cosi-project/runtime/pkg/state"
"github.com/hashicorp/go-cleanhttp"
"github.com/mdp/qrterminal/v3"
"github.com/siderolabs/go-procfs/procfs"
"golang.org/x/oauth2"
metalurl "github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url"
"github.com/siderolabs/talos/pkg/httpdefaults"
"github.com/siderolabs/talos/pkg/machinery/constants"
)
// Config represents the OAuth2 configuration.
type Config struct {
ClientID string
ClientSecret string
Audience string
Scopes []string
ExtraVariables []string
DeviceAuthURL string
TokenURL string
extraHeaders map[string]string
}
// NewConfig returns a new Config from cmdline.
//
// If OAuth2 is not configured, it returns os.ErrNotExist.
//
//nolint:gocyclo
func NewConfig(cmdline *procfs.Cmdline, downloadURL string) (*Config, error) {
var cfg Config
clientID := cmdline.Get(constants.KernelParamConfigOAuthClientID).First()
if clientID == nil {
return nil, os.ErrNotExist
}
cfg.ClientID = *clientID
if clientSecret := cmdline.Get(constants.KernelParamConfigOAuthClientSecret).First(); clientSecret != nil {
cfg.ClientSecret = *clientSecret
}
if audience := cmdline.Get(constants.KernelParamConfigOAuthAudience).First(); audience != nil {
cfg.Audience = *audience
}
for i := 0; ; i++ {
scope := cmdline.Get(constants.KernelParamConfigOAuthScope).Get(i)
if scope == nil {
break
}
cfg.Scopes = append(cfg.Scopes, *scope)
}
for i := 0; ; i++ {
extra := cmdline.Get(constants.KernelParamConfigOAuthExtraVariable).Get(i)
if extra == nil {
break
}
cfg.ExtraVariables = append(cfg.ExtraVariables, *extra)
}
if deviceAuthURL := cmdline.Get(constants.KernelParamConfigOAuthDeviceAuthURL).First(); deviceAuthURL != nil {
cfg.DeviceAuthURL = *deviceAuthURL
} else {
u, err := url.Parse(downloadURL)
if err != nil {
return nil, err
}
u.Path = "/device/code"
cfg.DeviceAuthURL = u.String()
}
if tokenURL := cmdline.Get(constants.KernelParamConfigOAuthTokenURL).First(); tokenURL != nil {
cfg.TokenURL = *tokenURL
} else {
u, err := url.Parse(downloadURL)
if err != nil {
return nil, err
}
u.Path = "/token"
cfg.TokenURL = u.String()
}
return &cfg, nil
}
// DeviceAuthFlow represents the device auth flow response.
func (c *Config) DeviceAuthFlow(ctx context.Context, st state.State) error {
transport := httpdefaults.PatchTransport(cleanhttp.DefaultTransport())
client := &http.Client{
Transport: transport,
}
// register the HTTP client with OAuth2 flow
ctx = context.WithValue(ctx, oauth2.HTTPClient, client)
cfg := oauth2.Config{
ClientID: c.ClientID,
Scopes: c.Scopes,
Endpoint: oauth2.Endpoint{
DeviceAuthURL: c.DeviceAuthURL,
TokenURL: c.TokenURL,
},
}
log.Printf("[OAuth] starting the authentication device flow with the following settings:")
log.Printf("[OAuth] - client ID: %q", c.ClientID)
log.Printf("[OAuth] - device auth URL: %q", c.DeviceAuthURL)
log.Printf("[OAuth] - token URL: %q", c.TokenURL)
log.Printf("[OAuth] - extra variables: %q", c.ExtraVariables)
// acquire device variables
variables, err := c.getVariableValues(ctx, st)
if err != nil {
return fmt.Errorf("failed to get variable values: %w", err)
}
var deviceAuthOptions []oauth2.AuthCodeOption //nolint:prealloc
if c.Audience != "" {
deviceAuthOptions = append(deviceAuthOptions, oauth2.SetAuthURLParam("audience", c.Audience))
}
for k, v := range variables {
deviceAuthOptions = append(deviceAuthOptions, oauth2.SetAuthURLParam(k, v))
}
deviceAuthResponse, err := cfg.DeviceAuth(ctx, deviceAuthOptions...)
if err != nil {
return fmt.Errorf("failed to get device auth response: %w", err)
}
log.Printf("[OAuth] please visit the URL %s and enter the code %s", deviceAuthResponse.VerificationURI, deviceAuthResponse.UserCode)
if deviceAuthResponse.VerificationURIComplete != "" {
var qrBuf bytes.Buffer
qrterminal.GenerateHalfBlock(deviceAuthResponse.VerificationURIComplete, qrterminal.L, &qrBuf)
log.Printf("[OAuth] or scan the following QR code:\n%s", qrBuf.String())
}
log.Printf("[OAuth] waiting for the device to be authorized (expires at %s)...", deviceAuthResponse.Expiry.Format("15:04:05"))
if c.ClientSecret != "" {
deviceAuthOptions = append(deviceAuthOptions, oauth2.SetAuthURLParam("client_secret", c.ClientSecret))
}
token, err := cfg.DeviceAccessToken(ctx, deviceAuthResponse, deviceAuthOptions...)
if err != nil {
return fmt.Errorf("failed to get device access token: %w", err)
}
log.Printf("[OAuth] device authorized successfully")
c.extraHeaders = map[string]string{
"Authorization": token.Type() + " " + token.AccessToken,
}
return nil
}
// getVariableValues returns the variable values to include in the device auth request.
func (c *Config) getVariableValues(ctx context.Context, st state.State) (map[string]string, error) {
ctx, cancel := context.WithTimeout(ctx, constants.ConfigLoadAttemptTimeout/2)
defer cancel()
return metalurl.MapValues(ctx, st, c.ExtraVariables)
}
// ExtraHeaders returns the extra headers to include in the download request.
func (c *Config) ExtraHeaders() map[string]string {
return c.extraHeaders
}

View File

@ -0,0 +1,117 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package oauth2_test
import (
"context"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/siderolabs/go-procfs/procfs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/oauth2"
)
func TestNewConfig(t *testing.T) { //nolint:tparallel
t.Parallel()
for _, test := range []struct {
name string
cmdline string
expected *oauth2.Config
}{
{
name: "no config",
},
{
name: "only client ID",
cmdline: `talos.config.oauth.client_id=device_client_id`,
expected: &oauth2.Config{
ClientID: "device_client_id",
TokenURL: "https://example.com/token",
DeviceAuthURL: "https://example.com/device/code",
},
},
{
name: "client ID and custom URLs",
cmdline: `talos.config.oauth.client_id=device_client_id talos.config.oauth.token_url=https://google.com/token talos.config.oauth.device_auth_url=https://google.com/device/code`,
expected: &oauth2.Config{
ClientID: "device_client_id",
TokenURL: "https://google.com/token",
DeviceAuthURL: "https://google.com/device/code",
},
},
{
name: "complete config",
cmdline: `talos.config.oauth.client_id=device_client_id talos.config.oauth.client_secret=device_secret ` +
`talos.config.oauth.token_url=https://google.com/token talos.config.oauth.device_auth_url=https://google.com/device/code ` +
`talos.config.oauth.scope=foo talos.config.oauth.scope=bar talos.config.oauth.audience=world ` +
`talos.config.oauth.extra_variable=uuid talos.config.oauth.extra_variable=mac`,
expected: &oauth2.Config{
ClientID: "device_client_id",
ClientSecret: "device_secret",
Audience: "world",
Scopes: []string{"foo", "bar"},
ExtraVariables: []string{"uuid", "mac"},
TokenURL: "https://google.com/token",
DeviceAuthURL: "https://google.com/device/code",
},
},
} {
t.Run(test.name, func(t *testing.T) {
cfg, err := oauth2.NewConfig(procfs.NewCmdline(test.cmdline), "https://example.com/my/config")
if test.expected == nil {
require.Error(t, err)
assert.True(t, os.IsNotExist(err))
return
}
require.NoError(t, err)
assert.Equal(t, test.expected, cfg)
})
}
}
func TestDeviceAuthFlow(t *testing.T) {
t.Parallel()
cfg := &oauth2.Config{
ClientID: "device_client_id",
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close() //nolint:errcheck
t.Logf("received request: %s %s", r.Method, r.RequestURI)
switch r.Method + r.RequestURI {
case "POST/device/code":
w.Header().Add("Content-Type", "application/json")
w.Write([]byte(`{"device_code":"abcd", "user_code":"1234", "verification_uri":"https://example.com/verify","verification_uri_complete":"https://example.com/verify/1234","interval":1,"expires_in":36000}`)) //nolint:errcheck,lll
case "POST/token":
w.Header().Add("Content-Type", "application/json")
w.Write([]byte(`{"access_token":"abcd","token_type":"bearer","expires_in":3600,"refresh_token":"efgh","id_token":"ijkl"}`)) //nolint:errcheck
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(ts.Close)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
t.Cleanup(cancel)
cfg.DeviceAuthURL = ts.URL + "/device/code"
cfg.TokenURL = ts.URL + "/token"
require.NoError(t, cfg.DeviceAuthFlow(ctx, nil))
assert.Equal(t, map[string]string{"Authorization": "Bearer abcd"}, cfg.ExtraHeaders())
}

View File

@ -0,0 +1,85 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package url
import (
"context"
"fmt"
"log"
"github.com/cosi-project/runtime/pkg/state"
"github.com/siderolabs/gen/maps"
"github.com/siderolabs/gen/xslices"
)
// MapValues maps variable names to values.
//
//nolint:gocyclo
func MapValues(ctx context.Context, st state.State, variableNames []string) (map[string]string, error) {
// happy case
if len(variableNames) == 0 {
return nil, nil
}
availableVariables := AllVariables()
activeVariables := make(map[string]*Variable, len(variableNames))
for _, variableName := range variableNames {
if v, ok := availableVariables[variableName]; ok {
activeVariables[variableName] = v
} else {
return nil, fmt.Errorf("unsupported variable name: %q", variableName)
}
}
// setup watches
ctx, cancel := context.WithCancel(ctx)
defer cancel()
watchCh := make(chan state.Event)
for _, variable := range activeVariables {
if err := variable.Value.RegisterWatch(ctx, st, watchCh); err != nil {
return nil, fmt.Errorf("error watching variable %q: %w", variable.Key, err)
}
}
pendingVariables := xslices.ToSet(maps.Values(activeVariables))
// wait for all variables to be populated
waitLoop:
for len(pendingVariables) > 0 {
log.Printf("waiting for variables: %v", xslices.Map(maps.Keys(pendingVariables), func(v *Variable) string { return v.Key }))
var ev state.Event
select {
case <-ctx.Done():
// context was canceled, return what we have
break waitLoop
case ev = <-watchCh:
}
switch ev.Type {
case state.Errored:
return nil, fmt.Errorf("error watching variables: %w", ev.Error)
case state.Bootstrapped:
// ignored
case state.Created, state.Updated, state.Destroyed:
for _, variable := range activeVariables {
handled, err := variable.Value.EventHandler(ev)
if err != nil {
return nil, fmt.Errorf("error handling variable %q: %w", variable.Key, err)
}
if handled {
delete(pendingVariables, variable)
}
}
}
}
return maps.Map(activeVariables, func(k string, v *Variable) (string, string) { return k, v.Value.Get() }), nil
}

View File

@ -0,0 +1,107 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package url_test
import (
"context"
"testing"
"time"
"github.com/cosi-project/runtime/pkg/state"
"github.com/cosi-project/runtime/pkg/state/impl/inmem"
"github.com/cosi-project/runtime/pkg/state/impl/namespaced"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url"
)
func TestMapValues(t *testing.T) {
t.Parallel()
for _, test := range []struct {
name string
variableNames []string
preSetup []setupFunc
parallelSetup []setupFunc
expected map[string]string
}{
{
name: "no variables",
},
{
name: "multiple variables",
variableNames: []string{"uuid", "mac", "hostname", "code"},
expected: map[string]string{
"code": "top-secret",
"hostname": "some-node",
"mac": "12:34:56:78:90:ce",
"uuid": "0000-0000",
},
preSetup: []setupFunc{
createSysInfo("0000-0000", "12345"),
createMac("12:34:56:78:90:ce"),
createHostname("some-node"),
createCode("top-secret"),
},
},
{
name: "mixed wait variables",
variableNames: []string{"uuid", "mac", "hostname", "code"},
expected: map[string]string{
"code": "",
"hostname": "another-node",
"mac": "12:34:56:78:90:ab",
"uuid": "0000-1234",
},
preSetup: []setupFunc{
createSysInfo("0000-1234", "12345"),
createMac("12:34:56:78:90:ab"),
createHostname("example-node"),
},
parallelSetup: []setupFunc{
sleep(time.Second),
updateHostname("another-node"),
sleep(time.Second / 2),
},
},
} {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
st := state.WrapCore(namespaced.NewState(inmem.Build))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, f := range test.preSetup {
f(ctx, t, st)
}
errCh := make(chan error)
var result map[string]string
go func() {
var e error
result, e = url.MapValues(ctx, st, test.variableNames)
errCh <- e
}()
for _, f := range test.parallelSetup {
f(ctx, t, st)
}
err := <-errCh
require.NoError(t, err)
assert.Equal(t, test.expected, result)
})
}
}

View File

@ -72,7 +72,17 @@ func WithFormat(format string) Option {
// the config.
func WithHeaders(headers map[string]string) Option {
return func(d *downloadOptions) {
d.Headers = headers
if headers == nil {
return
}
if d.Headers == nil {
d.Headers = map[string]string{}
}
for k, v := range headers {
d.Headers[k] = v
}
}
}

View File

@ -25,6 +25,27 @@ const (
// to the config.
KernelParamConfig = "talos.config"
// KernelParamConfigOAuthClientID is the kernel parameter name for specifying the OAuth2 client ID.
KernelParamConfigOAuthClientID = "talos.config.oauth.client_id"
// KernelParamConfigOAuthClientSecret is the kernel parameter name for specifying the OAuth2 client secret.
KernelParamConfigOAuthClientSecret = "talos.config.oauth.client_secret"
// KernelParamConfigOAuthAudience is the kernel parameter name for specifying the OAuth2 audience.
KernelParamConfigOAuthAudience = "talos.config.oauth.audience"
// KernelParamConfigOAuthScope is the kernel parameter name for specifying the OAuth2 scopes (might be repeated).
KernelParamConfigOAuthScope = "talos.config.oauth.scope"
// KernelParamConfigOAuthDeviceAuthURL is the kernel parameter name for specifying the OAuth2 device auth URL.
KernelParamConfigOAuthDeviceAuthURL = "talos.config.oauth.device_auth_url"
// KernelParamConfigOAuthTokenURL is the kernel parameter name for specifying the OAuth2 token URL.
KernelParamConfigOAuthTokenURL = "talos.config.oauth.token_url"
// KernelParamConfigOAuthExtraVariable is the kernel parameter name for specifying the OAuth2 extra variable (might be repeated).
KernelParamConfigOAuthExtraVariable = "talos.config.oauth.extra_variable"
// ConfigNone indicates no config is required.
ConfigNone = "none"

View File

@ -0,0 +1,83 @@
---
title: "Machine Configuration OAuth2 Authentication"
description: "How to authenticate Talos machine configuration download (`talos.config=`) on `metal` platform using OAuth."
---
Talos Linux when running on the `metal` platform can be configured to authenticate the machine configuration download using OAuth2 device flow.
The machine configuration is fetched from the URL specified with `talos.config` kernel argument, and by default this HTTP request is not authenticated.
When the OAuth2 authentication is enabled, Talos will authenticate the request using OAuth device flow first, and then pass the token to the machine configuration download endpoint.
## Prerequisites
Obtain the following information:
* OAuth client ID (mandatory)
* OAuth client secret (optional)
* OAuth device endpoint
* OAuth token endpoint
* OAuth scopes, audience (optional)
* OAuth client secret (optional)
* extra Talos variables to send to the device auth endpoint (optional)
## Configuration
Set the following kernel parameters on the initial Talos boot to enable the OAuth flow:
* `talos.config` set to the URL of the machine configuration endpoint (which will be authenticated using OAuth)
* `talos.config.oauth.client_id` set to the OAuth client ID (required)
* `talos.config.oauth.client_secret` set to the OAuth client secret (optional)
* `talos.config.oauth.scope` set to the OAuth scopes (optional, repeat the parameter for multiple scopes)
* `talos.config.oauth.audience` set to the OAuth audience (optional)
* `talos.config.oauth.device_auth_url` set to the OAuth device endpoint (if not set defaults to `talos.config` URL with the path `/device/code`)
* `talos.config.oauth.token_url` set to the OAuth token endpoint (if not set defaults to `talos.config` URL with the path `/token`)
* `talos.config.oauth.extra_variable` set to the extra Talos variables to send to the device auth endpoint (optional, repeat the parameter for multiple variables)
The list of variables supported by the `talos.config.oauth.extra_variable` parameter is same as the [list of variables]({{< relref "../reference/kernel#talosconfig" >}}) supported by the `talos.config` parameter.
## Flow
On the initial Talos boot, when machine configuration is not available, Talos will print the following messages:
```text
[talos] downloading config {"component": "controller-runtime", "controller": "config.AcquireController", "platform": "metal"}
[talos] waiting for network to be ready
[talos] [OAuth] starting the authentication device flow with the following settings:
[talos] [OAuth] - client ID: "<REDACTED>"
[talos] [OAuth] - device auth URL: "https://oauth2.googleapis.com/device/code"
[talos] [OAuth] - token URL: "https://oauth2.googleapis.com/token"
[talos] [OAuth] - extra variables: ["uuid" "mac"]
[talos] waiting for variables: [uuid mac]
[talos] waiting for variables: [mac]
[talos] [OAuth] please visit the URL https://www.google.com/device and enter the code <REDACTED>
[talos] [OAuth] waiting for the device to be authorized (expires at 14:46:55)...
```
If the OAuth service provides the complete verification URL, the QR code to scan is also printed to the console:
```text
[talos] [OAuth] or scan the following QR code:
█████████████████████████████████
█████████████████████████████████
████ ▄▄▄▄▄ ██▄▀▀ ▀█ ▄▄▄▄▄ ████
████ █ █ █▄ ▀▄██▄██ █ █ ████
████ █▄▄▄█ ██▀▄██▄ ▀█ █▄▄▄█ ████
████▄▄▄▄▄▄▄█ ▀ █ ▀ █▄█▄▄▄▄▄▄▄████
████ ▀ ▄▄ ▄█ ██▄█ ███▄█▀████
████▀█▄ ▄▄▀▄▄█▀█▄██ ▄▀▄██▄ ▄████
████▄██▀█▄▄▄███▀ ▀█▄▄ ██ █▄ ████
████▄▀▄▄▄ ▄███ ▄ ▀ ▀▀▄▀▄▀█▄ ▄████
████▄█████▄█ █ ██ ▀ ▄▄▄ █▀▀████
████ ▄▄▄▄▄ █ █ ▀█▄█▄ █▄█ █▄ ████
████ █ █ █▄ ▄▀ ▀█▀▄▄▄ ▀█▄████
████ █▄▄▄█ █ ██▄ ▀ ▀███ ▀█▀▄████
████▄▄▄▄▄▄▄█▄▄█▄██▄▄▄▄█▄███▄▄████
█████████████████████████████████
```
Once the authentication flow is complete on the OAuth provider side, Talos will print the following message:
```text
[talos] [OAuth] device authorized
[talos] fetching machine config from: "http://example.com/config.yaml"
[talos] machine config loaded successfully {"component": "controller-runtime", "controller": "config.AcquireController", "sources": ["metal"]}
```

View File

@ -141,6 +141,10 @@ cp config.yaml iso/
mkisofs -joliet -rock -volid 'metal-iso' -output config.iso iso/
```
#### `talos.config.auth.*`
Kernel parameters prefixed with `talos.config.auth.` are used to configure [OAuth2 authentication for the machine configuration]({{< relref "../advanced/machine-config-oauth" >}}).
#### `talos.platform`
The platform name on which Talos will run.