Skip to content

Commit 5ffc3f1

Browse files
committed
feat: show siderolink status on dashboard
Add a new resource, `SiderolinkStatus`, which combines the following info: - The Siderolink API endpoint without the query parameters or fragments (potentially sensitive info due to the join token) - The status of the Siderolink connection This resource is not set as sensitive, so it can be retrieved by the users with `os:operator` role (e.g., using `talosctl dashboard` through Omni). Make use of this resource in the dashboard to display the status of the Siderolink connection. Additionally, rework the status columns in the dashboard to: - Display a Linux terminal compatible "tick" or a "cross" prefix for statuses in addition to the red/green color coding. - Move and combine some statuses to save rows and make them more even. Closes #8643. Signed-off-by: Utku Ozdemir <utku.ozdemir@siderolabs.com>
1 parent 6f6a5d1 commit 5ffc3f1

File tree

21 files changed

+758
-123
lines changed

21 files changed

+758
-123
lines changed

api/resource/definitions/siderolink/siderolink.proto

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ message ConfigSpec {
1515
bool tunnel = 5;
1616
}
1717

18+
// StatusSpec describes Siderolink status.
19+
message StatusSpec {
20+
string host = 1;
21+
bool connected = 2;
22+
}
23+
1824
// TunnelSpec describes Siderolink GRPC Tunnel configuration.
1925
message TunnelSpec {
2026
string api_endpoint = 1;

internal/app/machined/pkg/controllers/runtime/diagnostics.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ func (ctrl *DiagnosticsController) Run(ctx context.Context, r controller.Runtime
118118
return nil
119119
}
120120

121-
return safe.WriterModify(ctx, r, runtime.NewDiagnstic(runtime.NamespaceName, checkDescription.ID), func(res *runtime.Diagnostic) error {
121+
return safe.WriterModify(ctx, r, runtime.NewDiagnostic(runtime.NamespaceName, checkDescription.ID), func(res *runtime.Diagnostic) error {
122122
*res.TypedSpec() = *warning
123123

124124
return nil

internal/app/machined/pkg/controllers/siderolink/manager.go

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,13 @@ func (ctrl *ManagerController) Run(ctx context.Context, r controller.Runtime, lo
138138
case <-ctx.Done():
139139
return nil
140140
case <-ticker.C:
141-
reconnect, err := ctrl.shouldReconnect(wgClient)
141+
reconnect, err := peerDown(wgClient)
142142
if err != nil {
143+
if errors.Is(err, os.ErrNotExist) {
144+
// no Wireguard device, so no need to reconnect
145+
continue
146+
}
147+
143148
return err
144149
}
145150

@@ -476,27 +481,6 @@ func (ctrl *ManagerController) cleanupAddressSpecs(ctx context.Context, r contro
476481
return nil
477482
}
478483

479-
func (ctrl *ManagerController) shouldReconnect(wgClient *wgctrl.Client) (bool, error) {
480-
wgDevice, err := wgClient.Device(constants.SideroLinkName)
481-
if err != nil {
482-
if errors.Is(err, os.ErrNotExist) {
483-
// no Wireguard device, so no need to reconnect
484-
return false, nil
485-
}
486-
487-
return false, fmt.Errorf("error reading Wireguard device: %w", err)
488-
}
489-
490-
if len(wgDevice.Peers) != 1 {
491-
return false, fmt.Errorf("unexpected number of Wireguard peers: %d", len(wgDevice.Peers))
492-
}
493-
494-
peer := wgDevice.Peers[0]
495-
since := time.Since(peer.LastHandshakeTime)
496-
497-
return since >= wireguard.PeerDownInterval, nil
498-
}
499-
500484
func withTransportCredentials(insec bool) grpc.DialOption {
501485
var transportCredentials credentials.TransportCredentials
502486

internal/app/machined/pkg/controllers/siderolink/siderolink.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,35 @@
44

55
// Package siderolink provides controllers which manage file resources.
66
package siderolink
7+
8+
import (
9+
"fmt"
10+
"time"
11+
12+
"github.com/siderolabs/siderolink/pkg/wireguard"
13+
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
14+
15+
"github.com/siderolabs/talos/pkg/machinery/constants"
16+
)
17+
18+
// WireguardClient allows mocking Wireguard client.
19+
type WireguardClient interface {
20+
Device(string) (*wgtypes.Device, error)
21+
Close() error
22+
}
23+
24+
func peerDown(wgClient WireguardClient) (bool, error) {
25+
wgDevice, err := wgClient.Device(constants.SideroLinkName)
26+
if err != nil {
27+
return false, fmt.Errorf("error reading Wireguard device: %w", err)
28+
}
29+
30+
if len(wgDevice.Peers) != 1 {
31+
return false, fmt.Errorf("unexpected number of Wireguard peers: %d", len(wgDevice.Peers))
32+
}
33+
34+
peer := wgDevice.Peers[0]
35+
since := time.Since(peer.LastHandshakeTime)
36+
37+
return since >= wireguard.PeerDownInterval, nil
38+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
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+
package siderolink
6+
7+
import (
8+
"context"
9+
"errors"
10+
"fmt"
11+
"net"
12+
"net/url"
13+
"os"
14+
"time"
15+
16+
"github.com/cosi-project/runtime/pkg/controller"
17+
"github.com/cosi-project/runtime/pkg/safe"
18+
"github.com/cosi-project/runtime/pkg/state"
19+
"github.com/siderolabs/gen/optional"
20+
"go.uber.org/zap"
21+
"golang.zx2c4.com/wireguard/wgctrl"
22+
23+
"github.com/siderolabs/talos/pkg/machinery/resources/config"
24+
"github.com/siderolabs/talos/pkg/machinery/resources/siderolink"
25+
)
26+
27+
// DefaultStatusUpdateInterval is the default interval between status updates.
28+
const DefaultStatusUpdateInterval = 30 * time.Second
29+
30+
// StatusController reports siderolink status.
31+
type StatusController struct {
32+
// WGClientFunc is a function that returns a WireguardClient.
33+
//
34+
// When nil, it defaults to an actual Wireguard client.
35+
WGClientFunc func() (WireguardClient, error)
36+
37+
// Interval is the time between peer status checks.
38+
//
39+
// When zero, it defaults to DefaultStatusUpdateInterval.
40+
Interval time.Duration
41+
}
42+
43+
// Name implements controller.Controller interface.
44+
func (ctrl *StatusController) Name() string {
45+
return "siderolink.StatusController"
46+
}
47+
48+
// Inputs implements controller.Controller interface.
49+
func (ctrl *StatusController) Inputs() []controller.Input {
50+
return []controller.Input{
51+
{
52+
Namespace: config.NamespaceName,
53+
Type: siderolink.ConfigType,
54+
ID: optional.Some(siderolink.ConfigID),
55+
Kind: controller.InputWeak,
56+
},
57+
}
58+
}
59+
60+
// Outputs implements controller.Controller interface.
61+
func (ctrl *StatusController) Outputs() []controller.Output {
62+
return []controller.Output{
63+
{
64+
Type: siderolink.StatusType,
65+
Kind: controller.OutputExclusive,
66+
},
67+
}
68+
}
69+
70+
// Run implements controller.Controller interface.
71+
func (ctrl *StatusController) Run(ctx context.Context, r controller.Runtime, _ *zap.Logger) error {
72+
interval := ctrl.Interval
73+
if interval == 0 {
74+
interval = DefaultStatusUpdateInterval
75+
}
76+
77+
ticker := time.NewTicker(interval)
78+
defer ticker.Stop()
79+
80+
wgClientFunc := ctrl.WGClientFunc
81+
if wgClientFunc == nil {
82+
wgClientFunc = func() (WireguardClient, error) {
83+
return wgctrl.New()
84+
}
85+
}
86+
87+
wgClient, err := wgClientFunc()
88+
if err != nil {
89+
return fmt.Errorf("failed to create wireguard client: %w", err)
90+
}
91+
92+
for {
93+
select {
94+
case <-ctx.Done():
95+
return nil
96+
case <-r.EventCh():
97+
case <-ticker.C:
98+
}
99+
100+
r.StartTrackingOutputs()
101+
102+
if err = ctrl.reconcileStatus(ctx, r, wgClient); err != nil {
103+
return err
104+
}
105+
106+
if err = safe.CleanupOutputs[*siderolink.Status](ctx, r); err != nil {
107+
return err
108+
}
109+
110+
r.ResetRestartBackoff()
111+
}
112+
}
113+
114+
func (ctrl *StatusController) reconcileStatus(ctx context.Context, r controller.Runtime, wgClient WireguardClient) (err error) {
115+
cfg, err := safe.ReaderGetByID[*siderolink.Config](ctx, r, siderolink.ConfigID)
116+
if err != nil {
117+
if state.IsNotFoundError(err) {
118+
return nil
119+
}
120+
121+
return err
122+
}
123+
124+
if cfg.TypedSpec().APIEndpoint == "" {
125+
return nil
126+
}
127+
128+
var parsed *url.URL
129+
130+
if parsed, err = url.Parse(cfg.TypedSpec().APIEndpoint); err != nil {
131+
return fmt.Errorf("failed to parse siderolink API endpoint: %w", err)
132+
}
133+
134+
host, _, err := net.SplitHostPort(parsed.Host)
135+
if err != nil {
136+
host = parsed.Host
137+
}
138+
139+
down, err := peerDown(wgClient)
140+
if err != nil {
141+
if !errors.Is(err, os.ErrNotExist) {
142+
return err
143+
}
144+
145+
down = true // wireguard device does not exist, we mark it as down
146+
}
147+
148+
if err = safe.WriterModify(ctx, r, siderolink.NewStatus(), func(status *siderolink.Status) error {
149+
status.TypedSpec().Host = host
150+
status.TypedSpec().Connected = !down
151+
152+
return nil
153+
}); err != nil {
154+
return fmt.Errorf("failed to update status: %w", err)
155+
}
156+
157+
return nil
158+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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+
package siderolink_test
6+
7+
import (
8+
"os"
9+
"sync"
10+
"testing"
11+
"time"
12+
13+
"github.com/cosi-project/runtime/pkg/resource"
14+
"github.com/cosi-project/runtime/pkg/resource/rtestutils"
15+
"github.com/stretchr/testify/assert"
16+
"github.com/stretchr/testify/suite"
17+
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
18+
19+
"github.com/siderolabs/talos/internal/app/machined/pkg/controllers/ctest"
20+
siderolinkctrl "github.com/siderolabs/talos/internal/app/machined/pkg/controllers/siderolink"
21+
"github.com/siderolabs/talos/pkg/machinery/resources/config"
22+
"github.com/siderolabs/talos/pkg/machinery/resources/siderolink"
23+
)
24+
25+
type StatusSuite struct {
26+
ctest.DefaultSuite
27+
}
28+
29+
func TestStatusSuite(t *testing.T) {
30+
suite.Run(t, &StatusSuite{
31+
DefaultSuite: ctest.DefaultSuite{
32+
Timeout: 3 * time.Second,
33+
},
34+
})
35+
}
36+
37+
func (suite *StatusSuite) TestStatus() {
38+
wgClient := &mockWgClient{
39+
device: &wgtypes.Device{
40+
Peers: []wgtypes.Peer{
41+
{
42+
LastHandshakeTime: time.Now().Add(-time.Minute),
43+
},
44+
},
45+
},
46+
}
47+
48+
suite.Require().NoError(suite.Runtime().RegisterController(&siderolinkctrl.StatusController{
49+
WGClientFunc: func() (siderolinkctrl.WireguardClient, error) {
50+
return wgClient, nil
51+
},
52+
Interval: 100 * time.Millisecond,
53+
}))
54+
55+
rtestutils.AssertNoResource[*siderolink.Status](suite.Ctx(), suite.T(), suite.State(), siderolink.StatusID)
56+
57+
siderolinkConfig := siderolink.NewConfig(config.NamespaceName, siderolink.ConfigID)
58+
59+
siderolinkConfig.TypedSpec().APIEndpoint = "https://siderolink.example.org:1234?jointoken=supersecret&foo=bar#some=fragment"
60+
61+
suite.Require().NoError(suite.State().Create(suite.Ctx(), siderolinkConfig))
62+
63+
suite.assertStatus("siderolink.example.org", true)
64+
65+
// disconnect the peer
66+
67+
wgClient.setDevice(&wgtypes.Device{
68+
Peers: []wgtypes.Peer{
69+
{LastHandshakeTime: time.Now().Add(-time.Hour)},
70+
},
71+
})
72+
73+
// no device
74+
wgClient.setDevice(nil)
75+
suite.assertStatus("siderolink.example.org", false)
76+
77+
// reconnect the peer
78+
wgClient.setDevice(&wgtypes.Device{
79+
Peers: []wgtypes.Peer{
80+
{LastHandshakeTime: time.Now().Add(-5 * time.Second)},
81+
},
82+
})
83+
84+
suite.assertStatus("siderolink.example.org", true)
85+
86+
// update API endpoint
87+
88+
siderolinkConfig.TypedSpec().APIEndpoint = "https://new.example.org?jointoken=supersecret"
89+
90+
suite.Require().NoError(suite.State().Update(suite.Ctx(), siderolinkConfig))
91+
suite.assertStatus("new.example.org", true)
92+
93+
// no config
94+
95+
suite.Require().NoError(suite.State().Destroy(suite.Ctx(), siderolinkConfig.Metadata()))
96+
rtestutils.AssertNoResource[*siderolink.Status](suite.Ctx(), suite.T(), suite.State(), siderolink.StatusID)
97+
}
98+
99+
func (suite *StatusSuite) assertStatus(endpoint string, connected bool) {
100+
rtestutils.AssertResources(suite.Ctx(), suite.T(), suite.State(), []resource.ID{siderolink.StatusID},
101+
func(c *siderolink.Status, assert *assert.Assertions) {
102+
assert.Equal(endpoint, c.TypedSpec().Host)
103+
assert.Equal(connected, c.TypedSpec().Connected)
104+
})
105+
}
106+
107+
type mockWgClient struct {
108+
mu sync.Mutex
109+
device *wgtypes.Device
110+
}
111+
112+
func (m *mockWgClient) setDevice(device *wgtypes.Device) {
113+
m.mu.Lock()
114+
defer m.mu.Unlock()
115+
116+
m.device = device
117+
}
118+
119+
func (m *mockWgClient) Device(string) (*wgtypes.Device, error) {
120+
m.mu.Lock()
121+
defer m.mu.Unlock()
122+
123+
if m.device == nil {
124+
return nil, os.ErrNotExist
125+
}
126+
127+
return m.device, nil
128+
}
129+
130+
func (m *mockWgClient) Close() error {
131+
return nil
132+
}

0 commit comments

Comments
 (0)