Skip to content

Commit 9204ebd

Browse files
committed
v1.17.1: harden relay, viewer, and release plumbing
Tightens relay startup and cached stream behavior, adds checksum-verified self-update, and ships the health, logging, and completion fixes from the v1.17 hardening pass.
1 parent ef03de4 commit 9204ebd

31 files changed

Lines changed: 1480 additions & 385 deletions

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
1111
COPY --from=builder /tltv /tltv
1212
EXPOSE 8000
1313
WORKDIR /data
14+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["/tltv", "version"]
1415
ENTRYPOINT ["/tltv"]

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ tltv viewer demo.timelooptv.org
126126
| `loadtest <target>` | Multi-receiver load simulator |
127127
| `version` | Version, protocol version, platform info |
128128
| `update` | Self-update to latest GitHub release |
129-
| `completion` | Shell completions (`--install` for auto-install) |
129+
| `completion` | Shell completions and flag metadata (`--install`, `--flags`) |
130130

131131
## Global Flags
132132

bridge.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func cmdBridge(args []string) {
2727
nameArg := fs.String("name", os.Getenv("NAME"), "channel name (single-stream mode only)")
2828
fs.StringVar(nameArg, "n", os.Getenv("NAME"), "alias for --name")
2929
descriptionArg := fs.String("description", os.Getenv("DESCRIPTION"), "channel description")
30-
fs.StringVar(descriptionArg, "D", os.Getenv("DESCRIPTION"), "alias for --description")
30+
fs.StringVar(descriptionArg, "U", os.Getenv("DESCRIPTION"), "alias for --description")
3131
tagsArg := fs.String("tags", os.Getenv("TAGS"), "comma-separated tags (max 5)")
3232
fs.StringVar(tagsArg, "T", os.Getenv("TAGS"), "alias for --tags")
3333
languageArg := fs.String("language", os.Getenv("LANGUAGE"), "ISO 639-1 language code (e.g. en, ja)")
@@ -72,7 +72,7 @@ func cmdBridge(args []string) {
7272
proxyStr := addProxyFlag(fs)
7373

7474
// --- Config ---
75-
configPathBridge, dumpConfigBridge := addConfigFlags(fs)
75+
configPathBridge, dumpConfigBridge := addConfigFlags(fs, configFlagOpts{ConfigShort: "f", DumpShort: "D"})
7676

7777
// --- Cache ---
7878
cacheEnabled, cacheMaxEntries, cacheStatsInterval := addCacheFlags(fs)
@@ -97,7 +97,7 @@ func cmdBridge(args []string) {
9797
fmt.Fprintf(os.Stderr, " -G, --guide URL/PATH guide source: XMLTV or JSON (optional)\n\n")
9898
fmt.Fprintf(os.Stderr, "Channel defaults:\n")
9999
fmt.Fprintf(os.Stderr, " -n, --name STRING channel name (single-stream mode only)\n")
100-
fmt.Fprintf(os.Stderr, " -D, --description TEXT channel description\n")
100+
fmt.Fprintf(os.Stderr, " -U, --description TEXT channel description\n")
101101
fmt.Fprintf(os.Stderr, " -T, --tags LIST comma-separated tags (max 5)\n")
102102
fmt.Fprintf(os.Stderr, " -a, --language CODE ISO 639-1 language code (e.g. en, ja)\n")
103103
fmt.Fprintf(os.Stderr, " -z, --timezone TZ IANA timezone name for metadata\n")
@@ -113,8 +113,8 @@ func cmdBridge(args []string) {
113113
fmt.Fprintf(os.Stderr, " -P, --peers LIST tltv:// URIs to advertise in peer exchange\n")
114114
fmt.Fprintf(os.Stderr, " -g, --gossip re-advertise validated gossip-discovered channels\n\n")
115115
fmt.Fprintf(os.Stderr, "Config:\n")
116-
fmt.Fprintf(os.Stderr, " --config PATH config file (JSON)\n")
117-
fmt.Fprintf(os.Stderr, " --dump-config print resolved config as JSON and exit\n\n")
116+
fmt.Fprintf(os.Stderr, " -f, --config PATH config file (JSON)\n")
117+
fmt.Fprintf(os.Stderr, " -D, --dump-config print resolved config as JSON and exit\n\n")
118118
fmt.Fprintf(os.Stderr, "TLS:\n")
119119
fmt.Fprintf(os.Stderr, " --tls enable TLS (autocert via Let's Encrypt if no cert/key)\n")
120120
fmt.Fprintf(os.Stderr, " --tls-cert FILE TLS certificate file (PEM)\n")
@@ -159,6 +159,8 @@ func cmdBridge(args []string) {
159159
fmt.Fprintf(os.Stderr, "error: %v\n", err)
160160
os.Exit(1)
161161
}
162+
stopLogReopen := startLogReopenWatcher()
163+
defer stopLogReopen()
162164

163165
// Load config file (if specified). Config values fill in unset flags.
164166
var bridgeGuideEntries []guideEntry // from config inline guide
@@ -472,6 +474,9 @@ func cmdBridge(args []string) {
472474
return map[string]interface{}{}
473475
}
474476
info := viewerBuildInfo(current.ChannelID, current.Name, current.metadata, current.guideDoc)
477+
if icon := viewerIconDataURI(iconData, iconCT); icon != "" {
478+
info["icon_data"] = icon
479+
}
475480
info["stream_src"] = "/tltv/v1/channels/" + current.ChannelID + "/stream.m3u8"
476481
info["xmltv_url"] = "/tltv/v1/channels/" + current.ChannelID + "/guide.xml"
477482
if registry.hostname != "" {
@@ -495,6 +500,7 @@ func cmdBridge(args []string) {
495500
}
496501
if ch.IconFileName != "" {
497502
ref.IconPath = "/tltv/v1/channels/" + ch.ChannelID + "/" + ch.IconFileName
503+
ref.IconData = viewerIconDataURI(iconData, iconCT)
498504
}
499505
refs = append(refs, ref)
500506
}

bridge_server.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@ import (
1414
// bridgeServer implements the TLTV protocol HTTP endpoints for bridged channels.
1515
type bridgeServer struct {
1616
registry *bridgeRegistry
17-
cache *hlsCache // optional response cache (nil = disabled)
18-
peerReg *peerRegistry // optional external peers (nil = no --peers)
19-
gossipReg *peerRegistry // optional gossip-discovered peers (nil = no --gossip)
17+
cache *hlsCache // optional response cache (nil = disabled)
18+
peerReg *peerRegistry // optional external peers (nil = no --peers)
19+
gossipReg *peerRegistry // optional gossip-discovered peers (nil = no --gossip)
2020
mux *http.ServeMux
21-
iconData []byte // default icon data (nil = no default icon)
22-
iconCT string // icon content type
21+
iconData []byte // default icon data (nil = no default icon)
22+
iconCT string // icon content type
2323
}
2424

2525
// newBridgeServer creates a bridge HTTP server with all protocol endpoints registered.
@@ -181,6 +181,7 @@ func (s *bridgeServer) handleChannelPath(w http.ResponseWriter, r *http.Request)
181181
func (s *bridgeServer) handleHealth(w http.ResponseWriter, r *http.Request) {
182182
writeJSON(w, map[string]interface{}{
183183
"status": "ok",
184+
"version": version,
184185
"channels": s.registry.PublicChannelCount(),
185186
}, http.StatusOK)
186187
}
@@ -255,7 +256,7 @@ func (s *bridgeServer) serveCachedStream(w http.ResponseWriter, r *http.Request,
255256
}
256257

257258
cacheKey := r.URL.Path
258-
data, _, hit, err := s.cache.getOrFetch(cacheKey, func() (*hlsCacheFetchResult, error) {
259+
data, contentType, hit, err := s.cache.getOrFetch(cacheKey, func() (*hlsCacheFetchResult, error) {
259260
fr, err := hlsCacheFetchUpstream(bridgeStreamClient, fetchURL, r)
260261
if err != nil {
261262
return nil, err
@@ -272,7 +273,7 @@ func (s *bridgeServer) serveCachedStream(w http.ResponseWriter, r *http.Request,
272273
return
273274
}
274275

275-
setStreamHeaders(w, subPath, ch.IsPrivate())
276+
setStreamHeadersWithContentType(w, subPath, ch.IsPrivate(), contentType)
276277
if hit {
277278
w.Header().Set("Cache-Status", "HIT")
278279
} else {

bridge_stream.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ func bridgeServeUpstreamStream(w http.ResponseWriter, r *http.Request, manifestU
154154
return
155155
}
156156

157-
setStreamHeaders(w, subPath, private)
157+
setStreamHeadersWithContentType(w, subPath, private, resp.Header.Get("Content-Type"))
158158

159159
if strings.HasSuffix(subPath, ".m3u8") {
160160
// Read and rewrite manifest
@@ -276,7 +276,17 @@ func bridgeAppendToken(uri, token string) string {
276276

277277
// setStreamHeaders sets Content-Type and Cache-Control for stream responses.
278278
func setStreamHeaders(w http.ResponseWriter, subPath string, private bool) {
279-
w.Header().Set("Content-Type", streamContentType(subPath))
279+
setStreamHeadersWithContentType(w, subPath, private, "")
280+
}
281+
282+
// setStreamHeadersWithContentType prefers the canonical content type for known
283+
// file extensions and falls back to the upstream content type for unknown ones.
284+
func setStreamHeadersWithContentType(w http.ResponseWriter, subPath string, private bool, contentType string) {
285+
ct := streamContentType(subPath)
286+
if ct == "application/octet-stream" && contentType != "" {
287+
ct = contentType
288+
}
289+
w.Header().Set("Content-Type", ct)
280290

281291
if private {
282292
w.Header().Set("Cache-Control", "private, no-store")
@@ -304,6 +314,12 @@ func streamContentType(name string) string {
304314
return "audio/aac"
305315
case strings.HasSuffix(name, ".vtt"):
306316
return "text/vtt"
317+
case strings.HasSuffix(name, ".svg"):
318+
return "image/svg+xml"
319+
case strings.HasSuffix(name, ".png"):
320+
return "image/png"
321+
case strings.HasSuffix(name, ".jpg"), strings.HasSuffix(name, ".jpeg"):
322+
return "image/jpeg"
307323
default:
308324
return "application/octet-stream"
309325
}

bridge_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,9 @@ func TestBridgeHealth(t *testing.T) {
808808
if resp["status"] != "ok" {
809809
t.Errorf("status = %v, want ok", resp["status"])
810810
}
811+
if resp["version"] != version {
812+
t.Errorf("version = %v, want %s", resp["version"], version)
813+
}
811814
if resp["channels"] != float64(1) {
812815
t.Errorf("channels = %v, want 1", resp["channels"])
813816
}

loadtest.go

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ func cmdLoadtest(args []string) {
9595
fmt.Fprintf(os.Stderr, "error: %v\n", err)
9696
os.Exit(1)
9797
}
98+
stopLogReopen := startLogReopenWatcher()
99+
defer stopLogReopen()
98100

99101
// Parse durations
100102
testDuration, err := time.ParseDuration(*durationStr)
@@ -420,17 +422,17 @@ func loadtestPrintJSON(agg *loadtestAggregator, target, directURL string, durati
420422
}
421423

422424
result := map[string]interface{}{
423-
"target": displayTarget,
424-
"receivers": receivers,
425-
"duration_ms": elapsed.Milliseconds(),
426-
"ramp_ms": ramp.Milliseconds(),
427-
"segments_total": agg.totalSegments.Load() + agg.segmentErrors.Load(),
428-
"segments_ok": agg.totalSegments.Load(),
429-
"segment_errors": agg.segmentErrors.Load(),
430-
"manifests_total": agg.totalManifests.Load() + agg.manifestErrors.Load(),
431-
"manifests_ok": agg.totalManifests.Load(),
432-
"manifest_errors": agg.manifestErrors.Load(),
433-
"bytes_received": agg.bytesReceived.Load(),
425+
"target": displayTarget,
426+
"receivers": receivers,
427+
"duration_ms": elapsed.Milliseconds(),
428+
"ramp_ms": ramp.Milliseconds(),
429+
"segments_total": agg.totalSegments.Load() + agg.segmentErrors.Load(),
430+
"segments_ok": agg.totalSegments.Load(),
431+
"segment_errors": agg.segmentErrors.Load(),
432+
"manifests_total": agg.totalManifests.Load() + agg.manifestErrors.Load(),
433+
"manifests_ok": agg.totalManifests.Load(),
434+
"manifest_errors": agg.manifestErrors.Load(),
435+
"bytes_received": agg.bytesReceived.Load(),
434436
}
435437

436438
if elapsed.Seconds() > 0 {

logging.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"io"
88
"os"
9+
"os/signal"
910
"strings"
1011
"sync"
1112
"time"
@@ -28,6 +29,8 @@ var (
2829
logMinLevel logLevel = levelInfo
2930
logJSON bool
3031
logComponent string
32+
logFilePath string
33+
logFile *os.File
3134
)
3235

3336
// addLogFlags registers --log-level, --log-format, --log-file on a flag set.
@@ -42,6 +45,15 @@ func addLogFlags(fs *flag.FlagSet) (level, format, file *string) {
4245
// setupLogging configures the package-level logger. Must be called before
4346
// any log output. The component name is included in every log line.
4447
func setupLogging(level, format, file, component string) error {
48+
logMu.Lock()
49+
defer logMu.Unlock()
50+
51+
if logFile != nil {
52+
logFile.Close()
53+
logFile = nil
54+
}
55+
logOut = os.Stderr
56+
logFilePath = ""
4557
logComponent = component
4658

4759
switch strings.ToLower(level) {
@@ -70,11 +82,68 @@ func setupLogging(level, format, file, component string) error {
7082
return fmt.Errorf("open log file: %w", err)
7183
}
7284
logOut = f
85+
logFile = f
86+
logFilePath = file
87+
}
88+
89+
return nil
90+
}
91+
92+
func reopenLogFile() error {
93+
logMu.Lock()
94+
path := logFilePath
95+
oldFile := logFile
96+
logMu.Unlock()
97+
98+
if path == "" {
99+
return nil
73100
}
74101

102+
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
103+
if err != nil {
104+
return fmt.Errorf("open log file: %w", err)
105+
}
106+
107+
logMu.Lock()
108+
logOut = f
109+
logFile = f
110+
logMu.Unlock()
111+
112+
if oldFile != nil {
113+
oldFile.Close()
114+
}
75115
return nil
76116
}
77117

118+
func startLogReopenWatcher() func() {
119+
logMu.Lock()
120+
hasFile := logFilePath != ""
121+
logMu.Unlock()
122+
if !hasFile {
123+
return func() {}
124+
}
125+
126+
ch := make(chan os.Signal, 1)
127+
sighupNotify(ch)
128+
done := make(chan struct{})
129+
go func() {
130+
for {
131+
select {
132+
case <-done:
133+
signal.Stop(ch)
134+
return
135+
case <-ch:
136+
if err := reopenLogFile(); err != nil {
137+
logErrorf("reopen log file: %v", err)
138+
} else {
139+
logInfof("reopened log file")
140+
}
141+
}
142+
}
143+
}()
144+
return func() { close(done) }
145+
}
146+
78147
// logf writes a structured log entry at the given level.
79148
func logf(level logLevel, format string, args ...interface{}) {
80149
if level < logMinLevel {

0 commit comments

Comments
 (0)