@synopackageland/cli 0.13.1 → 0.15.0
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.
- package/CHANGELOG.md +18 -0
- package/README.md +44 -1
- package/dist/build-spk/generator.d.ts +3 -2
- package/dist/build-spk/generator.js +6 -850
- package/dist/build-spk/resource-worker.d.ts +2 -14
- package/dist/build-spk/resource-worker.js +3 -166
- package/dist/cli.js +208 -56
- package/dist/compose/analyze.d.ts +3 -61
- package/dist/compose/analyze.js +35 -776
- package/dist/compose/env.d.ts +5 -3
- package/dist/compose/env.js +17 -25
- package/dist/compose/runner.js +23 -6
- package/dist/contract/app-launch.d.ts +11 -0
- package/dist/contract/app-launch.js +18 -0
- package/dist/contract/web-launch.d.ts +3 -13
- package/dist/contract/web-launch.js +14 -81
- package/dist/ctl/cli.d.ts +36 -0
- package/dist/ctl/cli.js +218 -0
- package/dist/ctl/descriptor.d.ts +81 -0
- package/dist/ctl/descriptor.js +93 -0
- package/dist/ctl/dsm-auth.d.ts +10 -0
- package/dist/ctl/dsm-auth.js +30 -0
- package/dist/ctl/errors.d.ts +17 -0
- package/dist/ctl/errors.js +32 -0
- package/dist/ctl/flows.d.ts +11 -0
- package/dist/ctl/flows.js +52 -0
- package/dist/ctl/input.d.ts +20 -0
- package/dist/ctl/input.js +128 -0
- package/dist/ctl/json.d.ts +2 -0
- package/dist/ctl/json.js +38 -0
- package/dist/ctl/redaction.d.ts +15 -0
- package/dist/ctl/redaction.js +198 -0
- package/dist/ctl/service.d.ts +38 -0
- package/dist/ctl/service.js +156 -0
- package/dist/ctl/store.d.ts +51 -0
- package/dist/ctl/store.js +93 -0
- package/dist/ctl/transfers.d.ts +14 -0
- package/dist/ctl/transfers.js +74 -0
- package/dist/ctl/transport.d.ts +11 -0
- package/dist/ctl/transport.js +79 -0
- package/dist/deploy/deploy.d.ts +4 -3
- package/dist/deploy/deploy.js +5 -210
- package/dist/deploy/plan.js +26 -7
- package/dist/dev.js +5 -4
- package/dist/error-registry.js +1020 -831
- package/dist/info.js +120 -28
- package/dist/init.js +188 -64
- package/dist/inspect.js +21 -6
- package/dist/runtime/executor-digests.json +1 -12
- package/dist/skill.js +51 -9
- package/dist/standalone/api-description-inspect.d.ts +4 -0
- package/dist/standalone/api-description-inspect.js +12 -0
- package/dist/standalone/api-description.d.ts +7 -0
- package/dist/standalone/api-description.js +73 -0
- package/dist/standalone/build.d.ts +3 -0
- package/dist/standalone/build.js +140 -33
- package/dist/standalone/compose-errors.d.ts +3 -0
- package/dist/standalone/compose-errors.js +243 -0
- package/dist/standalone/compose.d.ts +97 -10
- package/dist/standalone/compose.js +306 -75
- package/dist/standalone/daemon.js +111 -29
- package/dist/standalone/dsm-window.js +55 -9
- package/dist/standalone/identity-gateway.d.ts +9 -2
- package/dist/standalone/identity-gateway.js +282 -66
- package/dist/standalone/inspect-spk.d.ts +15 -0
- package/dist/standalone/inspect-spk.js +714 -128
- package/dist/standalone/lifecycle.js +124 -42
- package/dist/standalone/manifest.d.ts +10 -1
- package/dist/standalone/manifest.js +308 -87
- package/dist/standalone/resource.js +6 -2
- package/dist/standalone/spk.d.ts +4 -1
- package/dist/standalone/spk.js +139 -39
- package/dist/template-paths.d.ts +0 -2
- package/dist/template-paths.js +0 -5
- package/dist/test-command.d.ts +1 -1
- package/dist/test-command.js +1 -1
- package/dist/validate.d.ts +3 -3
- package/dist/validate.js +32 -140
- package/package.json +5 -3
- package/schema/synology-app.schema.json +1 -1
- package/schema/synopkgland-api-v1.schema.json +423 -0
- package/skills/SKILL.md +34 -1
- package/skills/reference/compose.md +349 -0
- package/skills/reference/create.md +26 -4
- package/skills/reference/ctl.md +41 -0
- package/skills/reference/deploy.md +1 -1
- package/skills/reference/files.md +4 -0
- package/skills/reference/identity-gateway.md +8 -138
- package/skills/reference/portal.md +31 -0
- package/skills/reference/ui-vue.md +31 -0
- package/skills/reference/wrap.md +10 -30
- package/assets/go/daemon/fixture_linux_test.go +0 -15
- package/assets/go/daemon/fixture_other_test.go +0 -7
- package/assets/go/daemon/go.mod +0 -5
- package/assets/go/daemon/main.go +0 -636
- package/assets/go/daemon/main_test.go +0 -315
- package/assets/go/daemon/network.go +0 -107
- package/assets/go/daemon/network_test.go +0 -86
- package/assets/go/daemon/process_linux.go +0 -55
- package/assets/go/daemon/process_linux_test.go +0 -161
- package/assets/go/daemon/process_other.go +0 -14
- package/assets/go/daemon/testdata/child.go +0 -44
- package/assets/go/gateway/auth.go +0 -101
- package/assets/go/gateway/auth_test.go +0 -216
- package/assets/go/gateway/cgi.go +0 -133
- package/assets/go/gateway/cgi_test.go +0 -156
- package/assets/go/gateway/csrf.go +0 -73
- package/assets/go/gateway/csrf_test.go +0 -155
- package/assets/go/gateway/directory.go +0 -108
- package/assets/go/gateway/directory_test.go +0 -202
- package/assets/go/gateway/gateway.go +0 -412
- package/assets/go/gateway/gateway_test.go +0 -676
- package/assets/go/gateway/go.mod +0 -5
- package/assets/go/gateway/groups.go +0 -66
- package/assets/go/gateway/main.go +0 -45
- package/dist/build-spk/gateway.d.ts +0 -31
- package/dist/build-spk/gateway.js +0 -60
- package/dist/compose/privilege.d.ts +0 -14
- 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
|
-
}
|
package/assets/go/gateway/cgi.go
DELETED
|
@@ -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" }
|