// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause // Package synology contains dist Targets for building Synology Tailscale packages. package synology import ( "archive/tar" "bytes" "compress/gzip" "embed" "errors" "fmt" "io" "io/fs" "log" "os" "path/filepath" "time" "tailscale.com/release/dist" ) type target struct { filenameArch string dsmMajorVersion int dsmMinorVersion int goenv map[string]string packageCenter bool signer dist.Signer } func (t *target) String() string { return fmt.Sprintf("synology/dsm%s/%s", t.dsmVersionString(), t.filenameArch) } func (t *target) Build(b *dist.Build) ([]string, error) { inner, err := getSynologyBuilds(b).buildInnerPackage(b, t.dsmMajorVersion, t.goenv) if err != nil { return nil, err } return t.buildSPK(b, inner) } // dsmVersionInt combines major and minor version info into an int // representation. // // Version 7.2 becomes 72 as an example. func (t *target) dsmVersionInt() int { return t.dsmMajorVersion*10 + t.dsmMinorVersion } // dsmVersionString returns a string representation of the version // including minor version information if it exists. // // If dsmMinorVersion is 0 this returns dsmMajorVersion as a string, // otherwise it returns "dsmMajorVersion-dsmMinorVersion". func (t *target) dsmVersionString() string { dsmVersionString := fmt.Sprintf("%d", t.dsmMajorVersion) if t.dsmMinorVersion != 0 { dsmVersionString = fmt.Sprintf("%s-%d", dsmVersionString, t.dsmMinorVersion) } return dsmVersionString } func (t *target) buildSPK(b *dist.Build, inner *innerPkg) ([]string, error) { synoVersion := b.Version.Synology[t.dsmVersionInt()] filename := fmt.Sprintf("tailscale-%s-%s-%d-dsm%s.spk", t.filenameArch, b.Version.Short, synoVersion, t.dsmVersionString()) out := filepath.Join(b.Out, filename) if t.packageCenter { log.Printf("Building %s (for package center)", filename) } else { log.Printf("Building %s (for sideloading)", filename) } if synoVersion > 2147483647 { // Synology requires that version number is within int32 range. // Erroring here if we create a build with a higher version. // In this case, we'll want to adjust the VersionInfo.Synology logic in // the mkversion package. return nil, errors.New("syno version exceeds int32 range") } privFile := fmt.Sprintf("privilege-dsm%d", t.dsmMajorVersion) if t.packageCenter && t.dsmMajorVersion == 7 { privFile += ".for-package-center" } f, err := os.Create(out) if err != nil { return nil, err } defer f.Close() tw := tar.NewWriter(f) defer tw.Close() err = writeTar(tw, b.Time, memFile("INFO", t.mkInfo(b, inner.uncompressedSz), 0644), static("PACKAGE_ICON.PNG", "PACKAGE_ICON.PNG", 0644), static("PACKAGE_ICON_256.PNG", "PACKAGE_ICON_256.PNG", 0644), static("Tailscale.sc", "Tailscale.sc", 0644), dir("conf"), static("resource", "conf/resource", 0644), static(privFile, "conf/privilege", 0644), file(inner.path, "package.tgz", 0644), dir("scripts"), static("scripts/start-stop-status", "scripts/start-stop-status", 0644), static("scripts/postupgrade", "scripts/postupgrade", 0644), static("scripts/preupgrade", "scripts/preupgrade", 0644), ) if err != nil { return nil, err } if err := tw.Close(); err != nil { return nil, err } if err := f.Close(); err != nil { return nil, err } files := []string{out} if t.signer != nil { outSig := out + ".sig" if err := t.signer.SignFile(out, outSig); err != nil { return nil, err } files = append(files, outSig) } return files, nil } func (t *target) mkInfo(b *dist.Build, uncompressedSz int64) []byte { var ret bytes.Buffer f := func(k, v string) { fmt.Fprintf(&ret, "%s=%q\n", k, v) } f("package", "Tailscale") f("version", fmt.Sprintf("%s-%d", b.Version.Short, b.Version.Synology[t.dsmVersionInt()])) f("arch", t.filenameArch) f("description", "Connect all your devices using WireGuard, without the hassle.") f("displayname", "Tailscale") f("maintainer", "Tailscale, Inc.") f("maintainer_url", "https://github.com/tailscale/tailscale") f("create_time", b.Time.Format("20060102-15:04:05")) f("dsmuidir", "ui") f("dsmappname", "SYNO.SDS.Tailscale") f("startstop_restart_services", "nginx") switch t.dsmMajorVersion { case 6: f("os_min_ver", "6.0.1-7445") f("os_max_ver", "7.0-40000") case 7: f("os_min_ver", "7.0-40000") f("os_max_ver", "") default: panic(fmt.Sprintf("unsupported DSM major version %d", t.dsmMajorVersion)) } f("extractsize", fmt.Sprintf("%v", uncompressedSz>>10)) // in KiB return ret.Bytes() } type synologyBuildsMemoizeKey struct{} type innerPkg struct { path string uncompressedSz int64 } // synologyBuilds is extra build context shared by all synology builds. type synologyBuilds struct { innerPkgs dist.Memoize[*innerPkg] } // getSynologyBuilds returns the synologyBuilds for b, creating one if needed. func getSynologyBuilds(b *dist.Build) *synologyBuilds { return b.Extra(synologyBuildsMemoizeKey{}, func() any { return new(synologyBuilds) }).(*synologyBuilds) } // buildInnerPackage builds the inner tarball for synology packages, // which contains the files to unpack to disk on installation (as // opposed to the outer tarball, which contains package metadata) func (m *synologyBuilds) buildInnerPackage(b *dist.Build, dsmVersion int, goenv map[string]string) (*innerPkg, error) { key := []any{dsmVersion, goenv} return m.innerPkgs.Do(key, func() (*innerPkg, error) { if err := b.BuildWebClientAssets(); err != nil { return nil, err } ts, err := b.BuildGoBinary("tailscale.com/cmd/tailscale", goenv) if err != nil { return nil, err } tsd, err := b.BuildGoBinary("tailscale.com/cmd/tailscaled", goenv) if err != nil { return nil, err } tmp := b.TmpDir() out := filepath.Join(tmp, "package.tgz") f, err := os.Create(out) if err != nil { return nil, err } defer f.Close() gw := gzip.NewWriter(f) defer gw.Close() cw := &countingWriter{gw, 0} tw := tar.NewWriter(cw) defer tw.Close() err = writeTar(tw, b.Time, dir("bin"), file(tsd, "bin/tailscaled", 0755), file(ts, "bin/tailscale", 0755), dir("conf"), static("Tailscale.sc", "conf/Tailscale.sc", 0644), static(fmt.Sprintf("logrotate-dsm%d", dsmVersion), "conf/logrotate.conf", 0644), dir("ui"), static("PACKAGE_ICON_256.PNG", "ui/PACKAGE_ICON_256.PNG", 0644), static("config", "ui/config", 0644), static("index.cgi", "ui/index.cgi", 0755)) if err != nil { return nil, err } if err := tw.Close(); err != nil { return nil, err } if err := gw.Close(); err != nil { return nil, err } if err := f.Close(); err != nil { return nil, err } return &innerPkg{out, cw.n}, nil }) } // writeTar writes ents to tw. func writeTar(tw *tar.Writer, modTime time.Time, ents ...tarEntry) error { for _, ent := range ents { if err := ent(tw, modTime); err != nil { return err } } return nil } // tarEntry is a function that writes tar entries (files or // directories) to a tar.Writer. type tarEntry func(*tar.Writer, time.Time) error // fsFile returns a tarEntry that writes src in fsys to dst in the tar // file, with mode. func fsFile(fsys fs.FS, src, dst string, mode int64) tarEntry { return func(tw *tar.Writer, modTime time.Time) error { f, err := fsys.Open(src) if err != nil { return err } defer f.Close() fi, err := f.Stat() if err != nil { return err } hdr := &tar.Header{ Name: dst, Size: fi.Size(), Mode: mode, ModTime: modTime, } if err := tw.WriteHeader(hdr); err != nil { return err } if _, err = io.Copy(tw, f); err != nil { return err } return nil } } // file returns a tarEntry that writes src on disk into the tar file as // dst, with mode. func file(src, dst string, mode int64) tarEntry { return fsFile(os.DirFS(filepath.Dir(src)), filepath.Base(src), dst, mode) } //go:embed files/* var files embed.FS // static returns a tarEntry that writes src in files/ into the tar // file as dst, with mode. func static(src, dst string, mode int64) tarEntry { fsys, err := fs.Sub(files, "files") if err != nil { panic(err) } return fsFile(fsys, src, dst, mode) } // memFile returns a tarEntry that writes bs to dst in the tar file, // with mode. func memFile(dst string, bs []byte, mode int64) tarEntry { return func(tw *tar.Writer, modTime time.Time) error { hdr := &tar.Header{ Name: dst, Size: int64(len(bs)), Mode: mode, ModTime: modTime, } if err := tw.WriteHeader(hdr); err != nil { return err } if _, err := tw.Write(bs); err != nil { return err } return nil } } // dir returns a tarEntry that creates a world-readable directory in // the tar file. func dir(name string) tarEntry { return func(tw *tar.Writer, modTime time.Time) error { return tw.WriteHeader(&tar.Header{ Typeflag: tar.TypeDir, Name: name + "/", Mode: 0755, ModTime: modTime, // TODO: why tailscale? Files are being written as owned by root. Uname: "tailscale", Gname: "tailscale", }) } } type countingWriter struct { w io.Writer n int64 } func (cw *countingWriter) Write(bs []byte) (int, error) { n, err := cw.w.Write(bs) cw.n += int64(n) return n, err }