diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 34ca0cf..10b4c10 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -13,6 +13,19 @@ jobs: name: Run Tests runs-on: ubuntu-latest + # The audit/metrics/session models are redis-backed (model-redis opens a + # client on import), so the tests need a reachable redis at 127.0.0.1:6379. + services: + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + strategy: matrix: node-version: [20.x, 22.x] diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..fdcfe03 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,24 @@ +# Documentation + +This directory is the GitHub Pages documentation site for the Jump Host project. + +**Live site:** https://theta42.github.io/jump-host/ + +## Pages + +- `index.md` — overview and quick start +- `connecting.md` — usage: the username grammar, the TUI picker, SFTP/WinSCP +- `architecture.md` — how auth, access resolution, key injection, and bridging work +- `installation.md` — Docker, bare-metal, and theta-env install; the LDAP write-ACL + +## Local preview + +```bash +gem install jekyll bundler +cd docs && jekyll serve +# http://localhost:4000/jump-host/ +``` + +## Updating + +Edit the markdown, push to `master`, and GitHub Pages rebuilds automatically. diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..1216495 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,41 @@ +title: Jump Host +description: An SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics. +url: "https://theta42.github.io" +baseurl: "/jump-host" +logo: /assets/img/theta42.svg +lang: en_US + +plugins: + - jekyll-seo-tag + - jekyll-sitemap + +github: + repository_url: https://github.com/theta42/jump-host + zip_url: https://github.com/theta42/jump-host/archive/refs/heads/master.zip + tar_url: https://github.com/theta42/jump-host/archive/refs/heads/master.tar.gz + repository_name: theta42/jump-host + +nav: + - title: Home + page: / + icon: fa-house + - title: Connecting + page: /connecting.html + icon: fa-terminal + - title: Architecture + page: /architecture.html + icon: fa-sitemap + - title: Installation + page: /installation.html + icon: fa-download + - title: Changelog + url: https://github.com/theta42/jump-host/blob/master/CHANGELOG.md + icon: fa-list + +defaults: + - scope: + path: "" + type: "pages" + values: + layout: default + image: /assets/img/theta42.svg diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html new file mode 100644 index 0000000..132c524 --- /dev/null +++ b/docs/_layouts/default.html @@ -0,0 +1,82 @@ + + + + + + + + {% seo title=false %} + {% if page.title %}{{ page.title }} · {% endif %}{{ site.title }} + + + + + + + + + +
+
+
+
+
+
+ {{ content }} +
+
+
+
+
+
+ + + + + + diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..b6ec287 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,123 @@ +--- +layout: default +title: Architecture +description: How the jump host authenticates users, resolves reachable hosts from the directory, injects per-user keys, and bridges SSH — plus the web UI and audit model. +--- + +# Architecture + +The jump host is a Node.js service (using [`ssh2`](https://github.com/mscdex/ssh2) +as both an SSH **server** and **client**) with two faces: the SSH front door +(default `:2222`) and a web UI/API (`:3002`). It holds no user database of its +own — identity, authorization, and onward credentials all come from the shared +directory. + +``` + ┌────────────────────── jump host ──────────────────────┐ + ssh │ ssh2 Server (:2222) │ ssh2 Client + ─────┼─▶ 1. authenticate user ──▶ LDAP (sshPublicKey / bind) │ ───────────▶ downstream + user │ 2. resolve target ──▶ SSO /api/discovery │ sshd (as the + │ 3. inject key ──▶ LDAP (add sshPublicKey) │ real user) + │ 4. bridge channels ◀───────────────────────────────▶ │ + │ web UI/API (:3002) ──▶ audit + metrics (redis) │ + └───────────────────────────────────────────────────────┘ +``` + +## 1. Inbound authentication + +When a user connects, the jump host authenticates them against LDAP: + +- **Public key** — it looks up the user's `sshPublicKey` values in the directory + and matches the offered key (handling ssh2's probe-then-sign two-phase + publickey auth). The jump host's *own* injected key (identified by its comment + marker) is deliberately excluded from this match — only the jump host may hold + that private key, so accepting it inbound would be a bypass. +- **Password** — an LDAP simple bind as the user's DN. Policy is configurable: + `off` (keys only — recommended for a public host), `local` (passwords only + from loopback/RFC1918 clients, keys-only from the internet), or `all`. + +Every attempt — success or failure, with method and reason — is audited. + +## 2. Access & target resolution + +The hosts a user may reach are computed from the directory, not a local list: + +1. The user's LDAP group memberships (`(&(objectClass=groupOfNames)(member=…))`). +2. For each group, the SSO's + `GET /api/discovery/resources?group=` (authenticated with an API token), + unioned and filtered to `kind: host`. + +Each host's dial address is `metadata.ip` (or the hostname from +`metadata.address`) and port `metadata.sshPort` (default 22). Results are cached +briefly per user and shared by both the grammar path and the TUI picker. + +Target matching tries, in order: exact slug → `host_`-prefixed slug → display +name → IP → address hostname. A raw IP that isn't an accessible directory host +is refused unless explicitly allowed. + +> The directory auto-creates `_access` / `_admin` groups for every +> host and service (see the SSO's +> [Directory & Inventory](https://theta42.github.io/sso-manager-node/directory.html) +> docs), which is exactly what this authorization reads. + +## 3. Per-user key injection {#per-user-key-injection} + +The jump host holds **one** keypair. To connect downstream *as the user* +without asking them for anything, it must present a key the downstream `sshd` +will accept for that user. Downstream hosts (joined via +[ldap-client](https://github.com/theta42/ldap-client)) serve authorized keys +straight from LDAP via `AuthorizedKeysCommand`. So on a user's first connection, +the jump host appends its own public key to that user's `sshPublicKey` attribute +in LDAP — comment-marked so it's recognizable — then connects downstream with +its private key. + +- Idempotent: the key is added once; a redis flag skips the LDAP round-trip + afterwards. +- The jump host's bind account therefore needs **write access to the + `sshPublicKey` attribute** on user entries (an OpenLDAP ACL — see the README). + In the bundled theta-env deployment this is handled for you. +- Because the marker key is excluded from inbound auth (step 1), it grants only + the jump host's onward path, never inbound impersonation. + +## 4. Bridging + +Once the upstream connection is ready, the jump host splices SSH channels +between the two connections: + +- **shell / exec** — piped both ways, with window-change and exit-status + forwarded. +- **SFTP subsystem** — the two subsystem channels are raw-piped as opaque bytes; + no SFTP protocol parsing is needed, which is why WinSCP and `sftp` work + unchanged. +- Channel requests that arrive before the upstream is ready are buffered and + replayed, so nothing is dropped during the connect. +- The downstream host key's SHA256 fingerprint is recorded in the audit event + (trust-on-use in v1). + +Byte counts per direction are tallied cheaply for the audit record. + +## Web UI, API & audit + +A small Express app on `:3002` (admin login via LDAP, gated by +`auth.adminGroups`) exposes: + +- `GET /health` — open; `{status, activeSessions, version}` +- `GET /api/sessions` — active sessions +- `GET /api/audit?page=&uid=&target=&status=` — the paged audit log +- `GET /api/metrics` — counters (total, failures, top users/hosts) + +Audit events and counters live in redis. Each event captures: user, auth method, +mode (grammar/picker), target slug/address/port, channel type, client IP, +success + failure reason, downstream host-key fingerprint, timing, and bytes in/out. + +## Where it sits in the stack + +- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the + OpenLDAP directory (users, groups, `sshPublicKey`) and the inventory API this + jump host reads. +- **[ldap-client](https://github.com/theta42/ldap-client)** — enrolls the + downstream Linux hosts (SSSD/PAM + `AuthorizedKeysCommand`) that the jump host + connects into. +- **[Proxy](https://theta42.github.io/proxy/)** — fronts the jump host's web UI + under TLS. +- **[theta-env](https://theta42.github.io/theta-env/)** — wires it all together. diff --git a/docs/assets/css/style.css b/docs/assets/css/style.css new file mode 100644 index 0000000..e24a5de --- /dev/null +++ b/docs/assets/css/style.css @@ -0,0 +1,116 @@ +/* theta42 docs site — shares the in-app dark navbar/footer + card look + (Bootstrap 5 + Font Awesome, same as the running apps) rather than a + generic Jekyll theme. */ + +body { + background-color: #f4f5f6; +} + +.navbar-brand img { + filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4)); +} + +.navbar-nav .nav-link.active { + color: #fff; + font-weight: 600; +} + +/* Markdown content typography, scoped to the card body so it doesn't leak + into the nav/footer. */ +.site-content h1:first-child { + margin-top: 0; +} + +.site-content h1, +.site-content h2, +.site-content h3 { + font-weight: 700; +} + +.site-content h2 { + margin-top: 2.5rem; + padding-bottom: .4rem; + border-bottom: 1px solid #e9ecef; +} + +.site-content h3 { + margin-top: 1.75rem; +} + +.site-content a { + color: #a3671f; + text-decoration-color: rgba(163, 103, 31, .35); +} + +.site-content a:hover { + color: #8a5a16; +} + +.site-content pre { + background-color: #212529; + color: #f8f9fa; + padding: 1rem 1.25rem; + border-radius: .375rem; + overflow-x: auto; +} + +.site-content code { + color: #a3671f; + background-color: #f4f0e8; + padding: .15em .4em; + border-radius: .25rem; + font-size: .875em; +} + +.site-content pre code { + color: inherit; + background: none; + padding: 0; +} + +.site-content table { + display: block; + overflow-x: auto; + width: 100%; + border-collapse: collapse; + margin: 1.25rem 0; +} + +.site-content table th, +.site-content table td { + border: 1px solid #dee2e6; + padding: .5rem .75rem; + text-align: left; +} + +.site-content table th { + background-color: #f8f9fa; +} + +.site-content blockquote { + border-left: 4px solid #C59341; + padding: .5rem 1rem; + margin: 1.25rem 0; + background-color: #f8f6f1; + color: #495057; +} + +.site-content img { + max-width: 100%; + height: auto; +} + +/* Screenshot grids in the markdown use width="49%" inline attrs for a + two-up desktop layout -- stack them on narrow screens instead of + squeezing to illegibility. */ +@media (max-width: 576px) { + .site-content img[width] { + width: 100% !important; + margin-bottom: .75rem; + } +} + +.site-content hr { + margin: 2rem 0; + border-top: 1px solid #e9ecef; +} diff --git a/docs/assets/img/favicon.svg b/docs/assets/img/favicon.svg new file mode 100644 index 0000000..35e1881 --- /dev/null +++ b/docs/assets/img/favicon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/docs/assets/img/theta42.svg b/docs/assets/img/theta42.svg new file mode 100644 index 0000000..e598305 --- /dev/null +++ b/docs/assets/img/theta42.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 42 + + diff --git a/docs/connecting.md b/docs/connecting.md new file mode 100644 index 0000000..19eedfe --- /dev/null +++ b/docs/connecting.md @@ -0,0 +1,101 @@ +--- +layout: default +title: Connecting +description: How to reach downstream hosts through the jump host — the username grammar, the interactive picker, SFTP/WinSCP, and what access you get. +--- + +# Connecting + +You reach a downstream host two ways: name the target in your username, or log +in plain and pick it from a menu. Either way you authenticate **once**, to the +jump host, with your directory credentials. + +## The username grammar + +``` +{uid}_-_{target} +``` + +- `{uid}` — your directory username. +- `_-_` — the separator (legal in an SSH username everywhere, including WinSCP). +- `{target}` — the host to reach: a directory **slug** (`host_web01` or just + `web01`), the host's **display name**, its **IP**, or the hostname in its + directory `address`. + +```bash +ssh alice_-_web01@jump.example.com # by slug (host_ prefix optional) +ssh alice_-_10.0.0.10@jump.example.com # by IP (must be a host you can reach) +``` + +If the target matches a host your directory groups grant, you're bridged +straight to its `sshd` — same as if you'd SSH'd directly, but through the +audited jump host. + +## SFTP / WinSCP / scp + +Because the whole route is encoded in the username, file transfer tools that +only take one connection string work with no extra configuration: + +```bash +sftp -P 2222 alice_-_web01@jump.example.com +scp -P 2222 file.txt alice_-_web01@jump.example.com:/tmp/ +``` + +**WinSCP:** set Host name to `jump.example.com`, Port to `2222`, and User name +to `alice_-_web01`. SFTP is bridged as an opaque byte stream, so all operations +(browse, upload, download, rename) work normally. + +## The interactive picker + +Log in with just your username and you get a TUI list of every host you can +reach: + +```bash +ssh alice@jump.example.com +``` + +- **↑ / ↓** move the selection +- **type** to filter the list incrementally +- **Enter** connect to the highlighted host +- **number keys** jump straight to that row +- **q** or **Ctrl-C** to quit + +Pick a host and you're bridged into it. The picker only ever lists hosts your +directory access allows — it doubles as "what can I reach from here?" + +## What you can reach + +The set of hosts is computed per login: your LDAP group memberships intersected +with the SSO directory's hosts (via the `host__access` groups the +directory auto-creates for each machine). To get access to a new host, an admin +adds you to that host's access group in the SSO — nothing on the jump host +changes. + +Targets that don't resolve to a host you're allowed to reach are refused (and +audited). Raw IPs that aren't a known directory host are denied by default. + +## Authentication + +The jump host authenticates **you** against the directory: + +- **Public key** — matched against your `sshPublicKey` entries in LDAP. Use your + normal SSH key; the client picks it automatically. +- **Password** — your directory password (LDAP bind). Password auth is often + restricted to local networks or disabled entirely on a public jump host + (keys-only) — check with your operator. + +You never manage a separate credential for the downstream host: the jump host +handles onward authentication for you (see +[Architecture](architecture.html#per-user-key-injection)). + +## First connection to a host + +The very first time you reach a given downstream host, the jump host provisions +its access key for you behind the scenes. If that first attempt races the +directory's key-cache refresh you may see a brief + +``` +jump-host: first-time key propagation, retrying… +``` + +and it reconnects automatically. Subsequent connections are immediate. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..6c3bd45 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,104 @@ +--- +layout: default +title: Home +description: An SSH jump host for the theta42 stack — one public host, LDAP login, and directory-driven access to every downstream machine you're entitled to. +--- + +# Jump Host + +An SSH jump host for the [theta42](https://github.com/theta42) self-hosted +stack. Users SSH into **one** public host and land on any downstream host +they're entitled to — authenticated against the shared LDAP directory, +authorized from the [SSO Manager](https://theta42.github.io/sso-manager-node/)'s +inventory graph, and audited end to end. + +No per-host accounts, no distributing keys, no VPN. The same people who log in +to your SSO are the people who can reach your machines — and only the machines +their directory groups grant. + +Part of the theta42 self-hosted identity stack, alongside +[SSO Manager](https://theta42.github.io/sso-manager-node/) and +[Proxy](https://theta42.github.io/proxy/), composable with one command via +[theta-env](https://theta42.github.io/theta-env/). + +## Two ways to connect + +**Direct (WinSCP/SFTP-friendly):** + +```bash +ssh alice_-_web01@jump.example.com +sftp -P 2222 alice_-_web01@jump.example.com +``` + +The username grammar is `{uid}_-_{target}` — `target` is a directory host slug +(with or without the `host_` prefix), a bare hostname, or an IP. One username +string, no interactive step, so it works cleanly in WinSCP and scripts. + +**Interactive picker:** + +```bash +ssh alice@jump.example.com +``` + +A plain login shows a TUI list of the hosts you can reach; arrow-key or type to +filter, Enter to connect. + +See **[Connecting](connecting.html)** for the full usage guide. + +## Why a jump host (and why this one) + +A bastion/jump host is the standard way to give SSH access to internal machines +through a single audited entry point. What's usually painful is *authorization* +and *credentials*: who may reach which host, and how the bastion authenticates +onward without you copying keys everywhere. + +This jump host answers both from your directory: + +- **Authorization is your directory graph.** The hosts you can reach are the + union of your LDAP groups × the SSO's inventory (the `host__access` + groups the directory already auto-creates). Add someone to a group; they can + reach the host. No bastion-side allow-list to maintain. +- **Onward auth is automatic.** The jump host holds one key and injects its + public half into your `sshPublicKey` on first use, then connects downstream + **as you**. Downstream hosts already serve keys from LDAP (via + [ldap-client](https://github.com/theta42/ldap-client)'s + `AuthorizedKeysCommand`), so nothing downstream needs configuring. + +## Features + +- **Username-grammar routing** (`uid_-_target`) — straight-through to the host, + SFTP included (WinSCP works) +- **Interactive TUI host picker** on plain login, scoped to your access +- **LDAP inbound auth** — public key or password (keys-only policy recommended + for a public host) +- **Directory-driven access** — reachable hosts come from the SSO inventory, not + a static list +- **Per-user key injection** — no downstream changes, no key distribution +- **Shell, exec, and SFTP** bridging +- **Web UI + HTTP API** for auditing and metrics — active sessions, a searchable + audit log, per-user/per-host counters +- **Full audit trail** — who, target, method, result, bytes, duration, and the + downstream host-key fingerprint +- Packaged like the rest of the stack: one-command Docker, idempotent bare-metal + installer, or bundled in theta-env + +## Get it + +```bash +git clone https://github.com/theta42/jump-host.git +cd jump-host +cp secrets.js.example config/jump-secrets.js # then edit it +docker compose up -d --build +``` + +That's the standalone quick start. For bare-metal and the bundled theta-env +option, see **[Installation](installation.html)**. + +## Related projects + +- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OpenLDAP + directory + OIDC provider + the inventory graph this jump host reads. +- **[Proxy](https://theta42.github.io/proxy/)** — puts your web apps behind the + same identity; fronts this jump host's web UI. +- **[theta-env](https://theta42.github.io/theta-env/)** — runs the whole stack, + jump host included, with one command. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..417a453 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,109 @@ +--- +layout: default +title: Installation +description: Install the jump host three ways — bundled in the theta-env stack, standalone Docker, or bare metal — plus the required LDAP write-ACL and port-22 options. +--- + +# Installation + +Three ways to run the jump host, in increasing manual effort. All read their +config through [@simpleworkjs/conf](https://www.npmjs.com/package/@simpleworkjs/conf) +(`conf/base.js` < `conf/.js` < the `CONF_SECRETS` file < `app_*` env). + +## Requirements + +- The [SSO Manager](https://theta42.github.io/sso-manager-node/) (OpenLDAP + directory + `/api/discovery`), v1.3.0 or newer. +- Downstream hosts joined via + [ldap-client](https://github.com/theta42/ldap-client) (SSSD + + `AuthorizedKeysCommand`). +- An LDAP bind account with **write access to the `sshPublicKey` attribute** on + user entries (see below). +- An SSO API token (`sso_…`) for the directory queries. + +## 1. Unified theta-env stack (recommended) + +Enable it in `theta-env/setup.env`: + +```bash +CFG_JUMP_HOST_ENABLED=true +CFG_JUMP_HOST=jump.example.com +JUMP_SSH_PORT=2222 +``` + +Re-run `./setup.sh`. The stack builds the submodule (behind the `jump-host` +compose profile), mints the directory API token, writes +`./config/jump-secrets.js`, grants the `sshPublicKey` write-ACL, registers the +jump host in the proxy, and seeds a directory entry. Forward the public host's +`:22` (or `:2222`) to the container's published `JUMP_SSH_PORT`. + +## 2. Standalone Docker + +```bash +cp secrets.js.example config/jump-secrets.js +$EDITOR config/jump-secrets.js # LDAP bind (+ sshPublicKey write ACL), SSO url + token +docker compose up -d --build +``` + +Host keys persist in the `jump-data` volume. The web UI is on `:3002`; front it +with your own TLS/proxy. + +## 3. Bare metal + +```bash +curl -fsSL https://raw.githubusercontent.com/theta42/jump-host/master/ops/install.sh | sudo bash +sudo $EDITOR /etc/jump-host/secrets.js +sudo systemctl restart jump-host +journalctl -u jump-host -f +``` + +`ops/install.sh` installs Node 22 + Redis, hard-resets the checkout at +`/opt/theta42/jump-host` to the remote branch, symlinks the systemd unit, and +runs `npm ci`. Idempotent — re-run to update. Overridable via `REPO_DIR=`, +`BRANCH=`, `SECRETS_FILE=`. + +## The LDAP write-ACL (required) + +The jump host injects its public key into each user's `sshPublicKey`, so its +bind account must be able to **write** that attribute. In the bundled OpenLDAP: + +``` +access to attrs=sshPublicKey + by dn.exact="cn=ldapclient,ou=people,dc=example,dc=com" write + by self write + by * read +``` + +In the theta-env bundle this is handled for you (the jump host binds as the LDAP +admin). For a hardened standalone deployment, use a dedicated bind account with +exactly this attribute-scoped ACL. Without write access, key injection fails and +every bridge attempt is audited `key-inject-failed`. + +## Listening on port 22 + +The default SSH port is **2222** so the service needs no privilege. To listen on +22, set `ssh.listenPort: 22` and either: + +- **systemd:** uncomment `AmbientCapabilities=CAP_NET_BIND_SERVICE` in the unit; +- **Docker:** publish `22:22`; or +- **firewall:** DNAT `22 → 2222`. + +## Configuration reference + +Every key is documented in +[`secrets.js.example`](https://github.com/theta42/jump-host/blob/master/secrets.js.example): +`ldap` (bind + bases + TLS), `sso` (url + apiToken), `ssh` +(`listenPort`, `passwordAuth`, `allowRawIPs`, `keyComment`, timeouts, +`maxSessions`), `web.port`, `auth.adminGroups`, and `redis`. + +## Verifying + +```bash +ssh -p 2222 youruid@jump.example.com # TUI picker +ssh -p 2222 youruid_-_somehost@jump.example.com # direct +sftp -P 2222 youruid_-_somehost@jump.example.com # WinSCP path +curl -s http://localhost:3002/health +``` + +Watch `journalctl -u jump-host -f` (or `docker logs -f jump-host`) and the audit +log at `/audit` in the web UI. diff --git a/docs/robots.txt b/docs/robots.txt new file mode 100644 index 0000000..bd503ab --- /dev/null +++ b/docs/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://theta42.github.io/proxy/sitemap.xml diff --git a/nodejs/package.json b/nodejs/package.json index 15f2540..903d2b3 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -14,9 +14,9 @@ "scripts": { "start": "node ./bin/www", "dev": "npx nodemon --ignore public/ ./bin/www", - "test": "NODE_ENV=test node --test --test-force-exit 'test/**/*.test.js'", - "test:unit": "NODE_ENV=test node --test --test-force-exit 'test/unit/**/*.test.js'", - "test:integration": "NODE_ENV=test node --test --test-force-exit 'test/integration/**/*.test.js'" + "test": "NODE_ENV=test node --test --test-force-exit test/unit/*.test.js test/integration/*.test.js", + "test:unit": "NODE_ENV=test node --test --test-force-exit test/unit/*.test.js", + "test:integration": "NODE_ENV=test node --test --test-force-exit test/integration/*.test.js" }, "dependencies": { "@simpleworkjs/conf": "^1.2.0",