close1 0.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
close1/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """close1 — simple backups from servers over SSH."""
2
+
3
+ __version__ = "0.7.1"
close1/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from close1.cli import main
2
+
3
+ raise SystemExit(main())
close1/backup.py ADDED
@@ -0,0 +1,214 @@
1
+ """Backing up a single server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import logging
7
+ import posixpath
8
+ import re
9
+ import shutil
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from close1.config import FileItem, HostConfig
14
+ from close1.messages import t
15
+ from close1.rotate import to_delete
16
+ from close1.ssh import Remote, RemoteError, remote_path
17
+
18
+ log = logging.getLogger("close1")
19
+
20
+ _DATE_RE = re.compile(r"\$\{DATE\}|\$DATE\b")
21
+ ARCHIVE_EXT = {"tar.gz": ".tar.gz", "zip": ".zip"}
22
+
23
+
24
+ class BackupError(Exception):
25
+ pass
26
+
27
+
28
+ def substitute(text: str, date: str) -> str:
29
+ """$DATE and ${DATE} -> 2026-12-31."""
30
+ return _DATE_RE.sub(date, text)
31
+
32
+
33
+ def archive_type(name: str | None, default: str) -> str:
34
+ """Archive format for a directory: from the explicit name's extension, else the compress option."""
35
+ if name:
36
+ if name.endswith(".zip"):
37
+ return "zip"
38
+ if name.endswith((".tar.gz", ".tgz")):
39
+ return "tar.gz"
40
+ return default
41
+
42
+
43
+ @dataclass
44
+ class Plan:
45
+ """What to copy and under which local name."""
46
+
47
+ remote: str
48
+ local: str
49
+ archive: str | None # None for a regular file
50
+
51
+
52
+ def plan_files(items: list[tuple[FileItem, bool]], compress: str, reserved: set[str]) -> list[Plan]:
53
+ """Choose local names. items are (item, is_dir); reserved are names taken by stdout.
54
+
55
+ Explicit names are claimed first; the rest get the basename, or the full path joined with '-' on collision.
56
+ """
57
+ used = set(reserved)
58
+ locals_: list[str | None] = []
59
+ for item, is_dir in items:
60
+ local = None
61
+ if item.name:
62
+ local = item.name
63
+ archive = archive_type(item.name, compress) if is_dir else None
64
+ if archive and not local.endswith((".zip", ".tar.gz", ".tgz")):
65
+ local += ARCHIVE_EXT[archive]
66
+ if local in used:
67
+ raise BackupError(t("name_collision", path=item.remote))
68
+ used.add(local)
69
+ locals_.append(local)
70
+
71
+ plans = []
72
+ for (item, is_dir), local in zip(items, locals_):
73
+ archive = archive_type(item.name, compress) if is_dir else None
74
+ if local is None:
75
+ ext = ARCHIVE_EXT[archive] if archive else ""
76
+ stripped = item.remote.strip("/")
77
+ base = posixpath.basename(stripped) or "root"
78
+ full = stripped.replace("/", "-") or "root"
79
+ local = next((c + ext for c in (base, full) if c + ext not in used), None)
80
+ if local is None:
81
+ raise BackupError(t("name_collision", path=item.remote))
82
+ used.add(local)
83
+ plans.append(Plan(remote=item.remote, local=local, archive=archive))
84
+ return plans
85
+
86
+
87
+ def remote_kind(remote: Remote, path: str) -> str:
88
+ q = remote_path(path)
89
+ out = remote.run(f"if [ -d {q} ]; then echo dir; elif [ -e {q} ]; then echo file; else echo none; fi", retry=True)
90
+ return out.strip()
91
+
92
+
93
+ def _require_tool(remote: Remote, tool: str) -> None:
94
+ if remote.run(f"command -v {tool} >/dev/null 2>&1 && echo yes || echo no", retry=True).strip() != "yes":
95
+ raise BackupError(t("tool_missing", tool=tool))
96
+
97
+
98
+ def fetch(remote: Remote, plan: Plan, target: Path) -> None:
99
+ q = remote_path(plan.remote)
100
+ if plan.archive == "tar.gz":
101
+ cmd = f"tar -C {q} -czf - ."
102
+ elif plan.archive == "zip":
103
+ cmd = f"cd {q} && zip -qr - ."
104
+ else:
105
+ cmd = f"cat -- {q}"
106
+ with open(target, "wb") as f:
107
+ remote.run(cmd, stdout=f, retry=True)
108
+
109
+
110
+ class HostBackup:
111
+ def __init__(self, host: HostConfig, agent_sock: str | None, force: bool = False,
112
+ today: dt.date | None = None):
113
+ self.host = host
114
+ self.force = force
115
+ self.today = today or dt.date.today()
116
+ self.date = self.today.isoformat()
117
+ self.remote = Remote(host, agent_sock)
118
+ self.host_dir = host.dest / host.name
119
+ self.final = self.host_dir / self.date
120
+ self.partial = self.host_dir / f"{self.date}.partial"
121
+
122
+ def run(self) -> bool:
123
+ """True if the backup was made or is not needed, False on error."""
124
+ name = self.host.section
125
+ if self.final.exists() and not self.force:
126
+ log.info(t("already_done", name=name, date=self.date))
127
+ return True
128
+ if self.host.ssh_key and not self.remote.agent_sock:
129
+ log.warning(t("key_not_found", name=name, fp=self.host.ssh_key))
130
+ return True # not an error: the key may be loaded later
131
+ with self.remote:
132
+ try:
133
+ self.remote.check()
134
+ except RemoteError as e:
135
+ log.warning(t("unreachable", name=name, error=e.stderr or e.code))
136
+ return True # not an error: retry on the next run
137
+
138
+ try:
139
+ self._backup()
140
+ except (RemoteError, BackupError, OSError) as e:
141
+ log.error(t("failed", name=name, error=e))
142
+ return False
143
+ log.info(t("done", name=name, path=self.final))
144
+ self._rotate()
145
+ return True
146
+
147
+ def _run_stage(self, stage: str, commands: list[str]) -> None:
148
+ for cmd in commands:
149
+ cmd = substitute(cmd, self.date)
150
+ log.info(t("run_cmd", name=self.host.section, stage=stage, cmd=cmd))
151
+ out = self.remote.run(cmd)
152
+ if out.strip():
153
+ log.debug(out.rstrip())
154
+
155
+ def _backup(self) -> None:
156
+ h = self.host
157
+ if self.partial.exists():
158
+ shutil.rmtree(self.partial)
159
+ self.partial.mkdir(parents=True)
160
+ log.info(t("start", name=h.section, path=self.final))
161
+
162
+ try:
163
+ self._run_stage("pre", h.pre)
164
+ self._copy_files()
165
+ for local, cmd in h.stdout.items():
166
+ cmd = substitute(cmd, self.date)
167
+ log.info(t("run_cmd", name=h.section, stage="stdout", cmd=f"{cmd} > {local}"))
168
+ with open(self.partial / local, "wb") as f:
169
+ self.remote.run(cmd, stdout=f)
170
+ except BaseException:
171
+ # run post even on failure, e.g. to turn maintenance mode off
172
+ try:
173
+ self._run_stage("post", h.post)
174
+ except RemoteError as e:
175
+ log.error(t("failed", name=h.section, error=e))
176
+ raise
177
+ self._run_stage("post", h.post)
178
+
179
+ if self.final.exists():
180
+ old = self.host_dir / f"{self.date}.old"
181
+ if old.exists():
182
+ shutil.rmtree(old)
183
+ self.final.rename(old)
184
+ self.partial.rename(self.final)
185
+ shutil.rmtree(old)
186
+ else:
187
+ self.partial.rename(self.final)
188
+
189
+ def _copy_files(self) -> None:
190
+ h = self.host
191
+ items = []
192
+ for item in h.files:
193
+ path = substitute(item.remote, self.date)
194
+ kind = remote_kind(self.remote, path)
195
+ if kind == "none":
196
+ raise BackupError(t("remote_missing", path=path))
197
+ items.append((FileItem(remote=path, name=item.name), kind == "dir"))
198
+
199
+ tools = {"tar" if a == "tar.gz" else "zip"
200
+ for a in (archive_type(i.name, h.compress) for i, d in items if d)}
201
+ for tool in sorted(tools):
202
+ _require_tool(self.remote, tool)
203
+
204
+ for plan in plan_files(items, h.compress, set(h.stdout)):
205
+ log.info(t("copy", name=h.section, remote=plan.remote, local=plan.local))
206
+ fetch(self.remote, plan, self.partial / plan.local)
207
+
208
+ def _rotate(self) -> None:
209
+ for p in self.host_dir.glob("*.partial"):
210
+ if p.is_dir():
211
+ shutil.rmtree(p)
212
+ for p in to_delete(self.host_dir, self.host.keep):
213
+ log.info(t("rotate_delete", name=self.host.section, path=p))
214
+ shutil.rmtree(p)
close1/cli.py ADDED
@@ -0,0 +1,168 @@
1
+ """close1 command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import fcntl
7
+ import logging
8
+ import os
9
+ import shutil
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from close1 import __version__
14
+ from close1.backup import HostBackup
15
+ from close1.config import Config, ConfigError, config_search_paths, find_config, is_root, load_config
16
+ from close1.messages import t
17
+ from close1.ssh import Remote, RemoteError, find_agent, list_agents
18
+
19
+ log = logging.getLogger("close1")
20
+
21
+
22
+ def default_log_path() -> Path:
23
+ if is_root():
24
+ return Path("/var/log/close1.log")
25
+ base = os.environ.get("XDG_STATE_HOME") or "~/.local/state"
26
+ return Path(base).expanduser() / "close1" / "close1.log"
27
+
28
+
29
+ def setup_logging(log_path: Path, verbose: bool) -> None:
30
+ log.setLevel(logging.DEBUG)
31
+ log_path.parent.mkdir(parents=True, exist_ok=True)
32
+ fh = logging.FileHandler(log_path, encoding="utf-8")
33
+ fh.setLevel(logging.DEBUG if verbose else logging.INFO)
34
+ fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
35
+ log.addHandler(fh)
36
+
37
+ # Under cron (no tty) only errors go to stderr, so cron mails only on real problems.
38
+ sh = logging.StreamHandler(sys.stderr)
39
+ if verbose:
40
+ sh.setLevel(logging.DEBUG)
41
+ else:
42
+ sh.setLevel(logging.WARNING if sys.stderr.isatty() else logging.ERROR)
43
+ sh.setFormatter(logging.Formatter("%(levelname)s %(message)s"))
44
+ log.addHandler(sh)
45
+
46
+
47
+ def build_parser() -> argparse.ArgumentParser:
48
+ p = argparse.ArgumentParser(prog="close1", description="Back up files from servers over SSH")
49
+ p.add_argument("hosts", nargs="*", metavar="HOST", help="config sections to back up (default: all), or 'log' to show the log")
50
+ p.add_argument("-c", "--config", type=Path, help="config file (default: first of $CLOSE1_CONFIG, ~/.config/close1/close1.yml, /etc/close1.yml)")
51
+ p.add_argument("-f", "--force", action="store_true", help="overwrite today's backup")
52
+ p.add_argument("-v", "--verbose", action="store_true", help="verbose output")
53
+ p.add_argument("--check", action="store_true", help="check the SSH agent and host reachability, copy nothing")
54
+ p.add_argument("--version", action="version", version=f"close1 {__version__}")
55
+ return p
56
+
57
+
58
+ def select_hosts(cfg: Config, names: list[str]):
59
+ if not names:
60
+ return cfg.hosts
61
+ by_name = {h.section: h for h in cfg.hosts}
62
+ missing = [n for n in names if n not in by_name]
63
+ if missing:
64
+ raise ConfigError(t("unknown_host", name=", ".join(missing)))
65
+ return [by_name[n] for n in names]
66
+
67
+
68
+ def check(cfg: Config, hosts, sock: str | None, log_path: Path) -> int:
69
+ print(t("check_header", version=__version__, config=cfg.path, log=log_path))
70
+ agents = list_agents(cfg.ssh_auth_sock)
71
+ for agent_sock, keys in agents:
72
+ print(t("agent_found", sock=agent_sock, count=len(keys)))
73
+ for k in keys:
74
+ print(" " + k)
75
+ if not agents:
76
+ print(t("agent_not_found"))
77
+ rc = 0
78
+ for h in hosts:
79
+ host_sock = sock
80
+ if h.ssh_key:
81
+ host_sock = find_agent(cfg.ssh_auth_sock, h.ssh_key)[0]
82
+ if not host_sock:
83
+ print(t("check_fail", name=h.section, error=t("check_key_missing", fp=h.ssh_key)))
84
+ rc = 1
85
+ continue
86
+ print(t("check_agent", name=h.section, sock=host_sock))
87
+ try:
88
+ with Remote(h, host_sock) as remote:
89
+ remote.check()
90
+ print(t("check_ok", name=h.section))
91
+ except RemoteError as e:
92
+ print(t("check_fail", name=h.section, error=e.stderr or e.code))
93
+ rc = 1
94
+ return rc
95
+
96
+
97
+ def show_log(log_path: Path, verbose: bool) -> int:
98
+ """close1 log: print the log file (with -v, first a '# path' line)."""
99
+ if verbose:
100
+ print(f"# {log_path}", flush=True)
101
+ try:
102
+ with open(log_path, "rb") as f:
103
+ shutil.copyfileobj(f, sys.stdout.buffer)
104
+ sys.stdout.flush()
105
+ except FileNotFoundError:
106
+ print(t("log_missing", path=log_path), file=sys.stderr)
107
+ return 1
108
+ except BrokenPipeError: # e.g. close1 log | head
109
+ sys.stderr.close()
110
+ return 0
111
+
112
+
113
+ def main(argv: list[str] | None = None) -> int:
114
+ args = build_parser().parse_args(argv)
115
+ log_cmd = args.hosts == ["log"]
116
+ if log_cmd:
117
+ args.hosts = []
118
+
119
+ if args.config:
120
+ cfg_path = args.config
121
+ if not cfg_path.is_file():
122
+ print(t("config_not_found", path=cfg_path), file=sys.stderr)
123
+ return 2
124
+ else:
125
+ cfg_path = find_config()
126
+ if cfg_path is None:
127
+ paths = "\n".join(f" {p}" for p in config_search_paths())
128
+ print(t("config_not_found_search", paths=paths), file=sys.stderr)
129
+ return 2
130
+ try:
131
+ cfg = load_config(cfg_path)
132
+ hosts = select_hosts(cfg, args.hosts)
133
+ except ConfigError as e:
134
+ print(t("config_error", path=cfg_path, error=e), file=sys.stderr)
135
+ return 2
136
+
137
+ log_path = Path(cfg.log).expanduser() if cfg.log else default_log_path()
138
+ if log_cmd:
139
+ return show_log(log_path, args.verbose)
140
+ setup_logging(log_path, args.verbose)
141
+ log.debug(t("log_file", version=__version__, config=cfg_path, path=log_path))
142
+
143
+ sock, keys = find_agent(cfg.ssh_auth_sock)
144
+ if args.check:
145
+ return check(cfg, hosts, sock, log_path)
146
+
147
+ lock_path = log_path.with_suffix(".lock")
148
+ with open(lock_path, "w") as lock:
149
+ try:
150
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
151
+ except BlockingIOError:
152
+ log.info(t("already_running", path=lock_path))
153
+ return 0
154
+
155
+ if sock:
156
+ log.debug(t("agent_found", sock=sock, count=len(keys)))
157
+ else:
158
+ log.warning(t("agent_not_found"))
159
+
160
+ ok = True
161
+ for h in hosts:
162
+ host_sock = find_agent(cfg.ssh_auth_sock, h.ssh_key)[0] if h.ssh_key else sock
163
+ ok = HostBackup(h, host_sock, force=args.force).run() and ok
164
+ return 0 if ok else 1
165
+
166
+
167
+ if __name__ == "__main__":
168
+ raise SystemExit(main())
close1/config.py ADDED
@@ -0,0 +1,271 @@
1
+ """Loading and validating the close1 config."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ DEFAULT_SECTION = "_default"
14
+ COMPRESS_TYPES = ("tar.gz", "zip")
15
+
16
+ # Allowed both in _default and in a host section.
17
+ COMMON_KEYS = {
18
+ "dest", "compress", "keep", "user", "port", "ssh_options", "ssh_key",
19
+ "connect_timeout", "retries", "retry_delay",
20
+ }
21
+ # Only in _default.
22
+ GLOBAL_KEYS = {"ssh_auth_sock", "log"}
23
+ # Only in a host section.
24
+ HOST_KEYS = {"name", "pre", "files", "stdout", "post"}
25
+
26
+ BUILTIN_DEFAULTS: dict[str, Any] = {
27
+ "dest": "~/backups",
28
+ "compress": "tar.gz",
29
+ "keep": 7,
30
+ "connect_timeout": 30,
31
+ "retries": 3,
32
+ "retry_delay": 10,
33
+ }
34
+
35
+ _HOST_RE = re.compile(r"^(?:(?P<user>[^@:\s]+)@)?(?P<host>[^@:\s]+)(?::(?P<port>\d+))?$")
36
+
37
+
38
+ class ConfigError(Exception):
39
+ pass
40
+
41
+
42
+ @dataclass
43
+ class FileItem:
44
+ remote: str
45
+ name: str | None = None # explicit local name
46
+
47
+
48
+ @dataclass
49
+ class HostConfig:
50
+ section: str
51
+ name: str # directory name under dest
52
+ host: str
53
+ user: str | None
54
+ port: int | None
55
+ ssh_options: list[str]
56
+ ssh_key: str | None # SHA256 fingerprint of the key to look for in agents
57
+ connect_timeout: int
58
+ retries: int
59
+ retry_delay: float
60
+ dest: Path
61
+ compress: str
62
+ keep: int # how many latest backups to keep
63
+ pre: list[str] = field(default_factory=list)
64
+ files: list[FileItem] = field(default_factory=list)
65
+ stdout: dict[str, str] = field(default_factory=dict)
66
+ post: list[str] = field(default_factory=list)
67
+
68
+
69
+ @dataclass
70
+ class Config:
71
+ path: Path
72
+ hosts: list[HostConfig]
73
+ ssh_auth_sock: str | None = None
74
+ log: str | None = None
75
+
76
+
77
+ def is_root() -> bool:
78
+ return os.geteuid() == 0
79
+
80
+
81
+ SYSTEM_CONFIG = Path("/etc/close1.yml")
82
+
83
+
84
+ def config_search_paths() -> list[Path]:
85
+ """Config search order: $CLOSE1_CONFIG, ~/.config/close1/close1.yml, /etc/close1.yml."""
86
+ paths = []
87
+ env = os.environ.get("CLOSE1_CONFIG")
88
+ if env:
89
+ paths.append(Path(env).expanduser())
90
+ base = os.environ.get("XDG_CONFIG_HOME") or "~/.config"
91
+ paths.append(Path(base).expanduser() / "close1" / "close1.yml")
92
+ paths.append(SYSTEM_CONFIG)
93
+ return paths
94
+
95
+
96
+ def find_config() -> Path | None:
97
+ """The first existing config from config_search_paths()."""
98
+ return next((p for p in config_search_paths() if p.is_file()), None)
99
+
100
+
101
+ def parse_host_key(key: str) -> tuple[str | None, str, int | None]:
102
+ """'backup@bot:12234' -> ('backup', 'bot', 12234)."""
103
+ m = _HOST_RE.match(key)
104
+ if not m:
105
+ raise ConfigError(f"invalid host name: {key!r}")
106
+ port = int(m["port"]) if m["port"] else None
107
+ return m["user"], m["host"], port
108
+
109
+
110
+ def parse_keep(value: Any) -> int:
111
+ """keep: how many latest backups to keep (a whole number >= 1)."""
112
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
113
+ raise ConfigError(f"keep: expected a whole number >= 1, got {value!r}")
114
+ return value
115
+
116
+
117
+ def check_local_name(name: Any, where: str) -> str:
118
+ if not isinstance(name, str) or not name or "/" in name or name in (".", ".."):
119
+ raise ConfigError(f"{where}: invalid local name {name!r}")
120
+ return name
121
+
122
+
123
+ def _str_list(value: Any, where: str) -> list[str]:
124
+ if value is None:
125
+ return []
126
+ if isinstance(value, str):
127
+ return [value]
128
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
129
+ raise ConfigError(f"{where}: expected a list of strings")
130
+ return value
131
+
132
+
133
+ def _parse_files(value: Any, where: str) -> list[FileItem]:
134
+ if value is None:
135
+ return []
136
+ if not isinstance(value, list):
137
+ raise ConfigError(f"{where}: expected a list")
138
+ items = []
139
+ for entry in value:
140
+ if isinstance(entry, str):
141
+ items.append(FileItem(remote=entry))
142
+ elif isinstance(entry, dict) and len(entry) == 1:
143
+ # "local name: remote path" — the key is the local name, as in stdout
144
+ ((name, remote),) = entry.items()
145
+ if isinstance(name, str) and "/" in name and isinstance(remote, str) and "/" not in remote:
146
+ raise ConfigError(f"{where}: use 'name: /path' (got '{name}: {remote}')")
147
+ if not isinstance(remote, str) or not remote:
148
+ raise ConfigError(f"{where}: invalid path {remote!r}")
149
+ items.append(FileItem(remote=remote, name=check_local_name(name, where)))
150
+ else:
151
+ raise ConfigError(f"{where}: expected a path or 'name: path', got {entry!r}")
152
+ return items
153
+
154
+
155
+ def _parse_stdout(value: Any, where: str) -> dict[str, str]:
156
+ if value is None:
157
+ return {}
158
+ if not isinstance(value, dict):
159
+ raise ConfigError(f"{where}: expected a mapping of 'file_name: command'")
160
+ result = {}
161
+ for name, cmd in value.items():
162
+ check_local_name(name, where)
163
+ if not isinstance(cmd, str) or not cmd.strip():
164
+ raise ConfigError(f"{where}.{name}: expected a command")
165
+ result[name] = cmd
166
+ return result
167
+
168
+
169
+ def _build_host(section: str, raw: dict[str, Any], defaults: dict[str, Any]) -> HostConfig:
170
+ unknown = set(raw) - COMMON_KEYS - HOST_KEYS
171
+ if unknown:
172
+ raise ConfigError(f"{section}: unknown options: {', '.join(sorted(unknown))}")
173
+ opts = {**BUILTIN_DEFAULTS, **defaults, **raw}
174
+
175
+ user, host, port = parse_host_key(section)
176
+ user = opts.get("user") if "user" in raw or user is None else user
177
+ if "port" in raw or port is None:
178
+ port = opts.get("port")
179
+ if port is not None and not isinstance(port, int):
180
+ raise ConfigError(f"{section}.port: expected a number")
181
+
182
+ ssh_key = opts.get("ssh_key")
183
+ if ssh_key is not None and not (isinstance(ssh_key, str) and ssh_key.startswith("SHA256:")):
184
+ raise ConfigError(f"{section}.ssh_key: expected a fingerprint like SHA256:..., got {ssh_key!r}")
185
+
186
+ for opt, minimum in (("connect_timeout", 1), ("retries", 0), ("retry_delay", 0)):
187
+ value = opts[opt]
188
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value < minimum:
189
+ raise ConfigError(f"{section}.{opt}: expected a number >= {minimum}, got {value!r}")
190
+ if not isinstance(opts["connect_timeout"], int) or not isinstance(opts["retries"], int):
191
+ raise ConfigError(f"{section}: connect_timeout and retries must be whole numbers")
192
+
193
+ compress = opts["compress"]
194
+ if compress not in COMPRESS_TYPES:
195
+ raise ConfigError(f"{section}.compress: expected {' | '.join(COMPRESS_TYPES)}, got {compress!r}")
196
+
197
+ hc = HostConfig(
198
+ section=section,
199
+ name=check_local_name(raw.get("name", host), f"{section}.name"),
200
+ host=host,
201
+ user=user,
202
+ port=port,
203
+ ssh_options=_str_list(opts.get("ssh_options"), f"{section}.ssh_options"),
204
+ ssh_key=ssh_key,
205
+ connect_timeout=opts["connect_timeout"],
206
+ retries=opts["retries"],
207
+ retry_delay=opts["retry_delay"],
208
+ dest=Path(str(opts["dest"])).expanduser(),
209
+ compress=compress,
210
+ keep=parse_keep(opts["keep"]),
211
+ pre=_str_list(raw.get("pre"), f"{section}.pre"),
212
+ files=_parse_files(raw.get("files"), f"{section}.files"),
213
+ stdout=_parse_stdout(raw.get("stdout"), f"{section}.stdout"),
214
+ post=_str_list(raw.get("post"), f"{section}.post"),
215
+ )
216
+
217
+ explicit = [f.name for f in hc.files if f.name] + list(hc.stdout)
218
+ dupes = {n for n in explicit if explicit.count(n) > 1}
219
+ if dupes:
220
+ raise ConfigError(f"{section}: duplicate local names: {', '.join(sorted(dupes))}")
221
+ return hc
222
+
223
+
224
+ def parse_config(data: Any, path: Path) -> Config:
225
+ if data is None:
226
+ data = {}
227
+ if not isinstance(data, dict):
228
+ raise ConfigError("expected a mapping of sections")
229
+
230
+ defaults = data.get(DEFAULT_SECTION) or {}
231
+ if not isinstance(defaults, dict):
232
+ raise ConfigError(f"{DEFAULT_SECTION}: expected a mapping")
233
+ unknown = set(defaults) - COMMON_KEYS - GLOBAL_KEYS
234
+ if unknown:
235
+ raise ConfigError(f"{DEFAULT_SECTION}: unknown options: {', '.join(sorted(unknown))}")
236
+ host_defaults = {k: v for k, v in defaults.items() if k in COMMON_KEYS}
237
+
238
+ hosts = []
239
+ for section, raw in data.items():
240
+ if section == DEFAULT_SECTION:
241
+ continue
242
+ if raw is None:
243
+ raw = {}
244
+ if not isinstance(section, str) or not isinstance(raw, dict):
245
+ raise ConfigError(f"{section}: expected a mapping of options")
246
+ hosts.append(_build_host(section, raw, host_defaults))
247
+
248
+ seen: dict[tuple[Path, str], str] = {}
249
+ for h in hosts:
250
+ key = (h.dest, h.name)
251
+ if key in seen:
252
+ raise ConfigError(
253
+ f"sections {seen[key]!r} and {h.section!r} write to the same directory {h.name!r}, set 'name'"
254
+ )
255
+ seen[key] = h.section
256
+
257
+ return Config(
258
+ path=path,
259
+ hosts=hosts,
260
+ ssh_auth_sock=defaults.get("ssh_auth_sock"),
261
+ log=defaults.get("log"),
262
+ )
263
+
264
+
265
+ def load_config(path: Path) -> Config:
266
+ with open(path, encoding="utf-8") as f:
267
+ try:
268
+ data = yaml.safe_load(f)
269
+ except yaml.YAMLError as e:
270
+ raise ConfigError(str(e)) from e
271
+ return parse_config(data, path)
close1/messages.py ADDED
@@ -0,0 +1,38 @@
1
+ """User-facing messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ MESSAGES: dict[str, str] = {
6
+ "config_not_found": "Config not found: {path}",
7
+ "config_not_found_search": "Config not found. Search order:\n{paths}\nCreate one (see close1-example.yml) or use -c",
8
+ "config_error": "Config error in {path}: {error}",
9
+ "unknown_host": "Host not found in config: {name}",
10
+ "log_file": "close1 {version}, config: {config}, log: {path}",
11
+ "log_missing": "No log yet: {path}",
12
+ "already_running": "close1 is already running (lock: {path}), exiting",
13
+ "agent_found": "SSH agent: {sock} (keys: {count})",
14
+ "agent_not_found": "No SSH agent with keys found, connections will most likely fail",
15
+ "key_not_found": "[{name}] key {fp} not found in any SSH agent, skipping",
16
+ "check_key_missing": "key {fp} not found in any SSH agent",
17
+ "check_agent": " {name}: agent {sock}",
18
+ "retry": "[{name}] connection problem, retry {n}/{total} in {delay}s: {error}",
19
+ "already_done": "[{name}] backup for {date} already exists, skipping (use -f to overwrite)",
20
+ "unreachable": "[{name}] host unreachable or no key in agent, skipping: {error}",
21
+ "start": "[{name}] starting backup to {path}",
22
+ "run_cmd": "[{name}] {stage}: {cmd}",
23
+ "copy": "[{name}] {remote} -> {local}",
24
+ "done": "[{name}] backup finished: {path}",
25
+ "failed": "[{name}] backup failed: {error}",
26
+ "rotate_delete": "[{name}] rotation: removing {path}",
27
+ "cmd_failed": "command exited with code {code}: {cmd}: {stderr}",
28
+ "remote_missing": "no such file or directory on server: {path}",
29
+ "tool_missing": "{tool} not found on server",
30
+ "name_collision": "cannot find a unique local name for {path}",
31
+ "check_header": "close1 {version}, config: {config}, log: {log}",
32
+ "check_ok": " {name}: OK",
33
+ "check_fail": " {name}: FAIL: {error}",
34
+ }
35
+
36
+
37
+ def t(key: str, **kwargs: object) -> str:
38
+ return MESSAGES[key].format(**kwargs)
close1/rotate.py ADDED
@@ -0,0 +1,21 @@
1
+ """Backup rotation: keep the N latest backups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ DATE_DIR_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
9
+
10
+
11
+ def backup_dirs(host_dir: Path) -> list[Path]:
12
+ """Backup directories named YYYY-MM-DD, newest first."""
13
+ if not host_dir.is_dir():
14
+ return []
15
+ dirs = [p for p in host_dir.iterdir() if p.is_dir() and DATE_DIR_RE.match(p.name)]
16
+ return sorted(dirs, key=lambda p: p.name, reverse=True)
17
+
18
+
19
+ def to_delete(host_dir: Path, keep: int) -> list[Path]:
20
+ """Backups beyond the `keep` latest ones, no matter how old they are."""
21
+ return backup_dirs(host_dir)[keep:]
close1/ssh.py ADDED
@@ -0,0 +1,199 @@
1
+ """SSH: agent discovery and running commands on a server via the system ssh."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import shlex
8
+ import shutil
9
+ import stat
10
+ import subprocess
11
+ import tempfile
12
+ import time
13
+ from pathlib import Path
14
+ from typing import IO
15
+
16
+ from close1.config import HostConfig
17
+ from close1.messages import t
18
+
19
+ log = logging.getLogger("close1")
20
+
21
+ # Always added: no interactive prompts (cron) and no agent forwarding to the backed-up server.
22
+ BASE_OPTIONS = ["BatchMode=yes", "ForwardAgent=no"]
23
+
24
+ # ssh exits with 255 on its own errors. These ones will not go away on retry.
25
+ PERMANENT_ERRORS = (
26
+ "Permission denied",
27
+ "Host key verification failed",
28
+ "Could not resolve hostname",
29
+ "REMOTE HOST IDENTIFICATION HAS CHANGED",
30
+ )
31
+
32
+
33
+ class RemoteError(Exception):
34
+ """A remote command (or ssh itself) failed."""
35
+
36
+ def __init__(self, cmd: str, code: int, stderr: str):
37
+ super().__init__(cmd, code, stderr)
38
+ self.cmd, self.code, self.stderr = cmd, code, stderr
39
+
40
+ def __str__(self) -> str:
41
+ return t("cmd_failed", code=self.code, cmd=self.cmd, stderr=self.stderr)
42
+
43
+
44
+ def is_connection_error(e: RemoteError) -> bool:
45
+ """A transient ssh connection problem (worth retrying)."""
46
+ return e.code == 255 and not any(m in e.stderr for m in PERMANENT_ERRORS)
47
+
48
+
49
+ def agent_candidates() -> list[str]:
50
+ """Usual locations of the user's ssh-agent socket (cron does not pass SSH_AUTH_SOCK)."""
51
+ result = []
52
+ env = os.environ.get("SSH_AUTH_SOCK")
53
+ if env:
54
+ result.append(env)
55
+ run = Path(os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}")
56
+ for rel in ("openssh_agent", "ssh-agent.socket", "gcr/ssh", "keyring/ssh", "gnupg/S.gpg-agent.ssh"):
57
+ p = str(run / rel)
58
+ if p not in result:
59
+ result.append(p)
60
+ return result
61
+
62
+
63
+ def agent_keys(sock: str) -> list[str]:
64
+ """Keys in the agent (ssh-add -l output), or an empty list."""
65
+ try:
66
+ if not stat.S_ISSOCK(os.stat(sock).st_mode):
67
+ return []
68
+ r = subprocess.run(
69
+ ["ssh-add", "-l"],
70
+ env={**os.environ, "SSH_AUTH_SOCK": sock},
71
+ capture_output=True,
72
+ text=True,
73
+ timeout=10,
74
+ )
75
+ except (OSError, subprocess.SubprocessError):
76
+ return []
77
+ if r.returncode != 0:
78
+ return []
79
+ return [line for line in r.stdout.splitlines() if line.strip()]
80
+
81
+
82
+ def has_key(keys: list[str], fingerprint: str) -> bool:
83
+ """Whether ssh-add -l lines contain the fingerprint (second field)."""
84
+ return any(len(line.split()) > 1 and line.split()[1] == fingerprint for line in keys)
85
+
86
+
87
+ def list_agents(explicit: str | None = None) -> list[tuple[str, list[str]]]:
88
+ """Agent sockets that hold keys, with their keys, in search order."""
89
+ candidates = [os.path.expanduser(explicit)] if explicit else agent_candidates()
90
+ return [(sock, keys) for sock in candidates if (keys := agent_keys(sock))]
91
+
92
+
93
+ def find_agent(explicit: str | None = None, fingerprint: str | None = None) -> tuple[str | None, list[str]]:
94
+ """The first agent socket that holds keys (or the given key, if fingerprint is set)."""
95
+ for sock, keys in list_agents(explicit):
96
+ if fingerprint is None or has_key(keys, fingerprint):
97
+ return sock, keys
98
+ return None, []
99
+
100
+
101
+ def remote_path(path: str) -> str:
102
+ """Quote a path for the remote shell, keeping ~/ expansion."""
103
+ if path == "~":
104
+ return '"$HOME"'
105
+ if path.startswith("~/"):
106
+ return '"$HOME"/' + shlex.quote(path[2:])
107
+ return shlex.quote(path)
108
+
109
+
110
+ class Remote:
111
+ """Runs commands on one server over a single multiplexed connection (ControlMaster).
112
+
113
+ Use as a context manager: the master connection is closed on exit.
114
+ """
115
+
116
+ def __init__(self, host: HostConfig, agent_sock: str | None = None):
117
+ self.host = host
118
+ self.agent_sock = agent_sock
119
+ self.env = dict(os.environ)
120
+ if agent_sock:
121
+ self.env["SSH_AUTH_SOCK"] = agent_sock
122
+ self.control_dir: str | None = None
123
+
124
+ def __enter__(self) -> "Remote":
125
+ # private (0700) directory for the control socket, removed on exit
126
+ self.control_dir = tempfile.mkdtemp(prefix="close1-")
127
+ return self
128
+
129
+ def __exit__(self, *exc: object) -> None:
130
+ if not self.control_dir:
131
+ return
132
+ try:
133
+ subprocess.run(
134
+ self._base() + ["-O", "exit", "--", self.host.host],
135
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
136
+ env=self.env, timeout=30,
137
+ )
138
+ except (OSError, subprocess.SubprocessError):
139
+ pass
140
+ shutil.rmtree(self.control_dir, ignore_errors=True)
141
+ self.control_dir = None
142
+
143
+ def _base(self) -> list[str]:
144
+ cmd = ["ssh", "-o", f"ConnectTimeout={self.host.connect_timeout}"]
145
+ for opt in BASE_OPTIONS + self.host.ssh_options:
146
+ cmd += ["-o", opt]
147
+ if self.control_dir:
148
+ cmd += ["-o", "ControlMaster=auto",
149
+ "-o", f"ControlPath={self.control_dir}/%C",
150
+ "-o", "ControlPersist=60"]
151
+ if self.host.ssh_key and self.agent_sock:
152
+ # a specific agent was chosen for this host: override IdentityAgent from ~/.ssh/config too
153
+ cmd += ["-o", f"IdentityAgent={self.agent_sock}"]
154
+ if self.host.user:
155
+ cmd += ["-l", self.host.user]
156
+ if self.host.port:
157
+ cmd += ["-p", str(self.host.port)]
158
+ return cmd
159
+
160
+ def command(self, remote_cmd: str) -> list[str]:
161
+ return self._base() + ["--", self.host.host, remote_cmd]
162
+
163
+ def _run_once(self, remote_cmd: str, stdout: IO[bytes] | None) -> str:
164
+ r = subprocess.run(
165
+ self.command(remote_cmd),
166
+ stdin=subprocess.DEVNULL,
167
+ stdout=stdout if stdout is not None else subprocess.PIPE,
168
+ stderr=subprocess.PIPE,
169
+ env=self.env,
170
+ )
171
+ if r.returncode != 0:
172
+ err = r.stderr.decode(errors="replace").strip()
173
+ raise RemoteError(remote_cmd, r.returncode, err)
174
+ return r.stdout.decode(errors="replace") if stdout is None else ""
175
+
176
+ def run(self, remote_cmd: str, stdout: IO[bytes] | None = None, retry: bool = False) -> str:
177
+ """Run a command; stdout goes to the given file or is returned as a string.
178
+
179
+ retry=True repeats the command after connection errors. Use it only for commands
180
+ that are safe to repeat (reads): after a dropped connection we cannot know whether
181
+ the command already ran.
182
+ """
183
+ attempts = 1 + (self.host.retries if retry else 0)
184
+ for attempt in range(1, attempts + 1):
185
+ try:
186
+ return self._run_once(remote_cmd, stdout)
187
+ except RemoteError as e:
188
+ if attempt == attempts or not is_connection_error(e):
189
+ raise
190
+ log.warning(t("retry", name=self.host.section, n=attempt, total=attempts - 1,
191
+ delay=self.host.retry_delay, error=e.stderr or e.code))
192
+ time.sleep(self.host.retry_delay)
193
+ if stdout is not None:
194
+ stdout.seek(0)
195
+ stdout.truncate()
196
+ raise AssertionError("unreachable")
197
+
198
+ def check(self) -> None:
199
+ self.run("true", retry=True)
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: close1
3
+ Version: 0.7.1
4
+ Summary: Simple backups from servers over SSH
5
+ Author: Yaroslav Polyakov
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/yaroslaff/close1
8
+ Project-URL: Repository, https://github.com/yaroslaff/close1
9
+ Project-URL: Issues, https://github.com/yaroslaff/close1/issues
10
+ Keywords: backup,ssh,cron,pull,sqlite,mysql
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: System :: Archiving :: Backup
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: PyYAML>=5.4
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # close1
27
+
28
+ A simple tool for daily backups of files and databases over SSH.
29
+
30
+ ## Features
31
+ - Pulls files and directories (.tar.gz/.zip) from the servers to your machine; nothing is installed on the servers.
32
+ - Runs commands on the servers (for example, `mysqldump`, `.backup` in sqlite3, or stopping a web server for the duration of the backup and starting it again afterwards).
33
+ - Good SSH integration: uses your key through the SSH agent (even if it is passphrase-protected). ControlMaster for speed and for reliability on lossy networks.
34
+ - Tolerates missed cron runs (e.g. when the computer was off). You can run close1 from cron every hour and it still makes one backup per day.
35
+ - Careful rotation: you set how many backups to keep (say, 7). Rotation removes the oldest ones, but it will not delete a 10-day-old backup if there were no newer ones. Even in the worst case you still have some backups.
36
+
37
+ ## Install
38
+
39
+ ```sh
40
+ pipx install . # or: pip install -e '.[dev]' in a venv
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```sh
46
+ close1 --check # SSH agent, keys, host reachability (copies nothing)
47
+ close1 # back up all hosts from the config
48
+ close1 blog -f -v # only blog, overwrite today's backup, verbose
49
+ close1 log # show the log (-v: print its path first)
50
+ ```
51
+
52
+ Config: `-c path`, otherwise the first one found of `$CLOSE1_CONFIG`, `~/.config/close1/close1.yml`, `/etc/close1.yml`
53
+ (no merging). See `close1-example.yml` (short) and `close1-full-example.yml` (every option explained).
54
+ Log: `~/.local/state/close1/close1.log` (root: `/var/log/close1.log`).
55
+
56
+ ## Config
57
+
58
+ ```yaml
59
+ _default:
60
+ dest: ~/backups # -> ~/backups/<host>/<YYYY-MM-DD>/
61
+ keep: 7
62
+
63
+ blog:
64
+ pre:
65
+ - mysqldump blog | gzip > /tmp/blog-${DATE}.sql.gz
66
+ files:
67
+ - blog.sql.gz: /tmp/blog-${DATE}.sql.gz
68
+ - /var/www/blog/uploads
69
+ - /etc/nginx/sites-enabled
70
+ post:
71
+ - rm /tmp/blog-${DATE}.sql.gz
72
+
73
+ "backup@bot:2222":
74
+ files:
75
+ - /opt/bot/config.yml
76
+ stdout:
77
+ packages.txt: dpkg -l
78
+ ```
79
+
80
+ All options with explanations: [close1-full-example.yml](close1-full-example.yml).
81
+
82
+ ### SQLite
83
+
84
+ Do not copy a live SQLite file (in WAL mode part of the data lives in `-wal`). Take a snapshot in `pre` and fetch it:
85
+
86
+ ```yaml
87
+ pre:
88
+ - umask 077 && sqlite3 /opt/app/app.db ".backup /tmp/app-${DATE}.db" && gzip -f /tmp/app-${DATE}.db
89
+ files:
90
+ - app.db.gz: /tmp/app-${DATE}.db.gz
91
+ post:
92
+ - rm -f /tmp/app-${DATE}.db.gz
93
+ ```
94
+
95
+ ## Unreliable connections
96
+
97
+ All commands to a host go through one SSH connection (ControlMaster), so a backup needs a single handshake.
98
+ After a connection error (ssh exit code 255) close1 retries the connection check, file checks and downloads
99
+ (`retries` times, `retry_delay` seconds apart). `pre`, `post` and `stdout` commands are not retried: after a dropped
100
+ connection it is unknown whether they ran. If they fail, the backup fails and the next cron run starts it again.
101
+
102
+ ## cron and ssh-agent
103
+
104
+ close1 authenticates through ssh-agent. If `SSH_AUTH_SOCK` is not set (as under cron), it looks for the socket in
105
+ `/run/user/$UID/` (`openssh_agent`, `ssh-agent.socket`, `gcr/ssh`, `keyring/ssh`, `gnupg/S.gpg-agent.ssh`). ssh is always
106
+ called with `BatchMode=yes` and `ForwardAgent=no`. While you are not logged in or the key is not loaded into the agent,
107
+ the backup is skipped and retried on the next run.
108
+
109
+ By default the first agent that holds any key is used. If you have several agents (e.g. ssh-agent and gnome-keyring)
110
+ with different keys, set `ssh_key: SHA256:...` (globally or per host): close1 then uses the agent that holds this key
111
+ and passes it to ssh as `IdentityAgent`. If the key is in no agent, the host is skipped. `close1 --check` lists all
112
+ agents with their key fingerprints. The key comment (`user@host`) is not used for matching: it is arbitrary text.
113
+
114
+ ```cron
115
+ 17 * * * * $HOME/.local/bin/close1
116
+ ```
117
+
118
+ Only errors go to stderr (and so to cron mail); everything else goes to the log.
@@ -0,0 +1,14 @@
1
+ close1/__init__.py,sha256=jcd2L-ziBcbmeI8nWpxrl1M6YO-koFEdfcPS5Q7lgs8,78
2
+ close1/__main__.py,sha256=xPRazbQoIrTanWfx2QEQsurf9oOglLX2l9GwF8zHDEk,54
3
+ close1/backup.py,sha256=_RknlTMv6a5Nw7tB8hC4zFULf0LccBoekCsUAsnOx8Q,7765
4
+ close1/cli.py,sha256=2AUFFg6LnxwAh6GGa0v6Oem3wBKdE4upiK7kjyXF-SE,6052
5
+ close1/config.py,sha256=9M2ytwzCDJVY1aX1xwBh_IA0OrOK6-WDDL6TPXarmEc,9292
6
+ close1/messages.py,sha256=rFFrcAxOAForhWDdQVlXvxMMCktDscapK3a6wezF7Co,1949
7
+ close1/rotate.py,sha256=Sn1C9CW-249c9PQZjmeBeJN12Vm3bJrRAMuYMo5yYdY,652
8
+ close1/ssh.py,sha256=o6DcZlarKn4KLLHdwaSMQKFsRpvmc2LPuDC9ZbXybao,7368
9
+ close1-0.7.1.dist-info/licenses/LICENSE,sha256=hBsUwj1tQbFk1jd9i-yDQuIEK0aK2UARCrt52wQ-FyI,1074
10
+ close1-0.7.1.dist-info/METADATA,sha256=O8tzBMRplZBuHTMCuvJSOahtir0pKwzyS35o6--gzCM,4834
11
+ close1-0.7.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
12
+ close1-0.7.1.dist-info/entry_points.txt,sha256=BKgUgRg_ZvkCQokRvNhcxuzzNsQVAG9ef97w9OGcG_Y,43
13
+ close1-0.7.1.dist-info/top_level.txt,sha256=xtwqa419VPq-4EgzrdbJ6onNeAWNsAn60Krc3knrgh4,7
14
+ close1-0.7.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ close1 = close1.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yaroslav Polyakov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ close1