Skip to content

Commit b7b011f

Browse files
author
Mema
committed
feat: rewrite core in Go for better stability and reliability
1 parent 234deb7 commit b7b011f

7 files changed

Lines changed: 305 additions & 0 deletions

File tree

OPTIMIZE.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Optimized Tailscale Receiver (WIP)
2+
3+
## Current Issues
4+
1. **Inefficient Polling**: Constant shell execution of `tailscale file get` and `find` every 15s.
5+
2. **Resource Usage**: Bash overhead for heavy logic (integrity, virus scan, rate limiting).
6+
3. **Reliability**: Shell script `trap` and background process management can be flaky for long-running services.
7+
8+
## Optimization Strategy
9+
1. **Reduce Polling Frequency / Move to Events**: Tailscale doesn't support inotify for the internal Taildrop buffer, but we can optimize how we check.
10+
2. **Leaner Logic**: Strip out heavy "enterprise" features (virus scanning, complex rate limiting) if not needed, or make them truly optional and lightweight.
11+
3. **Memory/CPU**: Minimize sub-process spawning.
12+
13+
## Proposed Changes
14+
1. Rewrite core loop to be more "quiet" when idle.
15+
2. Use a more robust language (Go or Rust) if we want true "stable/reliable" but keep it as a simple binary.
16+
3. *Alternative*: Refactor the Bash script to use `tailscale status --json` for cleaner checks.
17+
18+
## Questions for Azzar
19+
- Do you really need ClamAV (virus scan) and Rate Limiting for a personal receiver?
20+
- Should we consider a Go rewrite for a single static binary? (Much more stable/reliable).

README_GO.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Tailscale Receiver v3 (Go Edition)
2+
3+
Beneran stable, reliable, dan irit resource.
4+
5+
## Kenapa versi Go?
6+
1. **Low Resource**: Jauh lebih irit RAM dibanding script Bash yang panggil `tailscale`, `find`, `comm`, `chown` berkali-kali tiap cycle.
7+
2. **Reliable**: Pake `signal.NotifyContext` buat graceful shutdown.
8+
3. **Static Binary**: Satu file binary doang, nggak butuh dependensi macem-macem.
9+
4. **Smart Polling**: Cek status Tailscale via JSON sebelum eksekusi `file get`.
10+
11+
## Cara Pakai (Baru)
12+
1. Build & Install: `./install-go.sh`
13+
2. Start: `sudo systemctl enable --now tailscale-receive-go`
14+
15+
## Env Configuration (/etc/default/tailscale-receive)
16+
- `TARGET_DIR`: Folder tujuan (default: `~/Downloads/tailscale`)
17+
- `TARGET_USER`: User pemilik file (default: `$USER`)
18+
- `POLL_INTERVAL`: Jeda antar cycle (default: `15s`)
19+
- `LOG_LEVEL`: Set ke `debug` buat liat detail
20+
- `ARCHIVE_DAYS`: Berapa hari file lama di simpen (default: `14`)
21+
22+
---
23+
*Optimized with ❤️ by Mema*

cmd/receiver/main.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"log"
7+
"os"
8+
"os/exec"
9+
"os/signal"
10+
"path/filepath"
11+
"syscall"
12+
"time"
13+
)
14+
15+
// Config holds the service configuration
16+
type Config struct {
17+
TargetDir string `json:"target_dir"`
18+
TargetUser string `json:"target_user"`
19+
PollInterval time.Duration `json:"poll_interval"`
20+
LogLevel string `json:"log_level"`
21+
ArchiveDays int `json:"archive_days"`
22+
ArchiveDir string `json:"archive_dir"`
23+
}
24+
25+
var (
26+
version = "3.0.0-beta"
27+
logger = log.New(os.Stdout, "[Tailscale-Receiver] ", log.LstdFlags)
28+
)
29+
30+
func main() {
31+
config := loadConfig()
32+
33+
logger.Printf("Starting Tailscale Receiver v%s", version)
34+
logger.Printf("Monitoring to: %s as user: %s", config.TargetDir, config.TargetUser)
35+
36+
// Ensure target directory exists
37+
if err := os.MkdirAll(config.TargetDir, 0755); err != nil {
38+
logger.Fatalf("Failed to create target directory: %v", err)
39+
}
40+
41+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
42+
defer stop()
43+
44+
ticker := time.NewTicker(config.PollInterval)
45+
defer ticker.Stop()
46+
47+
// Initial run
48+
processFiles(config)
49+
50+
for {
51+
select {
52+
case <-ctx.Done():
53+
logger.Println("Shutting down gracefully...")
54+
return
55+
case <-ticker.C:
56+
if isTailscaleUp() {
57+
processFiles(config)
58+
manageArchive(config)
59+
} else {
60+
if config.LogLevel == "debug" {
61+
logger.Println("Tailscale is down, skipping cycle")
62+
}
63+
}
64+
}
65+
}
66+
}
67+
68+
func loadConfig() Config {
69+
// Simple default config, override with ENVs
70+
cfg := Config{
71+
TargetDir: getEnv("TARGET_DIR", filepath.Join(os.Getenv("HOME"), "Downloads/tailscale")),
72+
TargetUser: getEnv("TARGET_USER", os.Getenv("USER")),
73+
PollInterval: 15 * time.Second,
74+
LogLevel: getEnv("LOG_LEVEL", "info"),
75+
ArchiveDays: 14,
76+
ArchiveDir: "archive",
77+
}
78+
79+
if intervalStr := os.Getenv("POLL_INTERVAL"); intervalStr != "" {
80+
if d, err := time.ParseDuration(intervalStr); err == nil {
81+
cfg.PollInterval = d
82+
} else if i, err := time.ParseDuration(intervalStr + "s"); err == nil {
83+
cfg.PollInterval = i
84+
}
85+
}
86+
87+
return cfg
88+
}
89+
90+
func getEnv(key, fallback string) string {
91+
if value, ok := os.LookupEnv(key); ok {
92+
return value
93+
}
94+
return fallback
95+
}
96+
97+
func isTailscaleUp() bool {
98+
// Optimized: just check if tailscale is running/online
99+
cmd := exec.Command("tailscale", "status", "--json")
100+
output, err := cmd.Output()
101+
if err != nil {
102+
return false
103+
}
104+
105+
var status struct {
106+
BackendState string `json:"BackendState"`
107+
}
108+
if err := json.Unmarshal(output, &status); err != nil {
109+
return false
110+
}
111+
112+
return status.BackendState == "Running"
113+
}
114+
115+
func processFiles(cfg Config) {
116+
// Execute tailscale file get
117+
// Using --conflict=rename to avoid overwriting
118+
cmd := exec.Command("tailscale", "file", "get", cfg.TargetDir)
119+
err := cmd.Run()
120+
if err != nil {
121+
// This is often just "no files", so we don't log as error unless debug
122+
if cfg.LogLevel == "debug" {
123+
logger.Printf("Tailscale file get: %v", err)
124+
}
125+
return
126+
}
127+
128+
// Fix ownership of files in TargetDir that belong to root
129+
files, err := os.ReadDir(cfg.TargetDir)
130+
if err != nil {
131+
return
132+
}
133+
134+
for _, f := range files {
135+
if f.IsDir() && f.Name() == cfg.ArchiveDir {
136+
continue
137+
}
138+
139+
fPath := filepath.Join(cfg.TargetDir, f.Name())
140+
info, err := f.Info()
141+
if err != nil {
142+
continue
143+
}
144+
145+
// Check if it belongs to root (typical when service runs as root)
146+
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
147+
if stat.Uid == 0 {
148+
logger.Printf("Found new file: %s, fixing ownership to %s", f.Name(), cfg.TargetUser)
149+
fixOwnership(fPath, cfg.TargetUser)
150+
notifyUser(f.Name(), cfg.TargetUser)
151+
}
152+
}
153+
}
154+
}
155+
156+
func fixOwnership(path, user string) {
157+
// We use the 'chown' binary for simplicity and recursive handling if needed
158+
// But for files, we can just use the user ID
159+
cmd := exec.Command("chown", "-R", user+":"+user, path)
160+
if err := cmd.Run(); err != nil {
161+
logger.Printf("Error changing ownership of %s: %v", path, err)
162+
}
163+
}
164+
165+
func notifyUser(filename, user string) {
166+
// Minimalist notification using notify-send
167+
// Only if not in a headless server environment usually
168+
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
169+
return
170+
}
171+
172+
cmd := exec.Command("sudo", "-u", user, "notify-send", "Tailscale", "Received: "+filename, "-i", "document-save")
173+
cmd.Run()
174+
}
175+
176+
func manageArchive(cfg Config) {
177+
if cfg.ArchiveDays <= 0 {
178+
return
179+
}
180+
181+
archivePath := filepath.Join(cfg.TargetDir, cfg.ArchiveDir)
182+
os.MkdirAll(archivePath, 0755)
183+
184+
files, err := os.ReadDir(cfg.TargetDir)
185+
if err != nil {
186+
return
187+
}
188+
189+
now := time.Now()
190+
for _, f := range files {
191+
if f.IsDir() {
192+
continue
193+
}
194+
195+
info, err := f.Info()
196+
if err != nil {
197+
continue
198+
}
199+
200+
if now.Sub(info.ModTime()) > time.Duration(cfg.ArchiveDays)*24*time.Hour {
201+
oldPath := filepath.Join(cfg.TargetDir, f.Name())
202+
newPath := filepath.Join(archivePath, f.Name())
203+
if err := os.Rename(oldPath, newPath); err == nil {
204+
logger.Printf("Archived old file: %s", f.Name())
205+
}
206+
}
207+
}
208+
}

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/1999AZZAR/tailscale_receiver
2+
3+
go 1.25.6

install-go.sh

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/bin/bash
2+
set -e
3+
4+
DEST="/usr/local/bin/tailscale-receiver-go"
5+
SERVICE_FILE="tailscale-receive-go.service"
6+
7+
echo "Building Tailscale Receiver (Go)..."
8+
go build -o tailscale-receiver-go cmd/receiver/main.go
9+
10+
echo "Installing binary to $DEST..."
11+
sudo cp tailscale-receiver-go "$DEST"
12+
sudo chmod +x "$DEST"
13+
14+
echo "Installing systemd service..."
15+
sudo cp "$SERVICE_FILE" /etc/systemd/system/
16+
17+
echo "Reloading systemd..."
18+
sudo systemctl daemon-reload
19+
20+
echo "Done! You can start the service with: sudo systemctl enable --now tailscale-receive-go"

tailscale-receive-go.service

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
[Unit]
2+
Description=Tailscale File Receiver Service (Go)
3+
Documentation=https://github.com/1999AZZAR/tailscale_receiver
4+
After=network-online.target tailscale.service
5+
Wants=network-online.target
6+
7+
[Service]
8+
Type=simple
9+
User=root
10+
Group=root
11+
EnvironmentFile=-/etc/default/tailscale-receive
12+
# Point to the new Go binary
13+
ExecStart=/usr/local/bin/tailscale-receiver-go
14+
Restart=on-failure
15+
RestartSec=10s
16+
17+
# Security hardening
18+
PrivateTmp=true
19+
ProtectSystem=strict
20+
ProtectHome=no
21+
NoNewPrivileges=true
22+
ProtectHostname=true
23+
ProtectClock=true
24+
ProtectControlGroups=true
25+
ProtectKernelTunables=true
26+
RestrictSUIDSGID=true
27+
LockPersonality=true
28+
RestrictRealtime=true
29+
30+
[Install]
31+
WantedBy=multi-user.target

tailscale-receiver-go

3.2 MB
Binary file not shown.

0 commit comments

Comments
 (0)