@synopackageland/cli 0.12.2 → 0.13.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 +8 -0
- package/assets/go/daemon/main.go +25 -0
- package/assets/go/daemon/network.go +107 -0
- package/assets/go/daemon/network_test.go +86 -0
- package/dist/error-registry.js +15 -0
- package/dist/runtime/executor-digests.json +1 -23
- package/dist/skill.js +1 -0
- package/dist/standalone/build.js +5 -1
- package/dist/standalone/compose.d.ts +22 -0
- package/dist/standalone/compose.js +90 -0
- package/dist/standalone/daemon.d.ts +2 -0
- package/dist/standalone/daemon.js +22 -14
- package/dist/standalone/identity-gateway.d.ts +73 -0
- package/dist/standalone/identity-gateway.js +215 -0
- package/dist/standalone/inspect-spk.js +40 -10
- package/dist/standalone/lifecycle.js +6 -0
- package/dist/standalone/manifest.d.ts +5 -0
- package/dist/standalone/manifest.js +12 -2
- package/dist/standalone/resource.d.ts +2 -0
- package/dist/standalone/resource.js +2 -0
- package/dist/standalone/spk.d.ts +2 -0
- package/dist/standalone/spk.js +20 -3
- package/dist/validate.js +1 -1
- package/package.json +2 -2
- package/skills/SKILL.md +1 -1
- package/skills/reference/create.md +2 -2
- package/skills/reference/identity-gateway.md +174 -0
- package/skills/reference/wrap.md +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0 (2026-10-17)
|
|
4
|
+
|
|
5
|
+
- Add the identity gateway for daemon web applications and package-account compose upstreams: OIDC, header and token modes, validated native payload targets and bounded connection policies. UDP connections are handled separately from TCP.
|
|
6
|
+
- `account.on_delete` declares application account/session cleanup when a local DSM account disappears; domain accounts support explicit gateway purge. Bundle the `identity-gateway` skill for wrapping and verifying existing open-source applications.
|
|
7
|
+
- Generated Apps pin templates/SDK 0.13.0 and UI 0.10.5; record the 0.13.0 executor/lifecycle digests. Skill examples use the released SDK version. This CLI builds SDK 0.13.x; upgrade backend and frontend pins together.
|
|
8
|
+
|
|
9
|
+
繁中摘要:daemon 與 package-account compose App 可宣告 identity gateway,以 DSM 身分透過 OIDC、header 或 token 登入,並限制原生 payload 目標與網路連線(區分 UDP/TCP)。`account.on_delete` 宣告本機 DSM 帳號刪除時的 App 帳號/session 清理;網域帳號支援明確 purge。新增 `identity-gateway` skill 說明既有 OSS App 的包裝及驗證。templates/SDK 同步 0.13.0、UI 0.10.5,更新 executor digest 與範例版本;此 CLI 建置 SDK 0.13.x,後端/前端 pin 應一併升級。
|
|
10
|
+
|
|
3
11
|
## 0.12.2 (2026-10-16)
|
|
4
12
|
|
|
5
13
|
- Generated Apps use templates 0.12.2, SDK 0.12.2 and UI 0.10.3. SDK 0.12.2 types `ctx.launchParams` and `ctx.onLaunch` and adds `followLaunchRoute`, `launchRoute` and `dsmLaunchURL`.
|
package/assets/go/daemon/main.go
CHANGED
|
@@ -33,6 +33,7 @@ type deadlines struct {
|
|
|
33
33
|
Uninstall int `json:"uninstall"`
|
|
34
34
|
}
|
|
35
35
|
type config struct {
|
|
36
|
+
Network *networkPolicy `json:"network,omitempty"`
|
|
36
37
|
Package string `json:"package"`
|
|
37
38
|
Exec string `json:"exec"`
|
|
38
39
|
Args []string `json:"args"`
|
|
@@ -464,6 +465,23 @@ func (c *controller) supervise() error {
|
|
|
464
465
|
signals := make(chan os.Signal, 1)
|
|
465
466
|
signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT)
|
|
466
467
|
defer signal.Stop(signals)
|
|
468
|
+
if c.Network != nil {
|
|
469
|
+
deadline := time.Now().Add(time.Duration(c.Timeouts.Start-2) * time.Second)
|
|
470
|
+
for {
|
|
471
|
+
err := checkNetwork("/proc/net", os.Geteuid(), c.Network)
|
|
472
|
+
if err == nil {
|
|
473
|
+
break
|
|
474
|
+
}
|
|
475
|
+
if !time.Now().Before(deadline) {
|
|
476
|
+
return err
|
|
477
|
+
}
|
|
478
|
+
select {
|
|
479
|
+
case <-signals:
|
|
480
|
+
return errors.New("compose startup interrupted")
|
|
481
|
+
case <-time.After(100 * time.Millisecond):
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
467
485
|
cmd := c.child(c.Args)
|
|
468
486
|
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
|
469
487
|
if err := cmd.Start(); err != nil {
|
|
@@ -552,8 +570,15 @@ func (c *controller) supervise() error {
|
|
|
552
570
|
}
|
|
553
571
|
}
|
|
554
572
|
}()
|
|
573
|
+
tick := time.NewTicker(time.Second)
|
|
574
|
+
defer tick.Stop()
|
|
555
575
|
for {
|
|
556
576
|
select {
|
|
577
|
+
case <-tick.C:
|
|
578
|
+
if err := checkNetwork("/proc/net", os.Geteuid(), c.Network); err != nil {
|
|
579
|
+
clean = stop() == nil
|
|
580
|
+
return err
|
|
581
|
+
}
|
|
557
582
|
case <-done:
|
|
558
583
|
return nil
|
|
559
584
|
case <-signals:
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bufio"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"os"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"strconv"
|
|
10
|
+
"strings"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// Host-network containers use the package UID. The kernel tables, rather than
|
|
14
|
+
// an application's claimed environment setting, prove its listener bindings.
|
|
15
|
+
// This is a configuration guard, not a network namespace or a firewall.
|
|
16
|
+
type networkPolicy struct {
|
|
17
|
+
Ports []int `json:"ports"`
|
|
18
|
+
PublicPorts []int `json:"public_ports"`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func containsPort(ports []int, port int) bool {
|
|
22
|
+
for _, p := range ports {
|
|
23
|
+
if p == port {
|
|
24
|
+
return true
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func checkSocketTable(raw string, uid int, p *networkPolicy, udp bool) error {
|
|
31
|
+
_, err := socketPorts(raw, uid, p, udp)
|
|
32
|
+
return err
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func socketPorts(raw string, uid int, p *networkPolicy, udp bool) (map[int]bool, error) {
|
|
36
|
+
found := map[int]bool{}
|
|
37
|
+
scan := bufio.NewScanner(strings.NewReader(raw))
|
|
38
|
+
if !scan.Scan() {
|
|
39
|
+
return nil, errors.New("missing socket table header")
|
|
40
|
+
}
|
|
41
|
+
for scan.Scan() {
|
|
42
|
+
f := strings.Fields(scan.Text())
|
|
43
|
+
if len(f) == 0 {
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if len(f) < 10 {
|
|
47
|
+
return nil, errors.New("invalid socket table")
|
|
48
|
+
}
|
|
49
|
+
owner, err := strconv.Atoi(f[7])
|
|
50
|
+
if err != nil {
|
|
51
|
+
return nil, err
|
|
52
|
+
}
|
|
53
|
+
if owner != uid {
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
if (!udp && f[3] != "0A") || (udp && f[3] != "07") {
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
endpoint := strings.Split(f[1], ":")
|
|
60
|
+
if len(endpoint) != 2 {
|
|
61
|
+
return nil, errors.New("invalid socket endpoint")
|
|
62
|
+
}
|
|
63
|
+
port64, err := strconv.ParseInt(endpoint[1], 16, 32)
|
|
64
|
+
if err != nil {
|
|
65
|
+
return nil, err
|
|
66
|
+
}
|
|
67
|
+
port := int(port64)
|
|
68
|
+
if !udp && containsPort(p.PublicPorts, port) {
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
// /proc/net's IPv4 and IPv6 words are little-endian on supported DSM arches.
|
|
72
|
+
loopback := endpoint[0] == "0100007F" || endpoint[0] == "00000000000000000000000001000000"
|
|
73
|
+
if !loopback {
|
|
74
|
+
return nil, fmt.Errorf("compose package listener on port %d is not bound to loopback", port)
|
|
75
|
+
}
|
|
76
|
+
if !udp && containsPort(p.Ports, port) && endpoint[0] == "0100007F" {
|
|
77
|
+
found[port] = true
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return found, scan.Err()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func checkNetwork(dir string, uid int, p *networkPolicy) error {
|
|
84
|
+
if p == nil {
|
|
85
|
+
return nil
|
|
86
|
+
}
|
|
87
|
+
found := map[int]bool{}
|
|
88
|
+
for _, name := range []string{"tcp", "tcp6", "udp", "udp6"} {
|
|
89
|
+
raw, err := os.ReadFile(filepath.Join(dir, name))
|
|
90
|
+
if err != nil {
|
|
91
|
+
return err
|
|
92
|
+
}
|
|
93
|
+
ports, err := socketPorts(string(raw), uid, p, strings.HasPrefix(name, "udp"))
|
|
94
|
+
if err != nil {
|
|
95
|
+
return err
|
|
96
|
+
}
|
|
97
|
+
for port := range ports {
|
|
98
|
+
found[port] = true
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for _, port := range p.Ports {
|
|
102
|
+
if !found[port] {
|
|
103
|
+
return fmt.Errorf("compose upstream port %d is not ready as the package UID on 127.0.0.1", port)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return nil
|
|
107
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestComposeNetworkPolicy(t *testing.T) {
|
|
11
|
+
uid := os.Geteuid()
|
|
12
|
+
for _, tc := range []struct {
|
|
13
|
+
name, addr, state, port string
|
|
14
|
+
ok bool
|
|
15
|
+
}{
|
|
16
|
+
{"loopback", "0100007F", "0A", "6D64", true},
|
|
17
|
+
{"wildcard", "00000000", "0A", "6D64", false},
|
|
18
|
+
{"other loopback", "0200007F", "0A", "6D64", false},
|
|
19
|
+
{"public gateway", "00000000", "0A", "6D60", true},
|
|
20
|
+
{"unknown port", "00000000", "0A", "6D66", false},
|
|
21
|
+
{"connected", "00000000", "01", "6D66", true},
|
|
22
|
+
} {
|
|
23
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
24
|
+
line := socketFixture(tc.addr, tc.port, tc.state, uid)
|
|
25
|
+
err := checkSocketTable(line, uid, &networkPolicy{Ports: []int{28004}, PublicPorts: []int{28000}}, false)
|
|
26
|
+
if (err == nil) != tc.ok {
|
|
27
|
+
t.Fatalf("got %v, expected ok %v", err, tc.ok)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func TestComposeNetworkRequiresOwnedUpstream(t *testing.T) {
|
|
34
|
+
dir := t.TempDir()
|
|
35
|
+
for _, name := range []string{"tcp", "tcp6", "udp", "udp6"} {
|
|
36
|
+
os.WriteFile(filepath.Join(dir, name), []byte("header\n"), 0600)
|
|
37
|
+
}
|
|
38
|
+
p := &networkPolicy{Ports: []int{28004}, PublicPorts: []int{28000}}
|
|
39
|
+
if err := checkNetwork(dir, os.Geteuid(), p); err == nil {
|
|
40
|
+
t.Fatal("missing upstream accepted")
|
|
41
|
+
}
|
|
42
|
+
os.WriteFile(filepath.Join(dir, "tcp"), []byte(socketFixture("0100007F", "6D64", "0A", os.Geteuid()+1)), 0600)
|
|
43
|
+
if err := checkNetwork(dir, os.Geteuid(), p); err == nil {
|
|
44
|
+
t.Fatal("foreign upstream accepted")
|
|
45
|
+
}
|
|
46
|
+
os.WriteFile(filepath.Join(dir, "tcp"), []byte(socketFixture("0100007F", "6D64", "0A", os.Geteuid())), 0600)
|
|
47
|
+
if err := checkNetwork(dir, os.Geteuid(), p); err != nil {
|
|
48
|
+
t.Fatal(err)
|
|
49
|
+
}
|
|
50
|
+
os.WriteFile(filepath.Join(dir, "udp"), []byte(socketFixture("00000000", "6D66", "07", os.Geteuid())), 0600)
|
|
51
|
+
if err := checkNetwork(dir, os.Geteuid(), p); err == nil {
|
|
52
|
+
t.Fatal("UDP wildcard accepted")
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func socketFixture(addr, port, state string, uid int) string {
|
|
57
|
+
return fmt.Sprintf("header\n 0: %s:%s 00000000:0000 %s 0:0 0:0 0 %d 0 1\n", addr, port, state, uid)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func TestComposeUDPConnectedIsNotAListener(t *testing.T) {
|
|
61
|
+
raw := socketFixture("EA53110A", "BEEF", "01", os.Geteuid())
|
|
62
|
+
if err := checkSocketTable(raw, os.Geteuid(), &networkPolicy{Ports: []int{28004}}, true); err != nil {
|
|
63
|
+
t.Fatal(err)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func TestComposeIPv6AndUDPPublicPort(t *testing.T) {
|
|
68
|
+
p := &networkPolicy{Ports: []int{28004}, PublicPorts: []int{28000}}
|
|
69
|
+
for _, tc := range []struct {
|
|
70
|
+
addr, port string
|
|
71
|
+
udp, ok bool
|
|
72
|
+
}{
|
|
73
|
+
{"00000000000000000000000001000000", "6D64", false, true},
|
|
74
|
+
{"00000000000000000000000000000000", "6D64", false, false},
|
|
75
|
+
{"00000000", "6D60", true, false},
|
|
76
|
+
} {
|
|
77
|
+
state := "0A"
|
|
78
|
+
if tc.udp {
|
|
79
|
+
state = "07"
|
|
80
|
+
}
|
|
81
|
+
err := checkSocketTable(socketFixture(tc.addr, tc.port, state, os.Geteuid()), os.Geteuid(), p, tc.udp)
|
|
82
|
+
if (err == nil) != tc.ok {
|
|
83
|
+
t.Fatalf("%+v: %v", tc, err)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
package/dist/error-registry.js
CHANGED
|
@@ -479,6 +479,21 @@ export const ERROR_REGISTRY = {
|
|
|
479
479
|
"description": "The built supervisor is missing while assembling the daemon SPK.",
|
|
480
480
|
"suggestion": "Build the complete artifacts with synopkgland build; do not call the assembler directly without the controller."
|
|
481
481
|
},
|
|
482
|
+
"STANDALONE_IDENTITY_GATEWAY_INVALID": {
|
|
483
|
+
"code": "STANDALONE_IDENTITY_GATEWAY_INVALID",
|
|
484
|
+
"description": "The identity_gateway declaration does not match the gateway contract.",
|
|
485
|
+
"suggestion": "Declare a daemon; set sdk, mode (oidc, header or token), the App's loopback upstream port and distinct listen ports other than 5000/5001; keep passthrough and redirects local paths outside /_sso; oidc needs issuer_port and clients, header needs headers.user, token needs token.exec in the daemon payload."
|
|
486
|
+
},
|
|
487
|
+
"STANDALONE_IDENTITY_GATEWAY_BUILD_FAILED": {
|
|
488
|
+
"code": "STANDALONE_IDENTITY_GATEWAY_BUILD_FAILED",
|
|
489
|
+
"description": "The SDK identity gateway could not be built from identity_gateway.sdk.",
|
|
490
|
+
"suggestion": "Use an SDK release that contains idgateway/cmd/gateway, install Go (builds pin go1.26.9) and check the compiler stderr."
|
|
491
|
+
},
|
|
492
|
+
"STANDALONE_IDENTITY_GATEWAY_ARTIFACT_REQUIRED": {
|
|
493
|
+
"code": "STANDALONE_IDENTITY_GATEWAY_ARTIFACT_REQUIRED",
|
|
494
|
+
"description": "The built identity gateway is missing while assembling the SPK.",
|
|
495
|
+
"suggestion": "Build the complete artifacts with synopkgland build; do not call the assembler directly without the gateway."
|
|
496
|
+
},
|
|
482
497
|
"STANDALONE_SHARED_FOLDER_INVALID": {
|
|
483
498
|
"code": "STANDALONE_SHARED_FOLDER_INVALID",
|
|
484
499
|
"description": "The install.sharedFolder declaration in dsm-package.yaml is invalid.",
|
|
@@ -1,27 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"0.
|
|
3
|
-
"x86_64": "abe0a42699fed06aeeb7520e5a4f3393cae64f83a66773b70630f0db0c5ed107",
|
|
4
|
-
"aarch64": "1fdf171a665279f16ca1c2605a54996b2359fa69531c6fe4e890f5bda61c1bef",
|
|
5
|
-
"armv7": "946f73df833929283f94289f025fd73897416777be5e0867d1e73f0b6c3031d7",
|
|
6
|
-
"lifecycle": {
|
|
7
|
-
"x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
|
|
8
|
-
"aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
|
|
9
|
-
"armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
|
|
10
|
-
},
|
|
11
|
-
"goToolchain": "go1.26.9"
|
|
12
|
-
},
|
|
13
|
-
"0.12.1": {
|
|
14
|
-
"x86_64": "efe409287770f6b70e7f8465e8767eeb2413b9ab8775020cf4dffb1555d7bf1f",
|
|
15
|
-
"aarch64": "d68c9889ed2391e918b9c278679ea0a01cd04425528c11090700a3b6341fc543",
|
|
16
|
-
"armv7": "9149a05efb9f2d7d86a400707099b3d657382b1c0dbad6588be8c500e90445cc",
|
|
17
|
-
"lifecycle": {
|
|
18
|
-
"x86_64": "d65500c4f04713a32b45d360059281391acfa5e00ee24895fdd2de7b82735372",
|
|
19
|
-
"aarch64": "94f6cd119186ff482b8aea56541dd1f9d53c147566bc1efdca6f5a1c86198331",
|
|
20
|
-
"armv7": "cafc97c89f5645c14a8cf31d6d9f258394ed6f2d086478a7aebd04d57c274b4e"
|
|
21
|
-
},
|
|
22
|
-
"goToolchain": "go1.26.9"
|
|
23
|
-
},
|
|
24
|
-
"0.12.2": {
|
|
2
|
+
"0.13.0": {
|
|
25
3
|
"x86_64": "efe409287770f6b70e7f8465e8767eeb2413b9ab8775020cf4dffb1555d7bf1f",
|
|
26
4
|
"aarch64": "d68c9889ed2391e918b9c278679ea0a01cd04425528c11090700a3b6341fc543",
|
|
27
5
|
"armv7": "9149a05efb9f2d7d86a400707099b3d657382b1c0dbad6588be8c500e90445cc",
|
package/dist/skill.js
CHANGED
|
@@ -5,6 +5,7 @@ const SKILL_CATALOG = [
|
|
|
5
5
|
{ id: "orchestrator", file: "SKILL.md", when: "Read first" },
|
|
6
6
|
{ id: "create", file: "reference/create.md", when: "Start from nothing or a one-liner" },
|
|
7
7
|
{ id: "wrap", file: "reference/wrap.md", when: "Existing Compose, or adding a contract" },
|
|
8
|
+
{ id: "identity-gateway", file: "reference/identity-gateway.md", when: "Wrap OSS native or container apps with DSM account SSO" },
|
|
8
9
|
{ id: "identity", file: "reference/identity.md", when: "Needs a DSM sign-in" },
|
|
9
10
|
{ id: "files", file: "reference/files.md", when: "Needs NAS files" },
|
|
10
11
|
{ id: "portal", file: "reference/portal.md", when: "Needs a DSM Application Portal alias link" },
|
package/dist/standalone/build.js
CHANGED
|
@@ -10,6 +10,7 @@ import { checkCgiBinary } from "./binary.js";
|
|
|
10
10
|
import { loadStandaloneManifest } from "./manifest.js";
|
|
11
11
|
import { assembleStandaloneSpk, StandaloneBuildError } from "./spk.js";
|
|
12
12
|
import { buildDaemonControl } from "./daemon.js";
|
|
13
|
+
import { buildIdentityGateway } from "./identity-gateway.js";
|
|
13
14
|
import { buildExecutor } from "../runtime/executor-build.js";
|
|
14
15
|
import { BACKGROUND_WORKER_MAIN } from "./background.js";
|
|
15
16
|
import { readApiPermissions } from "./api-permissions.js";
|
|
@@ -88,6 +89,8 @@ export async function buildStandalone(options) {
|
|
|
88
89
|
if (manifest.portal && !isDsmAtLeast(manifest.os_min_ver, PORTAL_EXECUTOR_MIN_DSM)) {
|
|
89
90
|
throw new StandaloneBuildError("STANDALONE_PORTAL_MIN_DSM", `[STANDALONE_PORTAL_MIN_DSM] ${portalMinDsmMessage(manifest.os_min_ver, "portal")}`);
|
|
90
91
|
}
|
|
92
|
+
if (manifest.compose && !isDsmAtLeast(manifest.os_min_ver, "7.2.1-69057"))
|
|
93
|
+
throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_INVALID", "compose identity_gateway requires DSM >= 7.2.1.");
|
|
91
94
|
const jobs = checkJobs(options.jobs);
|
|
92
95
|
const arches = options.arches ?? manifest.arches;
|
|
93
96
|
for (const arch of arches) {
|
|
@@ -111,9 +114,10 @@ export async function buildStandalone(options) {
|
|
|
111
114
|
const { cgi: cgiPath, worker: backgroundWorker, app } = await backendFor(builtManifest, arch, archWork, run, bridge);
|
|
112
115
|
const outputPath = join(stage, `${manifest.package}-${manifest.spkVersion}-${arch}.spk`);
|
|
113
116
|
const daemonControl = await buildDaemonControl(manifest, arch, archWork, run);
|
|
117
|
+
const identityGateway = await buildIdentityGateway(manifest, arch, archWork, run);
|
|
114
118
|
// A portal needs the executor, so the executor's SDK carries the panel text.
|
|
115
119
|
const portalText = manifest.portal && executor ? portalCatalogs(sdkSource(executor.sdk).goDir) : undefined;
|
|
116
|
-
await assembleStandaloneSpk({ manifest: builtManifest, arch, cgiPath, outputPath, daemonControl, backgroundWorker, executor, app, portalText });
|
|
120
|
+
await assembleStandaloneSpk({ manifest: builtManifest, arch, cgiPath, outputPath, daemonControl, identityGateway, backgroundWorker, executor, app, portalText });
|
|
117
121
|
return outputPath;
|
|
118
122
|
}));
|
|
119
123
|
return { outputs, warnings: [...warnings] };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { NativeArchFamily } from '../build-spk/dsm-arch.js';
|
|
2
|
+
import { type Daemon } from './daemon.js';
|
|
3
|
+
import type { StandaloneError, StandaloneManifest } from './manifest.js';
|
|
4
|
+
export interface ComposeApp {
|
|
5
|
+
file: string;
|
|
6
|
+
targets: Daemon['targets'];
|
|
7
|
+
services: Record<string, {
|
|
8
|
+
address_env: string;
|
|
9
|
+
port: number;
|
|
10
|
+
}>;
|
|
11
|
+
document: Record<string, any>;
|
|
12
|
+
}
|
|
13
|
+
/** Explicit host-network policy; ordinary Compose keeps its existing rejection rules. */
|
|
14
|
+
export declare function readComposeApp(value: unknown, root: string, arches: NativeArchFamily[], fail: (e: StandaloneError) => void): ComposeApp | undefined;
|
|
15
|
+
export declare function composeDaemon(m: Pick<StandaloneManifest, 'compose'>): Daemon;
|
|
16
|
+
export declare function composeNetwork(m: Pick<StandaloneManifest, 'compose' | 'identityGateway'>): {
|
|
17
|
+
ports: number[];
|
|
18
|
+
public_ports: number[];
|
|
19
|
+
};
|
|
20
|
+
export declare function composeSource(c: ComposeApp): string;
|
|
21
|
+
/** preinst only materializes values in the incoming payload; DSM creates var before acquire. */
|
|
22
|
+
export declare function renderComposeInstall(pkg: string): string;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { lstatSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
3
|
+
import { parse, stringify } from 'yaml';
|
|
4
|
+
import { readDaemon } from './daemon.js';
|
|
5
|
+
const record = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
6
|
+
const only = (v, keys) => Object.keys(v).every(k => keys.includes(k));
|
|
7
|
+
const vars = new Set(['SYNOPKGLAND_UID', 'SYNOPKGLAND_GID', 'SYNOPKG_PKGVAR']);
|
|
8
|
+
/** Explicit host-network policy; ordinary Compose keeps its existing rejection rules. */
|
|
9
|
+
export function readComposeApp(value, root, arches, fail) {
|
|
10
|
+
if (value === undefined)
|
|
11
|
+
return;
|
|
12
|
+
const invalid = (why) => { fail({ code: 'STANDALONE_IDENTITY_GATEWAY_INVALID', message: `compose identity_gateway: ${why}` }); return undefined; };
|
|
13
|
+
if (arches.some(a => a !== 'x86_64' && a !== 'aarch64'))
|
|
14
|
+
return invalid('Container Manager supports x86_64 and aarch64, not armv7.');
|
|
15
|
+
if (!record(value) || !only(value, ['file', 'targets', 'services']) || typeof value.file !== 'string')
|
|
16
|
+
return invalid('accepts file, targets and services.');
|
|
17
|
+
const path = resolve(root, value.file), rel = relative(root, path);
|
|
18
|
+
if (!rel || isAbsolute(value.file) || rel.startsWith('..') || value.file.split(/[\\/]/).includes('..'))
|
|
19
|
+
return invalid('file must be inside the project.');
|
|
20
|
+
let doc;
|
|
21
|
+
try {
|
|
22
|
+
if (lstatSync(path).isSymbolicLink())
|
|
23
|
+
return invalid('file must not be a symlink.');
|
|
24
|
+
doc = parse(readFileSync(path, 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return invalid('cannot read compose file.');
|
|
28
|
+
}
|
|
29
|
+
const payload = readDaemon({ exec: '_sso/gateway', targets: value.targets }, root, arches, fail);
|
|
30
|
+
if (!payload)
|
|
31
|
+
return;
|
|
32
|
+
if (!record(doc) || !only(doc, ['services']) || !record(doc.services) || !Object.keys(doc.services).length || !record(value.services))
|
|
33
|
+
return invalid('only services are supported; every container needs a services binding policy.');
|
|
34
|
+
if (Object.keys(value.services).sort().join() !== Object.keys(doc.services).sort().join())
|
|
35
|
+
return invalid('declare a binding policy for every deployed container.');
|
|
36
|
+
const ports = new Set();
|
|
37
|
+
for (const [name, service] of Object.entries(doc.services)) {
|
|
38
|
+
const policy = value.services[name];
|
|
39
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name) || !record(policy) || !only(policy, ['address_env', 'port']) || !/^[A-Z][A-Z0-9_]*$/.test(policy.address_env) || !Number.isInteger(policy.port) || policy.port < 1024 || policy.port > 65535 || ports.has(policy.port))
|
|
40
|
+
return invalid(`services.${name} needs address_env and a unique unprivileged port.`);
|
|
41
|
+
ports.add(policy.port);
|
|
42
|
+
if (!record(service) || !only(service, ['image', 'network_mode', 'user', 'cap_drop', 'security_opt', 'environment', 'volumes', 'restart', 'command', 'entrypoint', 'working_dir']))
|
|
43
|
+
return invalid(`${name}: unsupported Compose fields (no build, ports, devices, capabilities, namespaces or Docker socket).`);
|
|
44
|
+
if (service.network_mode !== 'host')
|
|
45
|
+
return invalid(`${name}: identity_gateway requires network_mode: host with the app bound to 127.0.0.1; bridge cannot reach the loopback issuer.`);
|
|
46
|
+
if (typeof service.image !== 'string' || !service.image.trim() || /\s/.test(service.image))
|
|
47
|
+
return invalid(`${name}: use a prebuilt image.`);
|
|
48
|
+
if (service.user !== '${SYNOPKGLAND_UID}:${SYNOPKGLAND_GID}')
|
|
49
|
+
return invalid(`${name}: user must be \${SYNOPKGLAND_UID}:\${SYNOPKGLAND_GID}, materialized from the package account.`);
|
|
50
|
+
if (JSON.stringify(service.cap_drop) !== '["ALL"]' || JSON.stringify(service.security_opt) !== '["no-new-privileges:true"]')
|
|
51
|
+
return invalid(`${name}: require cap_drop: [ALL] and security_opt: [no-new-privileges:true].`);
|
|
52
|
+
if (!record(service.environment) || service.environment[policy.address_env] !== '127.0.0.1' || Object.values(service.environment).some(v => typeof v !== 'string'))
|
|
53
|
+
return invalid(`${name}: environment.${policy.address_env} must be 127.0.0.1; verify the upstream's actual listen setting.`);
|
|
54
|
+
if (service.restart !== undefined && service.restart !== 'no')
|
|
55
|
+
return invalid(`${name}: restart must be no; Container Manager owns the lifecycle.`);
|
|
56
|
+
if (service.volumes !== undefined && (!Array.isArray(service.volumes) || service.volumes.some((v) => typeof v !== 'string' || !/^\$\{SYNOPKG_PKGVAR\}:\/[A-Za-z0-9_./-]+(?::rw|:ro)?$/.test(v) || v.split(':')[1].split('/').some(p => p === '..' || p === '.') || /docker\.sock|container-manager\.sock/.test(v))))
|
|
57
|
+
return invalid(`${name}: mount only the DSM-created \${SYNOPKG_PKGVAR} root, with a fixed container path.`);
|
|
58
|
+
for (const key of ['command', 'entrypoint'])
|
|
59
|
+
if (service[key] !== undefined && (!Array.isArray(service[key]) || !service[key].every((v) => typeof v === 'string')))
|
|
60
|
+
return invalid(`${name}: ${key} must be argv.`);
|
|
61
|
+
}
|
|
62
|
+
for (const match of JSON.stringify(doc).matchAll(/\$(?:\{([^}]+)\}|([A-Za-z_][A-Za-z0-9_]*))/g))
|
|
63
|
+
if (!vars.has(match[1] ?? match[2]))
|
|
64
|
+
return invalid('only package UID, GID and var interpolation is supported.');
|
|
65
|
+
return { file: path, targets: payload.targets, services: value.services, document: doc };
|
|
66
|
+
}
|
|
67
|
+
export function composeDaemon(m) {
|
|
68
|
+
return { exec: '_sso/gateway', args: ['run'], targets: m.compose.targets, env: {}, timeouts: { start: 30, stop: 30, status: 5, uninstall: 60 } };
|
|
69
|
+
}
|
|
70
|
+
export function composeNetwork(m) {
|
|
71
|
+
return { ports: Object.values(m.compose.services).map(s => s.port).sort((a, b) => a - b), public_ports: Object.values(m.identityGateway.listen).sort((a, b) => a - b) };
|
|
72
|
+
}
|
|
73
|
+
export function composeSource(c) { return stringify(c.document); }
|
|
74
|
+
/** preinst only materializes values in the incoming payload; DSM creates var before acquire. */
|
|
75
|
+
export function renderComposeInstall(pkg) {
|
|
76
|
+
return `#!/bin/sh
|
|
77
|
+
set -eu
|
|
78
|
+
[ "$(id -u)" != 0 ] || exit 1
|
|
79
|
+
umask 077
|
|
80
|
+
project="\${SYNOPKG_PKGINST_TEMP_DIR:?}/compose"
|
|
81
|
+
[ -d "$project" ] && [ ! -L "$project" ] || exit 1
|
|
82
|
+
[ ! -L "$project/.env" ] || exit 1
|
|
83
|
+
{
|
|
84
|
+
printf 'SYNOPKGLAND_UID=%s\\n' "$(id -u)"
|
|
85
|
+
printf 'SYNOPKGLAND_GID=%s\\n' "$(id -g)"
|
|
86
|
+
printf 'SYNOPKG_PKGVAR=%s\\n' '/var/packages/${pkg}/var'
|
|
87
|
+
} > "$project/.env"
|
|
88
|
+
chmod 600 "$project/.env"
|
|
89
|
+
`;
|
|
90
|
+
}
|
|
@@ -28,3 +28,5 @@ export declare function readDaemon(value: unknown, root: string, arches: NativeA
|
|
|
28
28
|
export declare function daemonConfig(manifest: StandaloneManifest): string;
|
|
29
29
|
export declare function buildDaemonControl(manifest: StandaloneManifest, arch: NativeArchFamily, work: string, run: CommandRunner): Promise<string | undefined>;
|
|
30
30
|
export declare function copyDaemonPayload(manifest: StandaloneManifest, arch: NativeArchFamily, target: string, controller: string): void;
|
|
31
|
+
/** Copies a bounded, link-free package-account payload tree. */
|
|
32
|
+
export declare function copyPayloadTree(root: string, source: string, target: string): void;
|
|
@@ -4,6 +4,8 @@ import { isAbsolute, join, relative, resolve } from "node:path";
|
|
|
4
4
|
import { PINNED_GO_HINT, pinnedGoBuildEnv, pinnedGoProblem } from "../build-spk/go-toolchain.js";
|
|
5
5
|
import { goAssetDir } from "../template-paths.js";
|
|
6
6
|
import { checkCgiBinary } from "./binary.js";
|
|
7
|
+
import { composeDaemon, composeNetwork } from "./compose.js";
|
|
8
|
+
import { gatewayDaemon } from "./identity-gateway.js";
|
|
7
9
|
import { StandaloneBuildError } from "./spk.js";
|
|
8
10
|
const fields = new Set(["exec", "targets", "args", "env", "timeouts", "uninstall_args"]);
|
|
9
11
|
const record = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
@@ -57,11 +59,11 @@ export function readDaemon(value, root, arches, fail) {
|
|
|
57
59
|
return { exec: value.exec, targets, args, ...(uninstall ? { uninstall_args: uninstall } : {}), env: Object.fromEntries(Object.keys(env).sort().map(key => [key, env[key]])), timeouts: timeouts };
|
|
58
60
|
}
|
|
59
61
|
export function daemonConfig(manifest) {
|
|
60
|
-
const { exec, args, uninstall_args, env, timeouts } = manifest.daemon;
|
|
61
|
-
return JSON.stringify({ package: manifest.package, exec, args, ...(uninstall_args ? { uninstall_args } : {}), env, timeouts }) + "\n";
|
|
62
|
+
const { exec, args, uninstall_args, env, timeouts } = manifest.compose ? composeDaemon(manifest) : manifest.identityGateway ? gatewayDaemon(manifest.daemon) : manifest.daemon;
|
|
63
|
+
return JSON.stringify({ package: manifest.package, exec, args, ...(uninstall_args ? { uninstall_args } : {}), env, timeouts, ...(manifest.compose ? { network: composeNetwork(manifest) } : {}) }) + "\n";
|
|
62
64
|
}
|
|
63
65
|
export async function buildDaemonControl(manifest, arch, work, run) {
|
|
64
|
-
if (!manifest.daemon)
|
|
66
|
+
if (!manifest.daemon && !manifest.compose)
|
|
65
67
|
return undefined;
|
|
66
68
|
const out = join(work, `daemon-control-${arch}`);
|
|
67
69
|
const result = await run("go", ["build", "-trimpath", "-buildvcs=false", "-ldflags", "-s -w -buildid=", "-o", out, "."], {
|
|
@@ -78,13 +80,27 @@ export async function buildDaemonControl(manifest, arch, work, run) {
|
|
|
78
80
|
return out;
|
|
79
81
|
}
|
|
80
82
|
export function copyDaemonPayload(manifest, arch, target, controller) {
|
|
81
|
-
const daemon = manifest.daemon;
|
|
83
|
+
const daemon = manifest.daemon ?? composeDaemon(manifest);
|
|
82
84
|
const source = daemon.targets[arch];
|
|
85
|
+
copyPayloadTree(manifest.root, source, join(target, "payload"));
|
|
86
|
+
if (!manifest.compose) {
|
|
87
|
+
const exec = join(source, daemon.exec);
|
|
88
|
+
const problem = checkCgiBinary(manifest.root, exec, arch);
|
|
89
|
+
if (problem)
|
|
90
|
+
throw new StandaloneBuildError(problem.code, problem.message);
|
|
91
|
+
chmodSync(join(target, "payload", daemon.exec), 0o755);
|
|
92
|
+
}
|
|
93
|
+
copyFileSync(controller, join(target, "control"));
|
|
94
|
+
chmodSync(join(target, "control"), 0o755);
|
|
95
|
+
writeFileSync(join(target, "config.json"), daemonConfig(manifest), { mode: 0o644 });
|
|
96
|
+
}
|
|
97
|
+
/** Copies a bounded, link-free package-account payload tree. */
|
|
98
|
+
export function copyPayloadTree(root, source, target) {
|
|
83
99
|
const invalid = (message) => { throw new StandaloneBuildError("STANDALONE_DAEMON_PAYLOAD_INVALID", message); };
|
|
84
100
|
let total = 0;
|
|
85
101
|
const walk = (from, to) => {
|
|
86
102
|
const stat = lstatSync(from);
|
|
87
|
-
const rel = relative(realpathSync(
|
|
103
|
+
const rel = relative(realpathSync(root), realpathSync(from));
|
|
88
104
|
if (rel.startsWith("..") || isAbsolute(rel) || stat.isSymbolicLink())
|
|
89
105
|
invalid(`A daemon payload path must not contain a symlink or escape the project: ${from}`);
|
|
90
106
|
if (stat.isDirectory()) {
|
|
@@ -106,19 +122,11 @@ export function copyDaemonPayload(manifest, arch, target, controller) {
|
|
|
106
122
|
try {
|
|
107
123
|
if (!lstatSync(source).isDirectory())
|
|
108
124
|
invalid("daemon.targets must point at a directory.");
|
|
109
|
-
walk(source,
|
|
125
|
+
walk(source, target);
|
|
110
126
|
}
|
|
111
127
|
catch (error) {
|
|
112
128
|
if (error instanceof StandaloneBuildError)
|
|
113
129
|
throw error;
|
|
114
130
|
invalid(`Cannot read the daemon payload: ${String(error)}`);
|
|
115
131
|
}
|
|
116
|
-
const exec = join(source, daemon.exec);
|
|
117
|
-
const problem = checkCgiBinary(manifest.root, exec, arch);
|
|
118
|
-
if (problem)
|
|
119
|
-
throw new StandaloneBuildError(problem.code, problem.message);
|
|
120
|
-
chmodSync(join(target, "payload", daemon.exec), 0o755);
|
|
121
|
-
copyFileSync(controller, join(target, "control"));
|
|
122
|
-
chmodSync(join(target, "control"), 0o755);
|
|
123
|
-
writeFileSync(join(target, "config.json"), daemonConfig(manifest), { mode: 0o644 });
|
|
124
132
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { NativeArchFamily } from "../build-spk/dsm-arch.js";
|
|
2
|
+
import type { CommandRunner } from "./build.js";
|
|
3
|
+
import { type Daemon } from "./daemon.js";
|
|
4
|
+
import type { StandaloneError, StandaloneManifest } from "./manifest.js";
|
|
5
|
+
/** The SDK's identity gateway: the daemon wrapper and the DSM-origin authorize CGI. */
|
|
6
|
+
export declare const GATEWAY_MAIN = "./idgateway/cmd/gateway";
|
|
7
|
+
/** Served by DSM at /webman/3rdparty/<package>/sso/authorize.cgi. */
|
|
8
|
+
export declare const GATEWAY_CGI_PATH = "ui/sso/authorize.cgi";
|
|
9
|
+
/** The gateway inside the daemon payload; the App's payload must leave _sso/ free. */
|
|
10
|
+
export declare const GATEWAY_PAYLOAD_DIR = "_sso";
|
|
11
|
+
export declare const GATEWAY_EXEC = "_sso/gateway";
|
|
12
|
+
export declare const GATEWAY_CONFIG_PATH = "sso/gateway.json";
|
|
13
|
+
export type GatewayMode = "oidc" | "header" | "token";
|
|
14
|
+
export interface GatewayRoles {
|
|
15
|
+
claim: string;
|
|
16
|
+
admin: string;
|
|
17
|
+
user?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface GatewayClient {
|
|
20
|
+
id: string;
|
|
21
|
+
redirect_paths: string[];
|
|
22
|
+
native_redirects: string[];
|
|
23
|
+
require_pkce: boolean;
|
|
24
|
+
roles?: GatewayRoles;
|
|
25
|
+
}
|
|
26
|
+
/** `identity_gateway`: signs DSM users in to the App's own web UI. */
|
|
27
|
+
export interface IdentityGateway {
|
|
28
|
+
/** The SDK release the gateway is built from. */
|
|
29
|
+
sdk: string;
|
|
30
|
+
mode: GatewayMode;
|
|
31
|
+
/** The App's loopback port. */
|
|
32
|
+
upstream: number;
|
|
33
|
+
listen: {
|
|
34
|
+
http?: number;
|
|
35
|
+
https?: number;
|
|
36
|
+
};
|
|
37
|
+
passthrough: string[];
|
|
38
|
+
redirects: Record<string, string>;
|
|
39
|
+
email_domain?: string;
|
|
40
|
+
session_ttl?: number;
|
|
41
|
+
roles?: GatewayRoles;
|
|
42
|
+
headers?: Partial<Record<"user" | "email" | "name" | "groups" | "uid", string>>;
|
|
43
|
+
oidc?: {
|
|
44
|
+
issuer_port: number;
|
|
45
|
+
clients: GatewayClient[];
|
|
46
|
+
rewrite_json: {
|
|
47
|
+
method: string;
|
|
48
|
+
path: string;
|
|
49
|
+
field: string;
|
|
50
|
+
}[];
|
|
51
|
+
};
|
|
52
|
+
token?: {
|
|
53
|
+
exec: string;
|
|
54
|
+
args: string[];
|
|
55
|
+
};
|
|
56
|
+
/** Account deletion: the gateway runs on_delete when a DSM user that signed in is deleted. */
|
|
57
|
+
account?: {
|
|
58
|
+
on_delete: DeleteAction[];
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** One delete step; {uid} and {name} are the only placeholders. */
|
|
62
|
+
export type DeleteAction = {
|
|
63
|
+
remove: string;
|
|
64
|
+
} | {
|
|
65
|
+
exec: string[];
|
|
66
|
+
};
|
|
67
|
+
export declare function readIdentityGateway(value: unknown, daemon: Pick<Daemon, "targets"> | undefined, fail: (error: StandaloneError) => void): IdentityGateway | undefined;
|
|
68
|
+
/** target/sso/gateway.json, the SDK gateway's configuration (idgateway.Config). */
|
|
69
|
+
export declare function identityGatewayConfig(m: StandaloneManifest): string;
|
|
70
|
+
/** The daemon as the controller runs it: the gateway, with the App as its child. */
|
|
71
|
+
export declare function gatewayDaemon(daemon: Daemon): Daemon;
|
|
72
|
+
/** Builds the gateway from the declared SDK release with the pinned Go, binding its config path. */
|
|
73
|
+
export declare function buildIdentityGateway(m: StandaloneManifest, arch: NativeArchFamily, work: string, run: CommandRunner): Promise<string | undefined>;
|