command_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. package cli
  2. import (
  3. "os"
  4. "path/filepath"
  5. "reflect"
  6. "strings"
  7. "testing"
  8. "debtik/internal/aerotun"
  9. "debtik/internal/auth"
  10. "debtik/internal/dynamicrouting"
  11. "debtik/internal/sdwan"
  12. "debtik/internal/services"
  13. "debtik/internal/vpn"
  14. )
  15. func TestQuotedCommandArguments(t *testing.T) {
  16. got, err := splitCommand(`/interface aerotun set test comment="office uplink" psk='literal $\\key' name=""`)
  17. want := []string{"/interface", "aerotun", "set", "test", "comment=office uplink", `psk=literal $\\key`, "name="}
  18. if err != nil || !reflect.DeepEqual(got, want) {
  19. t.Fatalf("%q %v", got, err)
  20. }
  21. if _, err := splitCommand(`comment="unfinished`); err == nil {
  22. t.Fatal("accepted unterminated quote")
  23. }
  24. }
  25. func TestBareLogAndHandlerRecovery(t *testing.T) {
  26. e := &Executor{}
  27. user := &auth.PublicUser{Role: auth.RoleFull}
  28. output, _ := e.Execute("/log", user)
  29. if commandExitStatus(output) != 0 {
  30. t.Fatal(output)
  31. }
  32. output, _ = e.Execute("/interface print", user) // absent manager simulates a handler failure
  33. if commandExitStatus(output) == 0 || !strings.Contains(output, "still running") {
  34. t.Fatal(output)
  35. }
  36. output, _ = e.Execute("nonsense", user)
  37. if commandExitStatus(output) == 0 {
  38. t.Fatal("unknown command returned success")
  39. }
  40. output, _ = e.Execute("/help", user)
  41. if commandExitStatus(output) != 0 {
  42. t.Fatal("help returned failure")
  43. }
  44. }
  45. func TestAeroTUNCLIConfigurationAndPermissions(t *testing.T) {
  46. m := aerotun.NewManagerWithPath(filepath.Join(t.TempDir(), "aerotun.json"))
  47. defer m.StopAll()
  48. e := &Executor{vpnMgr: &vpn.Manager{AeroTUN: m}}
  49. admin := &auth.PublicUser{Role: auth.RoleFull}
  50. psk := strings.Repeat("12", 32)
  51. command := `/interface aerotun add name=atest mode=server local-address=10.90.0.1/30 peer-address=10.90.0.2/32 allowed-ips=10.90.0.2/32 psk=` + psk + ` comment="office uplink"`
  52. output, _ := e.Execute(command, admin)
  53. if commandExitStatus(output) != 0 {
  54. t.Fatal(output)
  55. }
  56. inst := m.List()[0]
  57. if !inst.Disabled || inst.PublicKey == "" || inst.Comment != "office uplink" {
  58. t.Fatal("draft configuration not retained")
  59. }
  60. for _, role := range []auth.Role{auth.RoleRead, auth.RoleWrite} {
  61. output, _ = e.Execute("/interface aerotun remove atest", &auth.PublicUser{Role: role})
  62. if commandExitStatus(output) == 0 {
  63. t.Fatal("non-admin removed tunnel")
  64. }
  65. }
  66. output, _ = e.Execute("/interface aerotun print detail=yes", admin)
  67. if strings.Contains(output, psk) || strings.Contains(output, inst.PrivateKey) || !strings.Contains(output, inst.PublicKey) {
  68. t.Fatal("secret disclosure or missing public identity")
  69. }
  70. output, _ = e.Execute(`/interface aerotun set atest comment="updated uplink"`, admin)
  71. if commandExitStatus(output) != 0 {
  72. t.Fatal(output)
  73. }
  74. updated, _ := m.Get(inst.ID)
  75. if updated.PSK != psk || updated.PublicKey != inst.PublicKey {
  76. t.Fatal("edit changed identity")
  77. }
  78. output, _ = e.Execute("/interface aerotun remove atest", admin)
  79. if commandExitStatus(output) != 0 || len(m.List()) != 0 {
  80. t.Fatal(output)
  81. }
  82. }
  83. func TestVXLANCLIConfigurationAndPermissions(t *testing.T) {
  84. vxlanMgr := vpn.NewVXLANManager(filepath.Join(t.TempDir(), "vxlan.json"))
  85. e := &Executor{vpnMgr: &vpn.Manager{VXLAN: vxlanMgr}}
  86. admin := &auth.PublicUser{Role: auth.RoleFull}
  87. viewer := &auth.PublicUser{Role: auth.RoleRead}
  88. // 1. Add VXLAN
  89. output, _ := e.Execute(`/interface vxlan add name=vxlan99 vni=99 port=4789 mtu=1500 comment="test overlay"`, admin)
  90. if strings.Contains(output, "Error") {
  91. t.Fatalf("unexpected error adding vxlan: %s", output)
  92. }
  93. // 2. Print VXLAN
  94. output, _ = e.Execute("/interface vxlan print", viewer)
  95. if !strings.Contains(output, "vxlan99") || !strings.Contains(output, "99") {
  96. t.Fatalf("vxlan99 not found in print output: %s", output)
  97. }
  98. // 3. Add VTEP
  99. output, _ = e.Execute("/interface vxlan vteps add interface=vxlan99 remote-ip=192.168.1.50 comment=peera", admin)
  100. if strings.Contains(output, "Error") {
  101. t.Fatalf("unexpected error adding vtep: %s", output)
  102. }
  103. // 4. Print VTEP
  104. output, _ = e.Execute("/interface vxlan vteps print", viewer)
  105. if !strings.Contains(output, "192.168.1.50") {
  106. t.Fatalf("vtep not found in print output: %s", output)
  107. }
  108. // 5. Viewer cannot modify
  109. output, _ = e.Execute("/interface vxlan remove vxlan99", viewer)
  110. if !strings.Contains(output, "Permission denied") {
  111. t.Fatalf("expected permission denied for viewer, got: %s", output)
  112. }
  113. // 6. Set comment
  114. output, _ = e.Execute(`/interface vxlan set vxlan99 comment="updated vxlan"`, admin)
  115. if strings.Contains(output, "Error") {
  116. t.Fatalf("unexpected error setting vxlan: %s", output)
  117. }
  118. // 7. Remove VTEP
  119. output, _ = e.Execute("/interface vxlan vteps remove interface=vxlan99 remote-ip=192.168.1.50", admin)
  120. if strings.Contains(output, "Error") {
  121. t.Fatalf("unexpected error removing vtep: %s", output)
  122. }
  123. // 8. Remove VXLAN
  124. output, _ = e.Execute("/interface vxlan remove vxlan99", admin)
  125. if strings.Contains(output, "Error") {
  126. t.Fatalf("unexpected error removing vxlan: %s", output)
  127. }
  128. }
  129. func TestSDWANCLIConfigurationAndPermissions(t *testing.T) {
  130. sdwanMgr := sdwan.NewManager(filepath.Join(t.TempDir(), "sdwan.json"))
  131. defer sdwanMgr.Stop()
  132. e := &Executor{sdwanMgr: sdwanMgr}
  133. admin := &auth.PublicUser{Role: auth.RoleFull}
  134. viewer := &auth.PublicUser{Role: auth.RoleRead}
  135. // 1. Add SD-WAN group
  136. output, _ := e.Execute(`/ip sdwan add name=sdwan0 mode=weighted auto-nat=yes probe-interval=5 comment="Multi-WAN LB"`, admin)
  137. if strings.Contains(output, "Error") {
  138. t.Fatalf("unexpected error adding sdwan group: %s", output)
  139. }
  140. // 2. Print SD-WAN groups
  141. output, _ = e.Execute("/ip sdwan print", viewer)
  142. if !strings.Contains(output, "sdwan0") || !strings.Contains(output, "weighted") {
  143. t.Fatalf("sdwan0 not found in print output: %s", output)
  144. }
  145. // 3. Status
  146. output, _ = e.Execute("/ip sdwan status", viewer)
  147. if !strings.Contains(output, "SD-WAN Telemetry Status") {
  148. t.Fatalf("status output unexpected: %s", output)
  149. }
  150. // 4. Add Members
  151. output, _ = e.Execute("/ip sdwan member add group=sdwan0 interface=eth0 gateway=192.168.1.1 weight=70 target-probe=1.1.1.1", admin)
  152. if strings.Contains(output, "Error") {
  153. t.Fatalf("error adding member eth0: %s", output)
  154. }
  155. output, _ = e.Execute("/ip sdwan member add group=sdwan0 interface=eth1 gateway=192.168.2.1 weight=30 target-probe=8.8.8.8", admin)
  156. if strings.Contains(output, "Error") {
  157. t.Fatalf("error adding member eth1: %s", output)
  158. }
  159. // 5. Print Members
  160. output, _ = e.Execute("/ip sdwan member print", viewer)
  161. if !strings.Contains(output, "eth0") || !strings.Contains(output, "eth1") {
  162. t.Fatalf("members not found in member print output: %s", output)
  163. }
  164. // 6. Permission check for viewer
  165. output, _ = e.Execute("/ip sdwan remove sdwan0", viewer)
  166. if !strings.Contains(output, "Permission denied") {
  167. t.Fatalf("expected permission denied for viewer, got: %s", output)
  168. }
  169. // 7. Member set & disable
  170. output, _ = e.Execute("/ip sdwan member set group=sdwan0 interface=eth0 weight=80", admin)
  171. if strings.Contains(output, "Error") {
  172. t.Fatalf("error setting member weight: %s", output)
  173. }
  174. output, _ = e.Execute("/ip sdwan member disable group=sdwan0 interface=eth1", admin)
  175. if strings.Contains(output, "Error") {
  176. t.Fatalf("error disabling member eth1: %s", output)
  177. }
  178. // 8. Group disable & enable
  179. output, _ = e.Execute("/ip sdwan disable sdwan0", admin)
  180. if strings.Contains(output, "Error") {
  181. t.Fatalf("error disabling sdwan0: %s", output)
  182. }
  183. output, _ = e.Execute("/ip sdwan enable sdwan0", admin)
  184. if strings.Contains(output, "Error") {
  185. t.Fatalf("error enabling sdwan0: %s", output)
  186. }
  187. // 9. Remove member
  188. output, _ = e.Execute("/ip sdwan member remove group=sdwan0 interface=eth1", admin)
  189. if strings.Contains(output, "Error") {
  190. t.Fatalf("error removing member eth1: %s", output)
  191. }
  192. // 10. Remove group
  193. output, _ = e.Execute("/ip sdwan remove sdwan0", admin)
  194. if strings.Contains(output, "Error") {
  195. t.Fatalf("error removing group sdwan0: %s", output)
  196. }
  197. }
  198. func TestRoutingOSPFCLI(t *testing.T) {
  199. dynMgr := dynamicrouting.NewManager(filepath.Join(t.TempDir(), "dynamic.json"))
  200. e := &Executor{dynRoutingMgr: dynMgr}
  201. admin := &auth.PublicUser{Role: auth.RoleFull}
  202. viewer := &auth.PublicUser{Role: auth.RoleRead}
  203. // 1. Instance commands
  204. out, _ := e.Execute("/routing ospf instance print", admin)
  205. if !strings.Contains(out, "NAME") {
  206. t.Fatalf("expected NAME table header in instance print: %s", out)
  207. }
  208. out, _ = e.Execute("/routing ospf instance add name=ospf-test router-id=10.255.0.1 redistribute-connected=yes redistribute-static=no default-metric=30", admin)
  209. if !strings.Contains(out, "saved") {
  210. t.Fatalf("failed to add ospf instance: %s", out)
  211. }
  212. out, _ = e.Execute("/routing ospf instance set ospf-test default-metric=45", admin)
  213. if !strings.Contains(out, "updated") {
  214. t.Fatalf("failed to set ospf instance: %s", out)
  215. }
  216. out, _ = e.Execute("/routing ospf instance disable ospf-test", admin)
  217. if !strings.Contains(out, "disabled") {
  218. t.Fatalf("failed to disable ospf instance: %s", out)
  219. }
  220. out, _ = e.Execute("/routing ospf instance enable ospf-test", admin)
  221. if !strings.Contains(out, "enabled") {
  222. t.Fatalf("failed to enable ospf instance: %s", out)
  223. }
  224. // Viewer permission check
  225. out, _ = e.Execute("/routing ospf instance remove ospf-test", viewer)
  226. if !strings.Contains(out, "Permission denied") {
  227. t.Fatalf("expected permission denied for viewer: %s", out)
  228. }
  229. // 2. Area commands
  230. out, _ = e.Execute("/routing ospf area print", admin)
  231. if !strings.Contains(out, "AREA-ID") {
  232. t.Fatalf("expected AREA-ID table header in area print: %s", out)
  233. }
  234. out, _ = e.Execute("/routing ospf area add name=area1 area-id=0.0.0.1 type=stub default-cost=20 no-summaries=yes", admin)
  235. if !strings.Contains(out, "saved") {
  236. t.Fatalf("failed to add ospf area: %s", out)
  237. }
  238. out, _ = e.Execute("/routing ospf area set area1 default-cost=35", admin)
  239. if !strings.Contains(out, "updated") {
  240. t.Fatalf("failed to set ospf area: %s", out)
  241. }
  242. // 3. Area range commands
  243. out, _ = e.Execute("/routing ospf area range print", admin)
  244. if !strings.Contains(out, "PREFIX") {
  245. t.Fatalf("expected PREFIX in area range print: %s", out)
  246. }
  247. out, _ = e.Execute("/routing ospf area range add prefix=10.10.0.0/16 cost=10 advertise=no", admin)
  248. if !strings.Contains(out, "added") {
  249. t.Fatalf("failed to add area range: %s", out)
  250. }
  251. out, _ = e.Execute("/routing ospf area range set 10.10.0.0/16 cost=20 advertise=yes", admin)
  252. if !strings.Contains(out, "updated") {
  253. t.Fatalf("failed to set area range: %s", out)
  254. }
  255. out, _ = e.Execute("/routing ospf area range remove 10.10.0.0/16", admin)
  256. if !strings.Contains(out, "removed") {
  257. t.Fatalf("failed to remove area range: %s", out)
  258. }
  259. // 4. Interface template commands
  260. out, _ = e.Execute("/routing ospf interface print", admin)
  261. if !strings.Contains(out, "INTERFACE") {
  262. t.Fatalf("expected INTERFACE in interface print: %s", out)
  263. }
  264. out, _ = e.Execute("/routing ospf interface add interface=eth0,eth1 area=Default cost=15 network-type=nonbroadcast auth=md5 auth-key=secret123", admin)
  265. if !strings.Contains(out, "saved") {
  266. t.Fatalf("failed to add ospf interface template: %s", out)
  267. }
  268. out, _ = e.Execute("/routing ospf interface set eth0,eth1 cost=25", admin)
  269. if !strings.Contains(out, "updated") {
  270. t.Fatalf("failed to set ospf interface template: %s", out)
  271. }
  272. out, _ = e.Execute("/routing ospf interface remove eth0,eth1", admin)
  273. if !strings.Contains(out, "removed") {
  274. t.Fatalf("failed to remove ospf interface template: %s", out)
  275. }
  276. // 5. Static neighbor commands
  277. out, _ = e.Execute("/routing ospf static-neighbor print", admin)
  278. if !strings.Contains(out, "ADDRESS") {
  279. t.Fatalf("expected ADDRESS in static-neighbor print: %s", out)
  280. }
  281. out, _ = e.Execute("/routing ospf static-neighbor add address=192.168.100.2 poll-interval=30 cost=5", admin)
  282. if !strings.Contains(out, "saved") {
  283. t.Fatalf("failed to add static neighbor: %s", out)
  284. }
  285. out, _ = e.Execute("/routing ospf static-neighbor set 192.168.100.2 poll-interval=60", admin)
  286. if !strings.Contains(out, "updated") {
  287. t.Fatalf("failed to update static neighbor: %s", out)
  288. }
  289. out, _ = e.Execute("/routing ospf static-neighbor disable 192.168.100.2", admin)
  290. if !strings.Contains(out, "disabled") {
  291. t.Fatalf("failed to disable static neighbor: %s", out)
  292. }
  293. out, _ = e.Execute("/routing ospf static-neighbor enable 192.168.100.2", admin)
  294. if !strings.Contains(out, "enabled") {
  295. t.Fatalf("failed to enable static neighbor: %s", out)
  296. }
  297. out, _ = e.Execute("/routing ospf static-neighbor remove 192.168.100.2", admin)
  298. if !strings.Contains(out, "removed") {
  299. t.Fatalf("failed to remove static neighbor: %s", out)
  300. }
  301. // 6. Live status commands
  302. out, _ = e.Execute("/routing ospf neighbor print", admin)
  303. if !strings.Contains(out, "ROUTER-ID") {
  304. t.Fatalf("expected ROUTER-ID in neighbor print: %s", out)
  305. }
  306. out, _ = e.Execute("/routing ospf lsa print", admin)
  307. if !strings.Contains(out, "TYPE") {
  308. t.Fatalf("expected TYPE in lsa print: %s", out)
  309. }
  310. out, _ = e.Execute("/routing ospf route print", admin)
  311. if !strings.Contains(out, "PREFIX") {
  312. t.Fatalf("expected PREFIX in route print: %s", out)
  313. }
  314. // Cleanup
  315. out, _ = e.Execute("/routing ospf area remove area1", admin)
  316. if !strings.Contains(out, "removed") {
  317. t.Fatalf("failed to remove area1: %s", out)
  318. }
  319. out, _ = e.Execute("/routing ospf instance remove ospf-test", admin)
  320. if !strings.Contains(out, "removed") {
  321. t.Fatalf("failed to remove ospf-test: %s", out)
  322. }
  323. }
  324. func TestCLIRejectsInvalidNetworkParameters(t *testing.T) {
  325. svc := services.NewManager(filepath.Join(t.TempDir(), "services.json"))
  326. e := &Executor{servicesMgr: svc, vpnMgr: &vpn.Manager{VXLAN: &vpn.VXLANManager{}}}
  327. admin := &auth.PublicUser{Role: auth.RoleFull}
  328. for _, command := range []string{
  329. "/ip service set ssh port=invalid", "/ip service set ssh port=65536", "/ip service set ssh disabled=maybe",
  330. "/interface vxlan add vni=42 learning=maybe", "/interface vxlan add vni=42 port=invalid", "/interface vxlan add vni=42 port=65536", "/interface vxlan add vni=42 mtu=bad",
  331. } {
  332. output, _ := e.Execute(command, admin)
  333. if commandExitStatus(output) == 0 {
  334. t.Errorf("%s accepted: %q", command, output)
  335. }
  336. }
  337. service, _ := svc.GetService("ssh")
  338. if service.Disabled || service.Port != 2222 {
  339. t.Fatalf("service unexpectedly changed: %+v", service)
  340. }
  341. }
  342. func TestBFDCLIReportsSaveFailure(t *testing.T) {
  343. configPath := filepath.Join(t.TempDir(), "dynamic.json")
  344. mgr := dynamicrouting.NewManager(configPath)
  345. peer, err := mgr.SaveBFDNeighbor(dynamicrouting.BFDNeighbor{Address: "192.0.2.10", Disabled: true})
  346. if err != nil {
  347. t.Fatal(err)
  348. }
  349. if err = os.Remove(configPath); err != nil {
  350. t.Fatal(err)
  351. }
  352. if err = os.Mkdir(configPath, 0700); err != nil {
  353. t.Fatal(err)
  354. }
  355. executor := &Executor{dynRoutingMgr: mgr}
  356. output, _ := executor.Execute("/routing bfd peer enable "+peer.ID, &auth.PublicUser{Role: auth.RoleFull})
  357. if commandExitStatus(output) == 0 || !strings.Contains(output, "Error applying BFD peer") {
  358. t.Fatalf("failure reported as success: %q", output)
  359. }
  360. }
  361. func TestPingFailureWithOutputReturnsFailureStatus(t *testing.T) {
  362. dir := t.TempDir()
  363. path := filepath.Join(dir, "ping")
  364. if err := os.WriteFile(path, []byte("#!/bin/sh\nprintf '100%% packet loss\\n'\nexit 1\n"), 0755); err != nil {
  365. t.Fatal(err)
  366. }
  367. t.Setenv("PATH", dir)
  368. output, _ := (&Executor{}).Execute("ping 192.0.2.1 count=1", &auth.PublicUser{Role: auth.RoleFull})
  369. if commandExitStatus(output) != 1 || !strings.Contains(output, "100% packet loss") {
  370. t.Fatalf("failure lost: %q", output)
  371. }
  372. }