Procházet zdrojové kódy

Merge branch 'main' into v2

* main:
  feat(config): add option for audit file (fixes #9481) (#10066)
  chore(api): log X-Forwarded-For (#10035)
  chore(gui): update dependency copyrights, add script for periodic maintenance (#10067)
  chore(gui, man, authors): update docs, translations, and contributors
  chore(syncthing): remove support for TLS 1.2 sync connections (#10064)
  fix(osutil): give threads same I/O priority on Linux (#10063)
  chore(stun): switch lookup warning to debug level
  chore(gui, man, authors): update docs, translations, and contributors
Jakob Borg před 6 měsíci
rodič
revize
abe34fc1f6

+ 2 - 0
AUTHORS

@@ -277,6 +277,7 @@ Oyebanji Jacob Mayowa <[email protected]>
 Pablo <[email protected]>
 Pablo <[email protected]>
 Pascal Jungblut (pascalj) <[email protected]> <[email protected]>
 Pascal Jungblut (pascalj) <[email protected]> <[email protected]>
 Paul Brit <[email protected]>
 Paul Brit <[email protected]>
+Paul Donald <[email protected]>
 Pawel Palenica (qepasa) <[email protected]>
 Pawel Palenica (qepasa) <[email protected]>
 Paweł Rozlach <[email protected]>
 Paweł Rozlach <[email protected]>
 perewa <[email protected]>
 perewa <[email protected]>
@@ -327,6 +328,7 @@ Syncthing Release Automation <[email protected]>
 Sébastien WENSKE <[email protected]>
 Sébastien WENSKE <[email protected]>
 Taylor Khan (nelsonkhan) <[email protected]>
 Taylor Khan (nelsonkhan) <[email protected]>
 Terrance <[email protected]>
 Terrance <[email protected]>
+TheCreeper <[email protected]>
 Thomas <[email protected]>
 Thomas <[email protected]>
 Thomas Hipp <[email protected]>
 Thomas Hipp <[email protected]>
 Tim Abell (timabell) <[email protected]>
 Tim Abell (timabell) <[email protected]>

+ 1 - 0
build.sh

@@ -23,6 +23,7 @@ case "${1:-default}" in
 
 
 	prerelease)
 	prerelease)
 		script authors
 		script authors
+		script copyrights
 		build weblate
 		build weblate
 		pushd man ; ./refresh.sh ; popd
 		pushd man ; ./refresh.sh ; popd
 		git add -A gui man AUTHORS
 		git add -A gui man AUTHORS

+ 13 - 2
cmd/syncthing/main.go

@@ -533,8 +533,19 @@ func (c *serveCmd) syncthingMain() {
 		Verbose:               c.Verbose,
 		Verbose:               c.Verbose,
 		DBMaintenanceInterval: c.DBMaintenanceInterval,
 		DBMaintenanceInterval: c.DBMaintenanceInterval,
 	}
 	}
-	if c.Audit {
-		appOpts.AuditWriter = auditWriter(c.AuditFile)
+
+	if c.Audit || cfgWrapper.Options().AuditEnabled {
+		l.Infoln("Auditing is enabled.")
+
+		auditFile := cfgWrapper.Options().AuditFile
+
+		// Ignore config option if command-line option is set
+		if c.AuditFile != "" {
+			l.Debugln("Using the audit file from the command-line parameter.")
+			auditFile = c.AuditFile
+		}
+
+		appOpts.AuditWriter = auditWriter(auditFile)
 	}
 	}
 
 
 	app, err := syncthing.New(cfgWrapper, sdb, evLogger, cert, appOpts)
 	app, err := syncthing.New(cfgWrapper, sdb, evLogger, cert, appOpts)

+ 6 - 0
gui/default/assets/lang/lang-et.json

@@ -9,9 +9,15 @@
     "Add Folder": "Lisa kaust",
     "Add Folder": "Lisa kaust",
     "Add new folder?": "Lisa uus kaust?",
     "Add new folder?": "Lisa uus kaust?",
     "Address": "Aadress",
     "Address": "Aadress",
+    "Addresses": "Aadressid",
+    "All Data": "Kõik andmed",
+    "All Time": "Kõik ajad",
+    "Allowed Networks": "Lubatud võrgud",
     "Alphabetic": "Tähestikuline",
     "Alphabetic": "Tähestikuline",
     "Automatic upgrades": "Automaatsed uuendused",
     "Automatic upgrades": "Automaatsed uuendused",
     "Be careful!": "Ettevaatust!",
     "Be careful!": "Ettevaatust!",
+    "Cancel": "Loobu",
+    "Changelog": "Muudatuste nimekiri",
     "Close": "Sulge",
     "Close": "Sulge",
     "Configured": "Seadistatud",
     "Configured": "Seadistatud",
     "Connection Error": "Ühenduse viga",
     "Connection Error": "Ühenduse viga",

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
gui/default/syncthing/core/aboutModalView.html


+ 48 - 8
lib/api/api_auth.go

@@ -18,6 +18,7 @@ import (
 	ldap "github.com/go-ldap/ldap/v3"
 	ldap "github.com/go-ldap/ldap/v3"
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/events"
+	"github.com/syncthing/syncthing/lib/osutil"
 	"github.com/syncthing/syncthing/lib/rand"
 	"github.com/syncthing/syncthing/lib/rand"
 )
 )
 
 
@@ -27,15 +28,54 @@ const (
 	randomTokenLength  = 64
 	randomTokenLength  = 64
 )
 )
 
 
-func emitLoginAttempt(success bool, username, address string, evLogger events.Logger) {
-	evLogger.Log(events.LoginAttempt, map[string]interface{}{
+func emitLoginAttempt(success bool, username string, r *http.Request, evLogger events.Logger) {
+	remoteAddress, proxy := remoteAddress(r)
+	evData := map[string]any{
 		"success":       success,
 		"success":       success,
 		"username":      username,
 		"username":      username,
-		"remoteAddress": address,
-	})
-	if !success {
-		l.Infof("Wrong credentials supplied during API authorization from %s", address)
+		"remoteAddress": remoteAddress,
 	}
 	}
+	if proxy != "" {
+		evData["proxy"] = proxy
+	}
+	evLogger.Log(events.LoginAttempt, evData)
+
+	if success {
+		return
+	}
+	if proxy != "" {
+		l.Infof("Wrong credentials supplied during API authorization from %s proxied by %s", remoteAddress, proxy)
+	} else {
+		l.Infof("Wrong credentials supplied during API authorization from %s", remoteAddress)
+	}
+}
+
+func remoteAddress(r *http.Request) (remoteAddr, proxy string) {
+	remoteAddr = r.RemoteAddr
+	remoteIP := osutil.IPFromString(r.RemoteAddr)
+
+	// parse X-Forwarded-For only if the proxy connects via unix socket, localhost or a LAN IP
+	var localProxy bool
+	if remoteIP != nil {
+		remoteAddr = remoteIP.String()
+		localProxy = remoteIP.IsLoopback() || remoteIP.IsPrivate() || remoteIP.IsLinkLocalUnicast()
+	} else if remoteAddr == "@" {
+		localProxy = true
+	}
+
+	if !localProxy {
+		return
+	}
+
+	forwardedAddr, _, _ := strings.Cut(r.Header.Get("X-Forwarded-For"), ",")
+	forwardedAddr = strings.TrimSpace(forwardedAddr)
+	forwardedIP := osutil.IPFromString(forwardedAddr)
+
+	if forwardedIP != nil {
+		proxy = remoteAddr
+		remoteAddr = forwardedIP.String()
+	}
+	return
 }
 }
 
 
 func antiBruteForceSleep() {
 func antiBruteForceSleep() {
@@ -152,7 +192,7 @@ func (m *basicAuthAndSessionMiddleware) passwordAuthHandler(w http.ResponseWrite
 		return
 		return
 	}
 	}
 
 
-	emitLoginAttempt(false, req.Username, r.RemoteAddr, m.evLogger)
+	emitLoginAttempt(false, req.Username, r, m.evLogger)
 	antiBruteForceSleep()
 	antiBruteForceSleep()
 	forbidden(w)
 	forbidden(w)
 }
 }
@@ -175,7 +215,7 @@ func attemptBasicAuth(r *http.Request, guiCfg config.GUIConfiguration, ldapCfg c
 		return usernameFromIso, true
 		return usernameFromIso, true
 	}
 	}
 
 
-	emitLoginAttempt(false, username, r.RemoteAddr, evLogger)
+	emitLoginAttempt(false, username, r, evLogger)
 	antiBruteForceSleep()
 	antiBruteForceSleep()
 	return "", false
 	return "", false
 }
 }

+ 1 - 1
lib/api/tokenmanager.go

@@ -189,7 +189,7 @@ func (m *tokenCookieManager) createSession(username string, persistent bool, w h
 		Path:   "/",
 		Path:   "/",
 	})
 	})
 
 
-	emitLoginAttempt(true, username, r.RemoteAddr, m.evLogger)
+	emitLoginAttempt(true, username, r, m.evLogger)
 }
 }
 
 
 func (m *tokenCookieManager) hasValidSession(r *http.Request) bool {
 func (m *tokenCookieManager) hasValidSession(r *http.Request) bool {

+ 4 - 0
lib/config/config_test.go

@@ -92,6 +92,8 @@ func TestDefaultValues(t *testing.T) {
 			RawStunServers:            []string{"default"},
 			RawStunServers:            []string{"default"},
 			AnnounceLANAddresses:      true,
 			AnnounceLANAddresses:      true,
 			FeatureFlags:              []string{},
 			FeatureFlags:              []string{},
+			AuditEnabled:              false,
+			AuditFile:                 "",
 			ConnectionPriorityTCPLAN:  10,
 			ConnectionPriorityTCPLAN:  10,
 			ConnectionPriorityQUICLAN: 20,
 			ConnectionPriorityQUICLAN: 20,
 			ConnectionPriorityTCPWAN:  30,
 			ConnectionPriorityTCPWAN:  30,
@@ -295,6 +297,8 @@ func TestOverriddenValues(t *testing.T) {
 		StunKeepaliveMinS:         900,
 		StunKeepaliveMinS:         900,
 		RawStunServers:            []string{"foo"},
 		RawStunServers:            []string{"foo"},
 		FeatureFlags:              []string{"feature"},
 		FeatureFlags:              []string{"feature"},
+		AuditEnabled:              true,
+		AuditFile:                 "nggyu",
 		ConnectionPriorityTCPLAN:  40,
 		ConnectionPriorityTCPLAN:  40,
 		ConnectionPriorityQUICLAN: 45,
 		ConnectionPriorityQUICLAN: 45,
 		ConnectionPriorityTCPWAN:  50,
 		ConnectionPriorityTCPWAN:  50,

+ 10 - 11
lib/config/optionsconfiguration.go

@@ -67,22 +67,21 @@ type OptionsConfiguration struct {
 	AnnounceLANAddresses        bool     `json:"announceLANAddresses" xml:"announceLANAddresses" default:"true"`
 	AnnounceLANAddresses        bool     `json:"announceLANAddresses" xml:"announceLANAddresses" default:"true"`
 	SendFullIndexOnUpgrade      bool     `json:"sendFullIndexOnUpgrade" xml:"sendFullIndexOnUpgrade"`
 	SendFullIndexOnUpgrade      bool     `json:"sendFullIndexOnUpgrade" xml:"sendFullIndexOnUpgrade"`
 	FeatureFlags                []string `json:"featureFlags" xml:"featureFlag"`
 	FeatureFlags                []string `json:"featureFlags" xml:"featureFlag"`
+	AuditEnabled                bool     `json:"auditEnabled" xml:"auditEnabled" default:"false"`
+	AuditFile                   string   `json:"auditFile" xml:"auditFile"`
 	// The number of connections at which we stop trying to connect to more
 	// The number of connections at which we stop trying to connect to more
 	// devices, zero meaning no limit. Does not affect incoming connections.
 	// devices, zero meaning no limit. Does not affect incoming connections.
 	ConnectionLimitEnough int `json:"connectionLimitEnough" xml:"connectionLimitEnough"`
 	ConnectionLimitEnough int `json:"connectionLimitEnough" xml:"connectionLimitEnough"`
 	// The maximum number of connections which we will allow in total, zero
 	// The maximum number of connections which we will allow in total, zero
 	// meaning no limit. Affects incoming connections and prevents
 	// meaning no limit. Affects incoming connections and prevents
 	// attempting outgoing connections.
 	// attempting outgoing connections.
-	ConnectionLimitMax int `json:"connectionLimitMax" xml:"connectionLimitMax"`
-	// When set, this allows TLS 1.2 on sync connections, where we otherwise
-	// default to TLS 1.3+ only.
-	InsecureAllowOldTLSVersions        bool `json:"insecureAllowOldTLSVersions" xml:"insecureAllowOldTLSVersions"`
-	ConnectionPriorityTCPLAN           int  `json:"connectionPriorityTcpLan" xml:"connectionPriorityTcpLan" default:"10"`
-	ConnectionPriorityQUICLAN          int  `json:"connectionPriorityQuicLan" xml:"connectionPriorityQuicLan" default:"20"`
-	ConnectionPriorityTCPWAN           int  `json:"connectionPriorityTcpWan" xml:"connectionPriorityTcpWan" default:"30"`
-	ConnectionPriorityQUICWAN          int  `json:"connectionPriorityQuicWan" xml:"connectionPriorityQuicWan" default:"40"`
-	ConnectionPriorityRelay            int  `json:"connectionPriorityRelay" xml:"connectionPriorityRelay" default:"50"`
-	ConnectionPriorityUpgradeThreshold int  `json:"connectionPriorityUpgradeThreshold" xml:"connectionPriorityUpgradeThreshold" default:"0"`
+	ConnectionLimitMax                 int `json:"connectionLimitMax" xml:"connectionLimitMax"`
+	ConnectionPriorityTCPLAN           int `json:"connectionPriorityTcpLan" xml:"connectionPriorityTcpLan" default:"10"`
+	ConnectionPriorityQUICLAN          int `json:"connectionPriorityQuicLan" xml:"connectionPriorityQuicLan" default:"20"`
+	ConnectionPriorityTCPWAN           int `json:"connectionPriorityTcpWan" xml:"connectionPriorityTcpWan" default:"30"`
+	ConnectionPriorityQUICWAN          int `json:"connectionPriorityQuicWan" xml:"connectionPriorityQuicWan" default:"40"`
+	ConnectionPriorityRelay            int `json:"connectionPriorityRelay" xml:"connectionPriorityRelay" default:"50"`
+	ConnectionPriorityUpgradeThreshold int `json:"connectionPriorityUpgradeThreshold" xml:"connectionPriorityUpgradeThreshold" default:"0"`
 	// Legacy deprecated
 	// Legacy deprecated
 	DeprecatedUPnPEnabled        bool     `json:"-" xml:"upnpEnabled,omitempty"`        // Deprecated: Do not use.
 	DeprecatedUPnPEnabled        bool     `json:"-" xml:"upnpEnabled,omitempty"`        // Deprecated: Do not use.
 	DeprecatedUPnPLeaseM         int      `json:"-" xml:"upnpLeaseMinutes,omitempty"`   // Deprecated: Do not use.
 	DeprecatedUPnPLeaseM         int      `json:"-" xml:"upnpLeaseMinutes,omitempty"`   // Deprecated: Do not use.
@@ -187,7 +186,7 @@ func (opts OptionsConfiguration) StunServers() []string {
 		case "default":
 		case "default":
 			_, records, err := net.LookupSRV("stun", "udp", "syncthing.net")
 			_, records, err := net.LookupSRV("stun", "udp", "syncthing.net")
 			if err != nil {
 			if err != nil {
-				l.Warnln("Unable to resolve primary STUN servers via DNS:", err)
+				l.Debugf("Unable to resolve primary STUN servers via DNS:", err)
 			}
 			}
 
 
 			for _, record := range records {
 			for _, record := range records {

+ 2 - 0
lib/config/testdata/overridenvalues.xml

@@ -45,6 +45,8 @@
         <unackedNotificationID>asdfasdf</unackedNotificationID>
         <unackedNotificationID>asdfasdf</unackedNotificationID>
         <announceLANAddresses>false</announceLANAddresses>
         <announceLANAddresses>false</announceLANAddresses>
         <featureFlag>feature</featureFlag>
         <featureFlag>feature</featureFlag>
+        <auditEnabled>true</auditEnabled>
+        <auditFile>nggyu</auditFile>
         <connectionPriorityTcpLan>40</connectionPriorityTcpLan>
         <connectionPriorityTcpLan>40</connectionPriorityTcpLan>
         <connectionPriorityQuicLan>45</connectionPriorityQuicLan>
         <connectionPriorityQuicLan>45</connectionPriorityQuicLan>
         <connectionPriorityTcpWan>50</connectionPriorityTcpWan>
         <connectionPriorityTcpWan>50</connectionPriorityTcpWan>

+ 5 - 32
lib/osutil/lowprio_linux.go

@@ -15,32 +15,6 @@ import (
 	"syscall"
 	"syscall"
 )
 )
 
 
-const ioprioClassShift = 13
-
-type ioprioClass int
-
-const (
-	ioprioClassRT ioprioClass = iota + 1
-	ioprioClassBE
-	ioprioClassIdle
-)
-
-const (
-	ioprioWhoProcess = iota + 1
-	ioprioWhoPGRP
-	ioprioWhoUser
-)
-
-func ioprioSet(class ioprioClass, value int) error {
-	res, _, err := syscall.Syscall(syscall.SYS_IOPRIO_SET,
-		uintptr(ioprioWhoProcess), 0,
-		uintptr(class)<<ioprioClassShift|uintptr(value))
-	if res == 0 {
-		return nil
-	}
-	return err
-}
-
 // SetLowPriority lowers the process CPU scheduling priority, and possibly
 // SetLowPriority lowers the process CPU scheduling priority, and possibly
 // I/O priority depending on the platform and OS.
 // I/O priority depending on the platform and OS.
 func SetLowPriority() error {
 func SetLowPriority() error {
@@ -89,14 +63,13 @@ func SetLowPriority() error {
 		}
 		}
 	}
 	}
 
 
+	// For any new process, the default is to be assigned the IOPRIO_CLASS_BE
+	// scheduling class. This class directly maps the BE prio level to the
+	// niceness of a process, determined as: io_nice = (cpu_nice + 20) / 5.
+	// For example, a niceness of 11 results in an I/O priority of B6.
+	// https://www.kernel.org/doc/Documentation/block/ioprio.txt
 	if err := syscall.Setpriority(syscall.PRIO_PGRP, pidSelf, wantNiceLevel); err != nil {
 	if err := syscall.Setpriority(syscall.PRIO_PGRP, pidSelf, wantNiceLevel); err != nil {
 		return fmt.Errorf("set niceness: %w", err)
 		return fmt.Errorf("set niceness: %w", err)
 	}
 	}
-
-	// Best effort, somewhere to the end of the scale (0 through 7 being the
-	// range).
-	if err := ioprioSet(ioprioClassBE, 5); err != nil {
-		return fmt.Errorf("set I/O priority: %w", err)
-	}
 	return nil
 	return nil
 }
 }

+ 12 - 0
lib/osutil/net.go

@@ -8,6 +8,7 @@ package osutil
 
 
 import (
 import (
 	"net"
 	"net"
+	"strings"
 )
 )
 
 
 // GetInterfaceAddrs returns the IP networks of all interfaces that are up.
 // GetInterfaceAddrs returns the IP networks of all interfaces that are up.
@@ -46,6 +47,17 @@ func GetInterfaceAddrs(includePtP bool) ([]*net.IPNet, error) {
 	return nets, nil
 	return nets, nil
 }
 }
 
 
+func IPFromString(addr string) net.IP {
+	// strip the port
+	host, _, err := net.SplitHostPort(addr)
+	if err != nil {
+		host = addr
+	}
+	// strip IPv6 zone identifier
+	host, _, _ = strings.Cut(host, "%")
+	return net.ParseIP(host)
+}
+
 func IPFromAddr(addr net.Addr) (net.IP, error) {
 func IPFromAddr(addr net.Addr) (net.IP, error) {
 	switch a := addr.(type) {
 	switch a := addr.(type) {
 	case *net.TCPAddr:
 	case *net.TCPAddr:

+ 32 - 0
lib/osutil/osutil_test.go

@@ -135,3 +135,35 @@ func TestRenameOrCopy(t *testing.T) {
 		}
 		}
 	}
 	}
 }
 }
+
+func TestIPFromString(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		in  string
+		out string
+	}{
+		{"192.168.178.1", "192.168.178.1"},
+		{"192.168.178.1:8384", "192.168.178.1"},
+		{"fe80::20c:29ff:fe9a:46d2", "fe80::20c:29ff:fe9a:46d2"},
+		{"[fe80::20c:29ff:fe9a:46d2]:8384", "fe80::20c:29ff:fe9a:46d2"},
+		{"[fe80::20c:29ff:fe9a:46d2%eno1]:8384", "fe80::20c:29ff:fe9a:46d2"},
+		{"google.com", ""},
+		{"1.1.1.1.1", ""},
+		{"", ""},
+	}
+
+	for _, c := range cases {
+		ip := osutil.IPFromString(c.in)
+		var address string
+		if ip != nil {
+			address = ip.String()
+		} else {
+			address = ""
+		}
+
+		if c.out != address {
+			t.Fatalf("result should be %s != %s", c.out, address)
+		}
+	}
+}

+ 1 - 7
lib/syncthing/syncthing.go

@@ -251,13 +251,7 @@ func (a *App) startup() error {
 	// The TLS configuration is used for both the listening socket and outgoing
 	// The TLS configuration is used for both the listening socket and outgoing
 	// connections.
 	// connections.
 
 
-	var tlsCfg *tls.Config
-	if a.cfg.Options().InsecureAllowOldTLSVersions {
-		l.Infoln("TLS 1.2 is allowed on sync connections. This is less than optimally secure.")
-		tlsCfg = tlsutil.SecureDefaultWithTLS12()
-	} else {
-		tlsCfg = tlsutil.SecureDefaultTLS13()
-	}
+	tlsCfg := tlsutil.SecureDefaultTLS13()
 	tlsCfg.Certificates = []tls.Certificate{a.cert}
 	tlsCfg.Certificates = []tls.Certificate{a.cert}
 	tlsCfg.NextProtos = []string{bepProtocolName}
 	tlsCfg.NextProtos = []string{bepProtocolName}
 	tlsCfg.ClientAuth = tls.RequestClientCert
 	tlsCfg.ClientAuth = tls.RequestClientCert

+ 1 - 1
man/stdiscosrv.1

@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
 ..
-.TH "STDISCOSRV" "1" "Apr 04, 2025" "v1.29.3" "Syncthing"
+.TH "STDISCOSRV" "1" "Apr 21, 2025" "v1.29.3" "Syncthing"
 .SH NAME
 .SH NAME
 stdiscosrv \- Syncthing Discovery Server
 stdiscosrv \- Syncthing Discovery Server
 .SH SYNOPSIS
 .SH SYNOPSIS

+ 1 - 1
man/strelaysrv.1

@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
 ..
-.TH "STRELAYSRV" "1" "Apr 04, 2025" "v1.29.3" "Syncthing"
+.TH "STRELAYSRV" "1" "Apr 21, 2025" "v1.29.3" "Syncthing"
 .SH NAME
 .SH NAME
 strelaysrv \- Syncthing Relay Server
 strelaysrv \- Syncthing Relay Server
 .SH SYNOPSIS
 .SH SYNOPSIS

+ 1 - 1
man/syncthing-bep.7

@@ -28,7 +28,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
 ..
-.TH "SYNCTHING-BEP" "7" "Apr 04, 2025" "v1.29.3" "Syncthing"
+.TH "SYNCTHING-BEP" "7" "Apr 21, 2025" "v1.29.3" "Syncthing"
 .SH NAME
 .SH NAME
 syncthing-bep \- Block Exchange Protocol v1
 syncthing-bep \- Block Exchange Protocol v1
 .SH INTRODUCTION AND DEFINITIONS
 .SH INTRODUCTION AND DEFINITIONS

+ 6 - 9
man/syncthing-config.5

@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
 ..
-.TH "SYNCTHING-CONFIG" "5" "Apr 04, 2025" "v1.29.3" "Syncthing"
+.TH "SYNCTHING-CONFIG" "5" "Apr 21, 2025" "v1.29.3" "Syncthing"
 .SH NAME
 .SH NAME
 syncthing-config \- Syncthing Configuration
 syncthing-config \- Syncthing Configuration
 .SH SYNOPSIS
 .SH SYNOPSIS
@@ -221,7 +221,6 @@ may no longer correspond to the defaults.
         <sendFullIndexOnUpgrade>false</sendFullIndexOnUpgrade>
         <sendFullIndexOnUpgrade>false</sendFullIndexOnUpgrade>
         <connectionLimitEnough>0</connectionLimitEnough>
         <connectionLimitEnough>0</connectionLimitEnough>
         <connectionLimitMax>0</connectionLimitMax>
         <connectionLimitMax>0</connectionLimitMax>
-        <insecureAllowOldTLSVersions>false</insecureAllowOldTLSVersions>
     </options>
     </options>
     <remoteIgnoredDevice time=\(dq2022\-01\-09T20:02:01Z\(dq id=\(dq5SYI2FS\-LW6YAXI\-JJDYETS\-NDBBPIO\-256MWBO\-XDPXWVG\-24QPUM4\-PDW4UQU\(dq name=\(dqbugger\(dq address=\(dq192.168.0.20:22000\(dq></remoteIgnoredDevice>
     <remoteIgnoredDevice time=\(dq2022\-01\-09T20:02:01Z\(dq id=\(dq5SYI2FS\-LW6YAXI\-JJDYETS\-NDBBPIO\-256MWBO\-XDPXWVG\-24QPUM4\-PDW4UQU\(dq name=\(dqbugger\(dq address=\(dq192.168.0.20:22000\(dq></remoteIgnoredDevice>
     <defaults>
     <defaults>
@@ -1077,6 +1076,11 @@ header do not need this setting.
 When this setting is disabled, the GUI will not send 401 responses so users
 When this setting is disabled, the GUI will not send 401 responses so users
 won’t see browser popups prompting for username and password.
 won’t see browser popups prompting for username and password.
 .UNINDENT
 .UNINDENT
+.INDENT 0.0
+.TP
+.B metricsWithoutAuth
+If true, this allows access to the ‘/metrics’ without authentication.
+.UNINDENT
 .SH LDAP ELEMENT
 .SH LDAP ELEMENT
 .INDENT 0.0
 .INDENT 0.0
 .INDENT 3.5
 .INDENT 3.5
@@ -1196,7 +1200,6 @@ Search filter for user searches.
     <sendFullIndexOnUpgrade>false</sendFullIndexOnUpgrade>
     <sendFullIndexOnUpgrade>false</sendFullIndexOnUpgrade>
     <connectionLimitEnough>0</connectionLimitEnough>
     <connectionLimitEnough>0</connectionLimitEnough>
     <connectionLimitMax>0</connectionLimitMax>
     <connectionLimitMax>0</connectionLimitMax>
-    <insecureAllowOldTLSVersions>false</insecureAllowOldTLSVersions>
 </options>
 </options>
 .EE
 .EE
 .UNINDENT
 .UNINDENT
@@ -1533,12 +1536,6 @@ no limit.  Affects incoming connections and prevents attempting outgoing
 connections.  The mechanism is described in detail in a \fI\%separate
 connections.  The mechanism is described in detail in a \fI\%separate
 chapter\fP\&.
 chapter\fP\&.
 .UNINDENT
 .UNINDENT
-.INDENT 0.0
-.TP
-.B insecureAllowOldTLSVersions
-Only for compatibility with old versions of Syncthing on remote devices, as
-detailed in \fI\%insecureAllowOldTLSVersions\fP\&.
-.UNINDENT
 .SH DEFAULTS ELEMENT
 .SH DEFAULTS ELEMENT
 .INDENT 0.0
 .INDENT 0.0
 .INDENT 3.5
 .INDENT 3.5

+ 1 - 1
man/syncthing-device-ids.7

@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
 ..
-.TH "SYNCTHING-DEVICE-IDS" "7" "Apr 04, 2025" "v1.29.3" "Syncthing"
+.TH "SYNCTHING-DEVICE-IDS" "7" "Apr 21, 2025" "v1.29.3" "Syncthing"
 .SH NAME
 .SH NAME
 syncthing-device-ids \- Understanding Device IDs
 syncthing-device-ids \- Understanding Device IDs
 .sp
 .sp

+ 1 - 1
man/syncthing-event-api.7

@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
 ..
-.TH "SYNCTHING-EVENT-API" "7" "Apr 04, 2025" "v1.29.3" "Syncthing"
+.TH "SYNCTHING-EVENT-API" "7" "Apr 21, 2025" "v1.29.3" "Syncthing"
 .SH NAME
 .SH NAME
 syncthing-event-api \- Event API
 syncthing-event-api \- Event API
 .SH DESCRIPTION
 .SH DESCRIPTION

+ 1 - 1
man/syncthing-faq.7

@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
 ..
-.TH "SYNCTHING-FAQ" "7" "Apr 04, 2025" "v1.29.3" "Syncthing"
+.TH "SYNCTHING-FAQ" "7" "Apr 21, 2025" "v1.29.3" "Syncthing"
 .SH NAME
 .SH NAME
 syncthing-faq \- Frequently Asked Questions
 syncthing-faq \- Frequently Asked Questions
 .INDENT 0.0
 .INDENT 0.0

+ 1 - 1
man/syncthing-globaldisco.7

@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
 ..
-.TH "SYNCTHING-GLOBALDISCO" "7" "Apr 04, 2025" "v1.29.3" "Syncthing"
+.TH "SYNCTHING-GLOBALDISCO" "7" "Apr 21, 2025" "v1.29.3" "Syncthing"
 .SH NAME
 .SH NAME
 syncthing-globaldisco \- Global Discovery Protocol v3
 syncthing-globaldisco \- Global Discovery Protocol v3
 .SH ANNOUNCEMENTS
 .SH ANNOUNCEMENTS

+ 1 - 1
man/syncthing-localdisco.7

@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
 ..
-.TH "SYNCTHING-LOCALDISCO" "7" "Apr 04, 2025" "v1.29.3" "Syncthing"
+.TH "SYNCTHING-LOCALDISCO" "7" "Apr 21, 2025" "v1.29.3" "Syncthing"
 .SH NAME
 .SH NAME
 syncthing-localdisco \- Local Discovery Protocol v4
 syncthing-localdisco \- Local Discovery Protocol v4
 .SH MODE OF OPERATION
 .SH MODE OF OPERATION

+ 1 - 1
man/syncthing-networking.7

@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
 ..
-.TH "SYNCTHING-NETWORKING" "7" "Apr 04, 2025" "v1.29.3" "Syncthing"
+.TH "SYNCTHING-NETWORKING" "7" "Apr 21, 2025" "v1.29.3" "Syncthing"
 .SH NAME
 .SH NAME
 syncthing-networking \- Firewall Setup
 syncthing-networking \- Firewall Setup
 .SH ROUTER SETUP
 .SH ROUTER SETUP

+ 1 - 1
man/syncthing-relay.7

@@ -28,7 +28,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
 ..
-.TH "SYNCTHING-RELAY" "7" "Apr 04, 2025" "v1.29.3" "Syncthing"
+.TH "SYNCTHING-RELAY" "7" "Apr 21, 2025" "v1.29.3" "Syncthing"
 .SH NAME
 .SH NAME
 syncthing-relay \- Relay Protocol v1
 syncthing-relay \- Relay Protocol v1
 .SH WHAT IS A RELAY?
 .SH WHAT IS A RELAY?

+ 2 - 3
man/syncthing-rest-api.7

@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
 ..
-.TH "SYNCTHING-REST-API" "7" "Apr 04, 2025" "v1.29.3" "Syncthing"
+.TH "SYNCTHING-REST-API" "7" "Apr 21, 2025" "v1.29.3" "Syncthing"
 .SH NAME
 .SH NAME
 syncthing-rest-api \- REST API
 syncthing-rest-api \- REST API
 .sp
 .sp
@@ -279,8 +279,7 @@ Returns the current configuration.
     \(dqsendFullIndexOnUpgrade\(dq: false,
     \(dqsendFullIndexOnUpgrade\(dq: false,
     \(dqfeatureFlags\(dq: [],
     \(dqfeatureFlags\(dq: [],
     \(dqconnectionLimitEnough\(dq: 0,
     \(dqconnectionLimitEnough\(dq: 0,
-    \(dqconnectionLimitMax\(dq: 0,
-    \(dqinsecureAllowOldTLSVersions\(dq: false
+    \(dqconnectionLimitMax\(dq: 0
   },
   },
   \(dqremoteIgnoredDevices\(dq: [
   \(dqremoteIgnoredDevices\(dq: [
     {
     {

+ 1 - 1
man/syncthing-security.7

@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
 ..
-.TH "SYNCTHING-SECURITY" "7" "Apr 04, 2025" "v1.29.3" "Syncthing"
+.TH "SYNCTHING-SECURITY" "7" "Apr 21, 2025" "v1.29.3" "Syncthing"
 .SH NAME
 .SH NAME
 syncthing-security \- Security Principles
 syncthing-security \- Security Principles
 .sp
 .sp

+ 1 - 1
man/syncthing-stignore.5

@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
 ..
-.TH "SYNCTHING-STIGNORE" "5" "Apr 04, 2025" "v1.29.3" "Syncthing"
+.TH "SYNCTHING-STIGNORE" "5" "Apr 21, 2025" "v1.29.3" "Syncthing"
 .SH NAME
 .SH NAME
 syncthing-stignore \- Prevent files from being synchronized to other nodes
 syncthing-stignore \- Prevent files from being synchronized to other nodes
 .SH SYNOPSIS
 .SH SYNOPSIS

+ 1 - 1
man/syncthing-versioning.7

@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
 ..
-.TH "SYNCTHING-VERSIONING" "7" "Apr 04, 2025" "v1.29.3" "Syncthing"
+.TH "SYNCTHING-VERSIONING" "7" "Apr 21, 2025" "v1.29.3" "Syncthing"
 .SH NAME
 .SH NAME
 syncthing-versioning \- Keep automatic backups of deleted files by other nodes
 syncthing-versioning \- Keep automatic backups of deleted files by other nodes
 .sp
 .sp

+ 1 - 1
man/syncthing.1

@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
 ..
-.TH "SYNCTHING" "1" "Apr 04, 2025" "v1.29.3" "Syncthing"
+.TH "SYNCTHING" "1" "Apr 21, 2025" "v1.29.3" "Syncthing"
 .SH NAME
 .SH NAME
 syncthing \- Syncthing
 syncthing \- Syncthing
 .SH SYNOPSIS
 .SH SYNOPSIS

+ 489 - 0
script/copyrights.go

@@ -0,0 +1,489 @@
+// Copyright (C) 2025 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at https://mozilla.org/MPL/2.0/.
+
+//go:build ignore
+// +build ignore
+
+// Updates the list of software copyrights in aboutModalView.html based on the
+// output of `go mod graph`.
+
+package main
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"net/url"
+	"os"
+	"os/exec"
+	"regexp"
+	"slices"
+	"strconv"
+	"strings"
+	"time"
+
+	"golang.org/x/net/html"
+)
+
+var copyrightMap = map[string]string{
+	// https://github.com/aws/aws-sdk-go/blob/main/NOTICE.txt#L2
+	"aws/aws-sdk-go": "Copyright &copy; 2015 Amazon.com, Inc. or its affiliates, Copyright 2014-2015 Stripe, Inc",
+	// https://github.com/ccding/go-stun/blob/master/main.go#L1
+	"ccding/go-stun": "Copyright &copy; 2016 Cong Ding",
+	// https://github.com/search?q=repo%3Acertifi%2Fgocertifi%20copyright&type=code
+	// "certifi/gocertifi": "No copyrights found",
+	// https://github.com/search?q=repo%3Aebitengine%2Fpurego%20copyright&type=code
+	"ebitengine/purego": "Copyright &copy; 2022 The Ebitengine Authors",
+	// https://github.com/search?q=repo%3Agoogle%2Fpprof%20copyright&type=code
+	"google/pprof": "Copyright &copy; 2016 Google Inc",
+	// https://github.com/greatroar/blobloom/blob/master/README.md?plain=1#L74
+	"greatroar/blobloom": "Copyright &copy; 2020-2024 the Blobloom authors",
+	// https://github.com/jmespath/go-jmespath/blob/master/NOTICE#L2
+	"jmespath/go-jmespath": "Copyright &copy; 2015 James Saryerwinnie",
+	// https://github.com/maxmind/geoipupdate/blob/main/README.md?plain=1#L140
+	"maxmind/geoipupdate": "Copyright &copy; 2018-2024 by MaxMind, Inc",
+	// https://github.com/search?q=repo%3Apuzpuzpuz%2Fxsync%20copyright&type=code
+	// "puzpuzpuz/xsync": "No copyrights found",
+	// https://github.com/search?q=repo%3Atklauser%2Fnumcpus%20copyright&type=code
+	"tklauser/numcpus": "Copyright &copy; 2018-2024 Tobias Klauser",
+	// https://github.com/search?q=repo%3Auber-go%2Fmock%20copyright&type=code
+	"go.uber.org/mock": "Copyright &copy; 2010-2022 Google LLC",
+}
+
+var urlMap = map[string]string{
+	"fontawesome.io":             "https://github.com/FortAwesome/Font-Awesome",
+	"go.uber.org/automaxprocs":   "https://github.com/uber-go/automaxprocs",
+	"go.uber.org/mock":           "https://github.com/uber-go/mock",
+	"google.golang.org/protobuf": "https://github.com/protocolbuffers/protobuf-go",
+	"gopkg.in/yaml.v2":           "", // ignore, as gopkg.in/yaml.v3 supersedes
+	"gopkg.in/yaml.v3":           "https://github.com/go-yaml/yaml",
+	"sigs.k8s.io/yaml":           "https://github.com/kubernetes-sigs/yaml",
+}
+
+const htmlFile = "gui/default/syncthing/core/aboutModalView.html"
+
+type Type int
+
+const (
+	// TypeJS defines non-Go copyright notices
+	TypeJS Type = iota
+	// TypeKeep defines Go copyright notices for packages that are still used.
+	TypeKeep
+	// TypeToss defines Go copyright notices for packages that are no longer used.
+	TypeToss
+	// TypeNew defines Go copyright notices for new packages found via `go mod graph`.
+	TypeNew
+)
+
+type CopyrightNotice struct {
+	Type           Type
+	Name           string
+	HTML           string
+	Module         string
+	URL            string
+	Copyright      string
+	RepoURL        string
+	RepoCopyrights []string
+}
+
+var copyrightRe = regexp.MustCompile(`(?s)id="copyright-notices">(.+?)</ul>`)
+
+func main() {
+	bs := readAll(htmlFile)
+	matches := copyrightRe.FindStringSubmatch(string(bs))
+
+	if len(matches) <= 1 {
+		log.Fatal("Cannot find id copyright-notices in ", htmlFile)
+	}
+
+	modules := getModules()
+
+	notices := parseCopyrightNotices(matches[1])
+	old := len(notices)
+
+	// match up modules to notices
+	matched := map[string]bool{}
+	removes := 0
+	for i, notice := range notices {
+		if notice.Type == TypeJS {
+			continue
+		}
+		found := ""
+		for _, module := range modules {
+			if strings.Contains(module, notice.Name) {
+				found = module
+
+				break
+			}
+		}
+		if found != "" {
+			matched[found] = true
+			notices[i].Module = found
+
+			continue
+		}
+		removes++
+		fmt.Printf("Removing: %-40s %-55s %s\n", notice.Name, notice.URL, notice.Copyright)
+		notices[i].Type = TypeToss
+	}
+
+	// add new modules to notices
+	adds := 0
+	for _, module := range modules {
+		_, ok := matched[module]
+		if ok {
+			continue
+		}
+
+		adds++
+		notice := CopyrightNotice{}
+		notice.Name = module
+		if strings.HasPrefix(notice.Name, "github.com/") {
+			notice.Name = strings.ReplaceAll(notice.Name, "github.com/", "")
+		}
+		notice.Type = TypeNew
+
+		url, ok := urlMap[module]
+		if ok {
+			notice.URL = url
+			notice.RepoURL = url
+		} else {
+			notice.URL = "https://" + module
+			notice.RepoURL = "https://" + module
+		}
+		notices = append(notices, notice)
+	}
+
+	if removes == 0 && adds == 0 {
+		// authors.go is quiet, so let's be quiet too.
+		// fmt.Printf("No changes detected in %d modules and %d notices\n", len(modules), len(notices))
+		os.Exit(0)
+	}
+
+	// get copyrights via Github API for new modules
+	notfound := 0
+	for i, n := range notices {
+		if n.Type != TypeNew {
+			continue
+		}
+		copyright, ok := copyrightMap[n.Name]
+		if ok {
+			notices[i].Copyright = copyright
+
+			continue
+		}
+		notices[i].Copyright = defaultCopyright(n)
+
+		if strings.Contains(n.URL, "github.com/") {
+			notices[i].RepoURL = notices[i].URL
+			owner, repo := parseGitHubURL(n.URL)
+			licenseText := getLicenseText(owner, repo)
+			notices[i].RepoCopyrights = extractCopyrights(licenseText, n)
+
+			if len(notices[i].RepoCopyrights) > 0 {
+				notices[i].Copyright = notices[i].RepoCopyrights[0]
+			}
+
+			notices[i].HTML = fmt.Sprintf("<li><a href=\"%s\">%s</a>, %s.</li>", n.URL, n.Name, notices[i].Copyright)
+			if len(notices[i].RepoCopyrights) > 0 {
+				continue
+			}
+		}
+		fmt.Printf("Copyright not found: %-30s : using %q\n", n.Name, notices[i].Copyright)
+		notfound++
+	}
+
+	replacements := write(notices, bs)
+	fmt.Printf("Removed:              %3d\n", removes)
+	fmt.Printf("Added:                %3d\n", adds)
+	fmt.Printf("Copyrights not found: %3d\n", notfound)
+	fmt.Printf("Old package count:    %3d\n", old)
+	fmt.Printf("New package count:    %3d\n", replacements)
+}
+
+func write(notices []CopyrightNotice, bs []byte) int {
+	keys := make([]string, 0, len(notices))
+
+	noticeMap := make(map[string]CopyrightNotice, 0)
+
+	for _, n := range notices {
+		if n.Type != TypeKeep && n.Type != TypeNew {
+			continue
+		}
+		if n.Type == TypeNew {
+			fmt.Printf("Adding: %-40s %-55s %s\n", n.Name, n.URL, n.Copyright)
+		}
+		keys = append(keys, n.Name)
+		noticeMap[n.Name] = n
+	}
+
+	slices.Sort(keys)
+
+	indent := "          "
+	replacements := []string{}
+	for _, n := range notices {
+		if n.Type != TypeJS {
+			continue
+		}
+		replacements = append(replacements, indent+n.HTML)
+	}
+
+	for _, k := range keys {
+		n := noticeMap[k]
+		line := fmt.Sprintf("%s<li><a href=\"%s\">%s</a>, %s.</li>", indent, n.URL, n.Name, n.Copyright)
+		replacements = append(replacements, line)
+	}
+	replacement := strings.Join(replacements, "\n")
+
+	bs = copyrightRe.ReplaceAll(bs, []byte("id=\"copyright-notices\">\n"+replacement+"\n        </ul>"))
+	writeFile(htmlFile, string(bs))
+
+	return len(replacements)
+}
+
+func readAll(path string) []byte {
+	fd, err := os.Open(path)
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer fd.Close()
+
+	bs, err := io.ReadAll(fd)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	return bs
+}
+
+func writeFile(path string, data string) {
+	err := os.WriteFile(path, []byte(data), 0o644)
+	if err != nil {
+		log.Fatal(err)
+	}
+}
+
+func getModules() []string {
+	cmd := exec.Command("go", "mod", "graph")
+	output, err := cmd.Output()
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	seen := make(map[string]struct{})
+	scanner := bufio.NewScanner(bytes.NewReader(output))
+
+	for scanner.Scan() {
+		line := scanner.Text()
+		fields := strings.Fields(line)
+		if len(fields) == 0 {
+			continue
+		}
+
+		if !strings.HasPrefix(fields[0], "github.com/syncthing/syncthing") {
+			continue
+		}
+
+		// Get left-hand side of dependency pair (before '@')
+		mod := strings.SplitN(fields[1], "@", 2)[0]
+
+		// Keep only first 3 path components
+		parts := strings.Split(mod, "/")
+		if len(parts) == 1 {
+			continue
+		}
+		short := strings.Join(parts[:min(len(parts), 3)], "/")
+
+		if strings.HasPrefix(short, "golang.org/x") ||
+			strings.HasPrefix(short, "github.com/prometheus") ||
+			short == "go" {
+
+			continue
+		}
+
+		seen[short] = struct{}{}
+	}
+
+	adds := make([]string, 0)
+	for k := range seen {
+		adds = append(adds, k)
+	}
+
+	slices.Sort(adds)
+
+	return adds
+}
+
+func parseCopyrightNotices(input string) []CopyrightNotice {
+	doc, err := html.Parse(strings.NewReader("<ul>" + input + "</ul>"))
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	var notices []CopyrightNotice
+
+	typ := TypeJS
+
+	var f func(*html.Node)
+	f = func(n *html.Node) {
+		if n.Type == html.ElementNode && n.Data == "li" {
+			var notice CopyrightNotice
+			var aFound bool
+
+			for c := n.FirstChild; c != nil; c = c.NextSibling {
+				if c.Type == html.ElementNode && c.Data == "a" {
+					aFound = true
+					for _, attr := range c.Attr {
+						if attr.Key == "href" {
+							notice.URL = attr.Val
+						}
+					}
+					if c.FirstChild != nil && c.FirstChild.Type == html.TextNode {
+						notice.Name = strings.TrimSpace(c.FirstChild.Data)
+					}
+				} else if c.Type == html.TextNode && aFound {
+					// Anything after <a> is considered the copyright
+					notice.Copyright = strings.TrimSpace(html.UnescapeString(c.Data))
+					notice.Copyright = strings.Trim(notice.Copyright, "., ")
+				}
+				if typ == TypeJS && strings.Contains(notice.URL, "AudriusButkevicius") {
+					typ = TypeKeep
+				}
+				notice.Type = typ
+				var buf strings.Builder
+				_ = html.Render(&buf, n)
+				notice.HTML = buf.String()
+			}
+
+			notice.Copyright = strings.ReplaceAll(notice.Copyright, "©", "&copy;")
+			notice.HTML = strings.ReplaceAll(notice.HTML, "©", "&copy;")
+			notices = append(notices, notice)
+		}
+		for c := n.FirstChild; c != nil; c = c.NextSibling {
+			f(c)
+		}
+	}
+
+	f(doc)
+
+	return notices
+}
+
+func parseGitHubURL(u string) (string, string) {
+	parsed, err := url.Parse(u)
+	if err != nil {
+		log.Fatal(err)
+	}
+	parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
+	if len(parts) < 2 {
+		log.Fatal(fmt.Errorf("invalid GitHub URL: %q", parsed.Path))
+	}
+
+	return parts[0], parts[1]
+}
+
+func getLicenseText(owner, repo string) string {
+	url := fmt.Sprintf("https://api.github.com/repos/%s/%s/license", owner, repo)
+	req, _ := http.NewRequest("GET", url, nil)
+	req.Header.Set("Accept", "application/vnd.github.v3+json")
+
+	if token := os.Getenv("GITHUB_TOKEN"); token != "" {
+		req.Header.Set("Authorization", "Bearer "+token)
+	}
+
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer resp.Body.Close()
+
+	var result struct {
+		Content  string `json:"content"`
+		Encoding string `json:"encoding"`
+	}
+	body, _ := io.ReadAll(resp.Body)
+	err = json.Unmarshal(body, &result)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	if result.Encoding != "base64" {
+		log.Fatal(fmt.Sprintf("unexpected encoding: %s", result.Encoding))
+	}
+
+	decoded, err := base64.StdEncoding.DecodeString(result.Content)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	return string(decoded)
+}
+
+func extractCopyrights(license string, notice CopyrightNotice) []string {
+	lines := strings.Split(license, "\n")
+
+	re := regexp.MustCompile(`(?i)^\s*(copyright\s*(?:©|\(c\)|&copy;|19|20).*)$`)
+
+	copyrights := []string{}
+
+	for _, line := range lines {
+		if matches := re.FindStringSubmatch(strings.TrimSpace(line)); len(matches) == 2 {
+			copyright := strings.TrimSpace(matches[1])
+			re := regexp.MustCompile(`(?i)all rights reserved`)
+			copyright = re.ReplaceAllString(copyright, "")
+			copyright = strings.ReplaceAll(copyright, "©", "&copy;")
+			copyright = strings.ReplaceAll(copyright, "(C)", "&copy;")
+			copyright = strings.ReplaceAll(copyright, "(c)", "&copy;")
+			copyright = strings.Trim(copyright, "., ")
+			copyrights = append(copyrights, copyright)
+		}
+	}
+
+	if len(copyrights) > 0 {
+		return copyrights
+	}
+
+	return []string{}
+}
+
+func defaultCopyright(n CopyrightNotice) string {
+	year := time.Now().Format("2006")
+
+	return fmt.Sprintf("Copyright &copy; %v, the %s authors", year, n.Name)
+}
+
+func writeNotices(path string, notices []CopyrightNotice) {
+	s := ""
+	for i, n := range notices {
+		s += "#        : " + strconv.Itoa(i) + "\n" + n.String()
+	}
+	writeFile(path, s)
+}
+
+func (n CopyrightNotice) String() string {
+	return fmt.Sprintf("Type     : %v\nHTML     : %v\nName     : %v\nModule   : %v\nURL      : %v\nCopyright: %v\nRepoURL  : %v\nRepoCopys: %v\n\n",
+		n.Type, n.HTML, n.Name, n.Module, n.URL, n.Copyright, n.RepoURL, strings.Join(n.RepoCopyrights, ","))
+}
+
+func (t Type) String() string {
+	switch t {
+	case TypeJS:
+		return "TypeJS"
+	case TypeKeep:
+		return "TypeKeep"
+	case TypeToss:
+		return "TypeToss"
+	case TypeNew:
+		return "TypeNew"
+	default:
+		return "unknown"
+	}
+}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů