@synopackageland/cli 0.14.1 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +33 -18
  3. package/dist/build-spk/generator.d.ts +3 -2
  4. package/dist/build-spk/generator.js +6 -846
  5. package/dist/build-spk/resource-worker.d.ts +2 -14
  6. package/dist/build-spk/resource-worker.js +3 -166
  7. package/dist/cli.js +208 -56
  8. package/dist/compose/analyze.d.ts +3 -61
  9. package/dist/compose/analyze.js +35 -776
  10. package/dist/compose/env.d.ts +5 -3
  11. package/dist/compose/env.js +17 -25
  12. package/dist/compose/runner.js +23 -6
  13. package/dist/contract/unsupported-types.js +1 -1
  14. package/dist/contract/validate.js +2 -2
  15. package/dist/contract/web-launch.d.ts +1 -12
  16. package/dist/contract/web-launch.js +10 -74
  17. package/dist/ctl/cli.d.ts +36 -0
  18. package/dist/ctl/cli.js +218 -0
  19. package/dist/ctl/descriptor.d.ts +81 -0
  20. package/dist/ctl/descriptor.js +93 -0
  21. package/dist/ctl/dsm-auth.d.ts +10 -0
  22. package/dist/ctl/dsm-auth.js +30 -0
  23. package/dist/ctl/errors.d.ts +17 -0
  24. package/dist/ctl/errors.js +32 -0
  25. package/dist/ctl/flows.d.ts +11 -0
  26. package/dist/ctl/flows.js +52 -0
  27. package/dist/ctl/input.d.ts +20 -0
  28. package/dist/ctl/input.js +128 -0
  29. package/dist/ctl/json.d.ts +2 -0
  30. package/dist/ctl/json.js +38 -0
  31. package/dist/ctl/redaction.d.ts +15 -0
  32. package/dist/ctl/redaction.js +198 -0
  33. package/dist/ctl/service.d.ts +38 -0
  34. package/dist/ctl/service.js +156 -0
  35. package/dist/ctl/store.d.ts +51 -0
  36. package/dist/ctl/store.js +93 -0
  37. package/dist/ctl/transfers.d.ts +14 -0
  38. package/dist/ctl/transfers.js +74 -0
  39. package/dist/ctl/transport.d.ts +11 -0
  40. package/dist/ctl/transport.js +79 -0
  41. package/dist/deploy/deploy.d.ts +4 -3
  42. package/dist/deploy/deploy.js +5 -210
  43. package/dist/deploy/plan.js +26 -7
  44. package/dist/dev.js +5 -4
  45. package/dist/e2e/dsm.d.ts +2 -2
  46. package/dist/e2e/dsm.js +10 -6
  47. package/dist/error-registry.js +1020 -834
  48. package/dist/host/share-permission-lookup.d.ts +1 -1
  49. package/dist/host/share-permission.d.ts +1 -1
  50. package/dist/host/share-permission.js +1 -1
  51. package/dist/info.js +120 -28
  52. package/dist/init.js +188 -64
  53. package/dist/inspect.js +21 -6
  54. package/dist/runtime/executor-digests.json +5 -5
  55. package/dist/skill.js +51 -9
  56. package/dist/standalone/api-description-inspect.d.ts +4 -0
  57. package/dist/standalone/api-description-inspect.js +12 -0
  58. package/dist/standalone/api-description.d.ts +7 -0
  59. package/dist/standalone/api-description.js +73 -0
  60. package/dist/standalone/build.d.ts +3 -0
  61. package/dist/standalone/build.js +140 -33
  62. package/dist/standalone/compose-errors.d.ts +3 -0
  63. package/dist/standalone/compose-errors.js +243 -0
  64. package/dist/standalone/compose.d.ts +97 -10
  65. package/dist/standalone/compose.js +306 -75
  66. package/dist/standalone/daemon.js +111 -29
  67. package/dist/standalone/dsm-window.js +39 -8
  68. package/dist/standalone/identity-gateway.d.ts +9 -2
  69. package/dist/standalone/identity-gateway.js +282 -66
  70. package/dist/standalone/inspect-spk.d.ts +12 -0
  71. package/dist/standalone/inspect-spk.js +666 -136
  72. package/dist/standalone/lifecycle.js +124 -42
  73. package/dist/standalone/manifest.d.ts +8 -1
  74. package/dist/standalone/manifest.js +303 -86
  75. package/dist/standalone/resource.js +6 -2
  76. package/dist/standalone/spk.d.ts +4 -1
  77. package/dist/standalone/spk.js +138 -39
  78. package/dist/template-paths.d.ts +0 -2
  79. package/dist/template-paths.js +0 -5
  80. package/dist/test-command.d.ts +1 -1
  81. package/dist/test-command.js +1 -1
  82. package/dist/validate.d.ts +3 -3
  83. package/dist/validate.js +32 -140
  84. package/package.json +5 -3
  85. package/schema/synopkgland-api-v1.schema.json +423 -0
  86. package/skills/SKILL.md +4 -2
  87. package/skills/reference/compose.md +349 -0
  88. package/skills/reference/create.md +9 -18
  89. package/skills/reference/ctl.md +41 -0
  90. package/skills/reference/deploy.md +1 -1
  91. package/skills/reference/files.md +5 -1
  92. package/skills/reference/identity-gateway.md +8 -144
  93. package/skills/reference/wrap.md +10 -39
  94. package/assets/go/daemon/fixture_linux_test.go +0 -15
  95. package/assets/go/daemon/fixture_other_test.go +0 -7
  96. package/assets/go/daemon/go.mod +0 -5
  97. package/assets/go/daemon/main.go +0 -636
  98. package/assets/go/daemon/main_test.go +0 -315
  99. package/assets/go/daemon/network.go +0 -107
  100. package/assets/go/daemon/network_test.go +0 -86
  101. package/assets/go/daemon/process_linux.go +0 -55
  102. package/assets/go/daemon/process_linux_test.go +0 -161
  103. package/assets/go/daemon/process_other.go +0 -14
  104. package/assets/go/daemon/testdata/child.go +0 -44
  105. package/assets/go/gateway/auth.go +0 -101
  106. package/assets/go/gateway/auth_test.go +0 -216
  107. package/assets/go/gateway/cgi.go +0 -133
  108. package/assets/go/gateway/cgi_test.go +0 -156
  109. package/assets/go/gateway/csrf.go +0 -73
  110. package/assets/go/gateway/csrf_test.go +0 -155
  111. package/assets/go/gateway/directory.go +0 -108
  112. package/assets/go/gateway/directory_test.go +0 -202
  113. package/assets/go/gateway/gateway.go +0 -412
  114. package/assets/go/gateway/gateway_test.go +0 -676
  115. package/assets/go/gateway/go.mod +0 -5
  116. package/assets/go/gateway/groups.go +0 -66
  117. package/assets/go/gateway/main.go +0 -45
  118. package/dist/build-spk/gateway.d.ts +0 -31
  119. package/dist/build-spk/gateway.js +0 -60
  120. package/dist/compose/privilege.d.ts +0 -14
  121. package/dist/compose/privilege.js +0 -102
@@ -1,161 +0,0 @@
1
- //go:build linux
2
-
3
- package main
4
-
5
- import (
6
- "encoding/json"
7
- "os"
8
- "os/exec"
9
- "path/filepath"
10
- "strconv"
11
- "sync"
12
- "syscall"
13
- "testing"
14
- "time"
15
- )
16
-
17
- func waitDead(t *testing.T, pid int, birth string) {
18
- t.Helper()
19
- until := time.Now().Add(3 * time.Second)
20
- for time.Now().Before(until) {
21
- if !recordedChildAlive(pid, birth) {
22
- return
23
- }
24
- time.Sleep(20 * time.Millisecond)
25
- }
26
- t.Fatalf("PID %d remained alive", pid)
27
- }
28
- func readFixtureRecord(t *testing.T, data string) record {
29
- t.Helper()
30
- raw, err := os.ReadFile(filepath.Join(data, "daemon/daemon.pid"))
31
- if err != nil {
32
- t.Fatal(err)
33
- }
34
- var r record
35
- if err := json.Unmarshal(raw, &r); err != nil {
36
- t.Fatal(err)
37
- }
38
- return r
39
- }
40
-
41
- func TestLinuxSupervisorDeathAndConcurrentStart(t *testing.T) {
42
- control, dest, data := fixture(t, "normal")
43
- var wg sync.WaitGroup
44
- results := make(chan int, 6)
45
- for i := 0; i < 6; i++ {
46
- wg.Add(1)
47
- go func() { defer wg.Done(); rc, _ := runControl(control, "start", dest, data); results <- rc }()
48
- }
49
- wg.Wait()
50
- close(results)
51
- for rc := range results {
52
- if rc != 0 {
53
- t.Fatalf("concurrent start: %d", rc)
54
- }
55
- }
56
- r := readFixtureRecord(t, data)
57
- if err := syscall.Kill(r.Supervisor, syscall.SIGKILL); err != nil {
58
- t.Fatal(err)
59
- }
60
- waitDead(t, r.Child, r.Birth)
61
- if rc, out := runControl(control, "status", dest, data); rc != 1 {
62
- t.Fatalf("lost-supervisor status: %d %s", rc, out)
63
- }
64
- if rc, out := runControl(control, "stop", dest, data); rc != 0 {
65
- t.Fatalf("retire dead child: %d %s", rc, out)
66
- }
67
- if rc, out := runControl(control, "start", dest, data); rc != 0 {
68
- t.Fatalf("restart after death: %d %s", rc, out)
69
- }
70
- if r2 := readFixtureRecord(t, data); r2.Child == r.Child {
71
- t.Fatal("did not start fresh child")
72
- }
73
- }
74
-
75
- func TestLinuxInventoryNeverSignalsForeignPID(t *testing.T) {
76
- control, dest, data := fixture(t, "normal")
77
- if rc, out := runControl(control, "start", dest, data); rc != 0 {
78
- t.Fatalf("start: %d %s", rc, out)
79
- }
80
- r := readFixtureRecord(t, data)
81
- if rc, out := runControl(control, "stop", dest, data); rc != 0 {
82
- t.Fatalf("stop: %d %s", rc, out)
83
- }
84
- foreign := exec.Command("/bin/sleep", "30")
85
- if err := foreign.Start(); err != nil {
86
- t.Fatal(err)
87
- }
88
- defer func() { foreign.Process.Kill(); foreign.Wait() }()
89
- r.Supervisor = foreign.Process.Pid
90
- r.Child = foreign.Process.Pid
91
- r.Birth = processBirth(r.Child)
92
- raw, _ := json.Marshal(r)
93
- path := filepath.Join(data, "daemon/daemon.pid")
94
- if err := os.WriteFile(path, raw, 0600); err != nil {
95
- t.Fatal(err)
96
- }
97
- if os.Geteuid() == 0 {
98
- os.Chown(path, 65534, 65534)
99
- }
100
- for _, action := range []string{"stop", "start", "restart"} {
101
- if rc, out := runControl(control, action, dest, data); rc != 1 {
102
- t.Fatalf("%s accepted live unwitnessed PID: %d %s", action, rc, out)
103
- }
104
- if !recordedChildAlive(r.Child, r.Birth) {
105
- t.Fatal("signalled inventory PID")
106
- }
107
- }
108
- // A reused numeric PID with a different birth can be retired without killing it.
109
- r.Birth = "1"
110
- raw, _ = json.Marshal(r)
111
- os.WriteFile(path, raw, 0600)
112
- if rc, out := runControl(control, "stop", dest, data); rc != 0 {
113
- t.Fatalf("retire reused PID: %d %s", rc, out)
114
- }
115
- if !recordedChildAlive(foreign.Process.Pid, processBirth(foreign.Process.Pid)) {
116
- t.Fatal("killed reused PID")
117
- }
118
- }
119
-
120
- func TestLinuxChildSelfExitAndDeadlines(t *testing.T) {
121
- control, dest, data := fixture(t, "normal")
122
- if rc, out := runControl(control, "start", dest, data); rc != 0 {
123
- t.Fatalf("start: %d %s", rc, out)
124
- }
125
- r := readFixtureRecord(t, data)
126
- if err := syscall.Kill(r.Child, syscall.SIGTERM); err != nil {
127
- t.Fatal(err)
128
- }
129
- waitDead(t, r.Child, r.Birth)
130
- time.Sleep(100 * time.Millisecond)
131
- if rc, out := runControl(control, "status", dest, data); rc != 1 {
132
- t.Fatalf("self exit: %d %s", rc, out)
133
- }
134
- control, dest, data = fixture(t, "ignore")
135
- if rc, out := runControl(control, "start", dest, data); rc != 0 {
136
- t.Fatalf("start: %d %s", rc, out)
137
- }
138
- r = readFixtureRecord(t, data)
139
- began := time.Now()
140
- if rc, out := runControl(control, "stop", dest, data); rc != 0 {
141
- t.Fatalf("stop: %d %s", rc, out)
142
- }
143
- if elapsed := time.Since(began); elapsed < time.Second || elapsed > 3*time.Second {
144
- t.Fatalf("stop deadline: %s", elapsed)
145
- }
146
- waitDead(t, r.Child, r.Birth)
147
- }
148
-
149
- func TestParseLinuxProcBirth(t *testing.T) {
150
- fields := []string{"S", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "1234"}
151
- raw := "42 (a ) tricky name)"
152
- for _, f := range fields {
153
- raw += " " + f
154
- }
155
- if birth, alive := parseBirth(raw); birth != "1234" || !alive {
156
- t.Fatal(birth, alive)
157
- }
158
- if recordedChildAlive(os.Getpid(), strconv.Itoa(os.Getpid())) {
159
- t.Fatal("different process birth matched")
160
- }
161
- }
@@ -1,14 +0,0 @@
1
- //go:build !linux
2
-
3
- package main
4
-
5
- import (
6
- "net"
7
- "syscall"
8
- )
9
-
10
- // Host-only tests exercise lifecycle/quoting; published controllers target Linux.
11
- func childAttributes() *syscall.SysProcAttr { return &syscall.SysProcAttr{} }
12
- func processBirth(int) string { return "host-fixture" }
13
- func recordedChildAlive(pid int, _ string) bool { return syscall.Kill(pid, 0) == nil }
14
- func checkPeer(*net.UnixConn, int) error { return nil }
@@ -1,44 +0,0 @@
1
- // Linux/host fixture: a foreground process; never installed on a NAS.
2
- package main
3
-
4
- import (
5
- "encoding/json"
6
- "os"
7
- "os/signal"
8
- "syscall"
9
- "time"
10
- )
11
-
12
- func main() {
13
- if len(os.Args) > 1 {
14
- switch os.Args[1] {
15
- case "cleanup":
16
- os.Exit(0)
17
- case "cleanup-slow":
18
- time.Sleep(2500 * time.Millisecond)
19
- os.Exit(0)
20
- case "cleanup-fail":
21
- os.Exit(2)
22
- case "cleanup-hang":
23
- signal.Ignore(syscall.SIGTERM, syscall.SIGINT)
24
- for {
25
- time.Sleep(time.Second)
26
- }
27
- }
28
- }
29
- if os.Getenv("APP_MODE") == "exit" {
30
- os.Exit(2)
31
- }
32
- raw, _ := json.Marshal(map[string]any{"uid": os.Geteuid(), "args": os.Args[1:], "env": os.Environ()})
33
- fmtOutput := append(raw, '\n')
34
- os.Stdout.Write(fmtOutput)
35
- if os.Getenv("APP_MODE") == "ignore" {
36
- signal.Ignore(syscall.SIGTERM, syscall.SIGINT)
37
- for {
38
- time.Sleep(time.Second)
39
- }
40
- }
41
- c := make(chan os.Signal, 1)
42
- signal.Notify(c, syscall.SIGTERM, syscall.SIGINT)
43
- <-c
44
- }
@@ -1,101 +0,0 @@
1
- package main
2
-
3
- // DSM runs a package's CGI as the package account and forwards the browser's
4
- // cookies. The authenticator hands only the session carriers (cookie,
5
- // SynoToken, client address) to DSM's own authenticate.cgi, which prints the
6
- // signed-in user name, then resolves local groups or directory groups through DSM NSS.
7
- // It never parses the session cookie or trusts a user name from the request.
8
-
9
- import (
10
- "context"
11
- "errors"
12
- "fmt"
13
- "net"
14
- "net/http"
15
- "net/url"
16
- "os/exec"
17
- "regexp"
18
- "strings"
19
- "time"
20
- )
21
-
22
- // authenticateCGI is DSM's session check for third-party CGIs.
23
- const authenticateCGI = "/usr/syno/synoman/webman/modules/authenticate.cgi"
24
-
25
- var errNoSession = errors.New("no valid DSM session")
26
-
27
- type identity struct {
28
- User string
29
- Groups []string
30
- }
31
-
32
- type authenticator interface {
33
- Authenticate(r *http.Request) (identity, error)
34
- }
35
-
36
- type dsmAuthenticator struct {
37
- groupFile string
38
- passwdFile string
39
- directoryGroups func(context.Context, string) ([]string, error)
40
- timeout time.Duration
41
- run func(ctx context.Context, path string, env []string) ([]byte, error)
42
- }
43
-
44
- func newDSMAuthenticator() *dsmAuthenticator {
45
- return &dsmAuthenticator{groupFile: "/etc/group", passwdFile: "/etc/passwd", timeout: 10 * time.Second, run: runCGI, directoryGroups: (directoryLookup{timeout: 2 * time.Second, run: runDirectoryCommand}).groups}
46
- }
47
-
48
- var userName = regexp.MustCompile(`^[A-Za-z0-9._@\\-]{1,193}$`)
49
-
50
- func (a *dsmAuthenticator) Authenticate(r *http.Request) (identity, error) {
51
- cookie := r.Header.Get("Cookie")
52
- if cookie == "" {
53
- return identity{}, errNoSession
54
- }
55
- ctx, cancel := context.WithTimeout(r.Context(), a.timeout)
56
- defer cancel()
57
- out, err := a.run(ctx, authenticateCGI, sessionEnv(r, cookie))
58
- var exit interface{ ExitCode() int }
59
- if errors.As(err, &exit) {
60
- return identity{}, errNoSession
61
- }
62
- if err != nil {
63
- return identity{}, fmt.Errorf("run %s: %w", authenticateCGI, err)
64
- }
65
- user := strings.TrimSpace(string(out))
66
- if !userName.MatchString(user) {
67
- return identity{}, errNoSession
68
- }
69
- groups, err := a.groupsOf(ctx, user)
70
- if err != nil {
71
- return identity{}, err
72
- }
73
- return identity{User: user, Groups: groups}, nil
74
- }
75
-
76
- // sessionEnv is the complete environment authenticate.cgi sees: nothing from
77
- // this process and no request header besides the session carriers.
78
- func sessionEnv(r *http.Request, cookie string) []string {
79
- token := r.Header.Get("X-SYNO-TOKEN")
80
- if token == "" {
81
- // A navigation or iframe cannot send the header; DSM's front end appends ?SynoToken= instead.
82
- token = r.URL.Query().Get("SynoToken")
83
- }
84
- host, _, err := net.SplitHostPort(r.RemoteAddr)
85
- if err != nil {
86
- host = r.RemoteAddr
87
- }
88
- return []string{
89
- "REQUEST_METHOD=GET",
90
- "HTTP_COOKIE=" + cookie,
91
- "HTTP_X_SYNO_TOKEN=" + token,
92
- "QUERY_STRING=SynoToken=" + url.QueryEscape(token),
93
- "REMOTE_ADDR=" + host,
94
- }
95
- }
96
-
97
- func runCGI(ctx context.Context, path string, env []string) ([]byte, error) {
98
- cmd := exec.CommandContext(ctx, path)
99
- cmd.Env = env
100
- return cmd.Output()
101
- }
@@ -1,216 +0,0 @@
1
- package main
2
-
3
- import (
4
- "context"
5
- "errors"
6
- "net/http"
7
- "net/http/httptest"
8
- "os"
9
- "path/filepath"
10
- "slices"
11
- "strings"
12
- "testing"
13
- )
14
-
15
- const groupFile = `root:x:0:
16
- users:x:100:
17
- administrators:x:101:admin,alice
18
- staff:x:1030:alice,carol
19
- http:x:1023:
20
- `
21
-
22
- // DSM keeps no member list for users: the group is every local account's primary group.
23
- const passwdFile = `root:x:0:0::/root:/bin/sh
24
- alice:x:1001:100::/var/services/homes/alice:/sbin/nologin
25
- bob:x:1002:100::/var/services/homes/bob:/sbin/nologin
26
- carol:x:1003:100::/var/services/homes/carol:/sbin/nologin
27
- dave:x:1004:1030::/var/services/homes/dave:/sbin/nologin
28
- `
29
-
30
- type fakeRunner struct {
31
- out string
32
- err error
33
- env []string
34
- path string
35
- }
36
-
37
- func (f *fakeRunner) run(_ context.Context, path string, env []string) ([]byte, error) {
38
- f.path, f.env = path, env
39
- return []byte(f.out), f.err
40
- }
41
-
42
- type exitError struct{}
43
-
44
- func (*exitError) Error() string { return "exit status 1" }
45
- func (*exitError) ExitCode() int { return 1 }
46
-
47
- func newTestAuth(t *testing.T, runner *fakeRunner) *dsmAuthenticator {
48
- t.Helper()
49
- dir := t.TempDir()
50
- groups, passwd := filepath.Join(dir, "group"), filepath.Join(dir, "passwd")
51
- for path, content := range map[string]string{groups: groupFile, passwd: passwdFile} {
52
- if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
53
- t.Fatal(err)
54
- }
55
- }
56
- a := newDSMAuthenticator()
57
- a.groupFile, a.passwdFile = groups, passwd
58
- a.run = runner.run
59
- return a
60
- }
61
-
62
- func sessionRequest() *http.Request {
63
- r := httptest.NewRequest(http.MethodGet, "/webman/3rdparty/App/cgi/gateway.cgi/", nil)
64
- r.RemoteAddr = "192.0.2.7:51234"
65
- r.Header.Set("Cookie", "id=abc; did=xyz")
66
- r.Header.Set("X-SYNO-TOKEN", "tok+en/=")
67
- r.Header.Set("User-Agent", "should-not-pass")
68
- return r
69
- }
70
-
71
- func TestUserAndGroupsComeFromAuthenticateCGIAndGroupFile(t *testing.T) {
72
- runner := &fakeRunner{out: "alice\n"}
73
- id, err := newTestAuth(t, runner).Authenticate(sessionRequest())
74
- if err != nil || id.User != "alice" {
75
- t.Fatalf("got %+v, %v", id, err)
76
- }
77
- if want := []string{"users", "administrators", "staff"}; !slices.Equal(id.Groups, want) {
78
- t.Errorf("groups = %v, want %v", id.Groups, want)
79
- }
80
- if runner.path != authenticateCGI {
81
- t.Errorf("ran %q", runner.path)
82
- }
83
- }
84
-
85
- func TestOrdinaryUserHasNoAdministratorsGroup(t *testing.T) {
86
- id, err := newTestAuth(t, &fakeRunner{out: "bob\n"}).Authenticate(sessionRequest())
87
- if err != nil || id.User != "bob" || slices.Contains(id.Groups, "administrators") {
88
- t.Fatalf("got %+v, %v", id, err)
89
- }
90
- }
91
-
92
- func TestPrimaryGroupCountsAsMembershipEvenWithoutAMemberListEntry(t *testing.T) {
93
- id, err := newTestAuth(t, &fakeRunner{out: "bob\n"}).Authenticate(sessionRequest())
94
- if err != nil || !slices.Equal(id.Groups, []string{"users"}) {
95
- t.Fatalf("got %+v, %v", id, err)
96
- }
97
- }
98
-
99
- func TestPrimaryGroupIsNotDuplicatedWhenAlsoListedAsMember(t *testing.T) {
100
- a := newTestAuth(t, &fakeRunner{out: "dave"})
101
- if err := os.WriteFile(a.groupFile, []byte("staff:x:1030:dave\n"), 0o644); err != nil {
102
- t.Fatal(err)
103
- }
104
- id, _ := a.Authenticate(sessionRequest())
105
- if !slices.Equal(id.Groups, []string{"staff"}) {
106
- t.Fatalf("groups = %v", id.Groups)
107
- }
108
- }
109
-
110
- func TestMissingPasswdFileFailsClosedWithoutBeingNoSession(t *testing.T) {
111
- a := newTestAuth(t, &fakeRunner{out: "bob"})
112
- a.passwdFile = filepath.Join(t.TempDir(), "absent")
113
- if _, err := a.Authenticate(sessionRequest()); err == nil || errors.Is(err, errNoSession) {
114
- t.Fatalf("err = %v", err)
115
- }
116
- }
117
-
118
- func TestOnlySessionCarriersReachAuthenticateCGI(t *testing.T) {
119
- runner := &fakeRunner{out: "alice"}
120
- if _, err := newTestAuth(t, runner).Authenticate(sessionRequest()); err != nil {
121
- t.Fatal(err)
122
- }
123
- want := []string{
124
- "HTTP_COOKIE=id=abc; did=xyz",
125
- "HTTP_X_SYNO_TOKEN=tok+en/=",
126
- "QUERY_STRING=SynoToken=tok%2Ben%2F%3D",
127
- "REMOTE_ADDR=192.0.2.7",
128
- "REQUEST_METHOD=GET",
129
- }
130
- got := slices.Clone(runner.env)
131
- slices.Sort(got)
132
- if !slices.Equal(got, want) {
133
- t.Fatalf("env = %q\nwant %q", got, want)
134
- }
135
- }
136
-
137
- func TestMissingCookieNeverRunsAuthenticateCGI(t *testing.T) {
138
- runner := &fakeRunner{out: "alice"}
139
- r := sessionRequest()
140
- r.Header.Del("Cookie")
141
- _, err := newTestAuth(t, runner).Authenticate(r)
142
- if !errors.Is(err, errNoSession) || runner.path != "" {
143
- t.Fatalf("err=%v ran=%q", err, runner.path)
144
- }
145
- }
146
-
147
- func TestExpiredOrMalformedSessionsAreNoSession(t *testing.T) {
148
- for name, runner := range map[string]*fakeRunner{
149
- "expired session (exit status)": {err: &exitError{}},
150
- "empty output": {out: "\n"},
151
- "two lines": {out: "alice\nadmin\n"},
152
- "html output": {out: "<html>login</html>"},
153
- "overlong name": {out: strings.Repeat("a", 200)},
154
- "space in name": {out: "alice admin"},
155
- } {
156
- t.Run(name, func(t *testing.T) {
157
- _, err := newTestAuth(t, runner).Authenticate(sessionRequest())
158
- if !errors.Is(err, errNoSession) {
159
- t.Fatalf("err = %v", err)
160
- }
161
- })
162
- }
163
- }
164
-
165
- func TestAuthenticateCGIThatCannotRunFailsClosedWithoutBeingNoSession(t *testing.T) {
166
- _, err := newTestAuth(t, &fakeRunner{err: errors.New("fork/exec: no such file")}).Authenticate(sessionRequest())
167
- if err == nil || errors.Is(err, errNoSession) {
168
- t.Fatalf("err = %v", err)
169
- }
170
- }
171
-
172
- func TestGroupMembershipIsReadFreshOnEveryRequest(t *testing.T) {
173
- runner := &fakeRunner{out: "carol"}
174
- a := newTestAuth(t, runner)
175
- id, _ := a.Authenticate(sessionRequest())
176
- if !slices.Equal(id.Groups, []string{"users", "staff"}) {
177
- t.Fatalf("groups = %v", id.Groups)
178
- }
179
- if err := os.WriteFile(a.groupFile, []byte("users:x:100:\nstaff:x:1030:alice\n"), 0o644); err != nil {
180
- t.Fatal(err)
181
- }
182
- id, _ = a.Authenticate(sessionRequest())
183
- if !slices.Equal(id.Groups, []string{"users"}) {
184
- t.Fatalf("groups after removal = %v", id.Groups)
185
- }
186
- }
187
-
188
- func tokenEnv(t *testing.T, r *http.Request) []string {
189
- t.Helper()
190
- runner := &fakeRunner{out: "alice"}
191
- if _, err := newTestAuth(t, runner).Authenticate(r); err != nil {
192
- t.Fatal(err)
193
- }
194
- return runner.env
195
- }
196
-
197
- func TestQueryTokenIsUsedWhenHeaderIsEmpty(t *testing.T) {
198
- r := sessionRequest()
199
- r.Header.Del("X-SYNO-TOKEN")
200
- r.URL.RawQuery = "a=b&SynoToken=q%2Btok"
201
- env := tokenEnv(t, r)
202
- for _, want := range []string{"HTTP_X_SYNO_TOKEN=q+tok", "QUERY_STRING=SynoToken=q%2Btok"} {
203
- if !slices.Contains(env, want) {
204
- t.Fatalf("env = %q, missing %q", env, want)
205
- }
206
- }
207
- }
208
-
209
- func TestHeaderTokenWinsOverQueryToken(t *testing.T) {
210
- r := sessionRequest()
211
- r.URL.RawQuery = "SynoToken=from-query"
212
- env := tokenEnv(t, r)
213
- if !slices.Contains(env, "HTTP_X_SYNO_TOKEN=tok+en/=") || slices.Contains(env, "QUERY_STRING=SynoToken=from-query") {
214
- t.Fatalf("env = %q", env)
215
- }
216
- }
@@ -1,133 +0,0 @@
1
- package main
2
-
3
- import (
4
- "bufio"
5
- "bytes"
6
- "errors"
7
- "fmt"
8
- "io"
9
- "net"
10
- "net/http"
11
- "time"
12
- )
13
-
14
- // serveCGI answers one request the way net/http/cgi.Serve does: "Status:" and headers, a blank line, the body
15
- // on `out`. It is its own copy so that the reply can be hijacked: the handler then owns `in` and `out`, which
16
- // is how a WebSocket passes through DSM's CGI interface.
17
- func serveCGI(h http.Handler, req *http.Request, in io.Reader, out io.Writer) error {
18
- if req.Body == nil {
19
- req.Body = http.NoBody
20
- }
21
- rw := &cgiResponse{req: req, header: make(http.Header), in: in, out: out, bufw: bufio.NewWriter(out)}
22
- h.ServeHTTP(rw, req)
23
- if rw.hijacked {
24
- return nil
25
- }
26
- rw.Write(nil) // make sure a reply is sent
27
- return rw.bufw.Flush()
28
- }
29
-
30
- type cgiResponse struct {
31
- req *http.Request
32
- header http.Header
33
- code int
34
- wroteHeader bool
35
- wroteCGI bool
36
- hijacked bool
37
- in io.Reader
38
- out io.Writer
39
- bufw *bufio.Writer
40
- }
41
-
42
- func (r *cgiResponse) Header() http.Header { return r.header }
43
-
44
- func (r *cgiResponse) Flush() {
45
- if !r.hijacked {
46
- r.bufw.Flush()
47
- }
48
- }
49
-
50
- func (r *cgiResponse) WriteHeader(code int) {
51
- if r.wroteHeader {
52
- return
53
- }
54
- r.wroteHeader, r.code = true, code
55
- }
56
-
57
- func (r *cgiResponse) Write(p []byte) (int, error) {
58
- if r.hijacked {
59
- return 0, http.ErrHijacked
60
- }
61
- if !r.wroteHeader {
62
- r.WriteHeader(http.StatusOK)
63
- }
64
- if !r.wroteCGI {
65
- r.writeCGIHeader(p)
66
- }
67
- return r.bufw.Write(p)
68
- }
69
-
70
- // writeCGIHeader sends the header block; p, the first chunk of the body, is sniffed for a missing Content-Type.
71
- func (r *cgiResponse) writeCGIHeader(p []byte) {
72
- r.wroteCGI = true
73
- fmt.Fprintf(r.bufw, "Status: %d %s\r\n", r.code, http.StatusText(r.code))
74
- if _, hasType := r.header["Content-Type"]; !hasType {
75
- r.header.Set("Content-Type", http.DetectContentType(p))
76
- }
77
- r.header.Write(r.bufw)
78
- r.bufw.WriteString("\r\n")
79
- r.bufw.Flush()
80
- }
81
-
82
- // Hijack hands stdin and stdout to the handler. The first thing it writes is an HTTP/1.1 status line and
83
- // headers (that is what httputil.ReverseProxy writes for a 101); the CGI interface wants "Status: 101 ..."
84
- // there, so cgiConn rewrites the status line and leaves everything after the blank line alone.
85
- func (r *cgiResponse) Hijack() (net.Conn, *bufio.ReadWriter, error) {
86
- if r.wroteHeader || r.wroteCGI || r.hijacked {
87
- return nil, nil, errors.New("cgi: Hijack after the reply began")
88
- }
89
- r.hijacked = true
90
- c := &cgiConn{in: r.in, out: r.out}
91
- return c, bufio.NewReadWriter(bufio.NewReader(c), bufio.NewWriter(c)), nil
92
- }
93
-
94
- type cgiConn struct {
95
- in io.Reader
96
- out io.Writer
97
- head []byte // the header block, held back until it is complete
98
- headSent bool
99
- }
100
-
101
- func (c *cgiConn) Read(p []byte) (int, error) { return c.in.Read(p) }
102
-
103
- func (c *cgiConn) Write(p []byte) (int, error) {
104
- if c.headSent {
105
- return c.out.Write(p)
106
- }
107
- c.head = append(c.head, p...)
108
- end := bytes.Index(c.head, []byte("\r\n\r\n"))
109
- if end < 0 {
110
- return len(p), nil
111
- }
112
- c.headSent = true
113
- statusLine, headers, _ := bytes.Cut(c.head[:end+4], []byte("\r\n"))
114
- _, status, _ := bytes.Cut(statusLine, []byte(" ")) // "HTTP/1.1 101 Switching Protocols" -> "101 Switching Protocols"
115
- rewritten := append(append([]byte("Status: "), status...), "\r\n"...)
116
- rewritten = append(append(rewritten, headers...), c.head[end+4:]...)
117
- if _, err := c.out.Write(rewritten); err != nil {
118
- return 0, err
119
- }
120
- return len(p), nil
121
- }
122
-
123
- func (c *cgiConn) Close() error { return nil }
124
- func (c *cgiConn) LocalAddr() net.Addr { return cgiAddr{} }
125
- func (c *cgiConn) RemoteAddr() net.Addr { return cgiAddr{} }
126
- func (c *cgiConn) SetDeadline(time.Time) error { return nil }
127
- func (c *cgiConn) SetReadDeadline(time.Time) error { return nil }
128
- func (c *cgiConn) SetWriteDeadline(time.Time) error { return nil }
129
-
130
- type cgiAddr struct{}
131
-
132
- func (cgiAddr) Network() string { return "cgi" }
133
- func (cgiAddr) String() string { return "cgi" }