修复 Firefox 开机启动与离线包安装风险

main
wangqiyang 1 week ago
parent 500edd8745
commit ae122d5b3e

@ -28,8 +28,10 @@ func enableAutoStart(appPath string) error {
content := autoStartServiceContent(appPath)
kioskDebugf("autostart service content:\n%s", content)
cmd := exec.Command("sudo", "bash", "-c",
fmt.Sprintf("echo '%s' > %s", content, serviceFile))
cmd := exec.Command("sudo", "tee", serviceFile)
cmd.Stdin = strings.NewReader(content)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
kioskErrorf("write autostart service failed service=%s err=%v", serviceFile, err)
return err
@ -64,7 +66,8 @@ func autoStartServiceContent(appPath string) string {
appName := strings.TrimSuffix(filepath.Base(appPath), filepath.Ext(appPath))
return fmt.Sprintf(`[Unit]
Description=%s Background Service
After=network.target
Wants=network-online.target
After=network-online.target graphical.target display-manager.service
[Service]
Type=simple
@ -73,9 +76,10 @@ Restart=on-failure
RestartSec=10
User=root
WorkingDirectory=%s
Environment=DISPLAY=:0
[Install]
WantedBy=multi-user.target
WantedBy=graphical.target
`,
appName,
appPath,
@ -85,9 +89,22 @@ WantedBy=multi-user.target
func autoStartServiceMatches(content string, appPath string) bool {
execStart := normalizeSystemdValue(systemdUnitValue(content, "ExecStart"))
workingDirectory := normalizeSystemdValue(systemdUnitValue(content, "WorkingDirectory"))
wants := normalizeSystemdValue(systemdUnitValue(content, "Wants"))
after := normalizeSystemdValue(systemdUnitValue(content, "After"))
environment := normalizeSystemdValue(systemdUnitValue(content, "Environment"))
wantedBy := normalizeSystemdValue(systemdUnitValue(content, "WantedBy"))
expectedWorkdir := filepath.Dir(appPath)
matches := execStart == appPath && workingDirectory == expectedWorkdir
kioskInfof("autostart service fields execStart=%s expectedExec=%s workingDirectory=%s expectedWorkdir=%s matches=%v", execStart, appPath, workingDirectory, expectedWorkdir, matches)
expectedWants := "network-online.target"
expectedAfter := "network-online.target graphical.target display-manager.service"
expectedEnvironment := "DISPLAY=:0"
expectedWantedBy := "graphical.target"
matches := execStart == appPath &&
workingDirectory == expectedWorkdir &&
wants == expectedWants &&
after == expectedAfter &&
environment == expectedEnvironment &&
wantedBy == expectedWantedBy
kioskInfof("autostart service fields execStart=%s expectedExec=%s workingDirectory=%s expectedWorkdir=%s wants=%s expectedWants=%s after=%s expectedAfter=%s environment=%s expectedEnvironment=%s wantedBy=%s expectedWantedBy=%s matches=%v", execStart, appPath, workingDirectory, expectedWorkdir, wants, expectedWants, after, expectedAfter, environment, expectedEnvironment, wantedBy, expectedWantedBy, matches)
return matches
}

@ -0,0 +1,55 @@
package main
import (
"path/filepath"
"strings"
"testing"
)
func TestAutoStartServiceContentWaitsForGraphicalTarget(t *testing.T) {
appPath := filepath.Join("/opt", "tgk-touch", "m9zCtrlTty")
content := autoStartServiceContent(appPath)
for _, want := range []string{
"Wants=network-online.target",
"After=network-online.target graphical.target display-manager.service",
"Environment=DISPLAY=:0",
"WantedBy=graphical.target",
} {
if !strings.Contains(content, want) {
t.Fatalf("service content missing %q:\n%s", want, content)
}
}
}
func TestAutoStartServiceMatchesRejectsOldNetworkOnlyService(t *testing.T) {
appPath := filepath.Join("/opt", "tgk-touch", "m9zCtrlTty")
oldContent := `[Unit]
Description=m9zCtrlTty Background Service
After=network.target
[Service]
Type=simple
ExecStart=` + appPath + `
Restart=on-failure
RestartSec=10
User=root
WorkingDirectory=` + filepath.Dir(appPath) + `
[Install]
WantedBy=multi-user.target
`
if autoStartServiceMatches(oldContent, appPath) {
t.Fatal("old network-only service should be treated as mismatched")
}
}
func TestAutoStartServiceMatchesGeneratedContent(t *testing.T) {
appPath := filepath.Join("/opt", "tgk-touch", "m9zCtrlTty")
content := autoStartServiceContent(appPath)
if !autoStartServiceMatches(content, appPath) {
t.Fatalf("generated service content should match:\n%s", content)
}
}

@ -1,15 +1,51 @@
package main
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/towgo/towgo/lib/system"
g "tgk-touch/internal/global"
)
const firefoxInstallMarkerName = ".firefox-install-ok"
var forbiddenFirefoxDebPackages = map[string]struct{}{
"base-files": {},
"base-passwd": {},
"bash": {},
"coreutils": {},
"dash": {},
"debconf": {},
"dpkg": {},
"gcc-8-base": {},
"libblkid1": {},
"libc6": {},
"libgcc1": {},
"libmount1": {},
"libselinux1": {},
"libsystemd0": {},
"libudev1": {},
"login": {},
"mount": {},
"passwd": {},
"perl-base": {},
"sudo": {},
"systemd": {},
"systemd-sysv": {},
"tar": {},
"udev": {},
"util-linux": {},
}
func isFirefoxUsable() bool {
cmd := exec.Command("firefox", "--version")
out, err := cmd.CombinedOutput()
@ -27,6 +63,12 @@ func isFirefoxInstalled() bool {
}
func installFirefox() error {
bundlePath := firefoxBundlePath()
if err := validateFirefoxBundle(bundlePath); err != nil {
kioskErrorf("firefox install refused bundle=%s err=%v", bundlePath, err)
return err
}
var p struct {
DebPkgPath string `json:"DebPkgPath"`
DebPkgName string `json:"DebPkgName"`
@ -35,40 +77,40 @@ func installFirefox() error {
p.DebPkgPath = g.Config().Firefox.DebPkgPath
p.DebPkgName = g.Config().Firefox.DebPkgName
p.InstallShellPath = g.Config().Firefox.InstallShellPath
kioskInfof("firefox install begin programPath=%s debPath=%s debName=%s installShell=%s", system.GetPathOfProgram(), p.DebPkgPath, p.DebPkgName, p.InstallShellPath)
kioskInfof("firefox install begin programPath=%s bundle=%s debPath=%s debName=%s installShell=%s", system.GetPathOfProgram(), bundlePath, p.DebPkgPath, p.DebPkgName, p.InstallShellPath)
command := exec.Command("sudo", "sh", "-c", fmt.Sprintf(`
echo "=== 当前工作目录 ==="
echo "=== current work directory ==="
pwd
echo ""
echo "=== 卸载旧版 Firefox ==="
dpkg -P firefox || echo "警告:卸载旧版 Firefox 失败(可能未安装)"
echo "=== keep existing Firefox package ==="
echo "skip automatic dpkg purge to avoid touching base system packages"
echo ""
echo "=== 清理残留文件 ==="
rm -rf /opt/firefox* 2>/dev/null
echo "=== clean previous offline bundle files ==="
rm -rf /opt/firefox-deb /opt/firefox-deb.tar.gz 2>/dev/null
echo ""
echo "=== 开始安装 Firefox ==="
echo "1. 复制安装包到 /opt 目录..."
cp -v %s%s/%s /opt/firefox-deb.tar.gz || { echo "错误:复制安装包失败"; exit 1; }
echo "=== start Firefox install ==="
echo "1. copy offline bundle to /opt..."
cp -v %s /opt/firefox-deb.tar.gz || { echo "error: copy offline bundle failed"; exit 1; }
echo ""
echo "2. 解压安装包..."
cd /opt && tar -xzvf firefox-deb.tar.gz || { echo "错误:解压失败"; exit 1; }
echo "2. extract offline bundle..."
cd /opt && tar -xzvf firefox-deb.tar.gz || { echo "error: extract offline bundle failed"; exit 1; }
echo ""
echo "3. 安装依赖和主程序..."
cd /opt/firefox-deb && sudo dpkg -i *.deb || { echo "错误:安装 deb 包失败"; exit 1; }
echo "3. install Firefox deb packages..."
cd /opt/firefox-deb && sudo dpkg -i *.deb || { echo "error: install Firefox deb packages failed"; exit 1; }
echo ""
echo "4. 验证安装..."
firefox --version || { echo "错误Firefox 未正确安装"; exit 1; }
echo "4. verify Firefox..."
firefox --version || { echo "error: Firefox verification failed"; exit 1; }
echo ""
echo "=== Firefox 安装完成 ==="
`, system.GetPathOfProgram(), p.DebPkgPath, p.DebPkgName))
echo "=== Firefox install complete ==="
`, shellQuote(bundlePath)))
command.Stdout = os.Stdout
command.Stderr = os.Stderr
@ -80,3 +122,116 @@ echo "=== Firefox 安装完成 ==="
kioskInfof("firefox install command finished successfully")
return nil
}
func firefoxInstallMarkerPath() string {
return filepath.Join(system.GetPathOfProgram(), "data", firefoxInstallMarkerName)
}
func hasFirefoxInstallMarker() bool {
path := firefoxInstallMarkerPath()
info, err := os.Stat(path)
ok := err == nil && !info.IsDir()
kioskInfof("firefox install marker check path=%s ok=%v err=%v", path, ok, err)
return ok
}
func writeFirefoxInstallMarker(reason string) error {
path := firefoxInstallMarkerPath()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
kioskErrorf("create firefox install marker dir failed path=%s err=%v", filepath.Dir(path), err)
return err
}
content := fmt.Sprintf("reason=%s\ntime=%s\n", reason, time.Now().Format(time.RFC3339))
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
kioskErrorf("write firefox install marker failed path=%s err=%v", path, err)
return err
}
kioskInfof("firefox install marker written path=%s reason=%s", path, reason)
return nil
}
func firefoxBundlePath() string {
return filepath.Join(system.GetPathOfProgram(), g.Config().Firefox.DebPkgPath, g.Config().Firefox.DebPkgName)
}
func validateFirefoxBundle(bundlePath string) error {
packages, err := firefoxBundlePackageNames(bundlePath)
if err != nil {
return fmt.Errorf("read firefox bundle packages: %w", err)
}
if len(packages) == 0 {
return fmt.Errorf("firefox bundle has no deb packages")
}
blocked := unsafeFirefoxDebPackages(packages)
if len(blocked) > 0 {
return fmt.Errorf("firefox bundle contains blocked base system packages: %s", strings.Join(blocked, ", "))
}
kioskInfof("firefox bundle validation passed bundle=%s packages=%d", bundlePath, len(packages))
return nil
}
func firefoxBundlePackageNames(bundlePath string) ([]string, error) {
file, err := os.Open(bundlePath)
if err != nil {
return nil, err
}
defer file.Close()
gzipReader, err := gzip.NewReader(file)
if err != nil {
return nil, err
}
defer gzipReader.Close()
reader := tar.NewReader(gzipReader)
var packages []string
for {
header, err := reader.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if header == nil || header.FileInfo().IsDir() || !strings.HasSuffix(header.Name, ".deb") {
continue
}
name := debPackageName(filepath.Base(header.Name))
if name != "" {
packages = append(packages, name)
}
}
sort.Strings(packages)
return packages, nil
}
func debPackageName(fileName string) string {
name := strings.TrimSuffix(fileName, ".deb")
if packageName, _, ok := strings.Cut(name, "_"); ok {
return packageName
}
return name
}
func unsafeFirefoxDebPackages(packages []string) []string {
seen := make(map[string]struct{})
for _, name := range packages {
if _, blocked := forbiddenFirefoxDebPackages[name]; blocked {
seen[name] = struct{}{}
}
}
blocked := make([]string, 0, len(seen))
for name := range seen {
blocked = append(blocked, name)
}
sort.Strings(blocked)
return blocked
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}

@ -0,0 +1,48 @@
package main
import "testing"
func TestDebPackageName(t *testing.T) {
tests := map[string]string{
"firefox_120.0_arm64.deb": "firefox",
"libgtk-3-0_3.22.30_arm64.deb": "libgtk-3-0",
"firefox.deb": "firefox",
"plain-package": "plain-package",
}
for input, want := range tests {
if got := debPackageName(input); got != want {
t.Fatalf("debPackageName(%q) = %q, want %q", input, got, want)
}
}
}
func TestUnsafeFirefoxDebPackages(t *testing.T) {
packages := []string{
"firefox",
"libgtk-3-0",
"dpkg",
"coreutils",
"libc6",
"tar",
"dpkg",
}
want := []string{"coreutils", "dpkg", "libc6", "tar"}
got := unsafeFirefoxDebPackages(packages)
if !stringSlicesEqual(got, want) {
t.Fatalf("unsafeFirefoxDebPackages() = %v, want %v", got, want)
}
}
func stringSlicesEqual(left []string, right []string) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}

@ -51,6 +51,16 @@ func runClient(port int) {
for {
os.Setenv("DISPLAY", display)
if !isDisplaySocketReady(display) {
kioskWarnf("display socket not ready display=%s; waiting before firefox launch", display)
time.Sleep(2 * time.Second)
continue
}
if !isFirefoxUsable() {
kioskWarnf("firefox unusable during kiosk loop; waiting before firefox launch")
time.Sleep(10 * time.Second)
continue
}
if xauthPath := findXAuthorityPath(currentUser, display); xauthPath != "" {
os.Setenv("XAUTHORITY", xauthPath)
kioskInfof("xauthority selected path=%s", xauthPath)
@ -92,6 +102,28 @@ func runClient(port int) {
}
}
func isDisplaySocketReady(display string) bool {
path := displaySocketPath(display)
info, err := os.Stat(path)
ok := err == nil && !info.IsDir()
kioskDebugf("display socket check display=%s path=%s ok=%v err=%v", display, path, ok, err)
return ok
}
func displaySocketPath(display string) string {
name := strings.TrimSpace(display)
if idx := strings.LastIndex(name, ":"); idx >= 0 {
name = name[idx+1:]
}
if idx := strings.Index(name, "."); idx >= 0 {
name = name[:idx]
}
if name == "" {
name = "0"
}
return filepath.Join("/tmp/.X11-unix", "X"+name)
}
func findXAuthorityPath(currentUser *user.User, display string) string {
kioskDebugf("xauthority search begin display=%s", display)
candidates := []string{

@ -8,42 +8,54 @@ import (
func install() error {
kioskInfof("checking firefox usability")
// 1. 检查并修复 Firefox
if !isFirefoxUsable() {
kioskWarnf("firefox unusable; starting install/repair")
fmt.Println("Firefox missing or unusable, installing/repairing...")
if err := installFirefox(); err != nil {
kioskErrorf("firefox install/repair failed err=%v", err)
fmt.Printf("install/repair Firefox failed: %v\n", err)
return fmt.Errorf("install/repair Firefox failed: %w", err)
}
kioskInfof("firefox install/repair success")
fmt.Println("Firefox install/repair success")
} else {
var firefoxInstallErr error
if isFirefoxUsable() {
kioskInfof("firefox is usable")
fmt.Println("Firefox is installed and usable")
if err := writeFirefoxInstallMarker("usable"); err != nil {
kioskWarnf("write firefox install marker failed err=%v", err)
}
} else if hasFirefoxInstallMarker() {
kioskWarnf("firefox unusable but install marker exists; skip automatic repair")
fmt.Println("Firefox unusable; skip automatic repair on startup")
} else {
kioskWarnf("firefox unusable and no install marker; starting first-time install")
fmt.Println("Firefox missing or unusable, installing...")
if err := installFirefox(); err != nil {
kioskErrorf("firefox first-time install failed err=%v", err)
fmt.Printf("install Firefox failed: %v\n", err)
firefoxInstallErr = fmt.Errorf("install Firefox failed: %w", err)
} else {
if err := writeFirefoxInstallMarker("installed"); err != nil {
kioskWarnf("write firefox install marker after install failed err=%v", err)
}
kioskInfof("firefox first-time install success")
fmt.Println("Firefox install success")
}
}
// 2. 设置当前应用开机自启动
appPath, err := os.Executable()
if err != nil {
kioskErrorf("read executable path failed err=%v", err)
fmt.Printf("读取应用路径失败: %v\n", err)
fmt.Printf("read executable path failed: %v\n", err)
return fmt.Errorf("read executable path failed: %w", err)
}
kioskInfof("current executable path=%s workdir=%s", appPath, filepath.Dir(appPath))
if !isAutoStartEnabled(appPath) {
kioskWarnf("autostart service missing or mismatched; reinstalling service=%s", autoStartServiceFile(appPath))
fmt.Println("正在设置开机自启动...")
fmt.Println("setting autostart...")
if err := enableAutoStart(appPath); err != nil {
kioskErrorf("autostart setup failed service=%s err=%v", autoStartServiceFile(appPath), err)
fmt.Printf("设置开机自启动失败: %v\n", err)
fmt.Printf("setup autostart failed: %v\n", err)
return fmt.Errorf("setup autostart failed: %w", err)
}
fmt.Println("开机自启动设置成功")
fmt.Println("autostart setup success")
} else {
fmt.Println("已设置开机自启动")
fmt.Println("autostart already configured")
}
if firefoxInstallErr != nil {
return firefoxInstallErr
}
return nil
}

Loading…
Cancel
Save