summaryrefslogtreecommitdiff
path: root/internal/api/control_test.go
blob: 336aa52b8a8fac58d692664aca2a88581b328754 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
package api

import (
	"encoding/json"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"vpnem/internal/control"
	"vpnem/internal/models"
	"vpnem/internal/rules"
)

func TestCanPublishNodeState(t *testing.T) {
	tests := []struct {
		name  string
		state control.NodeState
		want  bool
	}{
		{name: "healthy", state: control.NodeState{BootstrapStatus: "healthy"}, want: true},
		{name: "ready", state: control.NodeState{BootstrapStatus: "ready"}, want: true},
		{name: "planned", state: control.NodeState{BootstrapStatus: "planned"}, want: false},
		{name: "failed", state: control.NodeState{BootstrapStatus: "failed"}, want: false},
		{name: "unreachable", state: control.NodeState{BootstrapStatus: "unreachable"}, want: false},
		{name: "healthy services", state: control.NodeState{
			BootstrapStatus: "healthy",
			Services:        []control.ServiceStatus{{Type: "socks5", Status: "running", Port: 1080}},
			Metadata:        map[string]any{"healthz_http_code": 200},
		}, want: true},
		{name: "degraded services", state: control.NodeState{
			BootstrapStatus: "healthy",
			Services:        []control.ServiceStatus{{Type: "socks5", Status: "unknown", Port: 1080}},
			Metadata:        map[string]any{"healthz_http_code": 503},
		}, want: false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := canPublishNodeState(tt.state)
			if got != tt.want {
				t.Fatalf("canPublishNodeState(%+v) = %v, want %v", tt.state, got, tt.want)
			}
		})
	}
}

func setupControlTestStore(t *testing.T) *rules.Store {
	t.Helper()
	dir := t.TempDir()

	writeJSON := func(name string, value any) {
		t.Helper()
		data, err := json.Marshal(value)
		if err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil {
			t.Fatal(err)
		}
	}

	writeJSON("servers.json", models.ServersResponse{Servers: []models.Server{}})
	writeJSON("rulesets.json", models.RuleSetManifest{RuleSets: []models.RuleSet{}})
	writeJSON("version.json", models.VersionResponse{Version: "test"})
	writeJSON("routing-policy.json", models.RoutingPolicy{Version: "test"})

	if err := os.MkdirAll(filepath.Join(dir, "control", "inventory"), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.MkdirAll(filepath.Join(dir, "control", "state"), 0o755); err != nil {
		t.Fatal(err)
	}

	return rules.NewStore(dir)
}

func TestBuildQuickProvisionNode(t *testing.T) {
	node, password, err := buildQuickProvisionNode(quickProvisionRequest{
		Host:         "89.124.96.166",
		RootPassword: "secret",
		EnableMulti:  true,
		EnableSocks:  true,
	})
	if err != nil {
		t.Fatalf("buildQuickProvisionNode() error = %v", err)
	}
	if password != "secret" {
		t.Fatalf("password = %q, want secret", password)
	}
	if node.Host != "89.124.96.166" {
		t.Fatalf("node.Host = %q", node.Host)
	}
	if !strings.Contains(node.Name, "Multi") {
		t.Fatalf("node.Name = %q, want generated multi-style name", node.Name)
	}
	if node.SSH.Auth != "password" {
		t.Fatalf("node.SSH.Auth = %q, want password", node.SSH.Auth)
	}
	if node.SSH.PasswordEnv == "" {
		t.Fatal("node.SSH.PasswordEnv should be set for persisted quick-provision nodes")
	}
	if node.SSH.Password != "secret" {
		t.Fatalf("node.SSH.Password mismatch")
	}
	if len(node.Protocols) != 3 {
		t.Fatalf("expected 3 protocols, got %d", len(node.Protocols))
	}
	seen := map[string]int{}
	for _, protocol := range node.Protocols {
		seen[protocol.Type] = protocol.Port
	}
	if seen["vless-reality"] != 443 {
		t.Fatalf("vless-reality port = %d, want 443", seen["vless-reality"])
	}
	if seen["hysteria2"] != 443 {
		t.Fatalf("hysteria2 port = %d, want 443", seen["hysteria2"])
	}
	if seen["socks5"] != 54101 {
		t.Fatalf("socks5 port = %d, want 54101", seen["socks5"])
	}
}

func TestBuildQuickProvisionNodeReality(t *testing.T) {
	node, _, err := buildQuickProvisionNode(quickProvisionRequest{
		Host:          "89.124.96.166",
		RootPassword:  "secret",
		EnableReality: true,
	})
	if err != nil {
		t.Fatalf("buildQuickProvisionNode() error = %v", err)
	}
	if len(node.Protocols) != 1 {
		t.Fatalf("expected 1 protocol, got %d", len(node.Protocols))
	}
	if node.Protocols[0].Type != "vless-reality" {
		t.Fatalf("protocol type = %q, want vless-reality", node.Protocols[0].Type)
	}
	if node.Protocols[0].Reality == nil || node.Protocols[0].Reality.ServerName == "" {
		t.Fatal("expected reality defaults to be set")
	}
}

func TestNodeNeedsProvisionedDNS(t *testing.T) {
	realityOnly := control.Node{
		Protocols: []control.ProtocolProfile{
			{Type: "vless-reality", Enabled: true, Port: 443},
		},
	}
	if nodeNeedsProvisionedDNS(realityOnly) {
		t.Fatal("did not expect DNS requirement for vless-reality-only node")
	}

	wsNode := control.Node{
		Protocols: []control.ProtocolProfile{
			{Type: "vless", Enabled: true, Port: 443, TLS: &control.TLSProfile{Enabled: true}},
		},
	}
	if !nodeNeedsProvisionedDNS(wsNode) {
		t.Fatal("expected DNS requirement for tls-enabled vless node")
	}
}

func TestVPNUIIncludesReinstallActions(t *testing.T) {
	if !strings.Contains(vpnUIHTML, "Начать установку") {
		t.Fatal("expected installer-style quick action in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Открыть тонкую настройку") {
		t.Fatal("expected advanced jump action in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Быстрая установка") {
		t.Fatal("expected installer-like quick install heading in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Тонкая настройка и сервисные действия") {
		t.Fatal("expected unified advanced section in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Что заполнится автоматически") {
		t.Fatal("expected auto defaults explanation in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Починить сервер") {
		t.Fatal("expected russian repair action in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Переустановить сервер") {
		t.Fatal("expected russian reinstall action in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Проверить VPS") {
		t.Fatal("expected russian inspect vps action in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Добавить SOCKS5") {
		t.Fatal("expected russian Add SOCKS5 action in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Удалить сервер") {
		t.Fatal("expected russian delete server action in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Основные действия") {
		t.Fatal("expected russian primary actions section in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Ручные переопределения протоколов") {
		t.Fatal("expected russian operator protocol overrides section in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Что можно сделать здесь") {
		t.Fatal("expected russian guide in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Выберите узел, чтобы увидеть самый безопасный следующий шаг.") {
		t.Fatal("expected russian node guide placeholder in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Можно ставить MULTI") {
		t.Fatal("expected russian quick status rail labels in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Готов к публикации") {
		t.Fatal("expected russian node status rail labels in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "data-fleet-filter=\"ready\"") {
		t.Fatal("expected node fleet filters in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Сейчас ни один узел не подходит под этот фильтр.") {
		t.Fatal("expected russian filtered empty state in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Копировать URI") {
		t.Fatal("expected russian copy uri action in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Копировать детали") {
		t.Fatal("expected russian copy details action in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Сейчас в системе") {
		t.Fatal("expected simplified current system summary in vpnui")
	}
	if !strings.Contains(vpnUIHTML, "Сервер работает") {
		t.Fatal("expected product-oriented node card language in vpnui")
	}
}

func TestFindNodeByHost(t *testing.T) {
	store := setupControlTestStore(t)
	handler := &Handler{store: store}

	if _, err := control.SaveNodeFile(filepath.Join(store.DataDir(), "control", "inventory"), control.Node{
		ID:       "nl-01",
		Name:     "NL 01",
		Provider: "custom-vps",
		Region:   "nl",
		Host:     "89.124.96.166",
		Enabled:  true,
		SSH:      control.SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"},
		Protocols: []control.ProtocolProfile{
			{Type: "socks5", Enabled: true, Port: 54101},
		},
	}); err != nil {
		t.Fatal(err)
	}

	node, err := handler.findNodeByHost("89.124.96.166")
	if err != nil {
		t.Fatalf("findNodeByHost() error = %v", err)
	}
	if node == nil || node.ID != "nl-01" {
		t.Fatalf("findNodeByHost() = %+v, want nl-01", node)
	}
}

func TestBuildQuickPreflightResponse(t *testing.T) {
	resp := buildQuickPreflightResponse("89.124.96.166", map[string]string{
		"OS_ID":     "ubuntu",
		"OS_PRETTY": "Ubuntu 24.04 LTS",
		"MANAGED":   "0",
		"DOCKER":    "1",
		"COMPOSE":   "1",
		"TCP_443":   "0",
		"UDP_443":   "1",
		"TCP_54101": "0",
	})

	if resp.SupportTier != "recommended" {
		t.Fatalf("SupportTier = %q, want recommended", resp.SupportTier)
	}
	if resp.QuickMulti.Supported {
		t.Fatal("expected quick multi to be blocked by busy UDP 443")
	}
	if resp.QuickSocks5.Supported != true {
		t.Fatal("expected quick socks5 to stay supported")
	}
	if got := resp.Ports["udp_443"]; got != "busy" {
		t.Fatalf("udp_443 = %q, want busy", got)
	}
	if len(resp.Capabilities) < 2 || resp.Capabilities[0] != "Можно ставить SOCKS5" || resp.Capabilities[1] != "Конфликт портов для MULTI" {
		t.Fatalf("Capabilities = %v, want russian socks5 + multi conflict labels", resp.Capabilities)
	}
	if resp.HostStateLabel != "Можно поставить SOCKS5" {
		t.Fatalf("HostStateLabel = %q, want Russian SOCKS5-only state", resp.HostStateLabel)
	}
	if resp.SuggestedMultiName == "" || resp.SuggestedSocksName == "" {
		t.Fatalf("expected suggested names to be generated, got multi=%q socks=%q", resp.SuggestedMultiName, resp.SuggestedSocksName)
	}
	if !strings.Contains(resp.RecommendedAction, "SOCKS5") {
		t.Fatalf("RecommendedAction = %q, want SOCKS5 hint", resp.RecommendedAction)
	}
}