mirror of
https://github.com/syncthing/syncthing.git
synced 2026-01-04 03:49:12 -05:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
683b48182c | ||
|
|
795aed306c | ||
|
|
cdefa535ed | ||
|
|
91084b83b4 | ||
|
|
5360e7153b | ||
|
|
5d0ca19350 | ||
|
|
e8d3529fed | ||
|
|
935a28c961 | ||
|
|
d21a2de055 | ||
|
|
6f1023665c | ||
|
|
c53a1f210c | ||
|
|
b71a930bfc | ||
|
|
a2cbc62521 | ||
|
|
768fd6bff8 | ||
|
|
48883e0e32 | ||
|
|
30fe2cf514 | ||
|
|
3850a08252 | ||
|
|
d0e407f3c3 | ||
|
|
16db6fcf3d | ||
|
|
4c5528bd0e | ||
|
|
d42fff1016 | ||
|
|
a28de73031 | ||
|
|
75310b58a0 | ||
|
|
8064957270 | ||
|
|
c1ec9a8826 | ||
|
|
1625b44892 | ||
|
|
d51760f410 | ||
|
|
7b1932d64e | ||
|
|
5bfc540c88 | ||
|
|
4cba99fcd4 | ||
|
|
2ae15aa454 | ||
|
|
47bcf4f8f4 | ||
|
|
a8b9096353 | ||
|
|
5328380691 |
7
.github/workflows/build-infra-dockers.yaml
vendored
7
.github/workflows/build-infra-dockers.yaml
vendored
@@ -6,7 +6,7 @@ on:
|
||||
- infrastructure
|
||||
|
||||
env:
|
||||
GO_VERSION: "~1.21.1"
|
||||
GO_VERSION: "~1.21.5"
|
||||
CGO_ENABLED: "0"
|
||||
BUILD_USER: docker
|
||||
BUILD_HOST: github.syncthing.net
|
||||
@@ -28,6 +28,11 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
check-latest: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
|
||||
41
.github/workflows/build-syncthing.yaml
vendored
41
.github/workflows/build-syncthing.yaml
vendored
@@ -12,7 +12,7 @@ env:
|
||||
# The go version to use for builds. We set check-latest to true when
|
||||
# installing, so we get the latest patch version that matches the
|
||||
# expression.
|
||||
GO_VERSION: "~1.21.1"
|
||||
GO_VERSION: "~1.21.5"
|
||||
|
||||
# Optimize compatibility on the slow archictures.
|
||||
GO386: softfloat
|
||||
@@ -48,20 +48,7 @@ jobs:
|
||||
runner: ["windows-latest", "ubuntu-latest", "macos-latest"]
|
||||
# The oldest version in this list should match what we have in our go.mod.
|
||||
# Variables don't seem to be supported here, or we could have done something nice.
|
||||
go: ["1.20", "1.21"]
|
||||
|
||||
# Don't run the Windows tests with Go 1.21.4 or 1.20.11; this can be
|
||||
# removed when 1.21.5 and 1.20.12 is released.
|
||||
exclude:
|
||||
- runner: windows-latest
|
||||
go: "1.20"
|
||||
- runner: windows-latest
|
||||
go: "1.21"
|
||||
include:
|
||||
- runner: windows-latest
|
||||
go: "~1.20.12 || ~1.20.0 <1.20.11"
|
||||
- runner: windows-latest
|
||||
go: "~1.21.5 || ~1.21.1 <1.21.4"
|
||||
go: ["~1.20.12", "~1.21.5"]
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Set git to use LF
|
||||
@@ -76,7 +63,7 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
cache: true
|
||||
@@ -111,7 +98,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: false
|
||||
@@ -167,11 +154,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
# Temporary version constraint to avoid 1.21.4 which has a bug in
|
||||
# path handling. This can be removed when 1.21.5 is released.
|
||||
go-version: "~1.21.5 || ~1.21.1 <1.21.4"
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: false
|
||||
check-latest: true
|
||||
|
||||
@@ -222,7 +207,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: false
|
||||
@@ -269,7 +254,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: false
|
||||
@@ -396,7 +381,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: false
|
||||
@@ -463,7 +448,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: false
|
||||
@@ -575,7 +560,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: false
|
||||
@@ -747,7 +732,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: false
|
||||
@@ -832,7 +817,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: false
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.ACTIONS_GITHUB_TOKEN }}
|
||||
- uses: actions/setup-go@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ^1.19.6
|
||||
- run: |
|
||||
|
||||
5
AUTHORS
5
AUTHORS
@@ -81,6 +81,7 @@ Chris Tonkinson <chris@masterbran.ch>
|
||||
Christian Kujau <ckujau@users.noreply.github.com>
|
||||
Christian Prescott <me@christianprescott.com>
|
||||
chucic <chucic@seznam.cz>
|
||||
cjc7373 <niuchangcun@gmail.com>
|
||||
Colin Kennedy (moshen) <moshen.colin@gmail.com>
|
||||
Cromefire_ <tim.l@nghorst.net> <26320625+cromefire@users.noreply.github.com>
|
||||
cui fliter <imcusg@gmail.com>
|
||||
@@ -136,6 +137,7 @@ Graham Miln (grahammiln) <graham.miln@dssw.co.uk> <graham.miln@miln.eu>
|
||||
greatroar <61184462+greatroar@users.noreply.github.com>
|
||||
Greg <gco@jazzhaiku.com>
|
||||
guangwu <guoguangwu@magic-shield.com>
|
||||
gudvinr <gudvinr@gmail.com>
|
||||
Han Boetes <han@boetes.org>
|
||||
HansK-p <42314815+HansK-p@users.noreply.github.com>
|
||||
Harrison Jones (harrisonhjones) <harrisonhjones@users.noreply.github.com>
|
||||
@@ -224,7 +226,7 @@ Max <github@germancoding.com>
|
||||
Max Schulze (kralo) <max.schulze@online.de> <kralo@users.noreply.github.com>
|
||||
MaximAL <almaximal@ya.ru>
|
||||
Maxime Thirouin <m@moox.io>
|
||||
Maximilian <maxi.rostock@outlook.de>
|
||||
Maximilian <maxi.rostock@outlook.de> <public@complexvector.space>
|
||||
mclang <1721600+mclang@users.noreply.github.com>
|
||||
Michael Jephcote (Rewt0r) <rewt0r@gmx.com> <Rewt0r@users.noreply.github.com>
|
||||
Michael Ploujnikov (plouj) <ploujj@gmail.com>
|
||||
@@ -289,6 +291,7 @@ Sacheendra Talluri (sacheendra) <sacheendra.t@gmail.com>
|
||||
Scott Klupfel (kluppy) <kluppy@going2blue.com>
|
||||
sec65 <106604020+sec65@users.noreply.github.com>
|
||||
Sergey Mishin (ralder) <ralder@yandex.ru>
|
||||
Sertonix <83883937+Sertonix@users.noreply.github.com>
|
||||
Shaarad Dalvi <60266155+shaaraddalvi@users.noreply.github.com> <shdalv@microsoft.com>
|
||||
Simon Frei (imsodin) <freisim93@gmail.com>
|
||||
Simon Mwepu <simonmwepu@gmail.com>
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -40,7 +41,7 @@ type currentFile struct {
|
||||
}
|
||||
|
||||
func (d *diskStore) Serve(ctx context.Context) {
|
||||
if err := os.MkdirAll(d.dir, 0750); err != nil {
|
||||
if err := os.MkdirAll(d.dir, 0o700); err != nil {
|
||||
log.Println("Creating directory:", err)
|
||||
return
|
||||
}
|
||||
@@ -60,7 +61,7 @@ func (d *diskStore) Serve(ctx context.Context) {
|
||||
case entry := <-d.inbox:
|
||||
path := d.fullPath(entry.path)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
log.Println("Creating directory:", err)
|
||||
continue
|
||||
}
|
||||
@@ -75,7 +76,7 @@ func (d *diskStore) Serve(ctx context.Context) {
|
||||
log.Println("Failed to compress crash report:", err)
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
|
||||
if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil {
|
||||
log.Printf("Failed to write %s: %v", entry.path, err)
|
||||
_ = os.Remove(path)
|
||||
continue
|
||||
@@ -147,6 +148,11 @@ func (d *diskStore) clean() {
|
||||
if len(d.currentFiles) > 0 {
|
||||
oldest = time.Since(time.Unix(d.currentFiles[0].mtime, 0)).Truncate(time.Minute)
|
||||
}
|
||||
|
||||
metricDiskstoreFilesTotal.Set(float64(len(d.currentFiles)))
|
||||
metricDiskstoreBytesTotal.Set(float64(d.currentSize))
|
||||
metricDiskstoreOldestAgeSeconds.Set(math.Round(oldest.Seconds()))
|
||||
|
||||
log.Printf("Clean complete: %d files, %d MB, oldest is %v ago", len(d.currentFiles), d.currentSize>>20, oldest)
|
||||
}
|
||||
|
||||
@@ -178,6 +184,11 @@ func (d *diskStore) inventory() error {
|
||||
if len(d.currentFiles) > 0 {
|
||||
oldest = time.Since(time.Unix(d.currentFiles[0].mtime, 0)).Truncate(time.Minute)
|
||||
}
|
||||
|
||||
metricDiskstoreFilesTotal.Set(float64(len(d.currentFiles)))
|
||||
metricDiskstoreBytesTotal.Set(float64(d.currentSize))
|
||||
metricDiskstoreOldestAgeSeconds.Set(math.Round(oldest.Seconds()))
|
||||
|
||||
log.Printf("Inventory complete: %d files, %d MB, oldest is %v ago", len(d.currentFiles), d.currentSize>>20, oldest)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/syncthing/syncthing/lib/sha256"
|
||||
"github.com/syncthing/syncthing/lib/ur"
|
||||
|
||||
@@ -33,14 +33,13 @@ import (
|
||||
const maxRequestSize = 1 << 20 // 1 MiB
|
||||
|
||||
type cli struct {
|
||||
Dir string `help:"Parent directory to store crash and failure reports in" env:"REPORTS_DIR" default:"."`
|
||||
DSN string `help:"Sentry DSN" env:"SENTRY_DSN"`
|
||||
Listen string `help:"HTTP listen address" default:":8080" env:"LISTEN_ADDRESS"`
|
||||
MaxDiskFiles int `help:"Maximum number of reports on disk" default:"100000" env:"MAX_DISK_FILES"`
|
||||
MaxDiskSizeMB int64 `help:"Maximum disk space to use for reports" default:"1024" env:"MAX_DISK_SIZE_MB"`
|
||||
CleanInterval time.Duration `help:"Interval between cleaning up old reports" default:"12h" env:"CLEAN_INTERVAL"`
|
||||
SentryQueue int `help:"Maximum number of reports to queue for sending to Sentry" default:"64" env:"SENTRY_QUEUE"`
|
||||
DiskQueue int `help:"Maximum number of reports to queue for writing to disk" default:"64" env:"DISK_QUEUE"`
|
||||
Dir string `help:"Parent directory to store crash and failure reports in" env:"REPORTS_DIR" default:"."`
|
||||
DSN string `help:"Sentry DSN" env:"SENTRY_DSN"`
|
||||
Listen string `help:"HTTP listen address" default:":8080" env:"LISTEN_ADDRESS"`
|
||||
MaxDiskFiles int `help:"Maximum number of reports on disk" default:"100000" env:"MAX_DISK_FILES"`
|
||||
MaxDiskSizeMB int64 `help:"Maximum disk space to use for reports" default:"1024" env:"MAX_DISK_SIZE_MB"`
|
||||
SentryQueue int `help:"Maximum number of reports to queue for sending to Sentry" default:"64" env:"SENTRY_QUEUE"`
|
||||
DiskQueue int `help:"Maximum number of reports to queue for writing to disk" default:"64" env:"DISK_QUEUE"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -72,6 +71,7 @@ func main() {
|
||||
mux.HandleFunc("/ping", func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
if params.DSN != "" {
|
||||
mux.HandleFunc("/newcrash/failure", handleFailureFn(params.DSN, filepath.Join(params.Dir, "failure_reports")))
|
||||
@@ -85,6 +85,11 @@ func main() {
|
||||
|
||||
func handleFailureFn(dsn, failureDir string) func(w http.ResponseWriter, req *http.Request) {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
result := "failure"
|
||||
defer func() {
|
||||
metricFailureReportsTotal.WithLabelValues(result).Inc()
|
||||
}()
|
||||
|
||||
lr := io.LimitReader(req.Body, maxRequestSize)
|
||||
bs, err := io.ReadAll(lr)
|
||||
req.Body.Close()
|
||||
@@ -135,6 +140,7 @@ func handleFailureFn(dsn, failureDir string) func(w http.ResponseWriter, req *ht
|
||||
log.Println("Failed to send failure report:", err)
|
||||
} else {
|
||||
log.Println("Sent failure report:", r.Description)
|
||||
result = "success"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
40
cmd/stcrashreceiver/metrics.go
Normal file
40
cmd/stcrashreceiver/metrics.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (C) 2023 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/.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var (
|
||||
metricCrashReportsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "syncthing",
|
||||
Subsystem: "crashreceiver",
|
||||
Name: "crash_reports_total",
|
||||
}, []string{"result"})
|
||||
metricFailureReportsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "syncthing",
|
||||
Subsystem: "crashreceiver",
|
||||
Name: "failure_reports_total",
|
||||
}, []string{"result"})
|
||||
metricDiskstoreFilesTotal = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: "syncthing",
|
||||
Subsystem: "crashreceiver",
|
||||
Name: "diskstore_files_total",
|
||||
})
|
||||
metricDiskstoreBytesTotal = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: "syncthing",
|
||||
Subsystem: "crashreceiver",
|
||||
Name: "diskstore_bytes_total",
|
||||
})
|
||||
metricDiskstoreOldestAgeSeconds = promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Namespace: "syncthing",
|
||||
Subsystem: "crashreceiver",
|
||||
Name: "diskstore_oldest_age_seconds",
|
||||
})
|
||||
)
|
||||
@@ -71,6 +71,11 @@ func (r *crashReceiver) serveHead(reportID string, w http.ResponseWriter, _ *htt
|
||||
|
||||
// servePut accepts and stores the given report.
|
||||
func (r *crashReceiver) servePut(reportID string, w http.ResponseWriter, req *http.Request) {
|
||||
result := "receive_failure"
|
||||
defer func() {
|
||||
metricCrashReportsTotal.WithLabelValues(result).Inc()
|
||||
}()
|
||||
|
||||
// Read at most maxRequestSize of report data.
|
||||
log.Println("Receiving report", reportID)
|
||||
lr := io.LimitReader(req.Body, maxRequestSize)
|
||||
@@ -81,13 +86,17 @@ func (r *crashReceiver) servePut(reportID string, w http.ResponseWriter, req *ht
|
||||
return
|
||||
}
|
||||
|
||||
result = "success"
|
||||
|
||||
// Store the report
|
||||
if !r.store.Put(reportID, bs) {
|
||||
log.Println("Failed to store report (queue full):", reportID)
|
||||
result = "queue_failure"
|
||||
}
|
||||
|
||||
// Send the report to Sentry
|
||||
if !r.sentry.Send(reportID, userIDFor(req), bs) {
|
||||
log.Println("Failed to send report to sentry (queue full):", reportID)
|
||||
result = "sentry_failure"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,15 @@ func main() {
|
||||
cfg.Options.NATTimeoutS = natTimeout
|
||||
})
|
||||
natSvc := nat.NewService(id, wrapper)
|
||||
mapping := mapping{natSvc.NewMapping(nat.TCP, addr.IP, addr.Port)}
|
||||
var ipVersion nat.IPVersion
|
||||
if strings.HasSuffix(proto, "4") {
|
||||
ipVersion = nat.IPv4Only
|
||||
} else if strings.HasSuffix(proto, "6") {
|
||||
ipVersion = nat.IPv6Only
|
||||
} else {
|
||||
ipVersion = nat.IPvAny
|
||||
}
|
||||
mapping := mapping{natSvc.NewMapping(nat.TCP, ipVersion, addr.IP, addr.Port)}
|
||||
|
||||
if natEnabled {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"reflect"
|
||||
|
||||
"github.com/AudriusButkevicius/recli"
|
||||
"github.com/alecthomas/kong"
|
||||
"github.com/syncthing/syncthing/lib/config"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
@@ -23,9 +24,20 @@ type configHandler struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func getConfigCommand(f *apiClientFactory) (cli.Command, error) {
|
||||
type configCommand struct {
|
||||
Args []string `arg:"" default:"-h"`
|
||||
}
|
||||
|
||||
func (c *configCommand) Run(ctx Context, _ *kong.Context) error {
|
||||
app := cli.NewApp()
|
||||
app.Name = "syncthing"
|
||||
app.Author = "The Syncthing Authors"
|
||||
app.Metadata = map[string]interface{}{
|
||||
"clientFactory": ctx.clientFactory,
|
||||
}
|
||||
|
||||
h := new(configHandler)
|
||||
h.client, h.err = f.getClient()
|
||||
h.client, h.err = ctx.clientFactory.getClient()
|
||||
if h.err == nil {
|
||||
h.cfg, h.err = getConfig(h.client)
|
||||
}
|
||||
@@ -38,17 +50,15 @@ func getConfigCommand(f *apiClientFactory) (cli.Command, error) {
|
||||
|
||||
commands, err := recli.New(recliCfg).Construct(&h.cfg)
|
||||
if err != nil {
|
||||
return cli.Command{}, fmt.Errorf("config reflect: %w", err)
|
||||
return fmt.Errorf("config reflect: %w", err)
|
||||
}
|
||||
|
||||
return cli.Command{
|
||||
Name: "config",
|
||||
HideHelp: true,
|
||||
Usage: "Configuration modification command group",
|
||||
Subcommands: commands,
|
||||
Before: h.configBefore,
|
||||
After: h.configAfter,
|
||||
}, nil
|
||||
app.Commands = commands
|
||||
app.HideHelp = true
|
||||
app.Before = h.configBefore
|
||||
app.After = h.configAfter
|
||||
|
||||
return app.Run(append([]string{app.Name}, c.Args...))
|
||||
}
|
||||
|
||||
func (h *configHandler) configBefore(c *cli.Context) error {
|
||||
|
||||
@@ -9,47 +9,37 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var debugCommand = cli.Command{
|
||||
Name: "debug",
|
||||
HideHelp: true,
|
||||
Usage: "Debug command group",
|
||||
Subcommands: []cli.Command{
|
||||
{
|
||||
Name: "file",
|
||||
Usage: "Show information about a file (or directory/symlink)",
|
||||
ArgsUsage: "FOLDER-ID PATH",
|
||||
Action: expects(2, debugFile()),
|
||||
},
|
||||
indexCommand,
|
||||
{
|
||||
Name: "profile",
|
||||
Usage: "Save a profile to help figuring out what Syncthing does.",
|
||||
ArgsUsage: "cpu | heap",
|
||||
Action: expects(1, profile()),
|
||||
},
|
||||
},
|
||||
type fileCommand struct {
|
||||
FolderID string `arg:""`
|
||||
Path string `arg:""`
|
||||
}
|
||||
|
||||
func debugFile() cli.ActionFunc {
|
||||
return func(c *cli.Context) error {
|
||||
query := make(url.Values)
|
||||
query.Set("folder", c.Args()[0])
|
||||
query.Set("file", normalizePath(c.Args()[1]))
|
||||
return indexDumpOutput("debug/file?" + query.Encode())(c)
|
||||
func (f *fileCommand) Run(ctx Context) error {
|
||||
indexDumpOutput := indexDumpOutputWrapper(ctx.clientFactory)
|
||||
|
||||
query := make(url.Values)
|
||||
query.Set("folder", f.FolderID)
|
||||
query.Set("file", normalizePath(f.Path))
|
||||
return indexDumpOutput("debug/file?" + query.Encode())
|
||||
}
|
||||
|
||||
type profileCommand struct {
|
||||
Type string `arg:"" help:"cpu | heap"`
|
||||
}
|
||||
|
||||
func (p *profileCommand) Run(ctx Context) error {
|
||||
switch t := p.Type; t {
|
||||
case "cpu", "heap":
|
||||
return saveToFile(fmt.Sprintf("debug/%vprof", p.Type), ctx.clientFactory)
|
||||
default:
|
||||
return fmt.Errorf("expected cpu or heap as argument, got %v", t)
|
||||
}
|
||||
}
|
||||
|
||||
func profile() cli.ActionFunc {
|
||||
return func(c *cli.Context) error {
|
||||
switch t := c.Args()[0]; t {
|
||||
case "cpu", "heap":
|
||||
return saveToFile(fmt.Sprintf("debug/%vprof", c.Args()[0]))(c)
|
||||
default:
|
||||
return fmt.Errorf("expected cpu or heap as argument, got %v", t)
|
||||
}
|
||||
}
|
||||
type debugCommand struct {
|
||||
File fileCommand `cmd:"" help:"Show information about a file (or directory/symlink)"`
|
||||
Profile profileCommand `cmd:"" help:"Save a profile to help figuring out what Syncthing does"`
|
||||
Index indexCommand `cmd:"" help:"Show information about the index (database)"`
|
||||
}
|
||||
|
||||
@@ -11,36 +11,25 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
"github.com/alecthomas/kong"
|
||||
)
|
||||
|
||||
var errorsCommand = cli.Command{
|
||||
Name: "errors",
|
||||
HideHelp: true,
|
||||
Usage: "Error command group",
|
||||
Subcommands: []cli.Command{
|
||||
{
|
||||
Name: "show",
|
||||
Usage: "Show pending errors",
|
||||
Action: expects(0, indexDumpOutput("system/error")),
|
||||
},
|
||||
{
|
||||
Name: "push",
|
||||
Usage: "Push an error to active clients",
|
||||
ArgsUsage: "ERROR-MESSAGE",
|
||||
Action: expects(1, errorsPush),
|
||||
},
|
||||
{
|
||||
Name: "clear",
|
||||
Usage: "Clear pending errors",
|
||||
Action: expects(0, emptyPost("system/error/clear")),
|
||||
},
|
||||
},
|
||||
type errorsCommand struct {
|
||||
Show struct{} `cmd:"" help:"Show pending errors"`
|
||||
Push errorsPushCommand `cmd:"" help:"Push an error to active clients"`
|
||||
Clear struct{} `cmd:"" help:"Clear pending errors"`
|
||||
}
|
||||
|
||||
func errorsPush(c *cli.Context) error {
|
||||
client := c.App.Metadata["client"].(APIClient)
|
||||
errStr := strings.Join(c.Args(), " ")
|
||||
type errorsPushCommand struct {
|
||||
ErrorMessage string `arg:""`
|
||||
}
|
||||
|
||||
func (e *errorsPushCommand) Run(ctx Context) error {
|
||||
client, err := ctx.clientFactory.getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
errStr := e.ErrorMessage
|
||||
response, err := client.Post("system/error", strings.TrimSpace(errStr))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -59,3 +48,13 @@ func errorsPush(c *cli.Context) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*errorsCommand) Run(ctx Context, kongCtx *kong.Context) error {
|
||||
switch kongCtx.Selected().Name {
|
||||
case "show":
|
||||
return indexDumpOutput("system/error", ctx.clientFactory)
|
||||
case "clear":
|
||||
return emptyPost("system/error/clear", ctx.clientFactory)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,32 +7,26 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli"
|
||||
"github.com/alecthomas/kong"
|
||||
)
|
||||
|
||||
var indexCommand = cli.Command{
|
||||
Name: "index",
|
||||
Usage: "Show information about the index (database)",
|
||||
Subcommands: []cli.Command{
|
||||
{
|
||||
Name: "dump",
|
||||
Usage: "Print the entire db",
|
||||
Action: expects(0, indexDump),
|
||||
},
|
||||
{
|
||||
Name: "dump-size",
|
||||
Usage: "Print the db size of different categories of information",
|
||||
Action: expects(0, indexDumpSize),
|
||||
},
|
||||
{
|
||||
Name: "check",
|
||||
Usage: "Check the database for inconsistencies",
|
||||
Action: expects(0, indexCheck),
|
||||
},
|
||||
{
|
||||
Name: "account",
|
||||
Usage: "Print key and value size statistics per key type",
|
||||
Action: expects(0, indexAccount),
|
||||
},
|
||||
},
|
||||
type indexCommand struct {
|
||||
Dump struct{} `cmd:"" help:"Print the entire db"`
|
||||
DumpSize struct{} `cmd:"" help:"Print the db size of different categories of information"`
|
||||
Check struct{} `cmd:"" help:"Check the database for inconsistencies"`
|
||||
Account struct{} `cmd:"" help:"Print key and value size statistics per key type"`
|
||||
}
|
||||
|
||||
func (*indexCommand) Run(kongCtx *kong.Context) error {
|
||||
switch kongCtx.Selected().Name {
|
||||
case "dump":
|
||||
return indexDump()
|
||||
case "dump-size":
|
||||
return indexDumpSize()
|
||||
case "check":
|
||||
return indexCheck()
|
||||
case "account":
|
||||
return indexAccount()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,12 +10,10 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
// indexAccount prints key and data size statistics per class
|
||||
func indexAccount(*cli.Context) error {
|
||||
func indexAccount() error {
|
||||
ldb, err := getDB()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -11,13 +11,11 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
|
||||
"github.com/syncthing/syncthing/lib/db"
|
||||
"github.com/syncthing/syncthing/lib/protocol"
|
||||
)
|
||||
|
||||
func indexDump(*cli.Context) error {
|
||||
func indexDump() error {
|
||||
ldb, err := getDB()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -11,12 +11,10 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
|
||||
"github.com/syncthing/syncthing/lib/db"
|
||||
)
|
||||
|
||||
func indexDumpSize(*cli.Context) error {
|
||||
func indexDumpSize() error {
|
||||
type sizedElement struct {
|
||||
key string
|
||||
size int
|
||||
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
|
||||
"github.com/syncthing/syncthing/lib/db"
|
||||
"github.com/syncthing/syncthing/lib/protocol"
|
||||
)
|
||||
@@ -35,7 +33,7 @@ type sequenceKey struct {
|
||||
sequence uint64
|
||||
}
|
||||
|
||||
func indexCheck(*cli.Context) (err error) {
|
||||
func indexCheck() (err error) {
|
||||
ldb, err := getDB()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -8,165 +8,88 @@ package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
"github.com/flynn-archive/go-shlex"
|
||||
"github.com/urfave/cli"
|
||||
|
||||
"github.com/syncthing/syncthing/cmd/syncthing/cmdutil"
|
||||
"github.com/syncthing/syncthing/lib/config"
|
||||
)
|
||||
|
||||
type preCli struct {
|
||||
type CLI struct {
|
||||
cmdutil.CommonOptions
|
||||
DataDir string `name:"data" placeholder:"PATH" env:"STDATADIR" help:"Set data directory (database and logs)"`
|
||||
GUIAddress string `name:"gui-address"`
|
||||
GUIAPIKey string `name:"gui-apikey"`
|
||||
HomeDir string `name:"home"`
|
||||
ConfDir string `name:"config"`
|
||||
DataDir string `name:"data"`
|
||||
|
||||
Show showCommand `cmd:"" help:"Show command group"`
|
||||
Debug debugCommand `cmd:"" help:"Debug command group"`
|
||||
Operations operationCommand `cmd:"" help:"Operation command group"`
|
||||
Errors errorsCommand `cmd:"" help:"Error command group"`
|
||||
Config configCommand `cmd:"" help:"Configuration modification command group" passthrough:""`
|
||||
Stdin stdinCommand `cmd:"" name:"-" help:"Read commands from stdin"`
|
||||
}
|
||||
|
||||
func Run() error {
|
||||
// This is somewhat a hack around a chicken and egg problem. We need to set
|
||||
// the home directory and potentially other flags to know where the
|
||||
// syncthing instance is running in order to get it's config ... which we
|
||||
// then use to construct the actual CLI ... at which point it's too late to
|
||||
// add flags there...
|
||||
c := preCli{}
|
||||
parseFlags(&c)
|
||||
return runInternal(c, os.Args)
|
||||
type Context struct {
|
||||
clientFactory *apiClientFactory
|
||||
}
|
||||
|
||||
func RunWithArgs(cliArgs []string) error {
|
||||
c := preCli{}
|
||||
parseFlagsWithArgs(cliArgs, &c)
|
||||
return runInternal(c, cliArgs)
|
||||
}
|
||||
|
||||
func runInternal(c preCli, cliArgs []string) error {
|
||||
// Not set as default above because the strings can be really long.
|
||||
err := cmdutil.SetConfigDataLocationsFromFlags(c.HomeDir, c.ConfDir, c.DataDir)
|
||||
func (cli CLI) AfterApply(kongCtx *kong.Context) error {
|
||||
err := cmdutil.SetConfigDataLocationsFromFlags(cli.HomeDir, cli.ConfDir, cli.DataDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Command line options: %w", err)
|
||||
return fmt.Errorf("command line options: %w", err)
|
||||
}
|
||||
|
||||
clientFactory := &apiClientFactory{
|
||||
cfg: config.GUIConfiguration{
|
||||
RawAddress: c.GUIAddress,
|
||||
APIKey: c.GUIAPIKey,
|
||||
RawAddress: cli.GUIAddress,
|
||||
APIKey: cli.GUIAPIKey,
|
||||
},
|
||||
}
|
||||
|
||||
configCommand, err := getConfigCommand(clientFactory)
|
||||
if err != nil {
|
||||
return err
|
||||
context := Context{
|
||||
clientFactory: clientFactory,
|
||||
}
|
||||
|
||||
// Implement the same flags at the upper CLI, but do nothing with them.
|
||||
// This is so that the usage text is the same
|
||||
fakeFlags := []cli.Flag{
|
||||
cli.StringFlag{
|
||||
Name: "gui-address",
|
||||
Usage: "Override GUI address to `URL` (e.g. \"192.0.2.42:8443\")",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "gui-apikey",
|
||||
Usage: "Override GUI API key to `API-KEY`",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "home",
|
||||
Usage: "Set configuration and data directory to `PATH`",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "config",
|
||||
Usage: "Set configuration directory (config and keys) to `PATH`",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "data",
|
||||
Usage: "Set data directory (database and logs) to `PATH`",
|
||||
},
|
||||
}
|
||||
|
||||
// Construct the actual CLI
|
||||
app := cli.NewApp()
|
||||
app.Author = "The Syncthing Authors"
|
||||
app.Metadata = map[string]interface{}{
|
||||
"clientFactory": clientFactory,
|
||||
}
|
||||
app.Commands = []cli.Command{{
|
||||
Name: "cli",
|
||||
Usage: "Syncthing command line interface",
|
||||
Flags: fakeFlags,
|
||||
Subcommands: []cli.Command{
|
||||
configCommand,
|
||||
showCommand,
|
||||
operationCommand,
|
||||
errorsCommand,
|
||||
debugCommand,
|
||||
{
|
||||
Name: "-",
|
||||
HideHelp: true,
|
||||
Usage: "Read commands from stdin",
|
||||
Action: func(ctx *cli.Context) error {
|
||||
if ctx.NArg() > 0 {
|
||||
return errors.New("command does not expect any arguments")
|
||||
}
|
||||
|
||||
// Drop the `-` not to recurse into self.
|
||||
args := make([]string, len(cliArgs)-1)
|
||||
copy(args, cliArgs)
|
||||
|
||||
fmt.Println("Reading commands from stdin...", args)
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
for scanner.Scan() {
|
||||
input, err := shlex.Split(scanner.Text())
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing input: %w", err)
|
||||
}
|
||||
if len(input) == 0 {
|
||||
continue
|
||||
}
|
||||
err = app.Run(append(args, input...))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
return app.Run(cliArgs)
|
||||
kongCtx.Bind(context)
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseFlags(c *preCli) error {
|
||||
// kong only needs to parse the global arguments after "cli" and before the
|
||||
// subcommand (if any).
|
||||
if len(os.Args) <= 2 {
|
||||
return nil
|
||||
}
|
||||
return parseFlagsWithArgs(os.Args[2:], c)
|
||||
}
|
||||
type stdinCommand struct{}
|
||||
|
||||
func parseFlagsWithArgs(args []string, c *preCli) error {
|
||||
for i := 0; i < len(args); i++ {
|
||||
if !strings.HasPrefix(args[i], "--") {
|
||||
args = args[:i]
|
||||
break
|
||||
func (*stdinCommand) Run() error {
|
||||
// Drop the `-` not to recurse into self.
|
||||
args := make([]string, len(os.Args)-1)
|
||||
copy(args, os.Args)
|
||||
|
||||
fmt.Println("Reading commands from stdin...", args)
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
for scanner.Scan() {
|
||||
input, err := shlex.Split(scanner.Text())
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing input: %w", err)
|
||||
}
|
||||
if !strings.Contains(args[i], "=") {
|
||||
i++
|
||||
if len(input) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var cli CLI
|
||||
p, err := kong.New(&cli)
|
||||
if err != nil {
|
||||
// can't happen, really
|
||||
return fmt.Errorf("creating parser: %w", err)
|
||||
}
|
||||
ctx, err := p.Parse(input)
|
||||
if err != nil {
|
||||
fmt.Println("Error:", err)
|
||||
continue
|
||||
}
|
||||
if err := ctx.Run(); err != nil {
|
||||
fmt.Println("Error:", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// We don't want kong to print anything nor os.Exit (e.g. on -h)
|
||||
parser, err := kong.New(c, kong.Writers(io.Discard, io.Discard), kong.Exit(func(int) {}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = parser.Parse(args)
|
||||
return err
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
@@ -12,48 +12,43 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
"github.com/syncthing/syncthing/lib/config"
|
||||
"github.com/syncthing/syncthing/lib/fs"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
var operationCommand = cli.Command{
|
||||
Name: "operations",
|
||||
HideHelp: true,
|
||||
Usage: "Operation command group",
|
||||
Subcommands: []cli.Command{
|
||||
{
|
||||
Name: "restart",
|
||||
Usage: "Restart syncthing",
|
||||
Action: expects(0, emptyPost("system/restart")),
|
||||
},
|
||||
{
|
||||
Name: "shutdown",
|
||||
Usage: "Shutdown syncthing",
|
||||
Action: expects(0, emptyPost("system/shutdown")),
|
||||
},
|
||||
{
|
||||
Name: "upgrade",
|
||||
Usage: "Upgrade syncthing (if a newer version is available)",
|
||||
Action: expects(0, emptyPost("system/upgrade")),
|
||||
},
|
||||
{
|
||||
Name: "folder-override",
|
||||
Usage: "Override changes on folder (remote for sendonly, local for receiveonly). WARNING: Destructive - deletes/changes your data.",
|
||||
ArgsUsage: "FOLDER-ID",
|
||||
Action: expects(1, foldersOverride),
|
||||
},
|
||||
{
|
||||
Name: "default-ignores",
|
||||
Usage: "Set the default ignores (config) from a file",
|
||||
ArgsUsage: "PATH",
|
||||
Action: expects(1, setDefaultIgnores),
|
||||
},
|
||||
},
|
||||
type folderOverrideCommand struct {
|
||||
FolderID string `arg:""`
|
||||
}
|
||||
|
||||
func foldersOverride(c *cli.Context) error {
|
||||
client, err := getClientFactory(c).getClient()
|
||||
type defaultIgnoresCommand struct {
|
||||
Path string `arg:""`
|
||||
}
|
||||
|
||||
type operationCommand struct {
|
||||
Restart struct{} `cmd:"" help:"Restart syncthing"`
|
||||
Shutdown struct{} `cmd:"" help:"Shutdown syncthing"`
|
||||
Upgrade struct{} `cmd:"" help:"Upgrade syncthing (if a newer version is available)"`
|
||||
FolderOverride folderOverrideCommand `cmd:"" help:"Override changes on folder (remote for sendonly, local for receiveonly). WARNING: Destructive - deletes/changes your data"`
|
||||
DefaultIgnores defaultIgnoresCommand `cmd:"" help:"Set the default ignores (config) from a file"`
|
||||
}
|
||||
|
||||
func (*operationCommand) Run(ctx Context, kongCtx *kong.Context) error {
|
||||
f := ctx.clientFactory
|
||||
|
||||
switch kongCtx.Selected().Name {
|
||||
case "restart":
|
||||
return emptyPost("system/restart", f)
|
||||
case "shutdown":
|
||||
return emptyPost("system/shutdown", f)
|
||||
case "upgrade":
|
||||
return emptyPost("system/upgrade", f)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *folderOverrideCommand) Run(ctx Context) error {
|
||||
client, err := ctx.clientFactory.getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -61,7 +56,7 @@ func foldersOverride(c *cli.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rid := c.Args()[0]
|
||||
rid := f.FolderID
|
||||
for _, folder := range cfg.Folders {
|
||||
if folder.ID == rid {
|
||||
response, err := client.Post("db/override", "")
|
||||
@@ -86,12 +81,12 @@ func foldersOverride(c *cli.Context) error {
|
||||
return fmt.Errorf("Folder %q not found", rid)
|
||||
}
|
||||
|
||||
func setDefaultIgnores(c *cli.Context) error {
|
||||
client, err := getClientFactory(c).getClient()
|
||||
func (d *defaultIgnoresCommand) Run(ctx Context) error {
|
||||
client, err := ctx.clientFactory.getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir, file := filepath.Split(c.Args()[0])
|
||||
dir, file := filepath.Split(d.Path)
|
||||
filesystem := fs.NewFilesystem(fs.FilesystemTypeBasic, dir)
|
||||
|
||||
fd, err := filesystem.Open(file)
|
||||
|
||||
@@ -9,37 +9,30 @@ package cli
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/urfave/cli"
|
||||
"github.com/alecthomas/kong"
|
||||
)
|
||||
|
||||
var pendingCommand = cli.Command{
|
||||
Name: "pending",
|
||||
HideHelp: true,
|
||||
Usage: "Pending subcommand group",
|
||||
Subcommands: []cli.Command{
|
||||
{
|
||||
Name: "devices",
|
||||
Usage: "Show pending devices",
|
||||
Action: expects(0, indexDumpOutput("cluster/pending/devices")),
|
||||
},
|
||||
{
|
||||
Name: "folders",
|
||||
Usage: "Show pending folders",
|
||||
Flags: []cli.Flag{
|
||||
cli.StringFlag{Name: "device", Usage: "Show pending folders offered by given device"},
|
||||
},
|
||||
Action: expects(0, folders()),
|
||||
},
|
||||
},
|
||||
type pendingCommand struct {
|
||||
Devices struct{} `cmd:"" help:"Show pending devices"`
|
||||
Folders struct {
|
||||
Device string `help:"Show pending folders offered by given device"`
|
||||
} `cmd:"" help:"Show pending folders"`
|
||||
}
|
||||
|
||||
func folders() cli.ActionFunc {
|
||||
return func(c *cli.Context) error {
|
||||
if c.String("device") != "" {
|
||||
func (p *pendingCommand) Run(ctx Context, kongCtx *kong.Context) error {
|
||||
indexDumpOutput := indexDumpOutputWrapper(ctx.clientFactory)
|
||||
|
||||
switch kongCtx.Selected().Name {
|
||||
case "devices":
|
||||
return indexDumpOutput("cluster/pending/devices")
|
||||
case "folders":
|
||||
if p.Folders.Device != "" {
|
||||
query := make(url.Values)
|
||||
query.Set("device", c.String("device"))
|
||||
return indexDumpOutput("cluster/pending/folders?" + query.Encode())(c)
|
||||
query.Set("device", p.Folders.Device)
|
||||
return indexDumpOutput("cluster/pending/folders?" + query.Encode())
|
||||
}
|
||||
return indexDumpOutput("cluster/pending/folders")(c)
|
||||
return indexDumpOutput("cluster/pending/folders")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,44 +7,36 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli"
|
||||
"github.com/alecthomas/kong"
|
||||
)
|
||||
|
||||
var showCommand = cli.Command{
|
||||
Name: "show",
|
||||
HideHelp: true,
|
||||
Usage: "Show command group",
|
||||
Subcommands: []cli.Command{
|
||||
{
|
||||
Name: "version",
|
||||
Usage: "Show syncthing client version",
|
||||
Action: expects(0, indexDumpOutput("system/version")),
|
||||
},
|
||||
{
|
||||
Name: "config-status",
|
||||
Usage: "Show configuration status, whether or not a restart is required for changes to take effect",
|
||||
Action: expects(0, indexDumpOutput("config/restart-required")),
|
||||
},
|
||||
{
|
||||
Name: "system",
|
||||
Usage: "Show system status",
|
||||
Action: expects(0, indexDumpOutput("system/status")),
|
||||
},
|
||||
{
|
||||
Name: "connections",
|
||||
Usage: "Report about connections to other devices",
|
||||
Action: expects(0, indexDumpOutput("system/connections")),
|
||||
},
|
||||
{
|
||||
Name: "discovery",
|
||||
Usage: "Show the discovered addresses of remote devices (from cache of the running syncthing instance)",
|
||||
Action: expects(0, indexDumpOutput("system/discovery")),
|
||||
},
|
||||
pendingCommand,
|
||||
{
|
||||
Name: "usage",
|
||||
Usage: "Show usage report",
|
||||
Action: expects(0, indexDumpOutput("svc/report")),
|
||||
},
|
||||
},
|
||||
type showCommand struct {
|
||||
Version struct{} `cmd:"" help:"Show syncthing client version"`
|
||||
ConfigStatus struct{} `cmd:"" help:"Show configuration status, whether or not a restart is required for changes to take effect"`
|
||||
System struct{} `cmd:"" help:"Show system status"`
|
||||
Connections struct{} `cmd:"" help:"Report about connections to other devices"`
|
||||
Discovery struct{} `cmd:"" help:"Show the discovered addresses of remote devices (from cache of the running syncthing instance)"`
|
||||
Usage struct{} `cmd:"" help:"Show usage report"`
|
||||
Pending pendingCommand `cmd:"" help:"Pending subcommand group"`
|
||||
}
|
||||
|
||||
func (*showCommand) Run(ctx Context, kongCtx *kong.Context) error {
|
||||
indexDumpOutput := indexDumpOutputWrapper(ctx.clientFactory)
|
||||
|
||||
switch kongCtx.Selected().Name {
|
||||
case "version":
|
||||
return indexDumpOutput("system/version")
|
||||
case "config-status":
|
||||
return indexDumpOutput("config/restart-required")
|
||||
case "system":
|
||||
return indexDumpOutput("system/status")
|
||||
case "connections":
|
||||
return indexDumpOutput("system/connections")
|
||||
case "discovery":
|
||||
return indexDumpOutput("system/discovery")
|
||||
case "usage":
|
||||
return indexDumpOutput("svc/report")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/syncthing/syncthing/lib/config"
|
||||
"github.com/syncthing/syncthing/lib/db/backend"
|
||||
"github.com/syncthing/syncthing/lib/locations"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
func responseToBArray(response *http.Response) ([]byte, error) {
|
||||
@@ -30,68 +29,72 @@ func responseToBArray(response *http.Response) ([]byte, error) {
|
||||
return bytes, response.Body.Close()
|
||||
}
|
||||
|
||||
func emptyPost(url string) cli.ActionFunc {
|
||||
return func(c *cli.Context) error {
|
||||
client, err := getClientFactory(c).getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = client.Post(url, "")
|
||||
func emptyPost(url string, apiClientFactory *apiClientFactory) error {
|
||||
client, err := apiClientFactory.getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = client.Post(url, "")
|
||||
return err
|
||||
}
|
||||
|
||||
func indexDumpOutputWrapper(apiClientFactory *apiClientFactory) func(url string) error {
|
||||
return func(url string) error {
|
||||
return indexDumpOutput(url, apiClientFactory)
|
||||
}
|
||||
}
|
||||
|
||||
func indexDumpOutput(url string) cli.ActionFunc {
|
||||
return func(c *cli.Context) error {
|
||||
client, err := getClientFactory(c).getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := client.Get(url)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return errors.New("not found (folder/file not in database)")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return prettyPrintResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
func saveToFile(url string) cli.ActionFunc {
|
||||
return func(c *cli.Context) error {
|
||||
client, err := getClientFactory(c).getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, params, err := mime.ParseMediaType(response.Header.Get("Content-Disposition"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filename := params["filename"]
|
||||
if filename == "" {
|
||||
return errors.New("Missing filename in response")
|
||||
}
|
||||
bs, err := responseToBArray(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.Write(bs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Wrote results to", filename)
|
||||
func indexDumpOutput(url string, apiClientFactory *apiClientFactory) error {
|
||||
client, err := apiClientFactory.getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := client.Get(url)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return errors.New("not found (folder/file not in database)")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return prettyPrintResponse(response)
|
||||
}
|
||||
|
||||
func saveToFile(url string, apiClientFactory *apiClientFactory) error {
|
||||
client, err := apiClientFactory.getClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, params, err := mime.ParseMediaType(response.Header.Get("Content-Disposition"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filename := params["filename"]
|
||||
if filename == "" {
|
||||
return errors.New("Missing filename in response")
|
||||
}
|
||||
bs, err := responseToBArray(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f.Write(bs)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
err = f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Wrote results to", filename)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getConfig(c APIClient) (config.Configuration, error) {
|
||||
@@ -111,19 +114,6 @@ func getConfig(c APIClient) (config.Configuration, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func expects(n int, actionFunc cli.ActionFunc) cli.ActionFunc {
|
||||
return func(ctx *cli.Context) error {
|
||||
if ctx.NArg() != n {
|
||||
plural := ""
|
||||
if n != 1 {
|
||||
plural = "s"
|
||||
}
|
||||
return fmt.Errorf("expected %d argument%s, got %d", n, plural, ctx.NArg())
|
||||
}
|
||||
return actionFunc(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func prettyPrintJSON(data interface{}) error {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
@@ -159,7 +149,3 @@ func nulString(bs []byte) string {
|
||||
func normalizePath(path string) string {
|
||||
return filepath.ToSlash(filepath.Clean(path))
|
||||
}
|
||||
|
||||
func getClientFactory(c *cli.Context) *apiClientFactory {
|
||||
return c.App.Metadata["clientFactory"].(*apiClientFactory)
|
||||
}
|
||||
|
||||
@@ -88,9 +88,6 @@ above.
|
||||
STTRACE A comma separated string of facilities to trace. The valid
|
||||
facility strings are listed below.
|
||||
|
||||
STDEADLOCKTIMEOUT Used for debugging internal deadlocks; sets debug
|
||||
sensitivity. Use only under direction of a developer.
|
||||
|
||||
STLOCKTHRESHOLD Used for debugging internal deadlocks; sets debug
|
||||
sensitivity. Use only under direction of a developer.
|
||||
|
||||
@@ -139,7 +136,7 @@ var entrypoint struct {
|
||||
Serve serveOptions `cmd:"" help:"Run Syncthing"`
|
||||
Generate generate.CLI `cmd:"" help:"Generate key and config, then exit"`
|
||||
Decrypt decrypt.CLI `cmd:"" help:"Decrypt or verify an encrypted folder"`
|
||||
Cli struct{} `cmd:"" help:"Command line interface for Syncthing"`
|
||||
Cli cli.CLI `cmd:"" help:"Command line interface for Syncthing"`
|
||||
}
|
||||
|
||||
// serveOptions are the options for the `syncthing serve` command.
|
||||
@@ -173,7 +170,6 @@ type serveOptions struct {
|
||||
// Debug options below
|
||||
DebugDBIndirectGCInterval time.Duration `env:"STGCINDIRECTEVERY" help:"Database indirection GC interval"`
|
||||
DebugDBRecheckInterval time.Duration `env:"STRECHECKDBEVERY" help:"Database metadata recalculation interval"`
|
||||
DebugDeadlockTimeout int `placeholder:"SECONDS" env:"STDEADLOCKTIMEOUT" help:"Used for debugging internal deadlocks"`
|
||||
DebugGUIAssetsDir string `placeholder:"PATH" help:"Directory to load GUI assets from" env:"STGUIASSETS"`
|
||||
DebugPerfStats bool `env:"STPERFSTATS" help:"Write running performance statistics to perf-$pid.csv (Unix only)"`
|
||||
DebugProfileBlock bool `env:"STBLOCKPROFILE" help:"Write block profiles to block-$pid-$timestamp.pprof every 20 seconds"`
|
||||
@@ -213,17 +209,6 @@ func defaultVars() kong.Vars {
|
||||
}
|
||||
|
||||
func main() {
|
||||
// The "cli" subcommand uses a different command line parser, and e.g. help
|
||||
// gets mangled when integrating it as a subcommand -> detect it here at the
|
||||
// beginning.
|
||||
if len(os.Args) > 1 && os.Args[1] == "cli" {
|
||||
if err := cli.Run(); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// First some massaging of the raw command line to fit the new model.
|
||||
// Basically this means adding the default command at the front, and
|
||||
// converting -options to --options.
|
||||
@@ -249,7 +234,15 @@ func main() {
|
||||
|
||||
// Create a parser with an overridden help function to print our extra
|
||||
// help info.
|
||||
parser, err := kong.New(&entrypoint, kong.Help(helpHandler), defaultVars())
|
||||
parser, err := kong.New(
|
||||
&entrypoint,
|
||||
kong.ConfigureHelp(kong.HelpOptions{
|
||||
NoExpandSubcommands: true,
|
||||
Compact: true,
|
||||
}),
|
||||
kong.Help(helpHandler),
|
||||
defaultVars(),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -626,7 +619,6 @@ func syncthingMain(options serveOptions) {
|
||||
}
|
||||
|
||||
appOpts := syncthing.Options{
|
||||
DeadlockTimeoutS: options.DebugDeadlockTimeout,
|
||||
NoUpgrade: options.NoUpgrade,
|
||||
ProfilerAddr: options.DebugProfilerListen,
|
||||
ResetDeltaIdxs: options.DebugResetDeltaIdxs,
|
||||
@@ -637,10 +629,6 @@ func syncthingMain(options serveOptions) {
|
||||
if options.Audit {
|
||||
appOpts.AuditWriter = auditWriter(options.AuditFile)
|
||||
}
|
||||
if t := os.Getenv("STDEADLOCKTIMEOUT"); t != "" {
|
||||
secs, _ := strconv.Atoi(t)
|
||||
appOpts.DeadlockTimeoutS = secs
|
||||
}
|
||||
if dur, err := time.ParseDuration(os.Getenv("STRECHECKDBEVERY")); err == nil {
|
||||
appOpts.DBRecheckInterval = dur
|
||||
}
|
||||
|
||||
26
cmd/ursrv/serve/metrics.go
Normal file
26
cmd/ursrv/serve/metrics.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright (C) 2023 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/.
|
||||
|
||||
package serve
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var metricReportsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: "syncthing",
|
||||
Subsystem: "ursrv",
|
||||
Name: "reports_total",
|
||||
}, []string{"version"})
|
||||
|
||||
func init() {
|
||||
metricReportsTotal.WithLabelValues("fail")
|
||||
metricReportsTotal.WithLabelValues("duplicate")
|
||||
metricReportsTotal.WithLabelValues("v1")
|
||||
metricReportsTotal.WithLabelValues("v2")
|
||||
metricReportsTotal.WithLabelValues("v3")
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
@@ -26,6 +27,7 @@ import (
|
||||
|
||||
_ "github.com/lib/pq" // PostgreSQL driver
|
||||
"github.com/oschwald/geoip2-golang"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
@@ -196,6 +198,7 @@ func (cli *CLI) Run() error {
|
||||
http.HandleFunc("/performance.json", srv.performanceHandler)
|
||||
http.HandleFunc("/blockstats.json", srv.blockStatsHandler)
|
||||
http.HandleFunc("/locations.json", srv.locationsHandler)
|
||||
http.Handle("/metrics", promhttp.Handler())
|
||||
http.Handle("/static/", http.FileServer(http.FS(statics)))
|
||||
|
||||
go srv.cacheRefresher()
|
||||
@@ -289,6 +292,12 @@ func (s *server) locationsHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
|
||||
func (s *server) newDataHandler(w http.ResponseWriter, r *http.Request) {
|
||||
version := "fail"
|
||||
defer func() {
|
||||
// Version is "fail", "duplicate", "v2", "v3", ...
|
||||
metricReportsTotal.WithLabelValues(version).Inc()
|
||||
}()
|
||||
|
||||
defer r.Body.Close()
|
||||
|
||||
addr := r.Header.Get("X-Forwarded-For")
|
||||
@@ -334,6 +343,7 @@ func (s *server) newDataHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if err.Error() == `pq: duplicate key value violates unique constraint "uniqueidjsonindex"` {
|
||||
// We already have a report today for the same unique ID; drop
|
||||
// this one without complaining.
|
||||
version = "duplicate"
|
||||
return
|
||||
}
|
||||
log.Println("insert:", err)
|
||||
@@ -343,6 +353,8 @@ func (s *server) newDataHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Database Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
version = fmt.Sprintf("v%d", rep.URVersion)
|
||||
}
|
||||
|
||||
func (s *server) summaryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Name=Syncthing Web UI
|
||||
GenericName=File synchronization UI
|
||||
Comment=Opens Syncthing's Web UI in the default browser (Syncthing must already be started).
|
||||
Exec=/usr/bin/syncthing -browser-only
|
||||
Exec=/usr/bin/syncthing --browser-only
|
||||
Icon=syncthing
|
||||
Terminal=false
|
||||
Type=Application
|
||||
|
||||
22
go.mod
22
go.mod
@@ -38,23 +38,22 @@ require (
|
||||
github.com/prometheus/client_golang v1.17.0
|
||||
github.com/prometheus/common v0.45.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/quic-go/quic-go v0.40.0
|
||||
github.com/quic-go/quic-go v0.40.1
|
||||
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475
|
||||
github.com/sasha-s/go-deadlock v0.3.1
|
||||
github.com/shirou/gopsutil/v3 v3.23.10
|
||||
github.com/shirou/gopsutil/v3 v3.23.11
|
||||
github.com/syncthing/notify v0.0.0-20210616190510-c6b7342338d2
|
||||
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d
|
||||
github.com/thejerf/suture/v4 v4.0.2
|
||||
github.com/urfave/cli v1.22.14
|
||||
github.com/vitrun/qart v0.0.0-20160531060029-bf64b92db6b0
|
||||
golang.org/x/crypto v0.15.0
|
||||
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa
|
||||
golang.org/x/crypto v0.16.0
|
||||
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb
|
||||
golang.org/x/mod v0.14.0 // indirect
|
||||
golang.org/x/net v0.18.0
|
||||
golang.org/x/sys v0.14.0
|
||||
golang.org/x/net v0.19.0
|
||||
golang.org/x/sys v0.15.0
|
||||
golang.org/x/text v0.14.0
|
||||
golang.org/x/time v0.4.0
|
||||
golang.org/x/tools v0.15.0
|
||||
golang.org/x/time v0.5.0
|
||||
golang.org/x/tools v0.16.1
|
||||
google.golang.org/protobuf v1.31.0
|
||||
)
|
||||
|
||||
@@ -63,12 +62,11 @@ require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
|
||||
github.com/google/pprof v0.0.0-20231101202521-4ca4178f5c7a // indirect
|
||||
github.com/google/pprof v0.0.0-20231212022811-ec68065c825e // indirect
|
||||
github.com/google/uuid v1.4.0 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.13.1 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.13.2 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.12.0 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20230904192822-1876fd5063bc // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/quic-go/qtls-go1-20 v0.4.1 // indirect
|
||||
|
||||
46
go.sum
46
go.sum
@@ -79,8 +79,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20231101202521-4ca4178f5c7a h1:fEBsGL/sjAuJrgah5XqmmYsTLzJp/TO9Lhy39gkverk=
|
||||
github.com/google/pprof v0.0.0-20231101202521-4ca4178f5c7a/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
|
||||
github.com/google/pprof v0.0.0-20231212022811-ec68065c825e h1:bwOy7hAFd0C91URzMIEBfr6BAz29yk7Qj0cy6S7DJlU=
|
||||
github.com/google/pprof v0.0.0-20231212022811-ec68065c825e/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -128,8 +128,8 @@ github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vv
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
|
||||
github.com/onsi/ginkgo/v2 v2.13.1 h1:LNGfMbR2OVGBfXjvRZIZ2YCTQdGKtPLvuI1rMCCj3OU=
|
||||
github.com/onsi/ginkgo/v2 v2.13.1/go.mod h1:XStQ8QcGwLyF4HdfcZB8SFOS/MWCgDuXMSBe6zrvLgM=
|
||||
github.com/onsi/ginkgo/v2 v2.13.2 h1:Bi2gGVkfn6gQcjNjZJVO8Gf0FHzMPf2phUei9tejVMs=
|
||||
github.com/onsi/ginkgo/v2 v2.13.2/go.mod h1:XStQ8QcGwLyF4HdfcZB8SFOS/MWCgDuXMSBe6zrvLgM=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
@@ -139,9 +139,6 @@ github.com/oschwald/geoip2-golang v1.9.0 h1:uvD3O6fXAXs+usU+UGExshpdP13GAqp4GBrz
|
||||
github.com/oschwald/geoip2-golang v1.9.0/go.mod h1:BHK6TvDyATVQhKNbQBdrj9eAvuwOMi2zSFXizL3K81Y=
|
||||
github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs=
|
||||
github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY=
|
||||
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
|
||||
github.com/petermattis/goid v0.0.0-20230904192822-1876fd5063bc h1:8bQZVK1X6BJR/6nYUPxQEP+ReTsceJTKizeuwjWOPUA=
|
||||
github.com/petermattis/goid v0.0.0-20230904192822-1876fd5063bc/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -162,17 +159,15 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/quic-go/qtls-go1-20 v0.4.1 h1:D33340mCNDAIKBqXuAvexTNMUByrYmFYVfKfDN5nfFs=
|
||||
github.com/quic-go/qtls-go1-20 v0.4.1/go.mod h1:X9Nh97ZL80Z+bX/gUXMbipO6OxdiDi58b/fMC9mAL+k=
|
||||
github.com/quic-go/quic-go v0.40.0 h1:GYd1iznlKm7dpHD7pOVpUvItgMPo/jrMgDWZhMCecqw=
|
||||
github.com/quic-go/quic-go v0.40.0/go.mod h1:PeN7kuVJ4xZbxSv/4OX6S1USOX8MJvydwpTx31vx60c=
|
||||
github.com/quic-go/quic-go v0.40.1 h1:X3AGzUNFs0jVuO3esAGnTfvdgvL4fq655WaOi1snv1Q=
|
||||
github.com/quic-go/quic-go v0.40.1/go.mod h1:PeN7kuVJ4xZbxSv/4OX6S1USOX8MJvydwpTx31vx60c=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0=
|
||||
github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM=
|
||||
github.com/sclevine/spec v1.4.0 h1:z/Q9idDcay5m5irkZ28M7PtQM4aOISzOpj4bUPkDee8=
|
||||
github.com/shirou/gopsutil/v3 v3.23.10 h1:/N42opWlYzegYaVkWejXWJpbzKv2JDy3mrgGzKsh9hM=
|
||||
github.com/shirou/gopsutil/v3 v3.23.10/go.mod h1:JIE26kpucQi+innVlAUnIEOSBhBUkirr5b44yr55+WE=
|
||||
github.com/shirou/gopsutil/v3 v3.23.11 h1:i3jP9NjCPUz7FiZKxlMnODZkdSIp2gnzfrvsu9CuWEQ=
|
||||
github.com/shirou/gopsutil/v3 v3.23.11/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -210,10 +205,10 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
|
||||
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
|
||||
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
|
||||
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
|
||||
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
|
||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8=
|
||||
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
@@ -233,8 +228,8 @@ golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
|
||||
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
|
||||
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
|
||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -270,9 +265,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
|
||||
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -287,8 +281,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/time v0.4.0 h1:Z81tqI5ddIoXDPvVQ7/7CC9TnLM7ubaFG2qXYd5BbYY=
|
||||
golang.org/x/time v0.4.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
@@ -296,8 +290,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8=
|
||||
golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk=
|
||||
golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA=
|
||||
golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -180,7 +180,7 @@ input[type="checkbox"].extended-attributes-filter-rule-checkbox {
|
||||
margin-right: .14285715em;
|
||||
}
|
||||
|
||||
.remote-devices-panel {
|
||||
.inline-icon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@@ -460,15 +460,17 @@ ul.three-columns li, ul.two-columns li {
|
||||
}
|
||||
|
||||
@media (max-width: 419px) {
|
||||
/* the selectors are build to target only the content of folder and device
|
||||
panels as it would "destroy" e.g. out of sync or recent changes listings */
|
||||
/* The selectors are build to target only the content of folder and device
|
||||
panels as it would "destroy" e.g. out of sync or recent changes listings.
|
||||
The !important is needed to override .visible-xs that sets display to a
|
||||
specific table element instead of block. */
|
||||
div[id^='device-'].panel-collapse table,
|
||||
div[id^='folder-'].panel-collapse table,
|
||||
div[id^='device-'].panel-collapse tbody,
|
||||
div[id^='folder-'].panel-collapse tbody,
|
||||
div[id^='device-'].panel-collapse tr,
|
||||
div[id^='folder-'].panel-collapse tr {
|
||||
display: block;
|
||||
display: block !important;
|
||||
}
|
||||
div[id^='device-'].panel-collapse th,
|
||||
div[id^='folder-'].panel-collapse th,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"A device with that ID is already added.": "تم أضافه عنوان هذا الجهاز من قبل.",
|
||||
"A device with that ID is already added.": "أضيف هذا الجهاز بالفعل.",
|
||||
"A negative number of days doesn't make sense.": "لا يمكن استخدام قيمة سالبة لعدد الأيام.",
|
||||
"A new major version may not be compatible with previous versions.": "الإصدار الجديد قد لا يتوافق مع الإصدارات السابقة.",
|
||||
"API Key": "مفتاح API",
|
||||
"About": "حول",
|
||||
"Action": "اجراء",
|
||||
"Action": "إجراء",
|
||||
"Actions": "الإجراءات",
|
||||
"Active filter rules": "قواعد التصفية النشطة",
|
||||
"Add": "إضافة",
|
||||
"Add Device": "إضافة جهاز",
|
||||
"Add Folder": "إضافة مجلد",
|
||||
"Add Remote Device": "أضافه جهاز بعيد",
|
||||
"Add Remote Device": "إضافة جهاز بعيد",
|
||||
"Add devices from the introducer to our device list, for mutually shared folders.": "اضف أجهزة من المعرف/المقدم إلى قائمة الأجهزة الخاصة بنا، للمجلدات المشتركة بشكل متبادل.",
|
||||
"Add filter entry": "إضافة عامل التصفية",
|
||||
"Add ignore patterns": "أضف أنماط التجاهل",
|
||||
@@ -20,7 +20,7 @@
|
||||
"Addresses": "العناوين",
|
||||
"Advanced": "متقدم",
|
||||
"Advanced Configuration": "ضبط متقدم",
|
||||
"All Data": "كل المعلومات",
|
||||
"All Data": "كل البيانات",
|
||||
"All Time": "كل الوقت",
|
||||
"All folders shared with this device must be protected by a password, such that all sent data is unreadable without the given password.": "يجب حماية جميع المجلدات التي تمت مشاركتها مع هذا الجهاز بكلمة مرور ، بحيث تكون جميع البيانات المرسلة غير قابلة للقراءة بدون كلمة المرور المقدمة.",
|
||||
"Allow Anonymous Usage Reporting?": "السماح بإرسال تقارير الإستخدام المجهولة؟",
|
||||
@@ -30,6 +30,7 @@
|
||||
"An external command handles the versioning. It has to remove the file from the shared folder. If the path to the application contains spaces, it should be quoted.": "الإصدار يتم معالجته بواسطة أمر خارجي. يجب إزالة الملف من المجلدات المشتركة. إذا كان المسار للتطبيق يحتوي على مسافات، يجب وضعها بين علامتي تنصيص دلالة على الاقتباس.",
|
||||
"Anonymous Usage Reporting": "تقارير الإستخدام المجهولة",
|
||||
"Anonymous usage report format has changed. Would you like to move to the new format?": "هل تريد الانتقال الى التصميم الجديد لتقرير الاستخدام المجهول ؟",
|
||||
"Applied to LAN": "الشبكة المحلية",
|
||||
"Apply": "تقدم",
|
||||
"Are you sure you want to override all remote changes?": "هل أنت متأكد أنك تريد تجاوز كافة التغييرات عن بُعد؟",
|
||||
"Are you sure you want to permanently delete all these files?": "هل أنت متأكد أنك تريد حذف كل هذه الملفات بشكل دائم؟",
|
||||
@@ -38,6 +39,7 @@
|
||||
"Are you sure you want to restore {%count%} files?": "هل انت متاكد من استعادة {{count}} ملف؟",
|
||||
"Are you sure you want to revert all local changes?": "هل أنت متأكد أنك تريد التراجع عن كافة التغييرات المحلية؟",
|
||||
"Are you sure you want to upgrade?": "هل أنت متأكد أنك تريد الترقية؟",
|
||||
"Authentication Required": "يلزم الاستيثاق",
|
||||
"Authors": "المؤلفون",
|
||||
"Auto Accept": "القبول تلقائيا",
|
||||
"Automatic Crash Reporting": "التبليغ التلقائي للاخطاء",
|
||||
@@ -64,6 +66,7 @@
|
||||
"Configured": "تكوين",
|
||||
"Connected (Unused)": "متصل (غير مستخدم)",
|
||||
"Connection Error": "خطأ في الإتصال",
|
||||
"Connection Management": "إعدادات الاتصال",
|
||||
"Connection Type": "نوع الاتصال",
|
||||
"Connections": "اتصالات",
|
||||
"Connections via relays might be rate limited by the relay": "قد يكون معدل التوصيلات عبر المرحلات محدودًا بواسطة المرحل",
|
||||
@@ -96,6 +99,7 @@
|
||||
"Device ID": "هوية الجهاز",
|
||||
"Device Identification": "هوية الجهاز",
|
||||
"Device Name": "أسم الجهاز",
|
||||
"Device Status": "حالة الجهاز",
|
||||
"Device is untrusted, enter encryption password": "الجهاز غير موثوق به، أدخل كلمة مرور التشفير",
|
||||
"Device rate limits": "حدود معدل نقل البيانات",
|
||||
"Device that last modified the item": "اخر جهاز جهاز عدل على العنصر",
|
||||
@@ -127,25 +131,37 @@
|
||||
"Edit Device": "تعديل الجهاز",
|
||||
"Edit Device Defaults": "تحرير الإعدادات الافتراضية للجهاز",
|
||||
"Edit Folder": "تعديل المجلد",
|
||||
"Edit Folder Defaults": "تعديل الإعدادت الافتراضية للمجلد",
|
||||
"Editing {%path%}.": "تعديل {{path}}.",
|
||||
"Enable Crash Reporting": "تفعيل التبليغ عن الاخطاء",
|
||||
"Enable NAT traversal": "تفعيل اجتياز النات",
|
||||
"Enable Relaying": "تفعيل الترحيل",
|
||||
"Enabled": "مفعل",
|
||||
"Enables sending extended attributes to other devices, and applying incoming extended attributes. May require running with elevated privileges.": "يفعل إرسال البيانات الثانوية، وتطبيق البيانات الثانوية المستوردة. قد يطلب صلاحيات أكثر.",
|
||||
"Enables sending extended attributes to other devices, but not applying incoming extended attributes. This can have a significant performance impact. Always enabled when \"Sync Extended Attributes\" is enabled.": "تصدير بيانات ثانوية، ولا يطبق البيانات الثانوية المستوردة. قد يؤثر سلبا على الأداء. يفعل تلقائيا عند تفعيل \"مزامنة البيانات الثانوية\".",
|
||||
"Enables sending ownership information to other devices, and applying incoming ownership information. Typically requires running with elevated privileges.": "يفعل إرسال معلومات الملكية للأجهزة الأخرى، ويفعل معلومات الملكية المستوردة. غالبا ما يطلب صلاحيات أكثر.",
|
||||
"Enables sending ownership information to other devices, but not applying incoming ownership information. This can have a significant performance impact. Always enabled when \"Sync Ownership\" is enabled.": "يفعل إرسال معلومات الملكية للأجهزة الأخرى، ولكن لا يفعل معلومات الملكية المستوردة. يمكن أن يؤثر سلبا على الأداء. يفعل تلقائيا عند تفعيل \"مزامنة الملكية\".",
|
||||
"Enter a non-negative number (e.g., \"2.35\") and select a unit. Percentages are as part of the total disk size.": "أدخل رقمًا غير سالب (مثلًا، \"2.35\") واختر وحدة. النسب المئوية هي جزء من إجمالي حجم القرص.",
|
||||
"Enter a non-privileged port number (1024 - 65535).": "ادخل رقم منفذ غير مقيد (1024 - 65535).",
|
||||
"Enter comma separated (\"tcp://ip:port\", \"tcp://host:port\") addresses or \"dynamic\" to perform automatic discovery of the address.": "أدخل فواصل لكي تفصل بين العناوين (\"tcp://ip:port\", \"tcp://host:port\") أو \"العناوين الديناميكية\" للاكتشاف التلقائي للعنوان.",
|
||||
"Enter ignore patterns, one per line.": "ادخل نمط التجاهل، كل نمط في سطر.",
|
||||
"Enter up to three octal digits.": "أدخل ثلاثة أرقام ثُمَانِيَّةٍ أو أقل .",
|
||||
"Error": "خطأ",
|
||||
"Extended Attributes": "البيانات الثانوية",
|
||||
"Extended Attributes Filter": "مُنقِّح البيانات الثانوية",
|
||||
"External": "خارجي",
|
||||
"External File Versioning": "إصدار الملف الخارجي",
|
||||
"Failed Items": "العناصر الفاشلة",
|
||||
"Failed to setup, retrying": "فشل الأعداد، جاري المحاولة مره اخرى",
|
||||
"Failure to connect to IPv6 servers is expected if there is no IPv6 connectivity.": "من المتوقع فشل الاتصال بخوادم IPv6 إذا لم يكن هناك اتصال IPv6.",
|
||||
"File Pull Order": "ترتيب ملف السحب",
|
||||
"File Versioning": "ملف الإصدارات",
|
||||
"Files are moved to .stversions directory when replaced or deleted by Syncthing.": "الملفات يتم نقلها إلى دليل .stversions عند الاستبدال أو الحذف بواسطة البرنامج.",
|
||||
"Files are moved to date stamped versions in a .stversions directory when replaced or deleted by Syncthing.": "يتم نقل الملفات إلى الإصدارات المؤرخة المختومة في دليل .vversions عند استبدالها أو حذفها بواسطة Syncthing.",
|
||||
"Failed to load file versions.": "لم يُتَوَصَّل لنسخة الملف.",
|
||||
"Failed to load ignore patterns.": "فشل التَّوَصُّل إلى مُرَشِّحات التجاهل.",
|
||||
"Failed to setup, retrying": "فشل الإعداد، تجري المحاولة مرة أخرى",
|
||||
"Failure to connect to IPv6 servers is expected if there is no IPv6 connectivity.": "يُتوقع فشل الاتصال بخوادم IPv6، إذا لم يكن IPv6 متاحا.",
|
||||
"File Pull Order": "ترتيب استيراد الملفات",
|
||||
"File Versioning": "إصدارات الملف",
|
||||
"Files are moved to .stversions directory when replaced or deleted by Syncthing.": "الملفات يتم نقلها إلى مجلد `.stversions` عند الاستبدال أو الحذف بواسطة البرنامج.",
|
||||
"Files are moved to date stamped versions in a .stversions directory when replaced or deleted by Syncthing.": "يتم نقل الملفات إلى الإصدارات المؤرخة المختومة في مجلد `.stversions` عند استبدالها أو حذفها بواسطة Syncthing.",
|
||||
"Files are protected from changes made on other devices, but changes made on this device will be sent to the rest of the cluster.": "الملفات محمية من التغييرات التي تم إجراؤها على الأجهزة الأخرى ، ولكن سيتم إرسال التغييرات التي تم إجراؤها على هذا الجهاز إلى بقية الأجهزة.",
|
||||
"Files are synchronized from the cluster, but any changes made locally will not be sent to other devices.": "تُزامَنُ الملفات من العنقود، لكن التغيرات المحلية على هذا الجهاز لاتُطَبَّقُ على غيره من الأجهزة.",
|
||||
"Filesystem Watcher Errors": "أخطاء مراقب نظام الملفات",
|
||||
"Filter by date": "فلتره بالتاريخ ",
|
||||
"Filter by name": "فلتر باستخدام الاسم",
|
||||
@@ -153,14 +169,21 @@
|
||||
"Folder ID": "هوية المجلد",
|
||||
"Folder Label": "تسمية المجلد",
|
||||
"Folder Path": "مسار المجلد",
|
||||
"Folder Status": "حالة المجلد",
|
||||
"Folder Type": "نوع المجلد",
|
||||
"Folder type \"{%receiveEncrypted%}\" can only be set when adding a new folder.": "نوع المجلد \"{{receiveEncrypted}}\" لا يمكن إعداده إلا أثناء إنشاء الملف.",
|
||||
"Folder type \"{%receiveEncrypted%}\" cannot be changed after adding the folder. You need to remove the folder, delete or decrypt the data on disk, and add the folder again.": "نوع المجلد \"{{receiveEncrypted}}\" لا يمكن إعداده بعد إضافة المجلد. يجب أن تحذف المجلد وأن تفك تشفير البيانات على قرص التخزين أو تحذفها، بعدها تنشئ المجلد مجددا.",
|
||||
"Folders": "المجلدات",
|
||||
"For the following folders an error occurred while starting to watch for changes. It will be retried every minute, so the errors might go away soon. If they persist, try to fix the underlying issue and ask for help if you can't.": "للمجلدات التالية، حدث خطأ قبل بدء مشاهدة التغييرات. ستتم إعادة المحاولة كل دقيقة، نظرًا لذلك قد تختفي الأخطاء قريبًا. لكن إذا استمرت، فحاول حل المشكلة واطلب المساعدة إذا لم تستطع حل المشكلة.",
|
||||
"Forever": "للأبد",
|
||||
"Full Rescan Interval (s)": "مدة أعاده الفحص الكامل (ثانية)",
|
||||
"GUI": "واجهة المستخدم الرسومية",
|
||||
"GUI Authentication Password": "كلمة الس",
|
||||
"GUI / API HTTPS Certificate": "الواجهة / API وثيقة HTTPS",
|
||||
"GUI Authentication Password": "كلمة السر لتوثيق الواجهة",
|
||||
"GUI Authentication User": "أسم المستخدم لدخول واجهة الرسومية",
|
||||
"GUI Authentication: Set User and Password": "توثيق الواجهة: أنشئ كلمة مرور للمستخدم",
|
||||
"GUI Listen Address": "واجهة الرسومية الاستماع الى العنوان",
|
||||
"GUI Override Directory": "مجلد إحلال الواجهة",
|
||||
"GUI Theme": "شكل الواجه",
|
||||
"General": "عام",
|
||||
"Generate": "توليد",
|
||||
@@ -168,48 +191,85 @@
|
||||
"Global Discovery Servers": "الاكتشاف العالمي",
|
||||
"Global State": "الحالة العامة ",
|
||||
"Help": "مساعدة",
|
||||
"Hint: only deny-rules detected while the default is deny. Consider adding \"permit any\" as last rule.": "ملحوظة: إذا كان الإعداد الافتراضي هو الرفض، وحدها قواعد الرفض تُرصد. جرب إضافة \"السماح للكل\" كخيار أخير.",
|
||||
"Home page": "الصفحة الرئيسية",
|
||||
"However, your current settings indicate you might not want it enabled. We have disabled automatic crash reporting for you.": "ومع ذلك، تشير إعداداتك الحالية إلى أنك قد لا ترغب في تمكينه. لذلك تم تعطيل الإبلاغ التلقائي عن الأعطال.",
|
||||
"Identification": "التعرف",
|
||||
"If untrusted, enter encryption password": "في حالة الرِّيبة، أدخل كلمة سر التشفير",
|
||||
"If you want to prevent other users on this computer from accessing Syncthing and through it your files, consider setting up authentication.": "إذا أردت منع المستخدمين الآخرين على هذا الحاسب من الوصول لملفاتك من خلال Syncthing، يُنصَح بإعداد وثائق الملكية.",
|
||||
"Ignore": "تجاهل",
|
||||
"Ignore Patterns": "تجاهل الأنماط",
|
||||
"Ignore Permissions": "تجاهل الصلاحيات",
|
||||
"Ignore patterns can only be added after the folder is created. If checked, an input field to enter ignore patterns will be presented after saving.": "يمكنك إعداد أنماط التجاهل بعد إنشاء المجلد فقط. إذا فُعِّلَتْ، سيظهر حقل لإعداد هذه الأنماط بعد حفظ المجلد.",
|
||||
"Ignored Devices": "الأجهزة المتجاهلة",
|
||||
"Ignored Folders": "المجلدات المتجاهلة",
|
||||
"Ignored at": "تجاهل عند",
|
||||
"Included Software": "البرامج المُضمَّنة",
|
||||
"Incoming Rate Limit (KiB/s)": "الحد الأقصى البيانات الواردة (KiB/s)",
|
||||
"Incorrect configuration may damage your folder contents and render Syncthing inoperable.": "الإعدادات الغير صحيحه قد تدمر بيانات المجلد وتجعل المزامنة غير صالحه للعمل",
|
||||
"Incorrect user name or password.": "رُصِدَ خطأ في اسم المستخدم أو كلمة المرور.",
|
||||
"Internally used paths:": "المسار المستخدم محليّا:",
|
||||
"Introduced By": "عرف بواسطة",
|
||||
"Introducer": "المعرف",
|
||||
"Introduction": "تقديم",
|
||||
"Inversion of the given condition (i.e. do not exclude)": "عكس الحالة المذكورة (مثلا: لا تستثنِ)",
|
||||
"Keep Versions": "احتفظ بالاصدارات",
|
||||
"LDAP": "LDAP",
|
||||
"LDAP": "تعليمات الوصول البسيطة للمجلدات (LDAP)",
|
||||
"Largest First": "الأكبر أولا",
|
||||
"Last 30 Days": "الثلاثون يومًا السابقة",
|
||||
"Last 7 Days": "الأيام السبعة السابقة",
|
||||
"Last Month": "الشهر الماضي",
|
||||
"Last Scan": "اخر فحص",
|
||||
"Last seen": "اخر ظهور",
|
||||
"Latest Change": "اخر التغييرات",
|
||||
"Learn more": "اعرف اكثر ",
|
||||
"Learn more at {%url%}": "اطلع على المزيد في {{url}}",
|
||||
"Limit": "الحد",
|
||||
"Listener Failures": "فشل المستمع",
|
||||
"Listener Status": "حالة المستمع",
|
||||
"Listeners": "المستمعين",
|
||||
"Loading data...": "تحميل بيانات...",
|
||||
"Loading...": "تحميل...",
|
||||
"Local Additions": "الإضافات المحلِّيَّة",
|
||||
"Local Discovery": "الاكتشاف المحلي",
|
||||
"Local State": "الحالة المحلية",
|
||||
"Local State (Total)": "الحالة المحلية (مجموع)",
|
||||
"Locally Changed Items": "العناصر المتغيرة محليا",
|
||||
"Log": "سجل",
|
||||
"Log File": "سِجِلُّ الأحداث",
|
||||
"Log In": "تسجيل الدخول",
|
||||
"Log Out": "تسجيل الخروج",
|
||||
"Log in to see paths information.": "سجل دخولك لتطلع على معلومات المسار.",
|
||||
"Log in to see version information.": "سجل دخولك لتطلع على معلومات الإصدار.",
|
||||
"Log tailing paused. Scroll to the bottom to continue.": "تتبع السجل متوقف، مَرِّر للأسفل للاستئناف.",
|
||||
"Login failed, see Syncthing logs for details.": "فشل تسجيل الدخول، اطَّلِع على سِجِلِّ Syncthing للتفاصيل.",
|
||||
"Logs": "سجلات",
|
||||
"Major Upgrade": "ترقية أساسية",
|
||||
"Mass actions": "التأثيرات العامة",
|
||||
"Maximum Age": "أقصى مدة",
|
||||
"Maximum single entry size": "الحد الأقصى للمدخلات",
|
||||
"Maximum total size": "السعة القصوى",
|
||||
"Metadata Only": "البيانات الوصفية فقط",
|
||||
"Minimum Free Disk Space": "أدنى حد لمساحة التخزين الحرة",
|
||||
"Mod. Device": "وضع الجهاز",
|
||||
"Mod. Time": "وضع الوقت",
|
||||
"More than a month ago": "منذ أكثر من شهر",
|
||||
"More than a week ago": "منذ أكثر من أسبوع",
|
||||
"More than a year ago": "منذ أكثر من عام",
|
||||
"Move to top of queue": "الانتقال لأعلى قائمة الانتظار",
|
||||
"Multi level wildcard (matches multiple directory levels)": "المطابقة على مستويات عدة",
|
||||
"Never": "أبدا",
|
||||
"New Device": "جهاز جديد",
|
||||
"New Folder": "مجلد جديد",
|
||||
"Newest First": "الأحدث أولا",
|
||||
"No": "لا",
|
||||
"No File Versioning": "لا تقسيم لإصدارات الملفات",
|
||||
"No files will be deleted as a result of this operation.": "لن يتم حذف اي ملفات بسبب هذا العملية",
|
||||
"No rules set": "لم تحدد قواعد",
|
||||
"No upgrades": "لا يوجد ترقيات",
|
||||
"Not shared": "لم يُشارَك",
|
||||
"Notice": "ملاحظة",
|
||||
"Number of Connections": "عدد الاتصالات",
|
||||
"OK": "موافق",
|
||||
"Off": "اطفئ",
|
||||
"Oldest First": "الأقدم أولا",
|
||||
@@ -217,27 +277,48 @@
|
||||
"Options": "خيارات",
|
||||
"Out of Sync": "خارج التزامن",
|
||||
"Out of Sync Items": "عناصر خارج التزامن",
|
||||
"Outgoing Rate Limit (KiB/s)": "الحد من سرعة التصدير (كيلوبايت/ث)",
|
||||
"Override": "أَحِلَّ",
|
||||
"Override Changes": "تخطي التغييرات",
|
||||
"Ownership": "الملكية",
|
||||
"Password": "كلمة المرور",
|
||||
"Path": "مسار",
|
||||
"Path to the folder on the local computer. Will be created if it does not exist. The tilde character (~) can be used as a shortcut for": "مسار المجلد على هذا الحاسب. سيُنْشَأ إن لم يوجد مسبقا. علامة المد (~) يمكن استخدامها اختصارا ل",
|
||||
"Path where versions should be stored (leave empty for the default .stversions directory in the shared folder).": "المسار حيث تخزن الإصدارات (يترك فارغًا لدليل .vversions الافتراضي في المجلد المشترك).",
|
||||
"Paths": "المسارات",
|
||||
"Pause": "إيقاف",
|
||||
"Pause All": "أيقاف الكل ",
|
||||
"Paused": "توقف",
|
||||
"Paused (Unused)": "متوقف (مهمل)",
|
||||
"Pending changes": "التغييرات المعلقة",
|
||||
"Periodic scanning at given interval and disabled watching for changes": "المسح الدوري خلال فترة زمنية معينة وتعطيل مشاهدة التغييرات.",
|
||||
"Periodic scanning at given interval and enabled watching for changes": "المسح الدوري خلال فترة زمنية معينة وتفعيل مشاهدة التغييرات.",
|
||||
"Periodic scanning at given interval and failed setting up watching for changes, retrying every 1m:": "المسح الدوري خلال فترة زمنية معينة وفشل اعداد مشاهدة التغييرات، اعادة المحاولة كل 1 دقيقة.",
|
||||
"Permanently add it to the ignore list, suppressing further notifications.": "أدرجها أبداً على قائمة التجاهل، اكتم الإشعارات مستقبلا.",
|
||||
"Please consult the release notes before performing a major upgrade.": "يرجى العودة إلى ملاحظات الإصدار قبل تنفيذ ترقية رئيسية.",
|
||||
"Please set a GUI Authentication User and Password in the Settings dialog.": "تفضل بإنشاء مستخدما موثقا للواجهة وكلمة مرور من خلال قائمة الإعدادات.",
|
||||
"Please wait": "يرجى الانتظار",
|
||||
"Prefix indicating that the file can be deleted if preventing directory removal": "سابقة تشير بإمكانية حذف الملف إذا منع إزالة المجلد",
|
||||
"Prefix indicating that the pattern should be matched without case sensitivity": "سابقة تعني عدم لزوم حالة الحرف في البحث (غير مهمة للعربية)",
|
||||
"Preparing to Sync": "يُجَهَّزُ للمزامنة",
|
||||
"Preview": "معاينة",
|
||||
"Preview Usage Report": "معاينة تقرير الاستخدام",
|
||||
"QR code": "الصورة المشفرة (QR)",
|
||||
"QUIC LAN": "اتصال QUIC للشبكة المحلية (LAN)",
|
||||
"QUIC WAN": "QUIC الشبكة العامة",
|
||||
"Quick guide to supported patterns": "الدليل مختصر للأنماط المدعومة ",
|
||||
"Random": "عشوائي",
|
||||
"Receive Encrypted": "استلام المشفَّر",
|
||||
"Receive Only": "استقبال فقط",
|
||||
"Received data is already encrypted": "البيانات المستوردة مشفرة بالفعل",
|
||||
"Recent Changes": "اخر التغييرات",
|
||||
"Reduced by ignore patterns": "تقليص بواسطة تجاهل الأنماط. ",
|
||||
"Relay LAN": "ترحيل الشبكة المحلية (LAN)",
|
||||
"Relay WAN": "ترحيل الشبكة العامة (WAN)",
|
||||
"Release Notes": "ملاحظات الإصدار",
|
||||
"Release candidates contain the latest features and fixes. They are similar to the traditional bi-weekly Syncthing releases.": "مُمَهِّدات الإصدار تحتوي على آخر الخصائص والإصلاحات. وهي مماثلة للإصدارات النصف شهرية التقليدية لـ Syncthing .",
|
||||
"Remote Devices": "جهاز بعيد",
|
||||
"Remote GUI": "الواجهة النائية",
|
||||
"Remove": "إزالة",
|
||||
"Remove Device": "حذف جهاز",
|
||||
"Remove Folder": "حذف مجلد",
|
||||
@@ -253,77 +334,152 @@
|
||||
"Resume": "استرد",
|
||||
"Resume All": "استعادة الكل ",
|
||||
"Reused": "إعادة الاستخدام",
|
||||
"Revert": "الرجوع عن التغيير",
|
||||
"Revert Local Changes": "التراجع عن التغييرات",
|
||||
"Save": "حفظ",
|
||||
"Saving changes": "تُحفَظ التعديلات",
|
||||
"Scan Time Remaining": "فحص الوقت المتبقي",
|
||||
"Scanning": "يتم الفحص",
|
||||
"See external versioning help for supported templated command line parameters.": "راجع تعليمات الإصدارات الخارجية لمعرفة القيم المدعومة في سطر الأوامر. ",
|
||||
"Select All": "تحديد الكل",
|
||||
"Select a version": "اختار أصدار ",
|
||||
"Select additional devices to share this folder with.": "اختيار المزيد من الأجهزة التي ترغب في مشاركة هذا المجلد معها.",
|
||||
"Select additional folders to share with this device.": "اختيار المزيد من المجلدات لمشاركتها مع هذا الجهاز.",
|
||||
"Select latest version": "اختار اخر أصدار ",
|
||||
"Select oldest version": "اختيار أقدم إصدار",
|
||||
"Send & Receive": "إرسال واستقبال ",
|
||||
"Send Extended Attributes": "أرسل البيانات الثانوية",
|
||||
"Send Only": "إرسال فقط",
|
||||
"Send Ownership": "أرسل الملكية",
|
||||
"Set Ignores on Added Folder": "طبِّق التجاهلات على المجلدات المضافة",
|
||||
"Settings": "إعدادات",
|
||||
"Share": "مشاركة",
|
||||
"Share Folder": "مشاركة مجلد",
|
||||
"Share by Email": "شارك بالبريد الإلكتروني",
|
||||
"Share by SMS": "شارك برسائل الـ SMS",
|
||||
"Share this folder?": "مشاركة هذا المجلد؟",
|
||||
"Shared Folders": "المجلدات المُشارَكة",
|
||||
"Shared With": "مشاركة مع",
|
||||
"Sharing": "مشاركه",
|
||||
"Show ID": "عرض الهوية",
|
||||
"Show QR": "اظهار QR",
|
||||
"Show detailed discovery status": "اعرض حالة الاكتشاف تفصيليا",
|
||||
"Show detailed listener status": "اعرض حالة الاستماع تفصيليا",
|
||||
"Show diff with previous version": "اظهر الفرق مع النسخة السابقة ",
|
||||
"Shown instead of Device ID in the cluster status. Will be advertised to other devices as an optional default name.": "يُعرَض بدلا من الهوية في حالة العنقود. سيُروَّج للأجهزة الأخرى على انه اسم اساسي محتمل.",
|
||||
"Shown instead of Device ID in the cluster status. Will be updated to the name the device advertises if left empty.": "يُعرَض بدلا من الهوية في حالة العنقود. إذا تُرك فارغا، سيُحدَّث إلى الاسم المختار من قِبَل الجهاز.",
|
||||
"Shutdown": "إغلاق",
|
||||
"Shutdown Complete": "تم الإغلاق",
|
||||
"Simple": "بسيط",
|
||||
"Simple File Versioning": "التقسيم البسيط لإصدارات الملفات",
|
||||
"Single level wildcard (matches within a directory only)": "المقارنة على مستوى واحد (المقارنة مع الملفات في المجلد الحالي فقط)",
|
||||
"Size": "حجم",
|
||||
"Smallest First": "الأصغر أولا",
|
||||
"Some discovery methods could not be established for finding other devices or announcing this device:": "بعض أساليب الاستكشاف يمكن استخدامها للبحث عن أجهزة أخرى أو الإعلان عن هذا الجهاز:",
|
||||
"Some items could not be restored:": "بعض العناصر لا يمكن استرجاعها:",
|
||||
"Some listening addresses could not be enabled to accept connections:": "بعض عناوين الاستماع لا يمكن تفعيلها لقبول الاتصالات:",
|
||||
"Source Code": "مصدر الشفرة",
|
||||
"Stable releases and release candidates": "الإصدارات المستقرة والإصدارات المرشحة.",
|
||||
"Stable releases are delayed by about two weeks. During this time they go through testing as release candidates.": "الإصدارات المستقرة تأخرت بنحو أسبوعين. خلال هذه الفترة يتم إجراء الاختبارات كإصدارات مرشحة.",
|
||||
"Stable releases only": "الإصدارات المستقرة فقط",
|
||||
"Staggered": "مترنِّح",
|
||||
"Staggered File Versioning": "تقسمات إصدارات الملف مهترئة",
|
||||
"Start Browser": "تشغيل المتصفح",
|
||||
"Statistics": "إحصائيات",
|
||||
"Stopped": "متوقف",
|
||||
"Stores and syncs only encrypted data. Folders on all connected devices need to be set up with the same password or be of type \"{%receiveEncrypted%}\" too.": "يُزامن ويخزن البيانات المشفرة فقط. يجب أن تكون المجلدات على جميع الأجهزة مُجهزَّة بكلمة المرور نفسها، أو أن تكون من نوع \"{{receiveEncrypted}}\".",
|
||||
"Subject:": "الموضوع:",
|
||||
"Support": "الدعم",
|
||||
"Support Bundle": "حزمه مدعومه",
|
||||
"Sync Extended Attributes": "زامن الخصائص الثانوية",
|
||||
"Sync Ownership": "زامن الملكية",
|
||||
"Sync Protocol Listen Addresses": "عناوين بروتوكول استقبال المزامنة",
|
||||
"Sync Status": "وضع المزامنة",
|
||||
"Syncing": "يتم التزامن",
|
||||
"Syncthing device ID for \"{%devicename%}\"": "هوية Syncthing الجهاز لـ {{devicename}}",
|
||||
"Syncthing has been shut down.": "تم إيقاف Syncthing.",
|
||||
"Syncthing includes the following software or portions thereof:": "المزامنة تتضمن البرامج التالية أو أجزائها:",
|
||||
"Syncthing is Free and Open Source Software licensed as MPL v2.0.": "Syncthing هو برنامج حر مفتوح المصدر تحت ترخيص MPL v2.0 (ترخيص موزيلا العام النسخة الثانية).",
|
||||
"Syncthing is a continuous file synchronization program. It synchronizes files between two or more computers in real time, safely protected from prying eyes. Your data is your data alone and you deserve to choose where it is stored, whether it is shared with some third party, and how it's transmitted over the internet.": "Syncthing هو تطبيق للمزامنة المستمرة للملفات. يزامن الملفات بين جهازين أو أكثر بشكل لحظي، آمن من الأعين المتربصة. بياناتك ملك لك وحدك، من حقك أن تختار أين تُخَزَّن، وهل يطلع عليها طرف ثالث أم لا، وكيف تتنقل عبر الشبكة.",
|
||||
"Syncthing is listening on the following network addresses for connection attempts from other devices:": "Syncthing يترقب محاولات الاتصال على العنوان التالي:",
|
||||
"Syncthing is not listening for connection attempts from other devices on any address. Only outgoing connections from this device may work.": "Syncthing لا يترقب أي محاولة للاتصال على أي من عناوين الشبكة. الاتصالات الصادرة فقط هي التي يمكن أن تعمل.",
|
||||
"Syncthing is restarting.": "يتم إعادة تشغيل Syncthing.",
|
||||
"Syncthing is saving changes.": "Syncthing يحفظ التعديلات.",
|
||||
"Syncthing is upgrading.": "يتم تطوير Syncthing.",
|
||||
"Syncthing now supports automatically reporting crashes to the developers. This feature is enabled by default.": "Syncthing يدعم إعادة التشغيل التلقائي للمطورين. هذه الخاصية مفعلة بشكل افتراضي.",
|
||||
"Syncthing seems to be down, or there is a problem with your Internet connection. Retrying…": "Syncthing معطل على ما يبدو، ربما يكون العطل في شبكتك. إعادة المحاولة…",
|
||||
"Syncthing seems to be experiencing a problem processing your request. Please refresh the page or restart Syncthing if the problem persists.": "Syncthing يواجه مشكلة في معالجة طلبك. إذا استعصت المشكلة، أعد تحميل الصفحة رجاء.",
|
||||
"TCP LAN": "TCP الشبكة المحلية",
|
||||
"TCP WAN": "TCP الشبكة واسعة النطاق",
|
||||
"Take me back": "رجوع",
|
||||
"The GUI address is overridden by startup options. Changes here will not take effect while the override is in place.": "خيارات البدء حلَّت محل عنوان الواجهة. هذه التعديلات لن تدخل حيز التنفيذ ما بقي هذا الإحلال.",
|
||||
"The Syncthing Authors": "مُلَّاكُ Syncthing",
|
||||
"The Syncthing admin interface is configured to allow remote access without a password.": "واجهة مدير Syncthing معدة للسماح بالوصول بغير كلمة مرور.",
|
||||
"The aggregated statistics are publicly available at the URL below.": "الإحصاءات المجمعة متاحة للجميع على العنوان التالي.",
|
||||
"The cleanup interval cannot be blank.": "المدة بين عمليات التنظيف لا يمكن تركها فارغة.",
|
||||
"The configuration has been saved but not activated. Syncthing must restart to activate the new configuration.": "تم حفظ الإعدادات ولكن لم يتم تفعيلها بعد. يجب أعادة تشغيل Syncthing حتى تم تفعيل الإعدادات.",
|
||||
"The device ID cannot be blank.": "هوية الجهاز لا يمكن أن تكون فارغة.",
|
||||
"The device ID to enter here can be found in the \"Actions > Show ID\" dialog on the other device. Spaces and dashes are optional (ignored).": "يمكنك أن تجد هوية الجهاز الذي ينبغي استخدامه هنا في قائمة \"الإجراءات > عرض الهوية\" على الجهاز الآخر. المسافات والخطوط الاعتراضية تُتجاهَل.",
|
||||
"The encrypted usage report is sent daily. It is used to track common platforms, folder sizes, and app versions. If the reported data set is changed you will be prompted with this dialog again.": "تقارير الاستخدام المشفرة ترسل يوميا. تُستخدم هذه التقارير لتتبع المنصات الشائعة، أحجام المجلدات، إصدارات التطبيق. إذا تغيرت بنود هذا التقرير، ستواجَهُ بهذه النافذة مرة أخرى.",
|
||||
"The entered device ID does not look valid. It should be a 52 or 56 character string consisting of letters and numbers, with spaces and dashes being optional.": "الهوية المُقَدَّمَة ناقصة على ما يبدو. الهوية مكوَّنة من 52 أو 56 رمزا بين حروف وأرقام، مع تجاهل المسافات الفارغة وخطوط الاعتراض.",
|
||||
"The folder ID cannot be blank.": "هوية المجلد لا يمكن أن تكون فارغة.",
|
||||
"The folder ID must be unique.": "يجب أن يكون عنوان المجلد فريد ",
|
||||
"The folder content on other devices will be overwritten to become identical with this device. Files not present here will be deleted on other devices.": "ستحل محتويات هذا المجلد محل محتويات مجلدات الأجهزة الأخرى. الملفات التي ليس لها وجود هنا ستحذف عن الأجهزة الأخرى.",
|
||||
"The folder content on this device will be overwritten to become identical with other devices. Files newly added here will be deleted.": "ستحل البيانات في الأجهزة الأخرى محل البيانات في هذا المجلد. ستحذف الملفات التي ستُنشأ هنا.",
|
||||
"The folder path cannot be blank.": "مسار المجلد لا يمكن أن يكون فارغ",
|
||||
"The following intervals are used: for the first hour a version is kept every 30 seconds, for the first day a version is kept every hour, for the first 30 days a version is kept every day, until the maximum age a version is kept every week.": "المُدَد التالية مُستخدَمة: تُحفظ نسخة كل 30 ثانية في الساعة الأولى، ونسخة كل ساعة لبقية اليوم الأول، ونسخة يومية لأول ثلاثين يوما، ونسخة أسبوعية للأمد.",
|
||||
"The following items could not be synchronized.": "فشل مزامنة العناصر التالية",
|
||||
"The following items were changed locally.": "تم تغيير العناصر التالية محليا",
|
||||
"The following methods are used to discover other devices on the network and announce this device to be found by others:": "الطرق التالية مستخدمة للإعلان عن هذا الجهاز، وإيجاد الأجهزة الأخرى أيضا:",
|
||||
"The following text will automatically be inserted into a new message.": "النص التالي سيُضمَّن تلقائيا في رسالة جديدة.",
|
||||
"The following unexpected items were found.": "عُثِر على المحتوى غير المتوقع التالي.",
|
||||
"The interval must be a positive number of seconds.": "المُدة يجب أن تكون رقما موجبا من الثواني.",
|
||||
"The interval, in seconds, for running cleanup in the versions directory. Zero to disable periodic cleaning.": "المدة البينية بالثواني لإجراء عمليات التنظيف الدورية في مجلد الإصدارات. اجعلها صِفرا لتعطيل التنظيف الدوري.",
|
||||
"The maximum age must be a number and cannot be blank.": "الحد الأقصى للسن يجب أن يكون رقمًا وألا يكون فارغًا.",
|
||||
"The maximum time to keep a version (in days, set to 0 to keep versions forever).": "الحد الأقصى للاحتفاظ بإصدار ما (بالأيام ، اضبط على 0 للاحتفاظ بالإصدارات إلى الأبد).",
|
||||
"The number of connections must be a non-negative number.": "عدد الاتصالات يجب ألا يكون سالبا.",
|
||||
"The number of days must be a number and cannot be blank.": "حقل عدد الأيام يجب أن يكون رقم ولا يمكن تركه فارغ.",
|
||||
"The number of days to keep files in the trash can. Zero means forever.": "عدد أيام حفظ الملفات في سلة المهملات. الصفر يعني إلى الأبد.",
|
||||
"The number of old versions to keep, per file.": "عدد النسخ القديمة المحفوظة، لكل ملف. ",
|
||||
"The number of versions must be a number and cannot be blank.": "حقل عدد النسخ يجب أن يكون رقم ولا يمكن أن تركة فارغا.",
|
||||
"The path cannot be blank.": "المسار لا يمكن أن يكون فارغ.",
|
||||
"The rate limit is applied to the accumulated traffic of all connections to this device.": "السرعة القصوى المختارة لنقل البيانات المتراكمة من جميع الاتصالات على هذا الجهاز.",
|
||||
"The rate limit must be a non-negative number (0: no limit)": "يجب أن يكون الحد عددًا غير سالب (0: تعني بلا حد)",
|
||||
"The remote device has not accepted sharing this folder.": "الجهاز الآخر رفض مشاركة هذا المجلد.",
|
||||
"The remote device has paused this folder.": "الجهاز الآخر جمَّد هذا المجلد.",
|
||||
"The rescan interval must be a non-negative number of seconds.": "يجب أن يكون الفاصل الزمني لإعادة الفحص عددًا غير سالب من الثواني.",
|
||||
"There are no devices to share this folder with.": "لا توجد أجهزة أخرى لتشاركها هذا المجلد.",
|
||||
"There are no file versions to restore.": "لا توجد إصدارات يمكن استعادتها لهذا الملف.",
|
||||
"There are no folders to share with this device.": "لا توجد مجلدات لمشاركتها مع هذا الجهاز.",
|
||||
"They are retried automatically and will be synced when the error is resolved.": "تتم إعادة المحاولة تلقائيًا وسيتم مزامنتها عند إصلاح الخطأ.",
|
||||
"This Device": "هذا الجهاز",
|
||||
"This Month": "هذا الشهر",
|
||||
"This can easily give hackers access to read and change any files on your computer.": "هذا قد يسبب في اختراق جهازك.",
|
||||
"This device cannot automatically discover other devices or announce its own address to be found by others. Only devices with statically configured addresses can connect.": "لا يمكن لهذا الجهاز أن يرصد الأجهزة الأخرى تلقائيا، ولا أن يعلن عنوانه ليمكن الأجهزة الأخرى من إيجاده. الأجهزة ثابتة العناوين فقط يمكن أن تتصل.",
|
||||
"This is a major version upgrade.": "ترقية أساسية ",
|
||||
"This setting controls the free space required on the home (i.e., index database) disk.": "هذا الخيار يتحكم في المساحة الفارغة المطلوبة من القرص الرئيسي.",
|
||||
"Time": "الوقت",
|
||||
"Time the item was last modified": "توقيت اخر تعديل للعنصر",
|
||||
"To connect with the Syncthing device named \"{%devicename%}\", add a new remote device on your end with this ID:": "للاتصال بالجهاز المسمى {{devicename}}، أضف جهازًا مغايرًا جديدًا تحت هذا العنوان:",
|
||||
"To permit a rule, have the checkbox checked. To deny a rule, leave it unchecked.": "لتفعيل القاعدة، ظلل المربع. لتعطيلها اترك المربع فارغاً.",
|
||||
"Today": "اليوم",
|
||||
"Trash Can": "المنفى",
|
||||
"Trash Can File Versioning": "إصدارات الملفات المنفية",
|
||||
"Type": "نوع",
|
||||
"UNIX Permissions": "صلاحيات UNIX",
|
||||
"Unavailable": "غير متوفر",
|
||||
"Unavailable/Disabled by administrator or maintainer": "غير متوفر/معطل من قبل المسؤول أو الصيانة",
|
||||
"Undecided (will prompt)": "غير محدد ( ستظهر نافذة للتحديد لاحقًا )",
|
||||
"Unexpected Items": "المحتويات المفاجِئة",
|
||||
"Unexpected items have been found in this folder.": "عُثِر على محتويات غير متوقعة في هذا المجلد.",
|
||||
"Unignore": "لا يتم التجاهل",
|
||||
"Unknown": "غير معرف",
|
||||
"Unshared": "غير مشترك",
|
||||
"Unshared Devices": "الأجهزة غير المُشَارَكة",
|
||||
"Unshared Folders": "المجلدات غير المُشارَكة",
|
||||
"Untrusted": "غير موثوق",
|
||||
"Up to Date": "اخز أصدار ",
|
||||
"Updated {%file%}": "مُحَدَّث {{file}}",
|
||||
"Upgrade": "ترقية",
|
||||
"Upgrade To {%version%}": "ترقية الى {{version}} ",
|
||||
"Upgrading": "جاري الترقية",
|
||||
@@ -332,16 +488,35 @@
|
||||
"Usage reporting is always enabled for candidate releases.": "تقارير الاستخدام مفعلة دائمًا للنسخ المرشحة.",
|
||||
"Use HTTPS for GUI": "استخدام HTTPS مع الواجه الرسومية ",
|
||||
"Use notifications from the filesystem to detect changed items.": "استخدم أشغارات نظام الملفات لمعرفة الملفات المتغيرة",
|
||||
"User": "مستخدِم",
|
||||
"User Home": "منزل المستخدم",
|
||||
"Username/Password has not been set for the GUI authentication. Please consider setting it up.": "اسم المستخدم/كلمة المرور لم يُنشَآ لتوثيق الواجهة. يُرجى إنشاؤهما من فضلك.",
|
||||
"Using a QUIC connection over LAN": "استخدام اتصال QUIC بدلا من LAN",
|
||||
"Using a QUIC connection over WAN": "استخدام اتصال QUIC بدلا من WAN",
|
||||
"Using a direct TCP connection over LAN": "استخدام اتصال TCP مباشر بدلا من LAN",
|
||||
"Using a direct TCP connection over WAN": "استخدام اتصال TCP مباشر بدلا من WAN",
|
||||
"Version": "الإصدار",
|
||||
"Versions": "نسخ",
|
||||
"Versions Path": "مسار النسخ",
|
||||
"Versions are automatically deleted if they are older than the maximum age or exceed the number of files allowed in an interval.": "يتم حذف الإصدارات تلقائيًا إذا تجاوزت العمر الأقصى أو تجاوزت عدد الملفات المسموح بها خلال فاصل زمني محدد.",
|
||||
"Waiting to Clean": "في انتظار التنظيف",
|
||||
"Waiting to Scan": "في انتظار الفحص",
|
||||
"Waiting to Sync": "في انتظار المزامنة",
|
||||
"Warning": "تحذير",
|
||||
"Warning, this path is a parent directory of an existing folder \"{%otherFolder%}\".": "تحذير، هذا المجلد يحتوي داخله على مجلد آخر أضيف مسبقا {{otherFolder}}.",
|
||||
"Warning, this path is a parent directory of an existing folder \"{%otherFolderLabel%}\" ({%otherFolder%}).": "تحذير، هذا المجلد يحتوي داخله على مجلد آخر أضيف مسبقا\"{{otherFolderLabel}}\" ({{otherFolder}}).",
|
||||
"Warning, this path is a subdirectory of an existing folder \"{%otherFolder%}\".": "تحذير، هذا المجلد هو أحد محتويات مجلد مضاف مسبقا \"{{otherFolder}}\".",
|
||||
"Warning, this path is a subdirectory of an existing folder \"{%otherFolderLabel%}\" ({%otherFolder%}).": "تحذير، هذا المجلد هو أحد محتويات مجلد مضاف مسبقا \"{{otherFolderLabel}}\" ({{otherFolder}}).",
|
||||
"Warning: If you are using an external watcher like {%syncthingInotify%}, you should make sure it is deactivated.": "تحذير: إذا كنت مستخدما لمراقب خارجي كـ {{syncthingInotify}}، تأكد من تعطيله.",
|
||||
"Watch for Changes": "راقب التغييرات",
|
||||
"Watching for Changes": "جاري مراقبة التغيرات",
|
||||
"Watching for changes discovers most changes without periodic scanning.": "مراقبة التغييرات تكشف معظم التغييرات دون إجراء المسح الدوري.",
|
||||
"When adding a new device, keep in mind that this device must be added on the other side too.": "يجب أضافه الأجهزة الجديدة في الطرفين",
|
||||
"When adding a new folder, keep in mind that the Folder ID is used to tie folders together between devices. They are case sensitive and must match exactly between all devices.": "عند إضافة مجلد جديد ، ضع في الاعتبار أن معرف المجلد يُستخدم لربط المجلدات معًا بين الأجهزة المختلفة. وهي حساسة لحالة الأحرف لذا يجب أن تتطابق تمامًا بين جميع الأجهزة.",
|
||||
"When set to more than one on both devices, Syncthing will attempt to establish multiple concurrent connections. If the values differ, the highest will be used. Set to zero to let Syncthing decide.": "إذا عُرفَّ Syncthing بأنه أكثر من واحد على كلا الجهازين، فإنه سيحاول إقامة عدة اتصالات متوازية. إذا اختلفت القِيَم، أعلاها ستُستخدَم. صَفِّرها لتترك القرار لـ Syncthing.",
|
||||
"Yes": "نعم",
|
||||
"Yesterday": "أمس",
|
||||
"You can also copy and paste the text into a new message manually.": "يكنك نسخ النص لتدرجه في رسالة جديدة بنفسك.",
|
||||
"You can also select one of these nearby devices:": "يمكنك أيضا اختيار واحد من الأجهزة القريبة ",
|
||||
"You can change your choice at any time in the Settings dialog.": "يمكنك تغيير اختيارك في أي وقت بواسطة الاعدادات.",
|
||||
"You can read more about the two release channels at the link below.": "يمكنك قراءة المزيد عن إصداريّ القناتين عبر الرابط بالأسفل.",
|
||||
@@ -349,11 +524,31 @@
|
||||
"You have no ignored folders.": "لا يوجد مجلدات في قائمه التجاهل ",
|
||||
"You have unsaved changes. Do you really want to discard them?": "الإعدادات لم تحفظ. هل انت متأكد من الإلغاء؟ ",
|
||||
"You must keep at least one version.": "يجب الاحتفاظ بنسخة واحده على الاقل",
|
||||
"You should never add or change anything locally in a \"{%receiveEncrypted%}\" folder.": "ينبغي ألا تغير شيئا في المجلد المحلي في حالة كان \"{{receiveEncrypted}}\".",
|
||||
"Your SMS app should open to let you choose the recipient and send it from your own number.": "ينبغي أن يسمح تطبيق SMS لديك بأن تختار مستلما ويرسلها من رقمك.",
|
||||
"Your email app should open to let you choose the recipient and send it from your own address.": "ينبغي أن يسمح تطبيق البريد الإلكتروني الخاص بك باختيار مستلم و أن يرسلها من عنوانك.",
|
||||
"days": "أيام",
|
||||
"deleted": "مُسِحَ",
|
||||
"deny": "امنع",
|
||||
"directories": "مجلدات",
|
||||
"file": "ملف",
|
||||
"files": "ملفات",
|
||||
"folder": "مجلد",
|
||||
"full documentation": "الوثائق الكاملة",
|
||||
"items": "العناصر",
|
||||
"modified": "عُدِّل",
|
||||
"permit": "اسمح",
|
||||
"seconds": "ثواني",
|
||||
"theme": {
|
||||
"name": {
|
||||
"black": "أسوَد",
|
||||
"dark": "داكن",
|
||||
"default": "افتراضي",
|
||||
"light": "خفيف"
|
||||
}
|
||||
},
|
||||
"unknown device": "جهاز مجهول",
|
||||
"{%device%} wants to share folder \"{%folder%}\".": "{{device}} يريد مشاركة مجلد \"{{folder}}\". ",
|
||||
"{%device%} wants to share folder \"{%folderlabel%}\" ({%folder%}).": "{{device}} يريد مشاركة مجلد \"{{folderlabel}}\" ({{folder}}). "
|
||||
"{%device%} wants to share folder \"{%folderlabel%}\" ({%folder%}).": "{{device}} يريد مشاركة مجلد \"{{folderlabel}}\" ({{folder}}). ",
|
||||
"{%reintroducer%} might reintroduce this device.": "{{reintroducer}} يمكن أن يعيد تقديم هذا الجهاز."
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"Device ID": "Идентификатор на устройство",
|
||||
"Device Identification": "Идентификация на устройство",
|
||||
"Device Name": "Име на устройството",
|
||||
"Device Status": "Състояние на устройството",
|
||||
"Device is untrusted, enter encryption password": "Устройството е недоверено, въведете парола за шифроване",
|
||||
"Device rate limits": "Ограничаване на скоростта",
|
||||
"Device that last modified the item": "Устройство, което последно промени обекта",
|
||||
@@ -168,6 +169,7 @@
|
||||
"Folder ID": "Идентификатор на папката",
|
||||
"Folder Label": "Име на папката",
|
||||
"Folder Path": "Път до папката",
|
||||
"Folder Status": "Състояние на папката",
|
||||
"Folder Type": "Вид на папката",
|
||||
"Folder type \"{%receiveEncrypted%}\" can only be set when adding a new folder.": "Вида „{{receiveEncrypted}}“ може да бъде избран само при добавяне на папка.",
|
||||
"Folder type \"{%receiveEncrypted%}\" cannot be changed after adding the folder. You need to remove the folder, delete or decrypt the data on disk, and add the folder again.": "Видът папката „{{receiveEncrypted}}“ не може да бъде променян след нейното създаване. Трябва да я премахнете, изтриете или разшифровате съдържанието и да добавите папката отново.",
|
||||
@@ -545,7 +547,8 @@
|
||||
"light": "Светла"
|
||||
}
|
||||
},
|
||||
"{%device%} wants to share folder \"{%folder%}\".": "{{device}} споделя папката „{{folder}}“.",
|
||||
"{%device%} wants to share folder \"{%folderlabel%}\" ({%folder%}).": "{{device}} споделя папката „{{folderlabel}}“ ({{folder}}).",
|
||||
"unknown device": "непознато устройство",
|
||||
"{%device%} wants to share folder \"{%folder%}\".": "{{device}} иска папката „{{folder}}“ да бъде споделена.",
|
||||
"{%device%} wants to share folder \"{%folderlabel%}\" ({%folder%}).": "{{device}} иска папката „{{folderlabel}}“ ({{folder}}) да бъде споделена.",
|
||||
"{%reintroducer%} might reintroduce this device.": "Поръчителят {{reintroducer}} може отново да предложи това устройство."
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"Device ID": "Enheds-ID",
|
||||
"Device Identification": "Enhedsidentifikation",
|
||||
"Device Name": "Enhedsnavn",
|
||||
"Device Status": "Enhedsstatus",
|
||||
"Device is untrusted, enter encryption password": "Enhed er ikke-troværdig, indtast krypteringsadgangskode",
|
||||
"Device rate limits": "Enhedens hastighedsbegrænsning",
|
||||
"Device that last modified the item": "Enhed, som sidst ændrede filen",
|
||||
@@ -168,6 +169,7 @@
|
||||
"Folder ID": "Mappe-ID",
|
||||
"Folder Label": "Mappeetiket",
|
||||
"Folder Path": "Mappesti",
|
||||
"Folder Status": "Mappestatus",
|
||||
"Folder Type": "Mappetype",
|
||||
"Folder type \"{%receiveEncrypted%}\" can only be set when adding a new folder.": "Mappe type \"{{receiveEncrypted}}\" kan kun indstilles når en ny mappe tilføjes.",
|
||||
"Folder type \"{%receiveEncrypted%}\" cannot be changed after adding the folder. You need to remove the folder, delete or decrypt the data on disk, and add the folder again.": "Mappetype \"{{receiveEncrypted}}\" kan ikke ændres, efter at mappen er tilføjet. Du skal fjerne mappen, slette eller dekryptere dataene på disken og tilføje mappen igen.",
|
||||
@@ -545,6 +547,7 @@
|
||||
"light": "Lys"
|
||||
}
|
||||
},
|
||||
"unknown device": "ukendt enhed",
|
||||
"{%device%} wants to share folder \"{%folder%}\".": "{{device}} ønsker at dele mappen “{{folder}}”.",
|
||||
"{%device%} wants to share folder \"{%folderlabel%}\" ({%folder%}).": "{{device}} ønsker at dele mappen “{{folderlabel}}” ({{folder}}).",
|
||||
"{%reintroducer%} might reintroduce this device.": "{{reintroducer}} vil muligvis genindføre denne enhed."
|
||||
|
||||
@@ -545,6 +545,7 @@
|
||||
"light": "Light"
|
||||
}
|
||||
},
|
||||
"unknown device": "unknown device",
|
||||
"{%device%} wants to share folder \"{%folder%}\".": "{{device}} wants to share folder \"{{folder}}\".",
|
||||
"{%device%} wants to share folder \"{%folderlabel%}\" ({%folder%}).": "{{device}} wants to share folder \"{{folderlabel}}\" ({{folder}}).",
|
||||
"{%reintroducer%} might reintroduce this device.": "{{reintroducer}} might reintroduce this device."
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"Device ID": "Device ID",
|
||||
"Device Identification": "Device Identification",
|
||||
"Device Name": "Device Name",
|
||||
"Device Status": "Device Status",
|
||||
"Device is untrusted, enter encryption password": "Device is untrusted, enter encryption password",
|
||||
"Device rate limits": "Device rate limits",
|
||||
"Device that last modified the item": "Device that last modified the item",
|
||||
@@ -168,6 +169,7 @@
|
||||
"Folder ID": "Folder ID",
|
||||
"Folder Label": "Folder Label",
|
||||
"Folder Path": "Folder Path",
|
||||
"Folder Status": "Folder Status",
|
||||
"Folder Type": "Folder Type",
|
||||
"Folder type \"{%receiveEncrypted%}\" can only be set when adding a new folder.": "Folder type \"{{receiveEncrypted}}\" can only be set when adding a new folder.",
|
||||
"Folder type \"{%receiveEncrypted%}\" cannot be changed after adding the folder. You need to remove the folder, delete or decrypt the data on disk, and add the folder again.": "Folder type \"{{receiveEncrypted}}\" cannot be changed after adding the folder. You need to remove the folder, delete or decrypt the data on disk, and add the folder again.",
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"Device ID": "ID del Dispositivo",
|
||||
"Device Identification": "Identificación del Dispositivo",
|
||||
"Device Name": "Nombre del Dispositivo",
|
||||
"Device Status": "Estado del dispositivo",
|
||||
"Device is untrusted, enter encryption password": "El dispositivo no es de confianza, introduzca la contraseña de cifrado",
|
||||
"Device rate limits": "Límites de velocidad del dispositivo",
|
||||
"Device that last modified the item": "Dispositivo que modificó por última vez el ítem",
|
||||
@@ -168,6 +169,7 @@
|
||||
"Folder ID": "ID de carpeta",
|
||||
"Folder Label": "Etiqueta de la Carpeta",
|
||||
"Folder Path": "Ruta de la carpeta",
|
||||
"Folder Status": "Estado de la carpeta",
|
||||
"Folder Type": "Tipo de carpeta",
|
||||
"Folder type \"{%receiveEncrypted%}\" can only be set when adding a new folder.": "El tipo de carpeta \"{{receiveEncrypted}}\" solo puede ser establecido al agregar una nueva carpeta.",
|
||||
"Folder type \"{%receiveEncrypted%}\" cannot be changed after adding the folder. You need to remove the folder, delete or decrypt the data on disk, and add the folder again.": "El tipo de carpeta \"{{receiveEncrypted}}\" no se puede cambiar después de añadir la carpeta. Es necesario eliminar la carpeta, borrar o descifrar los datos en el disco y volver a añadir la carpeta.",
|
||||
|
||||
@@ -441,7 +441,7 @@
|
||||
"The number of old versions to keep, per file.": "Nombre maximal d'anciennes versions à conserver indéfiniment, par fichier.",
|
||||
"The number of versions must be a number and cannot be blank.": "Le nombre de versions doit être numérique, et ne peut pas être vide.",
|
||||
"The path cannot be blank.": "Le chemin ne peut pas être vide.",
|
||||
"The rate limit is applied to the accumulated traffic of all connections to this device.": "La limite de taux s'applique au trafic cumulé des connexions à notre appareil.",
|
||||
"The rate limit is applied to the accumulated traffic of all connections to this device.": "Les limites s'appliquent au trafic cumulé des connexions à notre appareil (0 = pas de limite).",
|
||||
"The rate limit must be a non-negative number (0: no limit)": "La limite de débit ne doit pas être négative (0 = pas de limite)",
|
||||
"The remote device has not accepted sharing this folder.": "L'appareil distant n'a pas (encore ?) accepté de partager ce répertoire.",
|
||||
"The remote device has paused this folder.": "L'appareil distant a mis ce partage en pause.",
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"Device ID": "기기 식별자",
|
||||
"Device Identification": "기기 식별자",
|
||||
"Device Name": "기기명",
|
||||
"Device Status": "기기 상태",
|
||||
"Device is untrusted, enter encryption password": "신뢰하지 않는 기기입니다; 암호화 비밀번호를 입력하십시오",
|
||||
"Device rate limits": "기기 속도 제한",
|
||||
"Device that last modified the item": "항목을 가장 최근에 수정한 기기",
|
||||
@@ -168,6 +169,7 @@
|
||||
"Folder ID": "폴더 식별자",
|
||||
"Folder Label": "폴더명",
|
||||
"Folder Path": "폴더 경로",
|
||||
"Folder Status": "폴더 상태",
|
||||
"Folder Type": "폴더 유형",
|
||||
"Folder type \"{%receiveEncrypted%}\" can only be set when adding a new folder.": "\"{{receiveEncrypted}}\" 폴더 유형은 새 폴더를 추가할 때만 설정할 수 있습니다.",
|
||||
"Folder type \"{%receiveEncrypted%}\" cannot be changed after adding the folder. You need to remove the folder, delete or decrypt the data on disk, and add the folder again.": "\"{{receiveEncrypted}}\" 폴더 유형은 폴더를 추가한 후에 변경할 수 없습니다. 폴더를 먼저 삭제하고, 저장 장치에 있는 데이터를 삭제 또는 해독한 다음에 폴더를 다시 추가하십시오.",
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"Device ID": "Apparaat-ID",
|
||||
"Device Identification": "Apparaat-identificatie",
|
||||
"Device Name": "Naam apparaat",
|
||||
"Device Status": "Apparaatstatus",
|
||||
"Device is untrusted, enter encryption password": "Apparaat wordt niet vertrouwd. Versleutelingswachtwoord opgeven",
|
||||
"Device rate limits": "Snelheidsbegrenzingen apparaat",
|
||||
"Device that last modified the item": "Apparaat dat het item laatst gewijzigd heeft",
|
||||
@@ -168,6 +169,7 @@
|
||||
"Folder ID": "Map-ID",
|
||||
"Folder Label": "Maplabel",
|
||||
"Folder Path": "Maplocatie",
|
||||
"Folder Status": "Mapstatus",
|
||||
"Folder Type": "Soort map",
|
||||
"Folder type \"{%receiveEncrypted%}\" can only be set when adding a new folder.": "Maptype \"{{receiveEncrypted}}\" kan alleen ingesteld worden bij het toevoegen van een nieuwe map.",
|
||||
"Folder type \"{%receiveEncrypted%}\" cannot be changed after adding the folder. You need to remove the folder, delete or decrypt the data on disk, and add the folder again.": "Maptype \"{{receiveEncrypted}}\" kan niet gewijzigd worden na het toevoegen van de map. U moet de map verwijderen, de gegevens op schijf verwijderen of ontsleutelen en daarna de map opnieuw toevoegen.",
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"Device ID": "Identyfikator urządzenia",
|
||||
"Device Identification": "Identyfikator urządzenia",
|
||||
"Device Name": "Nazwa urządzenia",
|
||||
"Device Status": "Stan urządzenia",
|
||||
"Device is untrusted, enter encryption password": "Urządzenie jest niezaufane; wprowadź szyfrujące hasło",
|
||||
"Device rate limits": "Ograniczenia prędkości urządzenia",
|
||||
"Device that last modified the item": "Urządzenie, które jako ostatnie zmodyfikowało ten element",
|
||||
@@ -168,6 +169,7 @@
|
||||
"Folder ID": "Identyfikator folderu",
|
||||
"Folder Label": "Etykieta folderu",
|
||||
"Folder Path": "Ścieżka folderu",
|
||||
"Folder Status": "Stan folderu",
|
||||
"Folder Type": "Rodzaj folderu",
|
||||
"Folder type \"{%receiveEncrypted%}\" can only be set when adding a new folder.": "Rodzaj folderu \"{{receiveEncrypted}}\" może być ustawiony tylko przy dodawaniu nowego folderu.",
|
||||
"Folder type \"{%receiveEncrypted%}\" cannot be changed after adding the folder. You need to remove the folder, delete or decrypt the data on disk, and add the folder again.": "Rodzaj folderu \"{{receiveEncrypted}}\" nie może być zmieniony po dodaniu folderu. Musisz najpierw usunąć folder, skasować bądź też odszyfrować dane na dysku, a następnie dodać folder ponownie.",
|
||||
@@ -411,7 +413,7 @@
|
||||
"TCP WAN": "TCP WAN",
|
||||
"Take me back": "Powrót",
|
||||
"The GUI address is overridden by startup options. Changes here will not take effect while the override is in place.": "Adres GUI jest nadpisywany przez opcje uruchamiania. Zmiany dokonane tutaj nie będą obowiązywać, dopóki nadpisywanie jest w użyciu.",
|
||||
"The Syncthing Authors": "The Syncthing Authors",
|
||||
"The Syncthing Authors": "Twórcy Syncthing",
|
||||
"The Syncthing admin interface is configured to allow remote access without a password.": "Ustawienia interfejsu administracyjnego aplikacji Syncthing zezwalają na zdalny dostęp bez hasła.",
|
||||
"The aggregated statistics are publicly available at the URL below.": "Zebrane statystyki są publicznie dostępne pod poniższym adresem.",
|
||||
"The cleanup interval cannot be blank.": "Przedział czasowy czyszczenia nie może być pusty.",
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"Device ID": "ID do dispositivo",
|
||||
"Device Identification": "Identificação do dispositivo",
|
||||
"Device Name": "Nome do dispositivo",
|
||||
"Device Status": "Estado do dispositivo",
|
||||
"Device is untrusted, enter encryption password": "Dispositivo não fiável, insira senha de encriptação",
|
||||
"Device rate limits": "Limites de velocidade do dispositivo",
|
||||
"Device that last modified the item": "Último dispositivo a modificar o item",
|
||||
@@ -168,6 +169,7 @@
|
||||
"Folder ID": "ID da pasta",
|
||||
"Folder Label": "Etiqueta da pasta",
|
||||
"Folder Path": "Caminho da pasta",
|
||||
"Folder Status": "Estado da pasta",
|
||||
"Folder Type": "Tipo de pasta",
|
||||
"Folder type \"{%receiveEncrypted%}\" can only be set when adding a new folder.": "O tipo de pasta \"{{receiveEncrypted}}\" apenas pode ser definido aquando da adição de uma nova pasta.",
|
||||
"Folder type \"{%receiveEncrypted%}\" cannot be changed after adding the folder. You need to remove the folder, delete or decrypt the data on disk, and add the folder again.": "O tipo de pasta \"{{receiveEncrypted}}\" não pode ser modificado depois de adicionar a pasta. Tem de remover a pasta, eliminar ou desencriptar os dados no disco e adicionar a pasta novamente.",
|
||||
|
||||
@@ -26,9 +26,11 @@
|
||||
"Allow Anonymous Usage Reporting?": "Разрешить анонимный отчет об использовании?",
|
||||
"Allowed Networks": "Разрешённые сети",
|
||||
"Alphabetic": "По алфавиту",
|
||||
"Altered by ignoring deletes.": "Изменено, игнорируя удаления.",
|
||||
"An external command handles the versioning. It has to remove the file from the shared folder. If the path to the application contains spaces, it should be quoted.": "Для версионирования используется внешняя программа. Ей нужно удалить файл из общей папки. Если путь к приложению содержит пробелы, его нужно взять в кавычки.",
|
||||
"Anonymous Usage Reporting": "Анонимный отчет об использовании",
|
||||
"Anonymous usage report format has changed. Would you like to move to the new format?": "Формат анонимных отчётов изменился. Хотите переключиться на новый формат?",
|
||||
"Applied to LAN": "Примененоо к LAN",
|
||||
"Apply": "Применить",
|
||||
"Are you sure you want to override all remote changes?": "Уверены, что хотите перезаписать все удалённые изменения?",
|
||||
"Are you sure you want to permanently delete all these files?": "Уверены, что хотите навсегда удалить эти файлы?",
|
||||
@@ -37,6 +39,7 @@
|
||||
"Are you sure you want to restore {%count%} files?": "Уверены, что хотите восстановить {{count}} файлов?",
|
||||
"Are you sure you want to revert all local changes?": "Уверены, что хотите отменить все локальные изменения?",
|
||||
"Are you sure you want to upgrade?": "Уверены, что хотите обновить?",
|
||||
"Authentication Required": "Требуется Аутентификация",
|
||||
"Authors": "Авторы",
|
||||
"Auto Accept": "Автопринятие",
|
||||
"Automatic Crash Reporting": "Автоматическая отправка отчётов о сбоях",
|
||||
@@ -63,6 +66,7 @@
|
||||
"Configured": "Сконфигурировано",
|
||||
"Connected (Unused)": "Подключено (не используется)",
|
||||
"Connection Error": "Ошибка подключения",
|
||||
"Connection Management": "Управление соединениями",
|
||||
"Connection Type": "Тип соединения",
|
||||
"Connections": "Подключения",
|
||||
"Connections via relays might be rate limited by the relay": "Соединения через промежуточные узлы могут быть ограничены по количеству запросов единицу времени",
|
||||
@@ -172,10 +176,12 @@
|
||||
"Forever": "Вечно",
|
||||
"Full Rescan Interval (s)": "Интервал полного сканирования (в секундах)",
|
||||
"GUI": "Интерфейс",
|
||||
"GUI / API HTTPS Certificate": "HTTPS-сертификат GUI/API",
|
||||
"GUI Authentication Password": "Пароль для доступа к панели управления",
|
||||
"GUI Authentication User": "Имя пользователя для доступа к панели управления",
|
||||
"GUI Authentication: Set User and Password": "GUI аутентификация: Установите имя пользователя и пароль",
|
||||
"GUI Listen Address": "Адрес GUI",
|
||||
"GUI Override Directory": "Каталог переопределения графического интерфейса",
|
||||
"GUI Theme": "Тема оформления",
|
||||
"General": "Общие",
|
||||
"Generate": "Сгенерировать",
|
||||
@@ -183,6 +189,7 @@
|
||||
"Global Discovery Servers": "Серверы глобального обнаружения",
|
||||
"Global State": "Глобальное состояние",
|
||||
"Help": "Помощь",
|
||||
"Hint: only deny-rules detected while the default is deny. Consider adding \"permit any\" as last rule.": "Подсказка: обнаруживаются только запрещающие правила, тогда как по умолчанию они отклоняются. Рассмотрите возможность добавления «разрешить любое» в качестве последнего правила.",
|
||||
"Home page": "Сайт",
|
||||
"However, your current settings indicate you might not want it enabled. We have disabled automatic crash reporting for you.": "Ваши настройки указывают что вы не хотите, чтобы эта функция была включена. Мы отключили отправку отчетов о сбоях.",
|
||||
"Identification": "Идентификация",
|
||||
@@ -198,9 +205,11 @@
|
||||
"Included Software": "Включенное программное обеспечение",
|
||||
"Incoming Rate Limit (KiB/s)": "Ограничение входящей скорости (КиБ/с)",
|
||||
"Incorrect configuration may damage your folder contents and render Syncthing inoperable.": "Неправильные настройки могут повредить содержимое папок и сделать Syncthing неработоспособным.",
|
||||
"Incorrect user name or password.": "Неверное имя пользователя или пароль.",
|
||||
"Internally used paths:": "Внутренние используемые пути:",
|
||||
"Introduced By": "Рекомендовано",
|
||||
"Introducer": "Рекомендатель",
|
||||
"Introduction": "Вступление",
|
||||
"Inversion of the given condition (i.e. do not exclude)": "Инвертировать текущее условие (например, исключить)",
|
||||
"Keep Versions": "Количество хранимых версий",
|
||||
"LDAP": "LDAP",
|
||||
@@ -226,11 +235,18 @@
|
||||
"Locally Changed Items": "Объекты, изменённые на этом компьютере",
|
||||
"Log": "Журнал",
|
||||
"Log File": "Файл журнала",
|
||||
"Log In": "Вход",
|
||||
"Log Out": "Выход",
|
||||
"Log in to see paths information.": "Войдите, чтобы увидеть информацию о путях.",
|
||||
"Log in to see version information.": "Войдите, чтобы просмотреть информацию о версии.",
|
||||
"Log tailing paused. Scroll to the bottom to continue.": "Вывод журнала приостановлен. Прокрутите вниз, чтобы продолжить.",
|
||||
"Login failed, see Syncthing logs for details.": "Не удалось войти в систему. Посмотреть подробности можно в журнале Syncthing",
|
||||
"Logs": "Журналы",
|
||||
"Major Upgrade": "Обновление основной версии",
|
||||
"Mass actions": "Массовые действия",
|
||||
"Maximum Age": "Максимальный срок",
|
||||
"Maximum single entry size": "Максимальный размер одной записи",
|
||||
"Maximum total size": "Общий максимальный размер",
|
||||
"Metadata Only": "Только метаданные",
|
||||
"Minimum Free Disk Space": "Минимальное свободное место на диске",
|
||||
"Mod. Device": "Изм. устройство",
|
||||
@@ -247,9 +263,11 @@
|
||||
"No": "Нет",
|
||||
"No File Versioning": "Без управления версиями файлов",
|
||||
"No files will be deleted as a result of this operation.": "В результате этой операции никакие файлы не будут удалены",
|
||||
"No rules set": "Правила не заданы",
|
||||
"No upgrades": "Нет обновлений",
|
||||
"Not shared": "Не зашаренный",
|
||||
"Notice": "Внимание",
|
||||
"Number of Connections": "Количество подключений",
|
||||
"OK": "ОК",
|
||||
"Off": "Отключить",
|
||||
"Oldest First": "Сначала старые",
|
||||
@@ -260,6 +278,8 @@
|
||||
"Outgoing Rate Limit (KiB/s)": "Ограничение исходящей скорости (КиБ/с)",
|
||||
"Override": "Перезаписать",
|
||||
"Override Changes": "Перезаписать изменения",
|
||||
"Ownership": "Владелец",
|
||||
"Password": "Пароль",
|
||||
"Path": "Путь",
|
||||
"Path to the folder on the local computer. Will be created if it does not exist. The tilde character (~) can be used as a shortcut for": "Путь к папке на локальном компьютере. Если её не существует, то она будет создана. Тильда (~) может использоваться как сокращение для",
|
||||
"Path where versions should be stored (leave empty for the default .stversions directory in the shared folder).": "Путь, в котором нужно хранить версии (оставьте пустым для папки по умолчанию .stversions внутри общей папки).",
|
||||
@@ -282,6 +302,8 @@
|
||||
"Preview": "Предварительный просмотр",
|
||||
"Preview Usage Report": "Посмотреть отчёт об использовании",
|
||||
"QR code": "QR-код",
|
||||
"QUIC LAN": "QUIC LAN",
|
||||
"QUIC WAN": "QUIC WAN",
|
||||
"Quick guide to supported patterns": "Краткое руководство по поддерживаемым шаблонам",
|
||||
"Random": "Случайно",
|
||||
"Receive Encrypted": "Принять шифрованный",
|
||||
@@ -313,6 +335,7 @@
|
||||
"Revert": "Обратить",
|
||||
"Revert Local Changes": "Отменить изменения на этом компьютере",
|
||||
"Save": "Сохранить",
|
||||
"Saving changes": "Изменения сохраняются",
|
||||
"Scan Time Remaining": "Оставшееся время сканирования",
|
||||
"Scanning": "Сканирование",
|
||||
"See external versioning help for supported templated command line parameters.": "Поддерживаемые шаблонные параметры командной строки см. в документации сторонней программы контроля версий",
|
||||
@@ -326,6 +349,7 @@
|
||||
"Send Extended Attributes": "Отправлять расширенные атрибуты",
|
||||
"Send Only": "Только отправить",
|
||||
"Send Ownership": "Отправлять информацию о владельце",
|
||||
"Set Ignores on Added Folder": "Установить игнорирования для добавленной папки",
|
||||
"Settings": "Настройки",
|
||||
"Share": "Предоставить доступ",
|
||||
"Share Folder": "Предоставить доступ к папке",
|
||||
@@ -362,9 +386,11 @@
|
||||
"Statistics": "Статистика",
|
||||
"Stopped": "Остановлено",
|
||||
"Stores and syncs only encrypted data. Folders on all connected devices need to be set up with the same password or be of type \"{%receiveEncrypted%}\" too.": "Хранит и синхронизирует только зашифрованные данные. Папки на всех подключённых устройствах должны быть настроены под один и тот же пароль или иметь тип «{{receiveEncrypted}}».",
|
||||
"Subject:": "Субьект:",
|
||||
"Support": "Поддержка",
|
||||
"Support Bundle": "Данные для поддержки",
|
||||
"Sync Extended Attributes": "Синхронизировать расширенные атрибуты",
|
||||
"Sync Ownership": "Синхронизация владений",
|
||||
"Sync Protocol Listen Addresses": "Адрес протокола синхронизации",
|
||||
"Sync Status": "Состояние синхронизации",
|
||||
"Syncing": "Синхронизация",
|
||||
@@ -376,10 +402,13 @@
|
||||
"Syncthing is listening on the following network addresses for connection attempts from other devices:": "Syncthing ожидает подключения от других устройств на следующих сетевых адресах:",
|
||||
"Syncthing is not listening for connection attempts from other devices on any address. Only outgoing connections from this device may work.": "Syncthing не ожидает попыток подключения ни на каких адресах. Только исходящие подключения могут работать на этом устройстве.",
|
||||
"Syncthing is restarting.": "Перезапуск Syncthing.",
|
||||
"Syncthing is saving changes.": "Синхронизация это сохранение изменений.",
|
||||
"Syncthing is upgrading.": "Обновление Syncthing.",
|
||||
"Syncthing now supports automatically reporting crashes to the developers. This feature is enabled by default.": "Syncthing теперь поддерживает автоматическую отправку отчетов о сбоях разработчикам. Эта функция включена по умолчанию.",
|
||||
"Syncthing seems to be down, or there is a problem with your Internet connection. Retrying…": "Кажется, Syncthing не запущен или есть проблемы с подключением к Интернету. Переподключаюсь...",
|
||||
"Syncthing seems to be experiencing a problem processing your request. Please refresh the page or restart Syncthing if the problem persists.": "Syncthing столкнулся с проблемой при обработке Вашего запроса. Пожалуйста, обновите страницу или перезапустите Syncthing если проблема повторится.",
|
||||
"TCP LAN": "TCP LAN",
|
||||
"TCP WAN": "TCP WAN",
|
||||
"Take me back": "Вернуться к редактированию",
|
||||
"The GUI address is overridden by startup options. Changes here will not take effect while the override is in place.": "Эти изменения не вступят в силу, пока адрес панели управления переопределён в настройках запуска.",
|
||||
"The Syncthing Authors": "Авторы Syncthing",
|
||||
@@ -406,12 +435,15 @@
|
||||
"The interval, in seconds, for running cleanup in the versions directory. Zero to disable periodic cleaning.": "Интервал в секундах для запуска очистки в каталоге версий. Ноль, чтобы отключить периодическую очистку.",
|
||||
"The maximum age must be a number and cannot be blank.": "Максимальный срок должен быть числом и не может быть пустым.",
|
||||
"The maximum time to keep a version (in days, set to 0 to keep versions forever).": "Максимальный срок хранения версии (в днях, 0 значит вечное хранение).",
|
||||
"The number of connections must be a non-negative number.": "Количество соединений должно быть неотрицательным.",
|
||||
"The number of days must be a number and cannot be blank.": "Количество дней должно быть числом и не может быть пустым.",
|
||||
"The number of days to keep files in the trash can. Zero means forever.": "Количество дней хранения файлов в корзине. Ноль значит навсегда.",
|
||||
"The number of old versions to keep, per file.": "Количество хранимых версий файла.",
|
||||
"The number of versions must be a number and cannot be blank.": "Количество версий должно быть числом и не может быть пустым.",
|
||||
"The path cannot be blank.": "Путь не может быть пустым.",
|
||||
"The rate limit is applied to the accumulated traffic of all connections to this device.": "Ограничение скорости применяется к накопленному трафику всех подключений к этому устройству.",
|
||||
"The rate limit must be a non-negative number (0: no limit)": "Скорость должна быть неотрицательным числом (0: нет ограничения)",
|
||||
"The remote device has not accepted sharing this folder.": "Удаленное устройство не разрешило общий доступ к этой папке.",
|
||||
"The remote device has paused this folder.": "Удаленное устройство приостановило эту папку.",
|
||||
"The rescan interval must be a non-negative number of seconds.": "Интервал пересканирования должен быть неотрицательным количеством секунд.",
|
||||
"There are no devices to share this folder with.": "Нет устройств, для которых будет доступна эта папка.",
|
||||
@@ -454,7 +486,11 @@
|
||||
"Usage reporting is always enabled for candidate releases.": "Отправка отчётов об использовании всегда включена для кандидатов в релизы.",
|
||||
"Use HTTPS for GUI": "Использовать HTTPS для панели управления",
|
||||
"Use notifications from the filesystem to detect changed items.": "Использовать уведомления от файловой системы для обнаружения изменённых объектов.",
|
||||
"User": "Пользователь",
|
||||
"User Home": "Папка пользователя",
|
||||
"Username/Password has not been set for the GUI authentication. Please consider setting it up.": "Имя пользователя/пароль не был установлен для GUI-аутентификации. Настройте его.",
|
||||
"Using a QUIC connection over LAN": "Использование QUIC-соединения по локальной сети",
|
||||
"Using a QUIC connection over WAN": "Использование QUIC-соединения по всеобщей сети",
|
||||
"Using a direct TCP connection over LAN": "Использование прямого TCP-соединения через LAN",
|
||||
"Using a direct TCP connection over WAN": "Использование прямого TCP-соединения через WAN",
|
||||
"Version": "Версия",
|
||||
@@ -475,6 +511,7 @@
|
||||
"Watching for changes discovers most changes without periodic scanning.": "Отслеживание обнаруживает изменения без периодического сканирования.",
|
||||
"When adding a new device, keep in mind that this device must be added on the other side too.": "Когда добавляете устройство, помните о том, что это же устройство должно быть добавлено и другой стороной.",
|
||||
"When adding a new folder, keep in mind that the Folder ID is used to tie folders together between devices. They are case sensitive and must match exactly between all devices.": "Когда добавляете новую папку, помните, что ID папок используются для того, чтобы связывать папки между всеми устройствами. Они чувствительны к регистру и должны совпадать на всех используемых устройствах.",
|
||||
"When set to more than one on both devices, Syncthing will attempt to establish multiple concurrent connections. If the values differ, the highest will be used. Set to zero to let Syncthing decide.": "Когда установлено значение больше еденицы, Syncthing попытается установить несколько одновременных подключений. Если значения различаются, будет использоваться наибольшее. Установите значение 0, чтобы позволить Syncthing решать самостоятельно.",
|
||||
"Yes": "Да",
|
||||
"Yesterday": "Вчера",
|
||||
"You can also copy and paste the text into a new message manually.": "Вы также можете скопировать и вставить текст в новое сообщение вручную.",
|
||||
@@ -486,8 +523,11 @@
|
||||
"You have unsaved changes. Do you really want to discard them?": "Есть несохранённые изменения. Вы действительно хотите отменить их?",
|
||||
"You must keep at least one version.": "Вы должны хранить как минимум одну версию.",
|
||||
"You should never add or change anything locally in a \"{%receiveEncrypted%}\" folder.": "Не добавляйте и не изменяйте ничего локально в папке «{{receiveEncrypted}}».",
|
||||
"Your SMS app should open to let you choose the recipient and send it from your own number.": "Должно открыться приложение SMS, где вы сможете выбрать получателя и отправителя со своего номера.",
|
||||
"Your email app should open to let you choose the recipient and send it from your own address.": "Ваше почтовое приложение должно открыться, чтобы вы могли выбрать получателя и отправителя со своего адреса.",
|
||||
"days": "дней",
|
||||
"deleted": "удалено",
|
||||
"deny": "отклонить",
|
||||
"directories": "папок",
|
||||
"file": "файл",
|
||||
"files": "файлов",
|
||||
@@ -495,6 +535,7 @@
|
||||
"full documentation": "полная документация",
|
||||
"items": "элементы",
|
||||
"modified": "изменено",
|
||||
"permit": "разрешить",
|
||||
"seconds": "сек.",
|
||||
"theme": {
|
||||
"name": {
|
||||
@@ -504,6 +545,7 @@
|
||||
"light": "Светлая"
|
||||
}
|
||||
},
|
||||
"unknown device": "неизвестное устройство",
|
||||
"{%device%} wants to share folder \"{%folder%}\".": "{{device}} хочет поделиться папкой «{{folder}}».",
|
||||
"{%device%} wants to share folder \"{%folderlabel%}\" ({%folder%}).": "{{device}} хочет поделиться папкой «{{folderlabel}}» ({{folder}}).",
|
||||
"{%reintroducer%} might reintroduce this device.": "{{reintroducer}} может повторно рекомендовать это устройство."
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"Device ID": "Enhets-ID",
|
||||
"Device Identification": "Enhetens identifikation",
|
||||
"Device Name": "Enhetsnamn",
|
||||
"Device Status": "Enhetsstatus",
|
||||
"Device is untrusted, enter encryption password": "Enheten är otillförlitlig, ange krypteringslösenord",
|
||||
"Device rate limits": "Enhetshastighetsgränser",
|
||||
"Device that last modified the item": "Enhet som senast ändrade objektet",
|
||||
@@ -168,6 +169,7 @@
|
||||
"Folder ID": "Mapp-ID",
|
||||
"Folder Label": "Mappetikett",
|
||||
"Folder Path": "Mappsökväg",
|
||||
"Folder Status": "Mappstatus",
|
||||
"Folder Type": "Mapptyp",
|
||||
"Folder type \"{%receiveEncrypted%}\" can only be set when adding a new folder.": "Mapptypen \"{{receiveEncrypted}}\" kan bara ställas in vid tilläggning av en ny mapp.",
|
||||
"Folder type \"{%receiveEncrypted%}\" cannot be changed after adding the folder. You need to remove the folder, delete or decrypt the data on disk, and add the folder again.": "Mapptypen \"{{receiveEncrypted}}\" kan inte ändras efter att mappen har lagts till. Du måste ta bort mappen, ta bort eller dekryptera data på disken och lägga till mappen igen.",
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"Device ID": "Cihaz Kimliği",
|
||||
"Device Identification": "Cihaz Kimliği",
|
||||
"Device Name": "Cihaz Adı",
|
||||
"Device Status": "Cihaz Durumu",
|
||||
"Device is untrusted, enter encryption password": "Cihaz güvenilmez, şifreleme parolasını girin",
|
||||
"Device rate limits": "Cihaz hız sınırları",
|
||||
"Device that last modified the item": "Öğeyi son değiştiren cihaz",
|
||||
@@ -168,6 +169,7 @@
|
||||
"Folder ID": "Klasör Kimliği",
|
||||
"Folder Label": "Klasör Etiketi",
|
||||
"Folder Path": "Klasör Yolu",
|
||||
"Folder Status": "Klasör Durumu",
|
||||
"Folder Type": "Klasör Türü",
|
||||
"Folder type \"{%receiveEncrypted%}\" can only be set when adding a new folder.": "Klasör türü \"{{receiveEncrypted}}\" yalnızca yeni bir klasör eklerken ayarlanabilir.",
|
||||
"Folder type \"{%receiveEncrypted%}\" cannot be changed after adding the folder. You need to remove the folder, delete or decrypt the data on disk, and add the folder again.": "Klasör türü \"{{receiveEncrypted}}\", klasör eklendikten sonra değiştirilemez. Klasörü kaldırmanız, diskteki verileri silmeniz veya şifresini çözmeniz ve klasörü tekrar eklemeniz gerekir.",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"Are you sure you want to revert all local changes?": "Ви впевнені, що бажаєте відкинути всі локальні зміни?",
|
||||
"Are you sure you want to upgrade?": "Впевнені, що хочете оновитися?",
|
||||
"Authors": "Автори",
|
||||
"Auto Accept": "Затверджувати автоматично пропоновані віддаленим пристроєм каталоги",
|
||||
"Auto Accept": "Автоприймання",
|
||||
"Automatic Crash Reporting": "Автоматичне звітування про збої",
|
||||
"Automatic upgrade now offers the choice between stable releases and release candidates.": "Автоматиче оновлення зараз дозволяє обирати між стабільними випусками та реліз-кандидатами.",
|
||||
"Automatic upgrades": "Автоматичні оновлення",
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"Device ID": "设备 ID",
|
||||
"Device Identification": "设备标识",
|
||||
"Device Name": "设备名",
|
||||
"Device Status": "设备状态",
|
||||
"Device is untrusted, enter encryption password": "设备不可信,请输入加密密码",
|
||||
"Device rate limits": "设备速率限制",
|
||||
"Device that last modified the item": "最近修改该项的设备",
|
||||
@@ -168,6 +169,7 @@
|
||||
"Folder ID": "文件夹 ID",
|
||||
"Folder Label": "文件夹标签",
|
||||
"Folder Path": "文件夹路径",
|
||||
"Folder Status": "文件夹状态",
|
||||
"Folder Type": "文件夹类型",
|
||||
"Folder type \"{%receiveEncrypted%}\" can only be set when adding a new folder.": "只能在添加新文件夹时设置文件夹类型“{{receiveEncrypted}}”。",
|
||||
"Folder type \"{%receiveEncrypted%}\" cannot be changed after adding the folder. You need to remove the folder, delete or decrypt the data on disk, and add the folder again.": "添加文件夹后,无法更改文件夹类型“ {{receiveEncrypted}}”。您需要删除该文件夹,删除或解密磁盘上的数据,然后再次添加该文件夹。",
|
||||
@@ -296,7 +298,7 @@
|
||||
"Please consult the release notes before performing a major upgrade.": "请在进行重大更新前查看发布说明。",
|
||||
"Please set a GUI Authentication User and Password in the Settings dialog.": "请在设置对话框中设置 GUI 验证用户及其密码。",
|
||||
"Please wait": "请稍候",
|
||||
"Prefix indicating that the file can be deleted if preventing directory removal": "可以删除文件的前缀来防止目录删除",
|
||||
"Prefix indicating that the file can be deleted if preventing directory removal": "此前缀表示,如果文件阻止删除目录则文件可被删除",
|
||||
"Prefix indicating that the pattern should be matched without case sensitivity": "此前缀表示,后面的模式在匹配时不区分大小写",
|
||||
"Preparing to Sync": "准备同步",
|
||||
"Preview": "预览",
|
||||
|
||||
@@ -57,13 +57,16 @@
|
||||
"Configured": "已設定",
|
||||
"Connected (Unused)": "已連線(未使用)",
|
||||
"Connection Error": "連線錯誤",
|
||||
"Connection Management": "連線管理",
|
||||
"Connection Type": "連線類型",
|
||||
"Connections": "連線",
|
||||
"Connections via relays might be rate limited by the relay": "通過中繼的連線可能會受到中繼的速率限制",
|
||||
"Continuously watching for changes is now available within Syncthing. This will detect changes on disk and issue a scan on only the modified paths. The benefits are that changes are propagated quicker and that less full scans are required.": "Syncthing 現在能持續地監視變動了。此機制將偵測到磁碟上的變動並僅對修改過的項目發起掃描。優點是檔案的變動將更快地傳播,並且減少完整掃描的需求。",
|
||||
"Copied from elsewhere": "從別處複製",
|
||||
"Copied from original": "從原處複製",
|
||||
"Copied!": "已複製!",
|
||||
"Copy": "複製",
|
||||
"Copy failed! Try to select and copy manually.": "複製失敗!嘗試手動選擇並複製。",
|
||||
"Currently Shared With Devices": "目前與裝置共享",
|
||||
"Danger!": "危險!",
|
||||
"Debugging Facilities": "除錯工具",
|
||||
@@ -382,6 +385,7 @@
|
||||
"The number of old versions to keep, per file.": "每個檔案要保留的舊版本數量。",
|
||||
"The number of versions must be a number and cannot be blank.": "每個檔案要保留的舊版本數量必須是數字且不能為空白。",
|
||||
"The path cannot be blank.": "路徑不能空白。",
|
||||
"The rate limit is applied to the accumulated traffic of all connections to this device.": "速率限制會套用到這台裝置所有連線的流量總和。",
|
||||
"The rate limit must be a non-negative number (0: no limit)": "限制速率必須為非負的數字 (0: 不設限制)",
|
||||
"The remote device has not accepted sharing this folder.": "遠端裝置尚未接受分享這個資料夾。",
|
||||
"The remote device has paused this folder.": "遠端裝置已暫停同步此資料夾。",
|
||||
@@ -442,6 +446,7 @@
|
||||
"Watching for changes discovers most changes without periodic scanning.": "監視變動會發現大多數變更,而無需定期掃描。",
|
||||
"When adding a new device, keep in mind that this device must be added on the other side too.": "當新增一個裝置時,務必記住,當前的這個裝置也同樣必須被添加至另一邊。",
|
||||
"When adding a new folder, keep in mind that the Folder ID is used to tie folders together between devices. They are case sensitive and must match exactly between all devices.": "當新增一個資料夾時,請記住,資料夾識別碼是用來將裝置之間的資料夾綁定在一起的。它們有區分大小寫,且必須在所有裝置之間完全相同。",
|
||||
"When set to more than one on both devices, Syncthing will attempt to establish multiple concurrent connections. If the values differ, the highest will be used. Set to zero to let Syncthing decide.": "當兩台裝置都設定為大於 1 時,Syncthing 會嘗試建立多個並行的連線。如果兩台裝置數值不同,最高的數值會被使用。設定為 0 以便讓 Syncthing 自行決定。",
|
||||
"Yes": "是",
|
||||
"You can also select one of these nearby devices:": "您亦可從這些附近裝置中擇一:",
|
||||
"You can change your choice at any time in the Settings dialog.": "您可以在設定對話框中隨時更改您的選擇。",
|
||||
|
||||
@@ -1 +1 @@
|
||||
var langPrettyprint = {"bg":"Bulgarian","ca":"Catalan","ca@valencia":"Valencian","cs":"Czech","da":"Danish","de":"German","en":"English","en-GB":"English (United Kingdom)","es":"Spanish","eu":"Basque","fr":"French","fy":"Frisian","hu":"Hungarian","id":"Indonesian","it":"Italian","ja":"Japanese","ko-KR":"Korean","lt":"Lithuanian","nl":"Dutch","pl":"Polish","pt-BR":"Portuguese (Brazil)","pt-PT":"Portuguese (Portugal)","ro-RO":"Romanian","ru":"Russian","sk":"Slovak","sl":"Slovenian","sv":"Swedish","tr":"Turkish","uk":"Ukrainian","zh-CN":"Chinese (Simplified)","zh-HK":"Chinese (Traditional, Hong Kong)","zh-TW":"Chinese (Traditional)"}
|
||||
var langPrettyprint = {"ar":"Arabic","bg":"Bulgarian","ca":"Catalan","ca@valencia":"Valencian","cs":"Czech","da":"Danish","de":"German","en":"English","en-GB":"English (United Kingdom)","es":"Spanish","eu":"Basque","fr":"French","fy":"Frisian","hu":"Hungarian","id":"Indonesian","it":"Italian","ja":"Japanese","ko-KR":"Korean","lt":"Lithuanian","nl":"Dutch","pl":"Polish","pt-BR":"Portuguese (Brazil)","pt-PT":"Portuguese (Portugal)","ro-RO":"Romanian","ru":"Russian","sk":"Slovak","sl":"Slovenian","sv":"Swedish","tr":"Turkish","uk":"Ukrainian","zh-CN":"Chinese (Simplified)","zh-HK":"Chinese (Traditional, Hong Kong)","zh-TW":"Chinese (Traditional)"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
var validLangs = ["bg","ca","ca@valencia","cs","da","de","en","en-GB","es","eu","fr","fy","hu","id","it","ja","ko-KR","lt","nl","pl","pt-BR","pt-PT","ro-RO","ru","sk","sl","sv","tr","uk","zh-CN","zh-HK","zh-TW"]
|
||||
var validLangs = ["ar","bg","ca","ca@valencia","cs","da","de","en","en-GB","es","eu","fr","fy","hu","id","it","ja","ko-KR","lt","nl","pl","pt-BR","pt-PT","ro-RO","ru","sk","sl","sv","tr","uk","zh-CN","zh-HK","zh-TW"]
|
||||
|
||||
@@ -395,37 +395,12 @@
|
||||
<span ng-if="folder.type == 'receiveencrypted'" class="fas fa-fw fa-lock"></span>
|
||||
</div>
|
||||
<div class="panel-status pull-right text-{{folderClass(folder)}}" ng-switch="folderStatus(folder)">
|
||||
<span ng-switch-when="paused"><span class="hidden-xs" translate>Paused</span><span class="visible-xs" aria-label="{{'Paused' | translate}}"><i class="fas fa-fw fa-pause"></i></span></span>
|
||||
<span ng-switch-when="unknown"><span class="hidden-xs" translate>Unknown</span><span class="visible-xs" aria-label="{{'Unknown' | translate}}"><i class="fas fa-fw fa-question-circle"></i></span></span>
|
||||
<span ng-switch-when="unshared"><span class="hidden-xs" translate>Unshared</span><span class="visible-xs" aria-label="{{'Unshared' | translate}}"><i class="fas fa-fw fa-unlink"></i></span></span>
|
||||
<span ng-switch-when="scan-waiting"><span class="hidden-xs" translate>Waiting to Scan</span><span class="visible-xs" aria-label="{{'Waiting to Scan' | translate}}"><i class="fas fa-fw fa-hourglass-half"></i></span></span>
|
||||
<span ng-switch-when="cleaning"><span class="hidden-xs" translate>Cleaning Versions</span><span class="visible-xs" aria-label="{{'Cleaning Versions' | translate}}"><i class="fas fa-fw fa-recycle"></i></span></span>
|
||||
<span ng-switch-when="clean-waiting"><span class="hidden-xs" translate>Waiting to Clean</span><span class="visible-xs" aria-label="{{'Waiting to Clean' | translate}}"><i class="fas fa-fw fa-hourglass-half"></i></span></span>
|
||||
<span ng-switch-when="stopped"><span class="hidden-xs" translate>Stopped</span><span class="visible-xs" aria-label="{{'Stopped' | translate}}"><i class="fas fa-fw fa-stop"></i></span></span>
|
||||
<span ng-switch-when="scanning">
|
||||
<span class="hidden-xs" translate>Scanning</span>
|
||||
<span class="hidden-xs" ng-if="scanPercentage(folder.id) != undefined">
|
||||
({{scanPercentage(folder.id) | percent}})
|
||||
</span>
|
||||
<span class="visible-xs" aria-label="{{'Scanning' | translate}}"><i class="fas fa-fw fa-search"></i></span>
|
||||
</span>
|
||||
<span ng-switch-when="idle"><span class="hidden-xs" translate>Up to Date</span><span class="visible-xs" aria-label="{{'Up to Date' | translate}}"><i class="fas fa-fw fa-check"></i></span></span>
|
||||
<span ng-switch-when="localadditions"><span class="hidden-xs" translate>Local Additions</span><span class="visible-xs" aria-label="{{'Local Additions' | translate}}"><i class="fas fa-fw fa-check"></i></span></span>
|
||||
<span ng-switch-when="sync-waiting">
|
||||
<span class="hidden-xs" translate>Waiting to Sync</span>
|
||||
<span class="visible-xs" aria-label="{{'Waiting to Sync' | translate}}"><i class="fas fa-fw fa-hourglass-half"></i></span>
|
||||
</span>
|
||||
<span ng-switch-when="sync-preparing">
|
||||
<span class="hidden-xs" translate>Preparing to Sync</span>
|
||||
<span class="visible-xs" aria-label="{{'Preparing to Sync' | translate}}"><i class="fas fa-fw fa-hourglass-half"></i></span>
|
||||
</span>
|
||||
<span ng-switch-when="syncing">
|
||||
<span class="hidden-xs" translate>Syncing</span>
|
||||
<span>({{syncPercentage(folder.id) | percent}}, {{model[folder.id].needBytes | binary}}B)</span>
|
||||
</span>
|
||||
<span ng-switch-when="outofsync"><span class="hidden-xs" translate>Out of Sync</span><span class="visible-xs" aria-label="{{'Out of Sync' | translate}}"><i class="fas fa-fw fa-exclamation-circle"></i></span></span>
|
||||
<span ng-switch-when="faileditems"><span class="hidden-xs" translate>Failed Items</span><span class="visible-xs" aria-label="{{'Failed Items' | translate}}"><i class="fas fa-fw fa-exclamation-circle"></i></span></span>
|
||||
<span ng-switch-when="localunencrypted"><span class="hidden-xs">{{'Unexpected Items' | translate}}</span><span class="visible-xs" aria-label="{{'Unexpected Items' | translate}}"><i class="fas fa-fw fa-exclamation-circle"></i></span></span>
|
||||
<span class="hidden-xs">{{folderStatusText(folder)}}</span>
|
||||
<span ng-switch-when="scanning" ng-if="scanPercentage(folder.id) != undefined">({{scanPercentage(folder.id) | percent}})</span>
|
||||
<span ng-switch-when="syncing">({{syncPercentage(folder.id) | percent}}, {{model[folder.id].needBytes | binary}}B)</span>
|
||||
<span class="inline-icon">
|
||||
<span class="visible-xs fa fa-fw {{folderStatusIcon(folder)}}" aria-label="{{folderStatusText(folder)}}"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="panel-title-text">
|
||||
<span tooltip data-original-title="{{folder.label.length != 0 ? folder.id : ''}}">{{folder.label.length != 0 ? folder.label : folder.id}}</span>
|
||||
@@ -436,6 +411,10 @@
|
||||
<div class="panel-body">
|
||||
<table class="table table-condensed table-striped table-auto">
|
||||
<tbody>
|
||||
<tr class="visible-xs">
|
||||
<th><span class="fa fa-fw {{folderStatusIcon(folder)}}"></span> <span translate>Folder Status</span></th>
|
||||
<td class="text-right">{{folderStatusText(folder)}}</td>
|
||||
</tr>
|
||||
<tr ng-show="folder.label != undefined && folder.label.length > 0">
|
||||
<th><span class="fas fa-fw fa-info-circle"></span> <span translate>Folder ID</span></th>
|
||||
<td class="text-right no-overflow-ellipse">{{folder.id}}</td>
|
||||
@@ -794,23 +773,16 @@
|
||||
<div class="panel-progress" ng-show="deviceStatus(deviceCfg) == 'syncing'" ng-attr-style="width: {{completion[deviceCfg.deviceID]._total | percent}}"></div>
|
||||
<h4 class="panel-title">
|
||||
<identicon class="panel-icon" data-value="deviceCfg.deviceID"></identicon>
|
||||
<span class="pull-right text-{{deviceClass(deviceCfg)}}">
|
||||
<span ng-switch="deviceStatus(deviceCfg)" class="remote-devices-panel">
|
||||
<span ng-switch-when="insync"><span class="hidden-xs" translate>Up to Date</span><span class="visible-xs" aria-label="{{'Up to Date' | translate}}"><i class="fas fa-fw fa-check"></i></span></span>
|
||||
<span ng-switch-when="unused-insync"><span class="hidden-xs" translate>Connected (Unused)</span><span class="visible-xs" aria-label="{{'Connected (Unused)' | translate}}"><i class="fas fa-fw fa-unlink"></i></span></span>
|
||||
<span ng-switch-when="syncing">
|
||||
<span class="hidden-xs" translate>Syncing</span> ({{completion[deviceCfg.deviceID]._total | percent}}, {{completion[deviceCfg.deviceID]._needBytes | binary}}B)
|
||||
</span>
|
||||
<span ng-switch-when="paused"><span class="hidden-xs" translate>Paused</span><span class="visible-xs" aria-label="{{'Paused' | translate}}"><i class="fas fa-fw fa-pause"></i></span></span>
|
||||
<span ng-switch-when="unused-paused"><span class="hidden-xs" translate>Paused (Unused)</span><span class="visible-xs" aria-label="{{'Paused (Unused)' | translate}}"><i class="fas fa-fw fa-unlink"></i></span></span>
|
||||
<span ng-switch-when="disconnected"><span class="hidden-xs" translate>Disconnected</span><span class="visible-xs" aria-label="{{'Disconnected' | translate}}"><i class="fas fa-fw fa-power-off"></i></span></span>
|
||||
<span ng-switch-when="disconnected-inactive"><span class="hidden-xs" translate>Disconnected (Inactive)</span><span class="visible-xs" aria-label="{{'Disconnected (Inactive)' | translate}}"><i class="fas fa-fw fa-power-off"></i></span></span>
|
||||
<span ng-switch-when="unused-disconnected"><span class="hidden-xs" translate>Disconnected (Unused)</span><span class="visible-xs" aria-label="{{'Disconnected (Unused)' | translate}}"><i class="fas fa-fw fa-unlink"></i></span></span>
|
||||
<div class="panel-status pull-right text-{{deviceClass(deviceCfg)}}" ng-switch="deviceStatus(deviceCfg)">
|
||||
<span class="hidden-xs">{{deviceStatusText(deviceCfg)}}</span>
|
||||
<span ng-switch-when="syncing">({{completion[deviceCfg.deviceID]._total | percent}}, {{completion[deviceCfg.deviceID]._needBytes | binary}}B)</span>
|
||||
<span class="inline-icon">
|
||||
<span class="visible-xs fa fa-fw {{deviceStatusIcon(deviceCfg)}}" aria-label="{{deviceStatusText(deviceCfg)}}"></span>
|
||||
</span>
|
||||
<span class="remote-devices-panel">
|
||||
<span class="inline-icon">
|
||||
<span ng-class="rdConnTypeIcon(rdConnType(deviceCfg.deviceID))" class="reception reception-theme"></span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="panel-title-text">{{deviceName(deviceCfg)}}</div>
|
||||
</h4>
|
||||
</button>
|
||||
@@ -818,6 +790,10 @@
|
||||
<div class="panel-body">
|
||||
<table class="table table-condensed table-striped table-auto">
|
||||
<tbody>
|
||||
<tr class="visible-xs">
|
||||
<th><span class="fa fa-fw {{deviceStatusIcon(deviceCfg)}}"></span> <span translate>Device Status</span></th>
|
||||
<td class="text-right">{{deviceStatusText(deviceCfg)}}</td>
|
||||
</tr>
|
||||
<tr ng-if="!connections[deviceCfg.deviceID].connected">
|
||||
<th><span class="fas fa-fw fa-eye"></span> <span translate>Last seen</span></th>
|
||||
<td class="text-right">
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<h4 class="text-center" translate>The Syncthing Authors</h4>
|
||||
<div class="row">
|
||||
<div class="col-md-12" id="contributor-list">
|
||||
Jakob Borg, Audrius Butkevicius, Jesse Lucas, Simon Frei, Tomasz Wilczyński, Alexander Graf, Alexandre Viau, Anderson Mesquita, André Colomb, Antony Male, Ben Schulz, Caleb Callaway, Daniel Harte, Evgeny Kuznetsov, Lars K.W. Gohlke, Lode Hoste, Michael Ploujnikov, Nate Morrison, Philippe Schommers, Ryan Sullivan, Sergey Mishin, Stefan Tatschner, Wulf Weich, bt90, greatroar, Aaron Bieber, Adam Piggott, Adel Qalieh, Alan Pope, Alberto Donato, Aleksey Vasenev, Alessandro G., Alex Lindeman, Alex Xu, Alexander Seiler, Alexandre Alves, Aman Gupta, Anatoli Babenia, Andreas Sommer, Andrew Dunham, Andrew Meyer, Andrew Rabert, Andrey D, Anjan Momi, Anthony Goeckner, Antoine Lamielle, Anur, Aranjedeath, Arkadiusz Tymiński, Aroun, Arthur Axel fREW Schmidt, Artur Zubilewicz, Aurélien Rainone, BAHADIR YILMAZ, Bart De Vries, Ben Curthoys, Ben Shepherd, Ben Sidhom, Benedikt Heine, Benedikt Morbach, Benjamin Nater, Benno Fünfstück, Benny Ng, Boqin Qin, Boris Rybalkin, Brandon Philips, Brendan Long, Brian R. Becker, Carsten Hagemann, Catfriend1, Cathryne Linenweaver, Cedric Staniewski, Chih-Hsuan Yen, Choongkyu, Chris Howie, Chris Joel, Chris Tonkinson, Christian Kujau, Christian Prescott, Colin Kennedy, Cromefire_, Cyprien Devillez, Dale Visser, Dan, Daniel Barczyk, Daniel Bergmann, Daniel Martí, Darshil Chanpura, David Rimmer, DeflateAwning, Denis A., Dennis Wilson, DerRockWolf, Devon G. Redekopp, Dimitri Papadopoulos Orfanos, Dmitry Saveliev, Domenic Horner, Dominik Heidler, Elias Jarlebring, Elliot Huffman, Emil Hessman, Emil Lundberg, Eng Zer Jun, Eric Lesiuta, Eric P, Erik Meitner, Evan Spensley, Federico Castagnini, Felix, Felix Ableitner, Felix Lampe, Felix Unterpaintner, Francois-Xavier Gsell, Frank Isemann, Gahl Saraf, Gilli Sigurdsson, Gleb Sinyavskiy, Graham Miln, Greg, Han Boetes, HansK-p, Harrison Jones, Heiko Zuerker, Hugo Locurcio, Iain Barnett, Ian Johnson, Ikko Ashimine, Ilya Brin, Iskander Sharipov, Jaakko Hannikainen, Jacek Szafarkiewicz, Jack Croft, Jacob, Jake Peterson, James O'Beirne, James Patterson, Jaroslav Lichtblau, Jaroslav Malec, Jauder Ho, Jaya Chithra, Jaya Kumar, Jeffery To, Jens Diemer, Jerry Jacobs, Jochen Voss, Johan Andersson, Johan Vromans, John Rinehart, Jonas Thelemann, Jonathan, Jonathan Cross, Jonta, Jose Manuel Delicado, Jörg Thalheim, Jędrzej Kula, K.B.Dharun Krishna, Kalle Laine, Karol Różycki, Kebin Liu, Keith Harrison, Keith Turner, Kelong Cong, Ken'ichi Kamada, Kevin Allen, Kevin Bushiri, Kevin White, Jr., Kurt Fitzner, LSmithx2, Lars Lehtonen, Laurent Arnoud, Laurent Etiemble, Leo Arias, Liu Siyuan, Lord Landon Agahnim, Lukas Lihotzki, Majed Abdulaziz, Marc Laporte, Marc Pujol, Marcin Dziadus, Marcus Legendre, Mario Majila, Mark Pulford, Martchus, Martin Polehla, Mateusz Naściszewski, Mateusz Ż, Matic Potočnik, Matt Burke, Matt Robenolt, Matteo Ruina, Maurizio Tomasi, Max, Max Schulze, MaximAL, Maxime Thirouin, Maximilian, MichaIng, Michael Jephcote, Michael Rienstra, Michael Tilli, Migelo, Mike Boone, MikeLund, MikolajTwarog, Mingxuan Lin, Naveen, Nicholas Rishel, Nick Busey, Nico Stapelbroek, Nicolas Braud-Santoni, Nicolas Perraut, Niels Peter Roest, Nils Jakobi, NinoM4ster, Nitroretro, NoLooseEnds, Oliver Freyermuth, Otiel, Oyebanji Jacob Mayowa, Pablo, Pascal Jungblut, Paul Brit, Pawel Palenica, Paweł Rozlach, Peter Badida, Peter Dave Hello, Peter Hoeg, Peter Marquardt, Phani Rithvij, Phil Davis, Phill Luby, Pier Paolo Ramon, Piotr Bejda, Pramodh KP, Quentin Hibon, Rahmi Pruitt, Richard Hartmann, Robert Carosi, Roberto Santalla, Robin Schoonover, Roman Zaynetdinov, Ross Smith II, Ruslan Yevdokymov, Ryan Qian, Sacheendra Talluri, Scott Klupfel, Shaarad Dalvi, Simon Mwepu, Sly_tom_cat, Stefan Kuntz, Steven Eckhoff, Suhas Gundimeda, Taylor Khan, Thomas Hipp, Tim Abell, Tim Howes, Tobias Klauser, Tobias Nygren, Tobias Tom, Tom Jakubowski, Tommy Thorn, Tully Robinson, Tyler Brazier, Tyler Kropp, Unrud, Veeti Paananen, Victor Buinsky, Vik, Vil Brekin, Vladimir Rusinov, Will Rouesnel, William A. Kennington III, Xavier O., Yannic A., andresvia, andyleap, boomsquared, chenrui, chucic, cui fliter, d-volution, derekriemer, desbma, digital, entity0xfe, georgespatton, ghjklw, guangwu, ignacy123, janost, jaseg, jelle van der Waa, jtagcat, klemens, luzpaz, marco-m, mclang, mv1005, orangekame3, otbutz, overkill, perewa, red_led, rubenbe, sec65, vapatel2, villekalliomaki, wangguoliang, wouter bolsterlee, xarx00, xjtdy888, 佛跳墙, 落心
|
||||
Jakob Borg, Audrius Butkevicius, Jesse Lucas, Simon Frei, Tomasz Wilczyński, Alexander Graf, Alexandre Viau, Anderson Mesquita, André Colomb, Antony Male, Ben Schulz, Caleb Callaway, Daniel Harte, Eric P, Evgeny Kuznetsov, Lars K.W. Gohlke, Lode Hoste, Michael Ploujnikov, Nate Morrison, Philippe Schommers, Ryan Sullivan, Sergey Mishin, Stefan Tatschner, Wulf Weich, bt90, greatroar, Aaron Bieber, Adam Piggott, Adel Qalieh, Alan Pope, Alberto Donato, Aleksey Vasenev, Alessandro G., Alex Lindeman, Alex Xu, Alexander Seiler, Alexandre Alves, Aman Gupta, Anatoli Babenia, Andreas Sommer, Andrew Dunham, Andrew Meyer, Andrew Rabert, Andrey D, Anjan Momi, Anthony Goeckner, Antoine Lamielle, Anur, Aranjedeath, Arkadiusz Tymiński, Aroun, Arthur Axel fREW Schmidt, Artur Zubilewicz, Aurélien Rainone, BAHADIR YILMAZ, Bart De Vries, Ben Curthoys, Ben Shepherd, Ben Sidhom, Benedikt Heine, Benedikt Morbach, Benjamin Nater, Benno Fünfstück, Benny Ng, Boqin Qin, Boris Rybalkin, Brandon Philips, Brendan Long, Brian R. Becker, Carsten Hagemann, Catfriend1, Cathryne Linenweaver, Cedric Staniewski, Chih-Hsuan Yen, Choongkyu, Chris Howie, Chris Joel, Chris Tonkinson, Christian Kujau, Christian Prescott, Colin Kennedy, Cromefire_, Cyprien Devillez, Dale Visser, Dan, Daniel Barczyk, Daniel Bergmann, Daniel Martí, Darshil Chanpura, David Rimmer, DeflateAwning, Denis A., Dennis Wilson, DerRockWolf, Devon G. Redekopp, Dimitri Papadopoulos Orfanos, Dmitry Saveliev, Domenic Horner, Dominik Heidler, Elias Jarlebring, Elliot Huffman, Emil Hessman, Emil Lundberg, Eng Zer Jun, Eric Lesiuta, Erik Meitner, Evan Spensley, Federico Castagnini, Felix, Felix Ableitner, Felix Lampe, Felix Unterpaintner, Francois-Xavier Gsell, Frank Isemann, Gahl Saraf, Gilli Sigurdsson, Gleb Sinyavskiy, Graham Miln, Greg, Han Boetes, HansK-p, Harrison Jones, Heiko Zuerker, Hugo Locurcio, Iain Barnett, Ian Johnson, Ikko Ashimine, Ilya Brin, Iskander Sharipov, Jaakko Hannikainen, Jacek Szafarkiewicz, Jack Croft, Jacob, Jake Peterson, James O'Beirne, James Patterson, Jaroslav Lichtblau, Jaroslav Malec, Jauder Ho, Jaya Chithra, Jaya Kumar, Jeffery To, Jens Diemer, Jerry Jacobs, Jochen Voss, Johan Andersson, Johan Vromans, John Rinehart, Jonas Thelemann, Jonathan, Jonathan Cross, Jonta, Jose Manuel Delicado, Jörg Thalheim, Jędrzej Kula, K.B.Dharun Krishna, Kalle Laine, Karol Różycki, Kebin Liu, Keith Harrison, Keith Turner, Kelong Cong, Ken'ichi Kamada, Kevin Allen, Kevin Bushiri, Kevin White, Jr., Kurt Fitzner, LSmithx2, Lars Lehtonen, Laurent Arnoud, Laurent Etiemble, Leo Arias, Liu Siyuan, Lord Landon Agahnim, Lukas Lihotzki, Majed Abdulaziz, Marc Laporte, Marc Pujol, Marcin Dziadus, Marcus Legendre, Mario Majila, Mark Pulford, Martchus, Martin Polehla, Mateusz Naściszewski, Mateusz Ż, Matic Potočnik, Matt Burke, Matt Robenolt, Matteo Ruina, Maurizio Tomasi, Max, Max Schulze, MaximAL, Maxime Thirouin, Maximilian, MichaIng, Michael Jephcote, Michael Rienstra, Michael Tilli, Migelo, Mike Boone, MikeLund, MikolajTwarog, Mingxuan Lin, Naveen, Nicholas Rishel, Nick Busey, Nico Stapelbroek, Nicolas Braud-Santoni, Nicolas Perraut, Niels Peter Roest, Nils Jakobi, NinoM4ster, Nitroretro, NoLooseEnds, Oliver Freyermuth, Otiel, Oyebanji Jacob Mayowa, Pablo, Pascal Jungblut, Paul Brit, Pawel Palenica, Paweł Rozlach, Peter Badida, Peter Dave Hello, Peter Hoeg, Peter Marquardt, Phani Rithvij, Phil Davis, Phill Luby, Pier Paolo Ramon, Piotr Bejda, Pramodh KP, Quentin Hibon, Rahmi Pruitt, Richard Hartmann, Robert Carosi, Roberto Santalla, Robin Schoonover, Roman Zaynetdinov, Ross Smith II, Ruslan Yevdokymov, Ryan Qian, Sacheendra Talluri, Scott Klupfel, Sertonix, Shaarad Dalvi, Simon Mwepu, Sly_tom_cat, Stefan Kuntz, Steven Eckhoff, Suhas Gundimeda, Taylor Khan, Thomas Hipp, Tim Abell, Tim Howes, Tobias Klauser, Tobias Nygren, Tobias Tom, Tom Jakubowski, Tommy Thorn, Tully Robinson, Tyler Brazier, Tyler Kropp, Unrud, Veeti Paananen, Victor Buinsky, Vik, Vil Brekin, Vladimir Rusinov, Will Rouesnel, William A. Kennington III, Xavier O., Yannic A., andresvia, andyleap, boomsquared, chenrui, chucic, cjc7373, cui fliter, d-volution, derekriemer, desbma, digital, entity0xfe, georgespatton, ghjklw, guangwu, gudvinr, ignacy123, janost, jaseg, jelle van der Waa, jtagcat, klemens, luzpaz, marco-m, mclang, mv1005, orangekame3, otbutz, overkill, perewa, red_led, rubenbe, sec65, vapatel2, villekalliomaki, wangguoliang, wouter bolsterlee, xarx00, xjtdy888, 佛跳墙, 落心
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1151,6 +1151,113 @@ angular.module('syncthing.core')
|
||||
}
|
||||
};
|
||||
|
||||
$scope.deviceStatusIcon = function(cfg) {
|
||||
switch ($scope.deviceStatus(cfg)) {
|
||||
case 'disconnected':
|
||||
case 'disconnected-inactive':
|
||||
return 'fa-power-off';
|
||||
case 'insync':
|
||||
return 'fa-check';
|
||||
case 'paused':
|
||||
return 'fa-pause';
|
||||
case 'syncing':
|
||||
return 'fa-sync';
|
||||
case 'unused-disconnected':
|
||||
case 'unused-insync':
|
||||
case 'unused-paused':
|
||||
return 'fa-unlink';
|
||||
}
|
||||
};
|
||||
|
||||
$scope.deviceStatusText = function(device) {
|
||||
switch ($scope.deviceStatus(device)) {
|
||||
case 'disconnected':
|
||||
return $translate.instant('Disconnected');
|
||||
case 'disconnected-inactive':
|
||||
return $translate.instant('Disconnected (Inactive)');
|
||||
case 'insync':
|
||||
return $translate.instant('Up to Date');
|
||||
case 'paused':
|
||||
return $translate.instant('Paused');
|
||||
case 'syncing':
|
||||
return $translate.instant('Syncing');
|
||||
case 'unused-disconnected':
|
||||
return $translate.instant('Disconnected (Unused)');
|
||||
case 'unused-insync':
|
||||
return $translate.instant('Connected (Unused)');
|
||||
case 'unused-paused':
|
||||
return $translate.instant('Paused (Unused)');
|
||||
}
|
||||
};
|
||||
|
||||
$scope.folderStatusIcon = function(cfg) {
|
||||
switch ($scope.folderStatus(cfg)) {
|
||||
case 'clean-waiting':
|
||||
case 'scan-waiting':
|
||||
case 'sync-preparing':
|
||||
case 'sync-waiting':
|
||||
return 'fa-hourglass-half';
|
||||
case 'cleaning':
|
||||
return 'fa-recycle';
|
||||
case 'faileditems':
|
||||
case 'localunencrypted':
|
||||
case 'outofsync':
|
||||
return 'fa-exclamation-circle';
|
||||
case 'idle':
|
||||
case 'localadditions':
|
||||
return 'fa-check';
|
||||
case 'paused':
|
||||
return 'fa-pause';
|
||||
case 'scanning':
|
||||
return 'fa-search';
|
||||
case 'stopped':
|
||||
return 'fa-stop';
|
||||
case 'syncing':
|
||||
return 'fa-sync';
|
||||
case 'unknown':
|
||||
return 'fa-question-circle';
|
||||
case 'unshared':
|
||||
return 'fa-unlink';
|
||||
}
|
||||
};
|
||||
|
||||
$scope.folderStatusText = function(folder) {
|
||||
switch ($scope.folderStatus(folder)) {
|
||||
case 'clean-waiting':
|
||||
return $translate.instant('Waiting to Clean');
|
||||
case 'cleaning':
|
||||
return $translate.instant('Cleaning Versions');
|
||||
case 'faileditems':
|
||||
return $translate.instant('Failed Items');
|
||||
case 'idle':
|
||||
return $translate.instant('Up to Date');
|
||||
case 'localadditions':
|
||||
return $translate.instant('Local Additions');
|
||||
case 'localunencrypted':
|
||||
return $translate.instant('Unexpected Items');
|
||||
case 'outofsync':
|
||||
return $translate.instant('Out of Sync');
|
||||
case 'paused':
|
||||
return $translate.instant('Paused');
|
||||
case 'scan-waiting':
|
||||
return $translate.instant('Waiting to Scan');
|
||||
case 'scanning':
|
||||
return $translate.instant('Scanning');
|
||||
case 'stopped':
|
||||
return $translate.instant('Stopped');
|
||||
case 'sync-preparing':
|
||||
return $translate.instant('Preparing to Sync');
|
||||
case 'sync-waiting':
|
||||
return $translate.instant('Waiting to Sync');
|
||||
case 'syncing':
|
||||
return $translate.instant('Syncing');
|
||||
case 'unknown':
|
||||
return $translate.instant('Unknown');
|
||||
case 'unshared':
|
||||
return $translate.instant('Unshared');
|
||||
}
|
||||
};
|
||||
|
||||
$scope.deviceClass = function (deviceCfg) {
|
||||
if (typeof $scope.connections[deviceCfg.deviceID] === 'undefined') {
|
||||
return 'info';
|
||||
|
||||
@@ -47,7 +47,6 @@ import (
|
||||
"github.com/syncthing/syncthing/lib/discover"
|
||||
"github.com/syncthing/syncthing/lib/events"
|
||||
"github.com/syncthing/syncthing/lib/fs"
|
||||
"github.com/syncthing/syncthing/lib/ignore"
|
||||
"github.com/syncthing/syncthing/lib/locations"
|
||||
"github.com/syncthing/syncthing/lib/logger"
|
||||
"github.com/syncthing/syncthing/lib/model"
|
||||
@@ -1349,11 +1348,6 @@ func (s *service) getDBIgnores(w http.ResponseWriter, r *http.Request) {
|
||||
folder := qs.Get("folder")
|
||||
|
||||
lines, patterns, err := s.model.LoadIgnores(folder)
|
||||
if err != nil && !ignore.IsParseError(err) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
sendJSON(w, map[string]interface{}{
|
||||
"ignore": lines,
|
||||
"expanded": patterns,
|
||||
|
||||
@@ -77,7 +77,15 @@ func (t *tcpListener) serve(ctx context.Context) error {
|
||||
l.Infof("TCP listener (%v) starting", tcaddr)
|
||||
defer l.Infof("TCP listener (%v) shutting down", tcaddr)
|
||||
|
||||
mapping := t.natService.NewMapping(nat.TCP, tcaddr.IP, tcaddr.Port)
|
||||
var ipVersion nat.IPVersion
|
||||
if t.uri.Scheme == "tcp4" {
|
||||
ipVersion = nat.IPv4Only
|
||||
} else if t.uri.Scheme == "tcp6" {
|
||||
ipVersion = nat.IPv6Only
|
||||
} else {
|
||||
ipVersion = nat.IPvAny
|
||||
}
|
||||
mapping := t.natService.NewMapping(nat.TCP, ipVersion, tcaddr.IP, tcaddr.Port)
|
||||
mapping.OnChanged(func() {
|
||||
t.notifyAddressesChanged(t)
|
||||
})
|
||||
|
||||
@@ -19,8 +19,8 @@ func fixupPort(uri *url.URL, defaultPort int) *url.URL {
|
||||
copyURI := *uri
|
||||
|
||||
host, port, err := net.SplitHostPort(uri.Host)
|
||||
if err != nil && strings.Contains(err.Error(), "missing port") {
|
||||
// addr is on the form "1.2.3.4" or "[fe80::1]"
|
||||
if e, ok := err.(*net.AddrError); ok && strings.Contains(e.Err, "missing port") {
|
||||
// addr is of the form "1.2.3.4" or "[fe80::1]"
|
||||
host = uri.Host
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||
// net.JoinHostPort will add the brackets again
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/syncthing/syncthing/lib/protocol"
|
||||
@@ -31,14 +32,13 @@ func (f *BasicFilesystem) GetXattr(path string, xattrFilter XattrFilter) ([]prot
|
||||
}
|
||||
|
||||
res := make([]protocol.Xattr, 0, len(attrs))
|
||||
var val, buf []byte
|
||||
var totSize int
|
||||
for _, attr := range attrs {
|
||||
if !xattrFilter.Permit(attr) {
|
||||
l.Debugf("get xattr %s: skipping attribute %q denied by filter", path, attr)
|
||||
continue
|
||||
}
|
||||
val, buf, err = getXattr(path, attr, buf)
|
||||
val, err := getXattr(path, attr)
|
||||
var errNo syscall.Errno
|
||||
if errors.As(err, &errNo) && errNo == 0x5d {
|
||||
// ENOATTR, returned on BSD when asking for an attribute that
|
||||
@@ -64,27 +64,52 @@ func (f *BasicFilesystem) GetXattr(path string, xattrFilter XattrFilter) ([]prot
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func getXattr(path, name string, buf []byte) (val []byte, rest []byte, err error) {
|
||||
if len(buf) == 0 {
|
||||
buf = make([]byte, 1024)
|
||||
}
|
||||
var xattrBufPool = sync.Pool{
|
||||
New: func() any { return make([]byte, 1024) },
|
||||
}
|
||||
|
||||
func getXattr(path, name string) ([]byte, error) {
|
||||
buf := xattrBufPool.Get().([]byte)
|
||||
defer func() {
|
||||
// Put the buffer back in the pool, or not if we're not supposed to
|
||||
// (we returned it to the caller).
|
||||
if buf != nil {
|
||||
xattrBufPool.Put(buf)
|
||||
}
|
||||
}()
|
||||
|
||||
size, err := unix.Lgetxattr(path, name, buf)
|
||||
if errors.Is(err, unix.ERANGE) {
|
||||
// Buffer was too small. Figure out how large it needs to be, and
|
||||
// allocate.
|
||||
size, err = unix.Lgetxattr(path, name, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("Lgetxattr %s %q: %w", path, name, err)
|
||||
return nil, fmt.Errorf("Lgetxattr %s %q: %w", path, name, err)
|
||||
}
|
||||
if size > len(buf) {
|
||||
xattrBufPool.Put(buf)
|
||||
buf = make([]byte, size)
|
||||
}
|
||||
size, err = unix.Lgetxattr(path, name, buf)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, buf, fmt.Errorf("Lgetxattr %s %q: %w", path, name, err)
|
||||
return nil, fmt.Errorf("Lgetxattr %s %q: %w", path, name, err)
|
||||
}
|
||||
return buf[:size], buf[size:], nil
|
||||
|
||||
if size >= len(buf)/4*3 {
|
||||
// The buffer is adequately sized (at least three quarters of it is
|
||||
// used), return it as-is.
|
||||
val := buf[:size]
|
||||
buf = nil // Don't put it back in the pool.
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// The buffer is larger than required, copy the data to a new buffer of
|
||||
// the correct size. This avoids having lots of 1024-sized allocations
|
||||
// sticking around when 24 bytes or whatever would be enough.
|
||||
val := make([]byte, size)
|
||||
copy(val, buf)
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (f *BasicFilesystem) SetXattr(path string, xattrs []protocol.Xattr, xattrFilter XattrFilter) error {
|
||||
|
||||
@@ -88,7 +88,7 @@ func (f *mtimeFS) Stat(name string) (FileInfo, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mtimeMapping.Real == info.ModTime() {
|
||||
if mtimeMapping.Real.Equal(info.ModTime()) {
|
||||
info = mtimeFileInfo{
|
||||
FileInfo: info,
|
||||
mtime: mtimeMapping.Virtual,
|
||||
@@ -108,7 +108,7 @@ func (f *mtimeFS) Lstat(name string) (FileInfo, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mtimeMapping.Real == info.ModTime() {
|
||||
if mtimeMapping.Real.Equal(info.ModTime()) {
|
||||
info = mtimeFileInfo{
|
||||
FileInfo: info,
|
||||
mtime: mtimeMapping.Virtual,
|
||||
@@ -215,7 +215,7 @@ func (f mtimeFile) Stat() (FileInfo, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mtimeMapping.Real == info.ModTime() {
|
||||
if mtimeMapping.Real.Equal(info.ModTime()) {
|
||||
info = mtimeFileInfo{
|
||||
FileInfo: info,
|
||||
mtime: mtimeMapping.Virtual,
|
||||
|
||||
@@ -1065,7 +1065,7 @@ func (f *folder) monitorWatch(ctx context.Context) {
|
||||
case ev := <-summaryChan:
|
||||
if data, ok := ev.Data.(FolderSummaryEventData); !ok {
|
||||
f.evLogger.Log(events.Failure, "Unexpected type of folder-summary event in folder.monitorWatch")
|
||||
} else if data.Summary.LocalTotalItems-data.Summary.LocalDeleted > kqueueItemCountThreshold {
|
||||
} else if data.Folder == f.folderID && data.Summary.LocalTotalItems-data.Summary.LocalDeleted > kqueueItemCountThreshold {
|
||||
f.warnedKqueue = true
|
||||
summarySub.Unsubscribe()
|
||||
summaryChan = nil
|
||||
|
||||
@@ -535,8 +535,8 @@ func setupROFolder(t *testing.T) (*testModel, *receiveOnlyFolder, context.Cancel
|
||||
<-m.started
|
||||
must(t, m.ScanFolder("ro"))
|
||||
|
||||
m.fmut.RLock()
|
||||
defer m.fmut.RUnlock()
|
||||
m.mut.RLock()
|
||||
defer m.mut.RUnlock()
|
||||
r, _ := m.folderRunners.Get("ro")
|
||||
f := r.(*receiveOnlyFolder)
|
||||
|
||||
|
||||
@@ -531,11 +531,6 @@ type Model struct {
|
||||
setIgnoresReturnsOnCall map[int]struct {
|
||||
result1 error
|
||||
}
|
||||
StartDeadlockDetectorStub func(time.Duration)
|
||||
startDeadlockDetectorMutex sync.RWMutex
|
||||
startDeadlockDetectorArgsForCall []struct {
|
||||
arg1 time.Duration
|
||||
}
|
||||
StateStub func(string) (string, time.Time, error)
|
||||
stateMutex sync.RWMutex
|
||||
stateArgsForCall []struct {
|
||||
@@ -3070,38 +3065,6 @@ func (fake *Model) SetIgnoresReturnsOnCall(i int, result1 error) {
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *Model) StartDeadlockDetector(arg1 time.Duration) {
|
||||
fake.startDeadlockDetectorMutex.Lock()
|
||||
fake.startDeadlockDetectorArgsForCall = append(fake.startDeadlockDetectorArgsForCall, struct {
|
||||
arg1 time.Duration
|
||||
}{arg1})
|
||||
stub := fake.StartDeadlockDetectorStub
|
||||
fake.recordInvocation("StartDeadlockDetector", []interface{}{arg1})
|
||||
fake.startDeadlockDetectorMutex.Unlock()
|
||||
if stub != nil {
|
||||
fake.StartDeadlockDetectorStub(arg1)
|
||||
}
|
||||
}
|
||||
|
||||
func (fake *Model) StartDeadlockDetectorCallCount() int {
|
||||
fake.startDeadlockDetectorMutex.RLock()
|
||||
defer fake.startDeadlockDetectorMutex.RUnlock()
|
||||
return len(fake.startDeadlockDetectorArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *Model) StartDeadlockDetectorCalls(stub func(time.Duration)) {
|
||||
fake.startDeadlockDetectorMutex.Lock()
|
||||
defer fake.startDeadlockDetectorMutex.Unlock()
|
||||
fake.StartDeadlockDetectorStub = stub
|
||||
}
|
||||
|
||||
func (fake *Model) StartDeadlockDetectorArgsForCall(i int) time.Duration {
|
||||
fake.startDeadlockDetectorMutex.RLock()
|
||||
defer fake.startDeadlockDetectorMutex.RUnlock()
|
||||
argsForCall := fake.startDeadlockDetectorArgsForCall[i]
|
||||
return argsForCall.arg1
|
||||
}
|
||||
|
||||
func (fake *Model) State(arg1 string) (string, time.Time, error) {
|
||||
fake.stateMutex.Lock()
|
||||
ret, specificReturn := fake.stateReturnsOnCall[len(fake.stateArgsForCall)]
|
||||
@@ -3351,8 +3314,6 @@ func (fake *Model) Invocations() map[string][][]interface{} {
|
||||
defer fake.serveMutex.RUnlock()
|
||||
fake.setIgnoresMutex.RLock()
|
||||
defer fake.setIgnoresMutex.RUnlock()
|
||||
fake.startDeadlockDetectorMutex.RLock()
|
||||
defer fake.startDeadlockDetectorMutex.RUnlock()
|
||||
fake.stateMutex.RLock()
|
||||
defer fake.stateMutex.RUnlock()
|
||||
fake.usageReportingStatsMutex.RLock()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -902,13 +902,13 @@ func TestIssue5063(t *testing.T) {
|
||||
defer cleanupModel(m)
|
||||
defer cancel()
|
||||
|
||||
m.pmut.Lock()
|
||||
m.mut.Lock()
|
||||
for _, c := range m.connections {
|
||||
conn := c.(*fakeConnection)
|
||||
conn.CloseCalls(func(_ error) {})
|
||||
defer m.Closed(c, errStopped) // to unblock deferred m.Stop()
|
||||
}
|
||||
m.pmut.Unlock()
|
||||
m.mut.Unlock()
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
@@ -1524,10 +1524,10 @@ func TestIgnores(t *testing.T) {
|
||||
FilesystemType: fs.FilesystemTypeFake,
|
||||
}
|
||||
ignores := ignore.New(fcfg.Filesystem(nil), ignore.WithCache(m.cfg.Options().CacheIgnoredFiles))
|
||||
m.fmut.Lock()
|
||||
m.mut.Lock()
|
||||
m.folderCfgs[fcfg.ID] = fcfg
|
||||
m.folderIgnores[fcfg.ID] = ignores
|
||||
m.fmut.Unlock()
|
||||
m.mut.Unlock()
|
||||
|
||||
_, _, err = m.LoadIgnores("fresh")
|
||||
if err != nil {
|
||||
@@ -2973,7 +2973,7 @@ func TestConnCloseOnRestart(t *testing.T) {
|
||||
ci := &protocolmocks.ConnectionInfo{}
|
||||
ci.ConnectionIDReturns(srand.String(16))
|
||||
m.AddConnection(protocol.NewConnection(device1, br, nw, testutil.NoopCloser{}, m, ci, protocol.CompressionNever, nil, m.keyGen), protocol.Hello{})
|
||||
m.pmut.RLock()
|
||||
m.mut.RLock()
|
||||
if len(m.closed) != 1 {
|
||||
t.Fatalf("Expected just one conn (len(m.closed) == %v)", len(m.closed))
|
||||
}
|
||||
@@ -2981,7 +2981,7 @@ func TestConnCloseOnRestart(t *testing.T) {
|
||||
for _, c := range m.closed {
|
||||
closed = c
|
||||
}
|
||||
m.pmut.RUnlock()
|
||||
m.mut.RUnlock()
|
||||
|
||||
waiter, err := w.RemoveDevice(device1)
|
||||
if err != nil {
|
||||
@@ -3074,12 +3074,12 @@ func TestDevicePause(t *testing.T) {
|
||||
sub := m.evLogger.Subscribe(events.DevicePaused)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
m.pmut.RLock()
|
||||
m.mut.RLock()
|
||||
var closed chan struct{}
|
||||
for _, c := range m.closed {
|
||||
closed = c
|
||||
}
|
||||
m.pmut.RUnlock()
|
||||
m.mut.RUnlock()
|
||||
|
||||
pauseDevice(t, m.cfg, device1, true)
|
||||
|
||||
@@ -3754,9 +3754,9 @@ func TestCompletionEmptyGlobal(t *testing.T) {
|
||||
defer wcfgCancel()
|
||||
defer cleanupModelAndRemoveDir(m, fcfg.Filesystem(nil).URI())
|
||||
files := []protocol.FileInfo{{Name: "foo", Version: protocol.Vector{}.Update(myID.Short()), Sequence: 1}}
|
||||
m.fmut.Lock()
|
||||
m.mut.Lock()
|
||||
m.folderFiles[fcfg.ID].Update(protocol.LocalDeviceID, files)
|
||||
m.fmut.Unlock()
|
||||
m.mut.Unlock()
|
||||
files[0].Deleted = true
|
||||
files[0].Version = files[0].Version.Update(device1.Short())
|
||||
must(t, m.IndexUpdate(conn, fcfg.ID, files))
|
||||
|
||||
@@ -1287,9 +1287,9 @@ func TestRequestReceiveEncrypted(t *testing.T) {
|
||||
|
||||
files := genFiles(2)
|
||||
files[1].LocalFlags = protocol.FlagLocalReceiveOnly
|
||||
m.fmut.RLock()
|
||||
m.mut.RLock()
|
||||
fset := m.folderFiles[fcfg.ID]
|
||||
m.fmut.RUnlock()
|
||||
m.mut.RUnlock()
|
||||
fset.Update(protocol.LocalDeviceID, files)
|
||||
|
||||
indexChan := make(chan []protocol.FileInfo, 10)
|
||||
|
||||
@@ -46,7 +46,6 @@ func (s *serviceMap[K, S]) Add(k K, v S) {
|
||||
if tok, ok := s.tokens[k]; ok {
|
||||
// There is already a service at this key, remove it first.
|
||||
s.supervisor.Remove(tok)
|
||||
s.eventLogger.Log(events.Failure, fmt.Sprintf("%s replaced service at key %v", s, k))
|
||||
}
|
||||
s.services[k] = v
|
||||
s.tokens[k] = s.supervisor.Add(v)
|
||||
@@ -59,6 +58,34 @@ func (s *serviceMap[K, S]) Get(k K) (v S, ok bool) {
|
||||
return
|
||||
}
|
||||
|
||||
// Stop removes the service at the given key from the supervisor, stopping it.
|
||||
// The service itself is still retained, i.e. a call to Get with the same key
|
||||
// will still return a result.
|
||||
func (s *serviceMap[K, S]) Stop(k K) {
|
||||
if tok, ok := s.tokens[k]; ok {
|
||||
s.supervisor.Remove(tok)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// StopAndWaitChan removes the service at the given key from the supervisor,
|
||||
// stopping it. The service itself is still retained, i.e. a call to Get with
|
||||
// the same key will still return a result.
|
||||
// The returned channel will produce precisely one error value: either the
|
||||
// return value from RemoveAndWait (possibly nil), or errSvcNotFound if the
|
||||
// service was not found.
|
||||
func (s *serviceMap[K, S]) StopAndWaitChan(k K, timeout time.Duration) <-chan error {
|
||||
ret := make(chan error, 1)
|
||||
if tok, ok := s.tokens[k]; ok {
|
||||
go func() {
|
||||
ret <- s.supervisor.RemoveAndWait(tok, timeout)
|
||||
}()
|
||||
} else {
|
||||
ret <- errSvcNotFound
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// Remove removes the service at the given key, stopping it on the supervisor.
|
||||
// If there is no service at the given key, nothing happens. The return value
|
||||
// indicates whether a service was removed.
|
||||
@@ -66,6 +93,8 @@ func (s *serviceMap[K, S]) Remove(k K) (found bool) {
|
||||
if tok, ok := s.tokens[k]; ok {
|
||||
found = true
|
||||
s.supervisor.Remove(tok)
|
||||
} else {
|
||||
_, found = s.services[k]
|
||||
}
|
||||
delete(s.services, k)
|
||||
delete(s.tokens, k)
|
||||
@@ -84,16 +113,8 @@ func (s *serviceMap[K, S]) RemoveAndWait(k K, timeout time.Duration) error {
|
||||
// value: either the return value from RemoveAndWait (possibly nil), or
|
||||
// errSvcNotFound if the service was not found.
|
||||
func (s *serviceMap[K, S]) RemoveAndWaitChan(k K, timeout time.Duration) <-chan error {
|
||||
ret := make(chan error, 1)
|
||||
if tok, ok := s.tokens[k]; ok {
|
||||
go func() {
|
||||
ret <- s.supervisor.RemoveAndWait(tok, timeout)
|
||||
}()
|
||||
} else {
|
||||
ret <- errSvcNotFound
|
||||
}
|
||||
ret := s.StopAndWaitChan(k, timeout)
|
||||
delete(s.services, k)
|
||||
delete(s.tokens, k)
|
||||
return ret
|
||||
}
|
||||
|
||||
|
||||
@@ -295,9 +295,9 @@ func folderIgnoresAlwaysReload(t testing.TB, m *testModel, fcfg config.FolderCon
|
||||
m.removeFolder(fcfg)
|
||||
fset := newFileSet(t, fcfg.ID, m.db)
|
||||
ignores := ignore.New(fcfg.Filesystem(nil), ignore.WithCache(true), ignore.WithChangeDetector(newAlwaysChanged()))
|
||||
m.fmut.Lock()
|
||||
m.mut.Lock()
|
||||
m.addAndStartFolderLockedWithIgnores(fcfg, fset, ignores)
|
||||
m.fmut.Unlock()
|
||||
m.mut.Unlock()
|
||||
}
|
||||
|
||||
func basicClusterConfig(local, remote protocol.DeviceID, folders ...string) protocol.ClusterConfig {
|
||||
@@ -319,9 +319,9 @@ func basicClusterConfig(local, remote protocol.DeviceID, folders ...string) prot
|
||||
}
|
||||
|
||||
func localIndexUpdate(m *testModel, folder string, fs []protocol.FileInfo) {
|
||||
m.fmut.RLock()
|
||||
m.mut.RLock()
|
||||
fset := m.folderFiles[folder]
|
||||
m.fmut.RUnlock()
|
||||
m.mut.RUnlock()
|
||||
|
||||
fset.Update(protocol.LocalDeviceID, fs)
|
||||
seq := fset.Sequence(protocol.LocalDeviceID)
|
||||
|
||||
@@ -11,100 +11,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/syncthing/syncthing/lib/events"
|
||||
"github.com/syncthing/syncthing/lib/fs"
|
||||
"github.com/syncthing/syncthing/lib/ur"
|
||||
)
|
||||
|
||||
type Holdable interface {
|
||||
Holders() string
|
||||
}
|
||||
|
||||
func newDeadlockDetector(timeout time.Duration, evLogger events.Logger, fatal func(error)) *deadlockDetector {
|
||||
return &deadlockDetector{
|
||||
warnTimeout: timeout,
|
||||
fatalTimeout: 10 * timeout,
|
||||
lockers: make(map[string]sync.Locker),
|
||||
evLogger: evLogger,
|
||||
fatal: fatal,
|
||||
}
|
||||
}
|
||||
|
||||
type deadlockDetector struct {
|
||||
warnTimeout, fatalTimeout time.Duration
|
||||
lockers map[string]sync.Locker
|
||||
evLogger events.Logger
|
||||
fatal func(error)
|
||||
}
|
||||
|
||||
func (d *deadlockDetector) Watch(name string, mut sync.Locker) {
|
||||
d.lockers[name] = mut
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(d.warnTimeout / 4)
|
||||
done := make(chan struct{}, 1)
|
||||
|
||||
go func() {
|
||||
mut.Lock()
|
||||
_ = 1 // empty critical section
|
||||
mut.Unlock()
|
||||
done <- struct{}{}
|
||||
}()
|
||||
|
||||
d.watchInner(name, done)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (d *deadlockDetector) watchInner(name string, done chan struct{}) {
|
||||
warn := time.NewTimer(d.warnTimeout)
|
||||
fatal := time.NewTimer(d.fatalTimeout)
|
||||
defer func() {
|
||||
warn.Stop()
|
||||
fatal.Stop()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-warn.C:
|
||||
failure := ur.FailureDataWithGoroutines(fmt.Sprintf("potential deadlock detected at %s (short timeout)", name))
|
||||
failure.Extra["timeout"] = d.warnTimeout.String()
|
||||
d.evLogger.Log(events.Failure, failure)
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-fatal.C:
|
||||
err := fmt.Errorf("potential deadlock detected at %s (long timeout)", name)
|
||||
failure := ur.FailureDataWithGoroutines(err.Error())
|
||||
failure.Extra["timeout"] = d.fatalTimeout.String()
|
||||
others := d.otherHolders()
|
||||
failure.Extra["other-holders"] = others
|
||||
d.evLogger.Log(events.Failure, failure)
|
||||
d.fatal(err)
|
||||
// Give it a minute to shut down gracefully, maybe shutting down
|
||||
// can get out of the deadlock (or it's not really a deadlock).
|
||||
time.Sleep(time.Minute)
|
||||
panic(fmt.Sprintf("%v:\n%v", err, others))
|
||||
case <-done:
|
||||
}
|
||||
}
|
||||
|
||||
func (d *deadlockDetector) otherHolders() string {
|
||||
var b strings.Builder
|
||||
for otherName, otherMut := range d.lockers {
|
||||
if otherHolder, ok := otherMut.(Holdable); ok {
|
||||
b.WriteString("===" + otherName + "===\n" + otherHolder.Holders() + "\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// inWritableDir calls fn(path), while making sure that the directory
|
||||
// containing `path` is writable for the duration of the call.
|
||||
func inWritableDir(fn func(string) error, targetFs fs.Filesystem, path string, ignorePerms bool) error {
|
||||
|
||||
@@ -19,9 +19,19 @@ const (
|
||||
UDP Protocol = "UDP"
|
||||
)
|
||||
|
||||
type IPVersion int8
|
||||
|
||||
const (
|
||||
IPvAny = iota
|
||||
IPv4Only
|
||||
IPv6Only
|
||||
)
|
||||
|
||||
type Device interface {
|
||||
ID() string
|
||||
GetLocalIPAddress() net.IP
|
||||
GetLocalIPv4Address() net.IP
|
||||
AddPortMapping(ctx context.Context, protocol Protocol, internalPort, externalPort int, description string, duration time.Duration) (int, error)
|
||||
GetExternalIPAddress(ctx context.Context) (net.IP, error)
|
||||
AddPinhole(ctx context.Context, protocol Protocol, addr Address, duration time.Duration) ([]net.IP, error)
|
||||
GetExternalIPv4Address(ctx context.Context) (net.IP, error)
|
||||
SupportsIPVersion(version IPVersion) bool
|
||||
}
|
||||
|
||||
@@ -162,15 +162,16 @@ func (s *Service) scheduleProcess() {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) NewMapping(protocol Protocol, ip net.IP, port int) *Mapping {
|
||||
func (s *Service) NewMapping(protocol Protocol, ipVersion IPVersion, ip net.IP, port int) *Mapping {
|
||||
mapping := &Mapping{
|
||||
protocol: protocol,
|
||||
address: Address{
|
||||
IP: ip,
|
||||
Port: port,
|
||||
},
|
||||
extAddresses: make(map[string]Address),
|
||||
extAddresses: make(map[string][]Address),
|
||||
mut: sync.NewRWMutex(),
|
||||
ipVersion: ipVersion,
|
||||
}
|
||||
|
||||
s.mut.Lock()
|
||||
@@ -224,7 +225,7 @@ func (s *Service) updateMapping(ctx context.Context, mapping *Mapping, nats map[
|
||||
func (s *Service) verifyExistingLocked(ctx context.Context, mapping *Mapping, nats map[string]Device, renew bool) (change bool) {
|
||||
leaseTime := time.Duration(s.cfg.Options().NATLeaseM) * time.Minute
|
||||
|
||||
for id, address := range mapping.extAddresses {
|
||||
for id, extAddrs := range mapping.extAddresses {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
@@ -239,28 +240,37 @@ func (s *Service) verifyExistingLocked(ctx context.Context, mapping *Mapping, na
|
||||
continue
|
||||
} else if renew {
|
||||
// Only perform renewals on the nat's that have the right local IP
|
||||
// address
|
||||
localIP := nat.GetLocalIPAddress()
|
||||
if !mapping.validGateway(localIP) {
|
||||
// address. For IPv6 the IP addresses are discovered by the service itself,
|
||||
// so this check is skipped.
|
||||
localIP := nat.GetLocalIPv4Address()
|
||||
if !mapping.validGateway(localIP) && nat.SupportsIPVersion(IPv4Only) {
|
||||
l.Debugf("Skipping %s for %s because of IP mismatch. %s != %s", id, mapping, mapping.address.IP, localIP)
|
||||
continue
|
||||
}
|
||||
|
||||
l.Debugf("Renewing %s -> %s mapping on %s", mapping, address, id)
|
||||
if !nat.SupportsIPVersion(mapping.ipVersion) {
|
||||
l.Debugf("Skipping renew on gateway %s because it doesn't match the listener address family", nat.ID())
|
||||
continue
|
||||
}
|
||||
|
||||
addr, err := s.tryNATDevice(ctx, nat, mapping.address.Port, address.Port, leaseTime)
|
||||
l.Debugf("Renewing %s -> %v open port on %s", mapping, extAddrs, id)
|
||||
// extAddrs either contains one IPv4 address, or possibly several
|
||||
// IPv6 addresses all using the same port. Therefore the first
|
||||
// entry always has the external port.
|
||||
responseAddrs, err := s.tryNATDevice(ctx, nat, mapping.address, extAddrs[0].Port, leaseTime)
|
||||
if err != nil {
|
||||
l.Debugf("Failed to renew %s -> mapping on %s", mapping, address, id)
|
||||
l.Debugf("Failed to renew %s -> %v open port on %s", mapping, extAddrs, id)
|
||||
mapping.removeAddressLocked(id)
|
||||
change = true
|
||||
continue
|
||||
}
|
||||
|
||||
l.Debugf("Renewed %s -> %s mapping on %s", mapping, address, id)
|
||||
l.Debugf("Renewed %s -> %v open port on %s", mapping, extAddrs, id)
|
||||
|
||||
if !addr.Equal(address) {
|
||||
mapping.removeAddressLocked(id)
|
||||
mapping.setAddressLocked(id, addr)
|
||||
// We shouldn't rely on the order in which the addresses are returned.
|
||||
// Therefore, we test for set equality and report change if there is any difference.
|
||||
if !addrSetsEqual(responseAddrs, extAddrs) {
|
||||
mapping.setAddressLocked(id, responseAddrs)
|
||||
change = true
|
||||
}
|
||||
}
|
||||
@@ -286,23 +296,27 @@ func (s *Service) acquireNewLocked(ctx context.Context, mapping *Mapping, nats m
|
||||
|
||||
// Only perform mappings on the nat's that have the right local IP
|
||||
// address
|
||||
localIP := nat.GetLocalIPAddress()
|
||||
if !mapping.validGateway(localIP) {
|
||||
localIP := nat.GetLocalIPv4Address()
|
||||
if !mapping.validGateway(localIP) && nat.SupportsIPVersion(IPv4Only) {
|
||||
l.Debugf("Skipping %s for %s because of IP mismatch. %s != %s", id, mapping, mapping.address.IP, localIP)
|
||||
continue
|
||||
}
|
||||
|
||||
l.Debugf("Acquiring %s mapping on %s", mapping, id)
|
||||
l.Debugf("Trying to open port %s on %s", mapping, id)
|
||||
|
||||
addr, err := s.tryNATDevice(ctx, nat, mapping.address.Port, 0, leaseTime)
|
||||
if err != nil {
|
||||
l.Debugf("Failed to acquire %s mapping on %s", mapping, id)
|
||||
if !nat.SupportsIPVersion(mapping.ipVersion) {
|
||||
l.Debugf("Skipping firewall traversal on gateway %s because it doesn't match the listener address family", nat.ID())
|
||||
continue
|
||||
}
|
||||
|
||||
l.Debugf("Acquired %s -> %s mapping on %s", mapping, addr, id)
|
||||
addrs, err := s.tryNATDevice(ctx, nat, mapping.address, 0, leaseTime)
|
||||
if err != nil {
|
||||
l.Debugf("Failed to acquire %s open port on %s", mapping, id)
|
||||
continue
|
||||
}
|
||||
|
||||
mapping.setAddressLocked(id, addr)
|
||||
l.Debugf("Opened port %s -> %v on %s", mapping, addrs, id)
|
||||
mapping.setAddressLocked(id, addrs)
|
||||
change = true
|
||||
}
|
||||
|
||||
@@ -311,19 +325,36 @@ func (s *Service) acquireNewLocked(ctx context.Context, mapping *Mapping, nats m
|
||||
|
||||
// tryNATDevice tries to acquire a port mapping for the given internal address to
|
||||
// the given external port. If external port is 0, picks a pseudo-random port.
|
||||
func (s *Service) tryNATDevice(ctx context.Context, natd Device, intPort, extPort int, leaseTime time.Duration) (Address, error) {
|
||||
func (s *Service) tryNATDevice(ctx context.Context, natd Device, intAddr Address, extPort int, leaseTime time.Duration) ([]Address, error) {
|
||||
var err error
|
||||
var port int
|
||||
// For IPv6, we just try to create the pinhole. If it fails, nothing can be done (probably no IGDv2 support).
|
||||
// If it already exists, the relevant UPnP standard requires that the gateway recognizes this and updates the lease time.
|
||||
// Since we usually have a global unicast IPv6 address so no conflicting mappings, we just request the port we're running on
|
||||
if natd.SupportsIPVersion(IPv6Only) {
|
||||
ipaddrs, err := natd.AddPinhole(ctx, TCP, intAddr, leaseTime)
|
||||
var addrs []Address
|
||||
for _, ipaddr := range ipaddrs {
|
||||
addrs = append(addrs, Address{
|
||||
ipaddr,
|
||||
intAddr.Port,
|
||||
})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
l.Debugln("Error extending lease on", natd.ID(), err)
|
||||
}
|
||||
return addrs, err
|
||||
}
|
||||
// Generate a predictable random which is based on device ID + local port + hash of the device ID
|
||||
// number so that the ports we'd try to acquire for the mapping would always be the same for the
|
||||
// same device trying to get the same internal port.
|
||||
predictableRand := rand.New(rand.NewSource(int64(s.id.Short()) + int64(intPort) + hash(natd.ID())))
|
||||
predictableRand := rand.New(rand.NewSource(int64(s.id.Short()) + int64(intAddr.Port) + hash(natd.ID())))
|
||||
|
||||
if extPort != 0 {
|
||||
// First try renewing our existing mapping, if we have one.
|
||||
name := fmt.Sprintf("syncthing-%d", extPort)
|
||||
port, err = natd.AddPortMapping(ctx, TCP, intPort, extPort, name, leaseTime)
|
||||
port, err = natd.AddPortMapping(ctx, TCP, intAddr.Port, extPort, name, leaseTime)
|
||||
if err == nil {
|
||||
extPort = port
|
||||
goto findIP
|
||||
@@ -334,32 +365,34 @@ func (s *Service) tryNATDevice(ctx context.Context, natd Device, intPort, extPor
|
||||
for i := 0; i < 10; i++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Address{}, ctx.Err()
|
||||
return []Address{}, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// Then try up to ten random ports.
|
||||
extPort = 1024 + predictableRand.Intn(65535-1024)
|
||||
name := fmt.Sprintf("syncthing-%d", extPort)
|
||||
port, err = natd.AddPortMapping(ctx, TCP, intPort, extPort, name, leaseTime)
|
||||
port, err = natd.AddPortMapping(ctx, TCP, intAddr.Port, extPort, name, leaseTime)
|
||||
if err == nil {
|
||||
extPort = port
|
||||
goto findIP
|
||||
}
|
||||
l.Debugln("Error getting new lease on", natd.ID(), err)
|
||||
l.Debugf("Error getting new lease on %s: %s", natd.ID(), err)
|
||||
}
|
||||
|
||||
return Address{}, err
|
||||
return nil, err
|
||||
|
||||
findIP:
|
||||
ip, err := natd.GetExternalIPAddress(ctx)
|
||||
ip, err := natd.GetExternalIPv4Address(ctx)
|
||||
if err != nil {
|
||||
l.Debugln("Error getting external ip on", natd.ID(), err)
|
||||
l.Debugf("Error getting external ip on %s: %s", natd.ID(), err)
|
||||
ip = nil
|
||||
}
|
||||
return Address{
|
||||
IP: ip,
|
||||
Port: extPort,
|
||||
return []Address{
|
||||
{
|
||||
IP: ip,
|
||||
Port: extPort,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -372,3 +405,27 @@ func hash(input string) int64 {
|
||||
h.Write([]byte(input))
|
||||
return int64(h.Sum64())
|
||||
}
|
||||
|
||||
func addrSetsEqual(a []Address, b []Address) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
// TODO: Rewrite this using slice.Contains once Go 1.21 is the minimum Go version.
|
||||
for _, aElem := range a {
|
||||
aElemFound := false
|
||||
for _, bElem := range b {
|
||||
if bElem.Equal(aElem) {
|
||||
aElemFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !aElemFound {
|
||||
// Found element in a that is not in b.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// b contains all elements of a and their lengths are equal, so the sets are equal.
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -17,24 +17,25 @@ import (
|
||||
type MappingChangeSubscriber func()
|
||||
|
||||
type Mapping struct {
|
||||
protocol Protocol
|
||||
address Address
|
||||
protocol Protocol
|
||||
ipVersion IPVersion
|
||||
address Address
|
||||
|
||||
extAddresses map[string]Address // NAT ID -> Address
|
||||
extAddresses map[string][]Address // NAT ID -> Address
|
||||
expires time.Time
|
||||
subscribers []MappingChangeSubscriber
|
||||
mut sync.RWMutex
|
||||
}
|
||||
|
||||
func (m *Mapping) setAddressLocked(id string, address Address) {
|
||||
l.Infof("New NAT port mapping: external %s address %s to local address %s.", m.protocol, address, m.address)
|
||||
m.extAddresses[id] = address
|
||||
func (m *Mapping) setAddressLocked(id string, addresses []Address) {
|
||||
l.Infof("New external port opened: external %s address(es) %v to local address %s.", m.protocol, addresses, m.address)
|
||||
m.extAddresses[id] = addresses
|
||||
}
|
||||
|
||||
func (m *Mapping) removeAddressLocked(id string) {
|
||||
addr, ok := m.extAddresses[id]
|
||||
addresses, ok := m.extAddresses[id]
|
||||
if ok {
|
||||
l.Infof("Removing NAT port mapping: external %s address %s, NAT %s is no longer available.", m.protocol, addr, id)
|
||||
l.Infof("Removing external open port: %s address(es) %v for gateway %s.", m.protocol, addresses, id)
|
||||
delete(m.extAddresses, id)
|
||||
}
|
||||
}
|
||||
@@ -73,7 +74,7 @@ func (m *Mapping) ExternalAddresses() []Address {
|
||||
m.mut.RLock()
|
||||
addrs := make([]Address, 0, len(m.extAddresses))
|
||||
for _, addr := range m.extAddresses {
|
||||
addrs = append(addrs, addr)
|
||||
addrs = append(addrs, addr...)
|
||||
}
|
||||
m.mut.RUnlock()
|
||||
return addrs
|
||||
@@ -86,7 +87,7 @@ func (m *Mapping) OnChanged(subscribed MappingChangeSubscriber) {
|
||||
}
|
||||
|
||||
func (m *Mapping) String() string {
|
||||
return fmt.Sprintf("%s %s", m.protocol, m.address)
|
||||
return fmt.Sprintf("%s/%s", m.address, m.protocol)
|
||||
}
|
||||
|
||||
func (m *Mapping) GoString() string {
|
||||
|
||||
@@ -70,11 +70,11 @@ func TestMappingClearAddresses(t *testing.T) {
|
||||
natSvc := NewService(protocol.EmptyDeviceID, w)
|
||||
// Mock a mapped port; avoids the need to actually map a port
|
||||
ip := net.ParseIP("192.168.0.1")
|
||||
m := natSvc.NewMapping(TCP, ip, 1024)
|
||||
m.extAddresses["test"] = Address{
|
||||
m := natSvc.NewMapping(TCP, IPv4Only, ip, 1024)
|
||||
m.extAddresses["test"] = []Address{{
|
||||
IP: ip,
|
||||
Port: 1024,
|
||||
}
|
||||
}}
|
||||
// Now try and remove the mapped port; prior to #4829 this deadlocked
|
||||
natSvc.RemoveMapping(m)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func (w *wrapper) ID() string {
|
||||
return fmt.Sprintf("NAT-PMP@%s", w.gatewayIP.String())
|
||||
}
|
||||
|
||||
func (w *wrapper) GetLocalIPAddress() net.IP {
|
||||
func (w *wrapper) GetLocalIPv4Address() net.IP {
|
||||
return w.localIP
|
||||
}
|
||||
|
||||
@@ -116,7 +116,18 @@ func (w *wrapper) AddPortMapping(ctx context.Context, protocol nat.Protocol, int
|
||||
return port, err
|
||||
}
|
||||
|
||||
func (w *wrapper) GetExternalIPAddress(ctx context.Context) (net.IP, error) {
|
||||
func (*wrapper) AddPinhole(_ context.Context, _ nat.Protocol, _ nat.Address, _ time.Duration) ([]net.IP, error) {
|
||||
// NAT-PMP doesn't support pinholes.
|
||||
return nil, errors.New("adding IPv6 pinholes is unsupported on NAT-PMP")
|
||||
}
|
||||
|
||||
func (*wrapper) SupportsIPVersion(version nat.IPVersion) bool {
|
||||
// NAT-PMP gateways should always try to create port mappings and not pinholes
|
||||
// since NAT-PMP doesn't support IPv6.
|
||||
return version == nat.IPvAny || version == nat.IPv4Only
|
||||
}
|
||||
|
||||
func (w *wrapper) GetExternalIPv4Address(ctx context.Context) (net.IP, error) {
|
||||
var result *natpmp.GetExternalAddressResult
|
||||
err := svcutil.CallWithContext(ctx, func() error {
|
||||
var err error
|
||||
|
||||
@@ -688,6 +688,13 @@ func CreateFileInfo(fi fs.FileInfo, name string, filesystem fs.Filesystem, scanO
|
||||
return protocol.FileInfo{}, fmt.Errorf("reading platform data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if ct := fi.InodeChangeTime(); !ct.IsZero() {
|
||||
f.InodeChangeNs = ct.UnixNano()
|
||||
} else {
|
||||
f.InodeChangeNs = 0
|
||||
}
|
||||
|
||||
if fi.IsSymlink() {
|
||||
f.Type = protocol.FileInfoTypeSymlink
|
||||
target, err := filesystem.ReadSymlink(name)
|
||||
@@ -698,19 +705,18 @@ func CreateFileInfo(fi fs.FileInfo, name string, filesystem fs.Filesystem, scanO
|
||||
f.NoPermissions = true // Symlinks don't have permissions of their own
|
||||
return f, nil
|
||||
}
|
||||
|
||||
f.Permissions = uint32(fi.Mode() & fs.ModePerm)
|
||||
f.ModifiedS = fi.ModTime().Unix()
|
||||
f.ModifiedNs = fi.ModTime().Nanosecond()
|
||||
|
||||
if fi.IsDir() {
|
||||
f.Type = protocol.FileInfoTypeDirectory
|
||||
return f, nil
|
||||
}
|
||||
|
||||
f.Size = fi.Size()
|
||||
f.Type = protocol.FileInfoTypeFile
|
||||
if ct := fi.InodeChangeTime(); !ct.IsZero() {
|
||||
f.InodeChangeNs = ct.UnixNano()
|
||||
} else {
|
||||
f.InodeChangeNs = 0
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
deadlock "github.com/sasha-s/go-deadlock"
|
||||
"github.com/syncthing/syncthing/lib/logger"
|
||||
)
|
||||
|
||||
@@ -22,8 +21,7 @@ var (
|
||||
// We make an exception in this package and have an actual "if debug { ...
|
||||
// }" variable, as it may be rather performance critical and does
|
||||
// nonstandard things (from a debug logging PoV).
|
||||
debug = logger.DefaultLogger.ShouldDebug("sync")
|
||||
useDeadlock = false
|
||||
debug = logger.DefaultLogger.ShouldDebug("sync")
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -31,10 +29,4 @@ func init() {
|
||||
threshold = time.Duration(n) * time.Millisecond
|
||||
}
|
||||
l.Debugf("Enabling lock logging at %v threshold", threshold)
|
||||
|
||||
if n, _ := strconv.Atoi(os.Getenv("STDEADLOCKTIMEOUT")); n > 0 {
|
||||
deadlock.Opts.DeadlockTimeout = time.Duration(n) * time.Second
|
||||
l.Debugf("Enabling lock deadlocking at %v", deadlock.Opts.DeadlockTimeout)
|
||||
useDeadlock = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,6 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/sasha-s/go-deadlock"
|
||||
)
|
||||
|
||||
var timeNow = time.Now
|
||||
@@ -39,9 +37,6 @@ type WaitGroup interface {
|
||||
}
|
||||
|
||||
func NewMutex() Mutex {
|
||||
if useDeadlock {
|
||||
return &deadlock.Mutex{}
|
||||
}
|
||||
if debug {
|
||||
mutex := &loggedMutex{}
|
||||
mutex.holder.Store(holder{})
|
||||
@@ -51,9 +46,6 @@ func NewMutex() Mutex {
|
||||
}
|
||||
|
||||
func NewRWMutex() RWMutex {
|
||||
if useDeadlock {
|
||||
return &deadlock.RWMutex{}
|
||||
}
|
||||
if debug {
|
||||
mutex := &loggedRWMutex{
|
||||
readHolders: make(map[int][]holder),
|
||||
|
||||
@@ -54,12 +54,11 @@ const (
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
AuditWriter io.Writer
|
||||
DeadlockTimeoutS int
|
||||
NoUpgrade bool
|
||||
ProfilerAddr string
|
||||
ResetDeltaIdxs bool
|
||||
Verbose bool
|
||||
AuditWriter io.Writer
|
||||
NoUpgrade bool
|
||||
ProfilerAddr string
|
||||
ResetDeltaIdxs bool
|
||||
Verbose bool
|
||||
// null duration means use default value
|
||||
DBRecheckInterval time.Duration
|
||||
DBIndirectGCInterval time.Duration
|
||||
@@ -251,12 +250,6 @@ func (a *App) startup() error {
|
||||
keyGen := protocol.NewKeyGenerator()
|
||||
m := model.NewModel(a.cfg, a.myID, a.ll, protectedFiles, a.evLogger, keyGen)
|
||||
|
||||
if a.opts.DeadlockTimeoutS > 0 {
|
||||
m.StartDeadlockDetector(time.Duration(a.opts.DeadlockTimeoutS) * time.Second)
|
||||
} else if !build.IsRelease || build.IsBeta {
|
||||
m.StartDeadlockDetector(20 * time.Minute)
|
||||
}
|
||||
|
||||
a.mainService.Add(m)
|
||||
|
||||
// The TLS configuration is used for both the listening socket and outgoing
|
||||
|
||||
@@ -6,14 +6,13 @@
|
||||
|
||||
package upgrade
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// SigningKey is the public key used to verify signed upgrades. It must match
|
||||
// the private key used to sign binaries for the built in upgrade mechanism to
|
||||
// accept an upgrade. Keys and signatures can be created and verified with the
|
||||
// stsigtool utility. The build script creates signed binaries when given the
|
||||
// -sign option.
|
||||
var SigningKey = []byte(`-----BEGIN EC PUBLIC KEY-----
|
||||
MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQA1iRk+p+DsmolixxVKcpEVlMDPOeQ
|
||||
1dWthURMqsjxoJuDAe5I98P/A0kXSdBI7avm5hXhX2opJ5TAyBZLHPpDTRoBg4WN
|
||||
7jUpeAjtPoVVxvOh37qDeDVcjCgJbbDTPKbjxq/Ae3SHlQMRcoes7lVY1+YJ8dPk
|
||||
2oPfjA6jtmo9aVbf/uo=
|
||||
-----END EC PUBLIC KEY-----`)
|
||||
//
|
||||
//go:embed signingkey.pem
|
||||
var SigningKey []byte
|
||||
|
||||
6
lib/upgrade/signingkey.pem
Normal file
6
lib/upgrade/signingkey.pem
Normal file
@@ -0,0 +1,6 @@
|
||||
-----BEGIN EC PUBLIC KEY-----
|
||||
MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQA1iRk+p+DsmolixxVKcpEVlMDPOeQ
|
||||
1dWthURMqsjxoJuDAe5I98P/A0kXSdBI7avm5hXhX2opJ5TAyBZLHPpDTRoBg4WN
|
||||
7jUpeAjtPoVVxvOh37qDeDVcjCgJbbDTPKbjxq/Ae3SHlQMRcoes7lVY1+YJ8dPk
|
||||
2oPfjA6jtmo9aVbf/uo=
|
||||
-----END EC PUBLIC KEY-----
|
||||
@@ -35,6 +35,7 @@ package upnp
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
@@ -49,33 +50,163 @@ type IGDService struct {
|
||||
ServiceID string
|
||||
URL string
|
||||
URN string
|
||||
LocalIP net.IP
|
||||
LocalIPv4 net.IP
|
||||
Interface *net.Interface
|
||||
|
||||
nat.Service
|
||||
}
|
||||
|
||||
// AddPinhole adds an IPv6 pinhole in accordance to http://upnp.org/specs/gw/UPnP-gw-WANIPv6FirewallControl-v1-Service.pdf
|
||||
// This is attempted for each IPv6 on the interface.
|
||||
func (s *IGDService) AddPinhole(ctx context.Context, protocol nat.Protocol, intAddr nat.Address, duration time.Duration) ([]net.IP, error) {
|
||||
var returnErr error
|
||||
var successfulIPs []net.IP
|
||||
if s.Interface == nil {
|
||||
return nil, errors.New("no interface")
|
||||
}
|
||||
|
||||
addrs, err := s.Interface.Addrs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !intAddr.IP.IsUnspecified() {
|
||||
// We have an explicit listener address. Check if that's on the interface
|
||||
// and pinhole it if so. It's not an error if not though, so don't return
|
||||
// an error if one doesn't occur.
|
||||
if intAddr.IP.To4() != nil {
|
||||
l.Debugf("Listener is IPv4. Not using gateway %s", s.ID())
|
||||
return nil, nil
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
ip, _, err := net.ParseCIDR(addr.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if ip.Equal(intAddr.IP) {
|
||||
err := s.tryAddPinholeForIP6(ctx, protocol, intAddr.Port, duration, intAddr.IP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []net.IP{
|
||||
intAddr.IP,
|
||||
}, nil
|
||||
}
|
||||
|
||||
l.Debugf("Listener IP %s not on interface for gateway %s", intAddr.IP, s.ID())
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Otherwise, try to get a pinhole for all IPs, since we are listening on all
|
||||
for _, addr := range addrs {
|
||||
ip, _, err := net.ParseCIDR(addr.String())
|
||||
if err != nil {
|
||||
l.Infof("Couldn't parse address %s: %s", addr, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Note that IsGlobalUnicast allows ULAs.
|
||||
if ip.To4() != nil || !ip.IsGlobalUnicast() || ip.IsPrivate() {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.tryAddPinholeForIP6(ctx, protocol, intAddr.Port, duration, ip); err != nil {
|
||||
l.Infof("Couldn't add pinhole for [%s]:%d/%s. %s", ip, intAddr.Port, protocol, err)
|
||||
returnErr = err
|
||||
} else {
|
||||
successfulIPs = append(successfulIPs, ip)
|
||||
}
|
||||
}
|
||||
|
||||
if len(successfulIPs) > 0 {
|
||||
// (Maybe partial) success, we added a pinhole for at least one GUA.
|
||||
return successfulIPs, nil
|
||||
} else {
|
||||
return nil, returnErr
|
||||
}
|
||||
}
|
||||
|
||||
func (s *IGDService) tryAddPinholeForIP6(ctx context.Context, protocol nat.Protocol, port int, duration time.Duration, ip net.IP) error {
|
||||
var protoNumber int
|
||||
if protocol == nat.TCP {
|
||||
protoNumber = 6
|
||||
} else if protocol == nat.UDP {
|
||||
protoNumber = 17
|
||||
} else {
|
||||
return errors.New("protocol not supported")
|
||||
}
|
||||
|
||||
const template = `<u:AddPinhole xmlns:u="%s">
|
||||
<RemoteHost></RemoteHost>
|
||||
<RemotePort>0</RemotePort>
|
||||
<Protocol>%d</Protocol>
|
||||
<InternalPort>%d</InternalPort>
|
||||
<InternalClient>%s</InternalClient>
|
||||
<LeaseTime>%d</LeaseTime>
|
||||
</u:AddPinhole>`
|
||||
|
||||
body := fmt.Sprintf(template, s.URN, protoNumber, port, ip, duration/time.Second)
|
||||
|
||||
// IP should be a global unicast address, so we can use it as the source IP.
|
||||
// By the UPnP spec, the source address for unauthenticated clients should be
|
||||
// the same as the InternalAddress the pinhole is requested for.
|
||||
// Currently, WANIPv6FirewallProtocol is restricted to IPv6 gateways, so we can always set the IP.
|
||||
resp, err := soapRequestWithIP(ctx, s.URL, s.URN, "AddPinhole", body, &net.TCPAddr{IP: ip})
|
||||
if err != nil && resp != nil {
|
||||
var errResponse soapErrorResponse
|
||||
if unmarshalErr := xml.Unmarshal(resp, &errResponse); unmarshalErr != nil {
|
||||
// There is an error response that we cannot parse.
|
||||
return unmarshalErr
|
||||
}
|
||||
// There is a parsable UPnP error. Return that.
|
||||
return fmt.Errorf("UPnP error: %s (%d)", errResponse.ErrorDescription, errResponse.ErrorCode)
|
||||
} else if resp != nil {
|
||||
var succResponse soapAddPinholeResponse
|
||||
if unmarshalErr := xml.Unmarshal(resp, &succResponse); unmarshalErr != nil {
|
||||
// Ignore errors since this is only used for debug logging.
|
||||
l.Debugf("Failed to parse response from gateway %s: %s", s.ID(), unmarshalErr)
|
||||
} else {
|
||||
l.Debugf("UPnPv6: UID for pinhole on [%s]:%d/%s is %d on gateway %s", ip, port, protocol, succResponse.UniqueID, s.ID())
|
||||
}
|
||||
}
|
||||
// Either there was no error or an error not handled above (no response, e.g. network error).
|
||||
return err
|
||||
}
|
||||
|
||||
// AddPortMapping adds a port mapping to the specified IGD service.
|
||||
func (s *IGDService) AddPortMapping(ctx context.Context, protocol nat.Protocol, internalPort, externalPort int, description string, duration time.Duration) (int, error) {
|
||||
tpl := `<u:AddPortMapping xmlns:u="%s">
|
||||
<NewRemoteHost></NewRemoteHost>
|
||||
<NewExternalPort>%d</NewExternalPort>
|
||||
<NewProtocol>%s</NewProtocol>
|
||||
<NewInternalPort>%d</NewInternalPort>
|
||||
<NewInternalClient>%s</NewInternalClient>
|
||||
<NewEnabled>1</NewEnabled>
|
||||
<NewPortMappingDescription>%s</NewPortMappingDescription>
|
||||
<NewLeaseDuration>%d</NewLeaseDuration>
|
||||
</u:AddPortMapping>`
|
||||
body := fmt.Sprintf(tpl, s.URN, externalPort, protocol, internalPort, s.LocalIP, description, duration/time.Second)
|
||||
if s.LocalIPv4 == nil {
|
||||
return 0, errors.New("no local IPv4")
|
||||
}
|
||||
|
||||
response, err := soapRequest(ctx, s.URL, s.URN, "AddPortMapping", body)
|
||||
const template = `<u:AddPortMapping xmlns:u="%s">
|
||||
<NewRemoteHost></NewRemoteHost>
|
||||
<NewExternalPort>%d</NewExternalPort>
|
||||
<NewProtocol>%s</NewProtocol>
|
||||
<NewInternalPort>%d</NewInternalPort>
|
||||
<NewInternalClient>%s</NewInternalClient>
|
||||
<NewEnabled>1</NewEnabled>
|
||||
<NewPortMappingDescription>%s</NewPortMappingDescription>
|
||||
<NewLeaseDuration>%d</NewLeaseDuration>
|
||||
</u:AddPortMapping>`
|
||||
body := fmt.Sprintf(template, s.URN, externalPort, protocol, internalPort, s.LocalIPv4, description, duration/time.Second)
|
||||
|
||||
response, err := soapRequestWithIP(ctx, s.URL, s.URN, "AddPortMapping", body, &net.TCPAddr{IP: s.LocalIPv4})
|
||||
if err != nil && duration > 0 {
|
||||
// Try to repair error code 725 - OnlyPermanentLeasesSupported
|
||||
envelope := &soapErrorResponse{}
|
||||
if unmarshalErr := xml.Unmarshal(response, envelope); unmarshalErr != nil {
|
||||
var envelope soapErrorResponse
|
||||
if unmarshalErr := xml.Unmarshal(response, &envelope); unmarshalErr != nil {
|
||||
return externalPort, unmarshalErr
|
||||
}
|
||||
|
||||
if envelope.ErrorCode == 725 {
|
||||
return s.AddPortMapping(ctx, protocol, internalPort, externalPort, description, 0)
|
||||
}
|
||||
|
||||
err = fmt.Errorf("UPnP Error: %s (%d)", envelope.ErrorDescription, envelope.ErrorCode)
|
||||
l.Infof("Couldn't add port mapping for %s (external port %d -> internal port %d/%s): %s", s.LocalIPv4, externalPort, internalPort, protocol, err)
|
||||
}
|
||||
|
||||
return externalPort, err
|
||||
@@ -83,34 +214,32 @@ func (s *IGDService) AddPortMapping(ctx context.Context, protocol nat.Protocol,
|
||||
|
||||
// DeletePortMapping deletes a port mapping from the specified IGD service.
|
||||
func (s *IGDService) DeletePortMapping(ctx context.Context, protocol nat.Protocol, externalPort int) error {
|
||||
tpl := `<u:DeletePortMapping xmlns:u="%s">
|
||||
const template = `<u:DeletePortMapping xmlns:u="%s">
|
||||
<NewRemoteHost></NewRemoteHost>
|
||||
<NewExternalPort>%d</NewExternalPort>
|
||||
<NewProtocol>%s</NewProtocol>
|
||||
</u:DeletePortMapping>`
|
||||
body := fmt.Sprintf(tpl, s.URN, externalPort, protocol)
|
||||
|
||||
body := fmt.Sprintf(template, s.URN, externalPort, protocol)
|
||||
|
||||
_, err := soapRequest(ctx, s.URL, s.URN, "DeletePortMapping", body)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetExternalIPAddress queries the IGD service for its external IP address.
|
||||
// GetExternalIPv4Address queries the IGD service for its external IP address.
|
||||
// Returns nil if the external IP address is invalid or undefined, along with
|
||||
// any relevant errors
|
||||
func (s *IGDService) GetExternalIPAddress(ctx context.Context) (net.IP, error) {
|
||||
tpl := `<u:GetExternalIPAddress xmlns:u="%s" />`
|
||||
|
||||
body := fmt.Sprintf(tpl, s.URN)
|
||||
func (s *IGDService) GetExternalIPv4Address(ctx context.Context) (net.IP, error) {
|
||||
const template = `<u:GetExternalIPAddress xmlns:u="%s" />`
|
||||
|
||||
body := fmt.Sprintf(template, s.URN)
|
||||
response, err := soapRequest(ctx, s.URL, s.URN, "GetExternalIPAddress", body)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
envelope := &soapGetExternalIPAddressResponseEnvelope{}
|
||||
err = xml.Unmarshal(response, envelope)
|
||||
if err != nil {
|
||||
var envelope soapGetExternalIPAddressResponseEnvelope
|
||||
if err := xml.Unmarshal(response, &envelope); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -119,12 +248,26 @@ func (s *IGDService) GetExternalIPAddress(ctx context.Context) (net.IP, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLocalIPAddress returns local IP address used to contact this service
|
||||
func (s *IGDService) GetLocalIPAddress() net.IP {
|
||||
return s.LocalIP
|
||||
// GetLocalIPv4Address returns local IP address used to contact this service
|
||||
func (s *IGDService) GetLocalIPv4Address() net.IP {
|
||||
return s.LocalIPv4
|
||||
}
|
||||
|
||||
// ID returns a unique ID for the servic
|
||||
// SupportsIPVersion checks whether this is a WANIPv6FirewallControl device,
|
||||
// in which case pinholing instead of port mapping should be done
|
||||
func (s *IGDService) SupportsIPVersion(version nat.IPVersion) bool {
|
||||
if version == nat.IPvAny {
|
||||
return true
|
||||
} else if version == nat.IPv6Only {
|
||||
return s.URN == urnWANIPv6FirewallControlV1
|
||||
} else if version == nat.IPv4Only {
|
||||
return s.URN != urnWANIPv6FirewallControlV1
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ID returns a unique ID for the service
|
||||
func (s *IGDService) ID() string {
|
||||
return s.UUID + "/" + s.Device.FriendlyName + "/" + s.ServiceID + "/" + s.URN + "/" + s.URL
|
||||
}
|
||||
|
||||
280
lib/upnp/upnp.go
280
lib/upnp/upnp.go
@@ -43,10 +43,12 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/syncthing/syncthing/lib/build"
|
||||
"github.com/syncthing/syncthing/lib/dialer"
|
||||
"github.com/syncthing/syncthing/lib/nat"
|
||||
"github.com/syncthing/syncthing/lib/osutil"
|
||||
@@ -63,6 +65,7 @@ type upnpService struct {
|
||||
}
|
||||
|
||||
type upnpDevice struct {
|
||||
IsIPv6 bool
|
||||
DeviceType string `xml:"deviceType"`
|
||||
FriendlyName string `xml:"friendlyName"`
|
||||
Devices []upnpDevice `xml:"deviceList>device"`
|
||||
@@ -82,6 +85,20 @@ func (e *UnsupportedDeviceTypeError) Error() string {
|
||||
return fmt.Sprintf("Unsupported UPnP device of type %s", e.deviceType)
|
||||
}
|
||||
|
||||
const (
|
||||
urnIgdV1 = "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
|
||||
urnIgdV2 = "urn:schemas-upnp-org:device:InternetGatewayDevice:2"
|
||||
urnWANDeviceV1 = "urn:schemas-upnp-org:device:WANDevice:1"
|
||||
urnWANDeviceV2 = "urn:schemas-upnp-org:device:WANDevice:2"
|
||||
urnWANConnectionDeviceV1 = "urn:schemas-upnp-org:device:WANConnectionDevice:1"
|
||||
urnWANConnectionDeviceV2 = "urn:schemas-upnp-org:device:WANConnectionDevice:2"
|
||||
urnWANIPConnectionV1 = "urn:schemas-upnp-org:service:WANIPConnection:1"
|
||||
urnWANIPConnectionV2 = "urn:schemas-upnp-org:service:WANIPConnection:2"
|
||||
urnWANIPv6FirewallControlV1 = "urn:schemas-upnp-org:service:WANIPv6FirewallControl:1"
|
||||
urnWANPPPConnectionV1 = "urn:schemas-upnp-org:service:WANPPPConnection:1"
|
||||
urnWANPPPConnectionV2 = "urn:schemas-upnp-org:service:WANPPPConnection:2"
|
||||
)
|
||||
|
||||
// Discover discovers UPnP InternetGatewayDevices.
|
||||
// The order in which the devices appear in the results list is not deterministic.
|
||||
func Discover(ctx context.Context, _, timeout time.Duration) []nat.Device {
|
||||
@@ -102,13 +119,28 @@ func Discover(ctx context.Context, _, timeout time.Duration) []nat.Device {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, deviceType := range []string{"urn:schemas-upnp-org:device:InternetGatewayDevice:1", "urn:schemas-upnp-org:device:InternetGatewayDevice:2"} {
|
||||
wg.Add(1)
|
||||
go func(intf net.Interface, deviceType string) {
|
||||
discover(ctx, &intf, deviceType, timeout, resultChan)
|
||||
wg.Done()
|
||||
}(intf, deviceType)
|
||||
}
|
||||
wg.Add(1)
|
||||
// Discovery is done sequentially per interface because we discovered that
|
||||
// FritzBox routers return a broken result sometimes if the IPv4 and IPv6
|
||||
// request arrive at the same time.
|
||||
go func(iface net.Interface) {
|
||||
defer wg.Done()
|
||||
hasGUA, err := interfaceHasGUAIPv6(iface)
|
||||
if err != nil {
|
||||
l.Debugf("Couldn't check for IPv6 GUAs on %s: %s", iface.Name, err)
|
||||
} else if hasGUA {
|
||||
// Discover IPv6 gateways on interface. Only discover IGDv2, since IGDv1
|
||||
// + IPv6 is not standardized and will lead to duplicates on routers.
|
||||
// Only do this when a non-link-local IPv6 is available. if we can't
|
||||
// enumerate the interface, the IPv6 code will not work anyway
|
||||
discover(ctx, &iface, urnIgdV2, timeout, resultChan, true)
|
||||
}
|
||||
|
||||
// Discover IPv4 gateways on interface.
|
||||
for _, deviceType := range []string{urnIgdV2, urnIgdV1} {
|
||||
discover(ctx, &iface, deviceType, timeout, resultChan, false)
|
||||
}
|
||||
}(intf)
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -117,7 +149,6 @@ func Discover(ctx context.Context, _, timeout time.Duration) []nat.Device {
|
||||
}()
|
||||
|
||||
seenResults := make(map[string]bool)
|
||||
|
||||
for {
|
||||
select {
|
||||
case result, ok := <-resultChan:
|
||||
@@ -141,33 +172,59 @@ func Discover(ctx context.Context, _, timeout time.Duration) []nat.Device {
|
||||
|
||||
// Search for UPnP InternetGatewayDevices for <timeout> seconds.
|
||||
// The order in which the devices appear in the result list is not deterministic
|
||||
func discover(ctx context.Context, intf *net.Interface, deviceType string, timeout time.Duration, results chan<- nat.Device) {
|
||||
ssdp := &net.UDPAddr{IP: []byte{239, 255, 255, 250}, Port: 1900}
|
||||
func discover(ctx context.Context, intf *net.Interface, deviceType string, timeout time.Duration, results chan<- nat.Device, ip6 bool) {
|
||||
var ssdp net.UDPAddr
|
||||
var template string
|
||||
if ip6 {
|
||||
ssdp = net.UDPAddr{IP: []byte{0xFF, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C}, Port: 1900}
|
||||
|
||||
tpl := `M-SEARCH * HTTP/1.1
|
||||
template = `M-SEARCH * HTTP/1.1
|
||||
HOST: [FF05::C]:1900
|
||||
ST: %s
|
||||
MAN: "ssdp:discover"
|
||||
MX: %d
|
||||
USER-AGENT: syncthing/%s
|
||||
|
||||
`
|
||||
} else {
|
||||
ssdp = net.UDPAddr{IP: []byte{239, 255, 255, 250}, Port: 1900}
|
||||
|
||||
template = `M-SEARCH * HTTP/1.1
|
||||
HOST: 239.255.255.250:1900
|
||||
ST: %s
|
||||
MAN: "ssdp:discover"
|
||||
MX: %d
|
||||
USER-AGENT: syncthing/1.0
|
||||
USER-AGENT: syncthing/%s
|
||||
|
||||
`
|
||||
searchStr := fmt.Sprintf(tpl, deviceType, timeout/time.Second)
|
||||
}
|
||||
|
||||
searchStr := fmt.Sprintf(template, deviceType, timeout/time.Second, build.Version)
|
||||
|
||||
search := []byte(strings.ReplaceAll(searchStr, "\n", "\r\n") + "\r\n")
|
||||
|
||||
l.Debugln("Starting discovery of device type", deviceType, "on", intf.Name)
|
||||
|
||||
socket, err := net.ListenMulticastUDP("udp4", intf, &net.UDPAddr{IP: ssdp.IP})
|
||||
proto := "udp4"
|
||||
if ip6 {
|
||||
proto = "udp6"
|
||||
}
|
||||
socket, err := net.ListenMulticastUDP(proto, intf, &net.UDPAddr{IP: ssdp.IP})
|
||||
|
||||
if err != nil {
|
||||
l.Debugln("UPnP discovery: listening to udp multicast:", err)
|
||||
if runtime.GOOS == "windows" && ip6 {
|
||||
// Requires https://github.com/golang/go/issues/63529 to be fixed.
|
||||
l.Infoln("Support for IPv6 UPnP is currently not available on Windows:", err)
|
||||
} else {
|
||||
l.Debugln("UPnP discovery: listening to udp multicast:", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer socket.Close() // Make sure our socket gets closed
|
||||
|
||||
l.Debugln("Sending search request for device type", deviceType, "on", intf.Name)
|
||||
|
||||
_, err = socket.WriteTo(search, ssdp)
|
||||
_, err = socket.WriteTo(search, &ssdp)
|
||||
if err != nil {
|
||||
if e, ok := err.(net.Error); !ok || !e.Timeout() {
|
||||
l.Debugln("UPnP discovery: sending search request:", err)
|
||||
@@ -190,7 +247,7 @@ loop:
|
||||
break
|
||||
}
|
||||
|
||||
n, _, err := socket.ReadFrom(resp)
|
||||
n, udpAddr, err := socket.ReadFromUDP(resp)
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -204,7 +261,7 @@ loop:
|
||||
break
|
||||
}
|
||||
|
||||
igds, err := parseResponse(ctx, deviceType, resp[:n])
|
||||
igds, err := parseResponse(ctx, deviceType, udpAddr, resp[:n], intf)
|
||||
if err != nil {
|
||||
switch err.(type) {
|
||||
case *UnsupportedDeviceTypeError:
|
||||
@@ -228,7 +285,7 @@ loop:
|
||||
l.Debugln("Discovery for device type", deviceType, "on", intf.Name, "finished.")
|
||||
}
|
||||
|
||||
func parseResponse(ctx context.Context, deviceType string, resp []byte) ([]IGDService, error) {
|
||||
func parseResponse(ctx context.Context, deviceType string, addr *net.UDPAddr, resp []byte, netInterface *net.Interface) ([]IGDService, error) {
|
||||
l.Debugln("Handling UPnP response:\n\n" + string(resp))
|
||||
|
||||
reader := bufio.NewReader(bytes.NewBuffer(resp))
|
||||
@@ -249,9 +306,14 @@ func parseResponse(ctx context.Context, deviceType string, resp []byte) ([]IGDSe
|
||||
}
|
||||
|
||||
deviceDescriptionURL, err := url.Parse(deviceDescriptionLocation)
|
||||
|
||||
if err != nil {
|
||||
l.Infoln("Invalid IGD location: " + err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
l.Infoln("Invalid source IP for IGD: " + err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deviceUSN := response.Header.Get("USN")
|
||||
@@ -259,6 +321,26 @@ func parseResponse(ctx context.Context, deviceType string, resp []byte) ([]IGDSe
|
||||
return nil, errors.New("invalid IGD response: USN not specified")
|
||||
}
|
||||
|
||||
deviceIP := net.ParseIP(deviceDescriptionURL.Hostname())
|
||||
// If the hostname of the device parses as an IPv6 link-local address, we need
|
||||
// to use the source IP address of the response as the hostname
|
||||
// instead of the one given, since only the former contains the zone index,
|
||||
// while the URL returned from the gateway cannot contain the zone index.
|
||||
// (It can't know how interfaces are named/numbered on our machine)
|
||||
if deviceIP != nil && deviceIP.To4() == nil && deviceIP.IsLinkLocalUnicast() {
|
||||
ipAddr := net.IPAddr{
|
||||
IP: addr.IP,
|
||||
Zone: addr.Zone,
|
||||
}
|
||||
|
||||
deviceDescriptionPort := deviceDescriptionURL.Port()
|
||||
deviceDescriptionURL.Host = "[" + ipAddr.String() + "]"
|
||||
if deviceDescriptionPort != "" {
|
||||
deviceDescriptionURL.Host += ":" + deviceDescriptionPort
|
||||
}
|
||||
deviceDescriptionLocation = deviceDescriptionURL.String()
|
||||
}
|
||||
|
||||
deviceUUID := strings.TrimPrefix(strings.Split(deviceUSN, "::")[0], "uuid:")
|
||||
response, err = http.Get(deviceDescriptionLocation)
|
||||
if err != nil {
|
||||
@@ -276,16 +358,27 @@ func parseResponse(ctx context.Context, deviceType string, resp []byte) ([]IGDSe
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Figure out our IP number, on the network used to reach the IGD.
|
||||
// We do this in a fairly roundabout way by connecting to the IGD and
|
||||
// checking the address of the local end of the socket. I'm open to
|
||||
// suggestions on a better way to do this...
|
||||
localIPAddress, err := localIP(ctx, deviceDescriptionURL)
|
||||
// Figure out our IPv4 address on the interface used to reach the IGD.
|
||||
localIPv4Address, err := localIPv4(netInterface)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// On Android, we cannot enumerate IP addresses on interfaces directly.
|
||||
// Therefore, we just try to connect to the IGD and look at which source IP
|
||||
// address was used. This is not ideal, but it's the best we can do. Maybe
|
||||
// we are on an IPv6-only network though, so don't error out in case pinholing is available.
|
||||
localIPv4Address, err = localIPv4Fallback(ctx, deviceDescriptionURL)
|
||||
if err != nil {
|
||||
l.Infoln("Unable to determine local IPv4 address for IGD: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
services, err := getServiceDescriptions(deviceUUID, localIPAddress, deviceDescriptionLocation, upnpRoot.Device)
|
||||
// This differs from IGDService.SupportsIPVersion(). While that method
|
||||
// determines whether an already completely discovered device uses the IPv6
|
||||
// firewall protocol, this just checks if the gateway's is IPv6. Currently we
|
||||
// only want to discover IPv6 UPnP endpoints on IPv6 gateways and vice versa,
|
||||
// which is why this needs to be stored but technically we could forgo this check
|
||||
// and try WANIPv6FirewallControl via IPv4. This leads to errors though so we don't do it.
|
||||
upnpRoot.Device.IsIPv6 = addr.IP.To4() == nil
|
||||
services, err := getServiceDescriptions(deviceUUID, localIPv4Address, deviceDescriptionLocation, upnpRoot.Device, netInterface)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -293,16 +386,46 @@ func parseResponse(ctx context.Context, deviceType string, resp []byte) ([]IGDSe
|
||||
return services, nil
|
||||
}
|
||||
|
||||
func localIP(ctx context.Context, url *url.URL) (net.IP, error) {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, time.Second)
|
||||
defer cancel()
|
||||
conn, err := dialer.DialContext(timeoutCtx, "tcp", url.Host)
|
||||
func localIPv4(netInterface *net.Interface) (net.IP, error) {
|
||||
addrs, err := netInterface.Addrs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
ip, _, err := net.ParseCIDR(addr.String())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if ip.To4() != nil {
|
||||
return ip, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("no IPv4 address found for interface " + netInterface.Name)
|
||||
}
|
||||
|
||||
func localIPv4Fallback(ctx context.Context, url *url.URL) (net.IP, error) {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, time.Second)
|
||||
defer cancel()
|
||||
|
||||
conn, err := dialer.DialContext(timeoutCtx, "udp4", url.Host)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer conn.Close()
|
||||
|
||||
return osutil.IPFromAddr(conn.LocalAddr())
|
||||
ip, err := osutil.IPFromAddr(conn.LocalAddr())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ip.To4() == nil {
|
||||
return nil, errors.New("tried to obtain IPv4 through fallback but got IPv6 address")
|
||||
}
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
func getChildDevices(d upnpDevice, deviceType string) []upnpDevice {
|
||||
@@ -325,21 +448,36 @@ func getChildServices(d upnpDevice, serviceType string) []upnpService {
|
||||
return result
|
||||
}
|
||||
|
||||
func getServiceDescriptions(deviceUUID string, localIPAddress net.IP, rootURL string, device upnpDevice) ([]IGDService, error) {
|
||||
func getServiceDescriptions(deviceUUID string, localIPAddress net.IP, rootURL string, device upnpDevice, netInterface *net.Interface) ([]IGDService, error) {
|
||||
var result []IGDService
|
||||
|
||||
if device.DeviceType == "urn:schemas-upnp-org:device:InternetGatewayDevice:1" {
|
||||
if device.IsIPv6 && device.DeviceType == urnIgdV1 {
|
||||
// IPv6 UPnP is only standardized for IGDv2. Furthermore, any WANIPConn services for IPv4 that
|
||||
// we may discover here are likely to be broken because many routers make the choice to not allow
|
||||
// port mappings for IPs differing from the source IP of the device making the request (which would be v6 here)
|
||||
return nil, nil
|
||||
} else if device.IsIPv6 && device.DeviceType == urnIgdV2 {
|
||||
descriptions := getIGDServices(deviceUUID, localIPAddress, rootURL, device,
|
||||
"urn:schemas-upnp-org:device:WANDevice:1",
|
||||
"urn:schemas-upnp-org:device:WANConnectionDevice:1",
|
||||
[]string{"urn:schemas-upnp-org:service:WANIPConnection:1", "urn:schemas-upnp-org:service:WANPPPConnection:1"})
|
||||
urnWANDeviceV2,
|
||||
urnWANConnectionDeviceV2,
|
||||
[]string{urnWANIPv6FirewallControlV1},
|
||||
netInterface)
|
||||
|
||||
result = append(result, descriptions...)
|
||||
} else if device.DeviceType == "urn:schemas-upnp-org:device:InternetGatewayDevice:2" {
|
||||
} else if device.DeviceType == urnIgdV1 {
|
||||
descriptions := getIGDServices(deviceUUID, localIPAddress, rootURL, device,
|
||||
"urn:schemas-upnp-org:device:WANDevice:2",
|
||||
"urn:schemas-upnp-org:device:WANConnectionDevice:2",
|
||||
[]string{"urn:schemas-upnp-org:service:WANIPConnection:2", "urn:schemas-upnp-org:service:WANPPPConnection:2"})
|
||||
urnWANDeviceV1,
|
||||
urnWANConnectionDeviceV1,
|
||||
[]string{urnWANIPConnectionV1, urnWANPPPConnectionV1},
|
||||
netInterface)
|
||||
|
||||
result = append(result, descriptions...)
|
||||
} else if device.DeviceType == urnIgdV2 {
|
||||
descriptions := getIGDServices(deviceUUID, localIPAddress, rootURL, device,
|
||||
urnWANDeviceV2,
|
||||
urnWANConnectionDeviceV2,
|
||||
[]string{urnWANIPConnectionV2, urnWANPPPConnectionV2},
|
||||
netInterface)
|
||||
|
||||
result = append(result, descriptions...)
|
||||
} else {
|
||||
@@ -352,7 +490,7 @@ func getServiceDescriptions(deviceUUID string, localIPAddress net.IP, rootURL st
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func getIGDServices(deviceUUID string, localIPAddress net.IP, rootURL string, device upnpDevice, wanDeviceURN string, wanConnectionURN string, URNs []string) []IGDService {
|
||||
func getIGDServices(deviceUUID string, localIPAddress net.IP, rootURL string, device upnpDevice, wanDeviceURN string, wanConnectionURN string, URNs []string, netInterface *net.Interface) []IGDService {
|
||||
var result []IGDService
|
||||
|
||||
devices := getChildDevices(device, wanDeviceURN)
|
||||
@@ -373,7 +511,9 @@ func getIGDServices(deviceUUID string, localIPAddress net.IP, rootURL string, de
|
||||
for _, URN := range URNs {
|
||||
services := getChildServices(connection, URN)
|
||||
|
||||
l.Debugln(rootURL, "- no services of type", URN, " found on connection.")
|
||||
if len(services) == 0 {
|
||||
l.Debugln(rootURL, "- no services of type", URN, " found on connection.")
|
||||
}
|
||||
|
||||
for _, service := range services {
|
||||
if service.ControlURL == "" {
|
||||
@@ -390,7 +530,8 @@ func getIGDServices(deviceUUID string, localIPAddress net.IP, rootURL string, de
|
||||
ServiceID: service.ID,
|
||||
URL: u.String(),
|
||||
URN: service.Type,
|
||||
LocalIP: localIPAddress,
|
||||
Interface: netInterface,
|
||||
LocalIPv4: localIPAddress,
|
||||
}
|
||||
|
||||
result = append(result, service)
|
||||
@@ -428,14 +569,18 @@ func replaceRawPath(u *url.URL, rp string) {
|
||||
}
|
||||
|
||||
func soapRequest(ctx context.Context, url, service, function, message string) ([]byte, error) {
|
||||
tpl := `<?xml version="1.0" ?>
|
||||
return soapRequestWithIP(ctx, url, service, function, message, nil)
|
||||
}
|
||||
|
||||
func soapRequestWithIP(ctx context.Context, url, service, function, message string, localIP *net.TCPAddr) ([]byte, error) {
|
||||
const template = `<?xml version="1.0" ?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>%s</s:Body>
|
||||
</s:Envelope>
|
||||
`
|
||||
var resp []byte
|
||||
|
||||
body := fmt.Sprintf(tpl, message)
|
||||
body := fmt.Sprintf(template, message)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -453,13 +598,27 @@ func soapRequest(ctx context.Context, url, service, function, message string) ([
|
||||
l.Debugln("SOAP Action: " + req.Header.Get("SOAPAction"))
|
||||
l.Debugln("SOAP Request:\n\n" + body)
|
||||
|
||||
r, err := http.DefaultClient.Do(req)
|
||||
dialer := net.Dialer{
|
||||
LocalAddr: localIP,
|
||||
}
|
||||
transport := &http.Transport{
|
||||
DialContext: dialer.DialContext,
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
r, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
l.Debugln("SOAP do:", err)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
resp, _ = io.ReadAll(r.Body)
|
||||
resp, err = io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
l.Debugf("Error reading SOAP response: %s, partial response (if present):\n\n%s", resp)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
l.Debugf("SOAP Response: %s\n\n%s\n\n", r.Status, resp)
|
||||
|
||||
r.Body.Close()
|
||||
@@ -471,6 +630,27 @@ func soapRequest(ctx context.Context, url, service, function, message string) ([
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func interfaceHasGUAIPv6(intf net.Interface) (bool, error) {
|
||||
addrs, err := intf.Addrs()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
ip, _, err := net.ParseCIDR(addr.String())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// IsGlobalUnicast returns true for ULAs, so check for those separately.
|
||||
if ip.To4() == nil && ip.IsGlobalUnicast() && !ip.IsPrivate() {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
type soapGetExternalIPAddressResponseEnvelope struct {
|
||||
XMLName xml.Name
|
||||
Body soapGetExternalIPAddressResponseBody `xml:"Body"`
|
||||
@@ -489,3 +669,7 @@ type soapErrorResponse struct {
|
||||
ErrorCode int `xml:"Body>Fault>detail>UPnPError>errorCode"`
|
||||
ErrorDescription string `xml:"Body>Fault>detail>UPnPError>errorDescription"`
|
||||
}
|
||||
|
||||
type soapAddPinholeResponse struct {
|
||||
UniqueID int `xml:"Body>AddPinholeResponse>UniqueID"`
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
|
||||
..
|
||||
.TH "STDISCOSRV" "1" "Nov 22, 2023" "v1.26.0" "Syncthing"
|
||||
.TH "STDISCOSRV" "1" "Dec 21, 2023" "v1.27.0" "Syncthing"
|
||||
.SH NAME
|
||||
stdiscosrv \- Syncthing Discovery Server
|
||||
.SH SYNOPSIS
|
||||
|
||||
@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
|
||||
..
|
||||
.TH "STRELAYSRV" "1" "Nov 22, 2023" "v1.26.0" "Syncthing"
|
||||
.TH "STRELAYSRV" "1" "Dec 21, 2023" "v1.27.0" "Syncthing"
|
||||
.SH NAME
|
||||
strelaysrv \- Syncthing Relay Server
|
||||
.SH SYNOPSIS
|
||||
|
||||
@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
|
||||
..
|
||||
.TH "SYNCTHING-BEP" "7" "Nov 22, 2023" "v1.26.0" "Syncthing"
|
||||
.TH "SYNCTHING-BEP" "7" "Dec 21, 2023" "v1.27.0" "Syncthing"
|
||||
.SH NAME
|
||||
syncthing-bep \- Block Exchange Protocol v1
|
||||
.SH INTRODUCTION AND DEFINITIONS
|
||||
|
||||
@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
|
||||
..
|
||||
.TH "SYNCTHING-CONFIG" "5" "Nov 22, 2023" "v1.26.0" "Syncthing"
|
||||
.TH "SYNCTHING-CONFIG" "5" "Dec 21, 2023" "v1.27.0" "Syncthing"
|
||||
.SH NAME
|
||||
syncthing-config \- Syncthing Configuration
|
||||
.SH SYNOPSIS
|
||||
|
||||
@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
|
||||
..
|
||||
.TH "SYNCTHING-DEVICE-IDS" "7" "Nov 22, 2023" "v1.26.0" "Syncthing"
|
||||
.TH "SYNCTHING-DEVICE-IDS" "7" "Dec 21, 2023" "v1.27.0" "Syncthing"
|
||||
.SH NAME
|
||||
syncthing-device-ids \- Understanding Device IDs
|
||||
.sp
|
||||
|
||||
@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
|
||||
..
|
||||
.TH "SYNCTHING-EVENT-API" "7" "Nov 22, 2023" "v1.26.0" "Syncthing"
|
||||
.TH "SYNCTHING-EVENT-API" "7" "Dec 21, 2023" "v1.27.0" "Syncthing"
|
||||
.SH NAME
|
||||
syncthing-event-api \- Event API
|
||||
.SH DESCRIPTION
|
||||
|
||||
@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
|
||||
..
|
||||
.TH "SYNCTHING-FAQ" "7" "Nov 22, 2023" "v1.26.0" "Syncthing"
|
||||
.TH "SYNCTHING-FAQ" "7" "Dec 21, 2023" "v1.27.0" "Syncthing"
|
||||
.SH NAME
|
||||
syncthing-faq \- Frequently Asked Questions
|
||||
.INDENT 0.0
|
||||
|
||||
@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
|
||||
..
|
||||
.TH "SYNCTHING-GLOBALDISCO" "7" "Nov 22, 2023" "v1.26.0" "Syncthing"
|
||||
.TH "SYNCTHING-GLOBALDISCO" "7" "Dec 21, 2023" "v1.27.0" "Syncthing"
|
||||
.SH NAME
|
||||
syncthing-globaldisco \- Global Discovery Protocol v3
|
||||
.SH ANNOUNCEMENTS
|
||||
|
||||
@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
|
||||
..
|
||||
.TH "SYNCTHING-LOCALDISCO" "7" "Nov 22, 2023" "v1.26.0" "Syncthing"
|
||||
.TH "SYNCTHING-LOCALDISCO" "7" "Dec 21, 2023" "v1.27.0" "Syncthing"
|
||||
.SH NAME
|
||||
syncthing-localdisco \- Local Discovery Protocol v4
|
||||
.SH MODE OF OPERATION
|
||||
|
||||
@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
|
||||
..
|
||||
.TH "SYNCTHING-NETWORKING" "7" "Nov 22, 2023" "v1.26.0" "Syncthing"
|
||||
.TH "SYNCTHING-NETWORKING" "7" "Dec 21, 2023" "v1.27.0" "Syncthing"
|
||||
.SH NAME
|
||||
syncthing-networking \- Firewall Setup
|
||||
.SH ROUTER SETUP
|
||||
|
||||
@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
|
||||
..
|
||||
.TH "SYNCTHING-RELAY" "7" "Nov 22, 2023" "v1.26.0" "Syncthing"
|
||||
.TH "SYNCTHING-RELAY" "7" "Dec 21, 2023" "v1.27.0" "Syncthing"
|
||||
.SH NAME
|
||||
syncthing-relay \- Relay Protocol v1
|
||||
.SH WHAT IS A RELAY?
|
||||
|
||||
@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
|
||||
..
|
||||
.TH "SYNCTHING-REST-API" "7" "Nov 22, 2023" "v1.26.0" "Syncthing"
|
||||
.TH "SYNCTHING-REST-API" "7" "Dec 21, 2023" "v1.27.0" "Syncthing"
|
||||
.SH NAME
|
||||
syncthing-rest-api \- REST API
|
||||
.sp
|
||||
|
||||
@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
|
||||
..
|
||||
.TH "SYNCTHING-SECURITY" "7" "Nov 22, 2023" "v1.26.0" "Syncthing"
|
||||
.TH "SYNCTHING-SECURITY" "7" "Dec 21, 2023" "v1.27.0" "Syncthing"
|
||||
.SH NAME
|
||||
syncthing-security \- Security Principles
|
||||
.sp
|
||||
|
||||
@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
|
||||
..
|
||||
.TH "SYNCTHING-STIGNORE" "5" "Nov 22, 2023" "v1.26.0" "Syncthing"
|
||||
.TH "SYNCTHING-STIGNORE" "5" "Dec 21, 2023" "v1.27.0" "Syncthing"
|
||||
.SH NAME
|
||||
syncthing-stignore \- Prevent files from being synchronized to other nodes
|
||||
.SH SYNOPSIS
|
||||
@@ -44,12 +44,12 @@ syncthing-stignore \- Prevent files from being synchronized to other nodes
|
||||
.SH DESCRIPTION
|
||||
.sp
|
||||
If some files should not be synchronized to (or from) other devices, a file called
|
||||
\fB\&.stignore\fP can be created containing file patterns to ignore. The
|
||||
\fB\&.stignore\fP file must be placed in the root of the synced folder. The
|
||||
\fB\&.stignore\fP file itself will never be synced to other devices, although it can
|
||||
\fB#include\fP files that \fIare\fP synchronized between devices. All patterns are
|
||||
relative to the synced folder root.
|
||||
The contents of the \fB\&.stignore\fP file must be UTF\-8 encoded.
|
||||
\fB\&.stignore\fP can be created containing file patterns to ignore. The \fB\&.stignore\fP
|
||||
file must be placed in the root of the synced folder (files in other locations are
|
||||
not applied). The \fB\&.stignore\fP file itself will never be synced to other devices,
|
||||
although it can \fB#include\fP files that \fIare\fP synchronized between devices. All
|
||||
patterns are relative to the synced folder root. The contents of the \fB\&.stignore\fP
|
||||
file must be UTF\-8 encoded.
|
||||
.sp
|
||||
\fBNOTE:\fP
|
||||
.INDENT 0.0
|
||||
@@ -115,8 +115,9 @@ patterns from a file in a subdirectory, the patterns themselves are
|
||||
still relative to the synced folder \fIroot\fP\&. Example:
|
||||
\fB#include more\-patterns.txt\fP\&.
|
||||
.sp
|
||||
Any \fB#include\fP directives inside a file loaded by \fB#include\fP require paths specified relative
|
||||
to the directory containing the loaded file, rather than the synchronised root directory.
|
||||
Any \fB#include\fP directives inside a file loaded by \fB#include\fP require paths
|
||||
specified relative to the directory containing the loaded file, rather than the
|
||||
synchronised root directory.
|
||||
.IP \(bu 2
|
||||
A pattern beginning with a \fB!\fP prefix negates the pattern: matching files
|
||||
are \fIincluded\fP (that is, \fInot\fP ignored). This can be used to override
|
||||
|
||||
@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
|
||||
..
|
||||
.TH "SYNCTHING-VERSIONING" "7" "Nov 22, 2023" "v1.26.0" "Syncthing"
|
||||
.TH "SYNCTHING-VERSIONING" "7" "Dec 21, 2023" "v1.27.0" "Syncthing"
|
||||
.SH NAME
|
||||
syncthing-versioning \- Keep automatic backups of deleted files by other nodes
|
||||
.sp
|
||||
|
||||
@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
|
||||
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
|
||||
..
|
||||
.TH "SYNCTHING" "1" "Nov 22, 2023" "v1.26.0" "Syncthing"
|
||||
.TH "SYNCTHING" "1" "Dec 21, 2023" "v1.27.0" "Syncthing"
|
||||
.SH NAME
|
||||
syncthing \- Syncthing
|
||||
.SH SYNOPSIS
|
||||
|
||||
Reference in New Issue
Block a user