Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60ba9ba989 |
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 theta42
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -16,6 +16,15 @@ Each project still runs **standalone** (`docker compose up` in its own folder);
|
|||||||
this repo just composes them and automates the first-run glue so they find each
|
this repo just composes them and automates the first-run glue so they find each
|
||||||
other.
|
other.
|
||||||
|
|
||||||
|
**Why use this instead of running the two separately?** The two only become
|
||||||
|
useful once the proxy is registered as an OIDC client of the SSO and pointed at
|
||||||
|
the SSO's LDAP directory — and the SSO's domain has to match across half a dozen
|
||||||
|
config fields or logins silently fail with `Invalid Credentials`. Doing that by
|
||||||
|
hand is fiddly and easy to get wrong. `setup.sh` asks for your domain once (in
|
||||||
|
`setup.env`), generates both config files with it filled in everywhere, registers
|
||||||
|
the proxy as an OIDC client, and snapshots state before every rebuild — so you
|
||||||
|
get a working SSO + proxy stack in one command and a safe way to upgrade it.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────┐
|
||||||
│ your browser / apps │
|
│ your browser / apps │
|
||||||
@@ -412,8 +421,12 @@ exactly in the bootstrap) so the SSO can verify them on bind.
|
|||||||
off-host (see *Backups and restore*).
|
off-host (see *Backups and restore*).
|
||||||
3. **LDAPS uses the SSO's self-signed cert by default.** The proxy binds with
|
3. **LDAPS uses the SSO's self-signed cert by default.** The proxy binds with
|
||||||
`ldap.tlsOptions.rejectUnauthorized=false` (in `proxy-secrets.js`). For strict
|
`ldap.tlsOptions.rejectUnauthorized=false` (in `proxy-secrets.js`). For strict
|
||||||
trust, mount the SSO's cert (`ldap-certs` volume) into the proxy and set
|
trust, this is a **two-step change, not config-only**: (a) edit
|
||||||
`ldap.tlsOptions.ca=<path>` in `./config/proxy-secrets.js`.
|
`docker-compose.yml` to also mount the `ldap-certs` volume into the `proxy`
|
||||||
|
service (it's currently only mounted into `sso-manager`) — see the
|
||||||
|
commented-out boilerplate in the `proxy` service's `volumes:` block — then
|
||||||
|
(b) set `ldap.tlsOptions.ca=<path>` in `./config/proxy-secrets.js` to the
|
||||||
|
mounted cert path and `docker compose up -d proxy` to pick up the new mount.
|
||||||
4. **Re-running `setup.sh` resets the bootstrap admin + service passwords to
|
4. **Re-running `setup.sh` resets the bootstrap admin + service passwords to
|
||||||
the `./config/` values.** If you change a user's password in the SSO UI
|
the `./config/` values.** If you change a user's password in the SSO UI
|
||||||
later, re-running `setup.sh` will reset the bootstrap admin's password back
|
later, re-running `setup.sh` will reset the bootstrap admin's password back
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ services:
|
|||||||
- proxy-data:/data
|
- proxy-data:/data
|
||||||
- proxy-cache:/var/cache/nginx/proxy
|
- proxy-cache:/var/cache/nginx/proxy
|
||||||
- proxy-logs:/var/log/nginx
|
- proxy-logs:/var/log/nginx
|
||||||
|
# OPTIONAL, for strict LDAPS trust (see README "Security notes"): mount
|
||||||
|
# the SSO's self-signed cert into the proxy read-only, then set
|
||||||
|
# ldap.tlsOptions.ca=<path-below> in ./config/proxy-secrets.js.
|
||||||
|
# - ldap-certs:/etc/ssl/sso-ldap-certs:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"]
|
test: ["CMD", "curl", "-fsS", "http://localhost:3000/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ theta-env is a **composition** repo: it builds the two existing projects from
|
|||||||
their git submodules and adds the glue that wires them together. It does not
|
their git submodules and adds the glue that wires them together. It does not
|
||||||
fork or patch them — both projects work unchanged on their own.
|
fork or patch them — both projects work unchanged on their own.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## The three repos
|
## The three repos
|
||||||
|
|
||||||
| Repo | Role |
|
| Repo | Role |
|
||||||
@@ -22,6 +24,8 @@ fork or patch them — both projects work unchanged on their own.
|
|||||||
The two projects are pinned as **git submodules**. `git clone --recursive`
|
The two projects are pinned as **git submodules**. `git clone --recursive`
|
||||||
fetches all three in one step; `git submodule update --remote` bumps them.
|
fetches all three in one step; `git submodule update --remote` bumps them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## The two containers
|
## The two containers
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -68,6 +72,8 @@ redis instances is the no-source-patch path and is fine at this scale.
|
|||||||
| `3001` (sso) | localhost only | SSO web UI (first-run convenience; fronted by the proxy normally) |
|
| `3001` (sso) | localhost only | SSO web UI (first-run convenience; fronted by the proxy normally) |
|
||||||
| `389` (sso) | **no** | plain LDAP — internal only (app↔slapd over localhost) |
|
| `389` (sso) | **no** | plain LDAP — internal only (app↔slapd over localhost) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## The first-run bootstrap
|
## The first-run bootstrap
|
||||||
|
|
||||||
`./setup.sh` orchestrates first-run wiring; `bootstrap/bootstrap.js` does the
|
`./setup.sh` orchestrates first-run wiring; `bootstrap/bootstrap.js` does the
|
||||||
@@ -127,6 +133,8 @@ admin creds from `./config/sso-secrets.js` sidesteps that entirely, and going
|
|||||||
through the HTTP API for the OAuth client validates the whole admin login path
|
through the HTTP API for the OAuth client validates the whole admin login path
|
||||||
end-to-end.
|
end-to-end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Idempotency
|
## Idempotency
|
||||||
|
|
||||||
Re-running `./setup.sh` converges to `./config/`:
|
Re-running `./setup.sh` converges to `./config/`:
|
||||||
@@ -139,6 +147,8 @@ Re-running `./setup.sh` converges to `./config/`:
|
|||||||
So `setup.sh` is safe to re-run after editing `./config/`, after a `docker
|
So `setup.sh` is safe to re-run after editing `./config/`, after a `docker
|
||||||
compose down`, or after restoring from backup.
|
compose down`, or after restoring from backup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Backups and restore
|
## Backups and restore
|
||||||
|
|
||||||
`./setup.sh` auto-snapshots `./config/` + LDAP + both Redis to
|
`./setup.sh` auto-snapshots `./config/` + LDAP + both Redis to
|
||||||
|
|||||||
+16
-2
@@ -14,13 +14,15 @@ It exists for people whose needs are met by these two projects and who want to
|
|||||||
run them "very simply." Each project still works **standalone**; this repo just
|
run them "very simply." Each project still works **standalone**; this repo just
|
||||||
wires them together and automates the first-run glue.
|
wires them together and automates the first-run glue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone --recursive https://github.com/theta42/theta-env.git
|
git clone --recursive https://github.com/theta42/theta-env.git
|
||||||
cd theta-env
|
cd theta-env
|
||||||
./setup.sh # generates ./config/ the first time — edit it, then re-run
|
cp setup.env.example setup.env # then edit setup.env: set CFG_BASE_DN to your domain
|
||||||
./setup.sh # builds + bootstraps + starts the stack
|
./setup.sh # first run: generates ./config/ from setup.env, builds + bootstraps + starts
|
||||||
```
|
```
|
||||||
|
|
||||||
You need **Docker** + **Docker Compose**. `./setup.sh` is idempotent — re-run any
|
You need **Docker** + **Docker Compose**. `./setup.sh` is idempotent — re-run any
|
||||||
@@ -30,6 +32,8 @@ See the [Quickstart Guide](quickstart.html) for a walkthrough of `./config/` and
|
|||||||
what `setup.sh` does, [Architecture](architecture.html) for how the pieces fit
|
what `setup.sh` does, [Architecture](architecture.html) for how the pieces fit
|
||||||
together, and [Standalone](standalone.html) for running each project on its own.
|
together, and [Standalone](standalone.html) for running each project on its own.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## What you get
|
## What you get
|
||||||
|
|
||||||
- **SSO Manager** at `https://<SSO_HOST>` — log in as your first admin to manage
|
- **SSO Manager** at `https://<SSO_HOST>` — log in as your first admin to manage
|
||||||
@@ -44,6 +48,8 @@ together, and [Standalone](standalone.html) for running each project on its own.
|
|||||||
creator (carrying their permissions); mint/rotate/revoke under **API Tokens**
|
creator (carrying their permissions); mint/rotate/revoke under **API Tokens**
|
||||||
in each UI. See each submodule's DEPLOYMENT for the details.
|
in each UI. See each submodule's DEPLOYMENT for the details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## The `./config/` values you must set
|
## The `./config/` values you must set
|
||||||
|
|
||||||
All config and secrets live in `./config/sso-secrets.js` +
|
All config and secrets live in `./config/sso-secrets.js` +
|
||||||
@@ -62,6 +68,8 @@ There is **no `.env`**. Set at least these in `./config/sso-secrets.js`:
|
|||||||
See `config.example/` for the full annotated shape (SMTP, LDAP cert CN, proxy
|
See `config.example/` for the full annotated shape (SMTP, LDAP cert CN, proxy
|
||||||
OIDC/LDAP/auth, …).
|
OIDC/LDAP/auth, …).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -88,6 +96,8 @@ The proxy is **both** an OIDC client of the SSO (for login) **and** a direct LDA
|
|||||||
client (for user lookups). See [Architecture](architecture.html) for the full
|
client (for user lookups). See [Architecture](architecture.html) for the full
|
||||||
diagram + the first-run bootstrap flow.
|
diagram + the first-run bootstrap flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Quickstart Guide](quickstart.html) — full walkthrough of `./config/` + `setup.sh`.
|
- [Quickstart Guide](quickstart.html) — full walkthrough of `./config/` + `setup.sh`.
|
||||||
@@ -95,11 +105,15 @@ diagram + the first-run bootstrap flow.
|
|||||||
design, and how the bootstrap wires the proxy into a fresh SSO.
|
design, and how the bootstrap wires the proxy into a fresh SSO.
|
||||||
- [Standalone](standalone.html) — running SSO Manager or the proxy on its own.
|
- [Standalone](standalone.html) — running SSO Manager or the proxy on its own.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Community
|
## Community
|
||||||
|
|
||||||
- [GitHub Repository](https://github.com/theta42/theta-env)
|
- [GitHub Repository](https://github.com/theta42/theta-env)
|
||||||
- [Issue Tracker](https://github.com/theta42/theta-env/issues)
|
- [Issue Tracker](https://github.com/theta42/theta-env/issues)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License — see the repository for details.
|
MIT License — see the repository for details.
|
||||||
@@ -7,6 +7,8 @@ title: Quickstart
|
|||||||
|
|
||||||
[← Back to Home](index.html)
|
[← Back to Home](index.html)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- A Linux host with **Docker** + **Docker Compose** (the v2 plugin `docker
|
- A Linux host with **Docker** + **Docker Compose** (the v2 plugin `docker
|
||||||
@@ -18,6 +20,8 @@ title: Quickstart
|
|||||||
certs; otherwise the proxy serves a self-signed fallback (browsers warn —
|
certs; otherwise the proxy serves a self-signed fallback (browsers warn —
|
||||||
expected for LAN use).
|
expected for LAN use).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. Clone
|
## 1. Clone
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -32,6 +36,8 @@ step. If you forgot it:
|
|||||||
git submodule update --init --recursive
|
git submodule update --init --recursive
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2. Configure `setup.env` (enter your domain once)
|
## 2. Configure `setup.env` (enter your domain once)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -63,6 +69,8 @@ file shape.
|
|||||||
> exist, `./setup.sh` migrates them into `./config/` preserving your existing
|
> exist, `./setup.sh` migrates them into `./config/` preserving your existing
|
||||||
> secrets — no need to write a `setup.env`.
|
> secrets — no need to write a `setup.env`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 3. Run
|
## 3. Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -86,6 +94,8 @@ What happens:
|
|||||||
The first run builds two Docker images (a few minutes). Subsequent runs are
|
The first run builds two Docker images (a few minutes). Subsequent runs are
|
||||||
fast.
|
fast.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 4. Point DNS at the host
|
## 4. Point DNS at the host
|
||||||
|
|
||||||
`stack.ssoHost` and `stack.proxyHost` (from `./config/sso-secrets.js`) must
|
`stack.ssoHost` and `stack.proxyHost` (from `./config/sso-secrets.js`) must
|
||||||
@@ -98,6 +108,8 @@ echo "127.0.0.1 sso.lab.local proxy.lab.local" | sudo tee -a /etc/hosts
|
|||||||
(The proxy needs port 80 reachable for Let's Encrypt; on a LAN without that it
|
(The proxy needs port 80 reachable for Let's Encrypt; on a LAN without that it
|
||||||
serves a self-signed cert — browsers will warn, which is fine for home-lab use.)
|
serves a self-signed cert — browsers will warn, which is fine for home-lab use.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 5. Log in
|
## 5. Log in
|
||||||
|
|
||||||
Open `https://<SSO_HOST>` and log in as your bootstrap admin
|
Open `https://<SSO_HOST>` and log in as your bootstrap admin
|
||||||
@@ -110,6 +122,8 @@ it). Add the Host records you want to protect with OIDC.
|
|||||||
First-run fallbacks (if DNS/TLS isn't ready yet): SSO UI at
|
First-run fallbacks (if DNS/TLS isn't ready yet): SSO UI at
|
||||||
`http://127.0.0.1:3001`, proxy UI at `http://127.0.0.1:3000`.
|
`http://127.0.0.1:3001`, proxy UI at `http://127.0.0.1:3000`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Re-running
|
## Re-running
|
||||||
|
|
||||||
`./setup.sh` is **idempotent** — safe to re-run after editing `./config/`, after
|
`./setup.sh` is **idempotent** — safe to re-run after editing `./config/`, after
|
||||||
@@ -118,6 +132,8 @@ then converges the stack to your `./config/` values (LDAP service account + admi
|
|||||||
passwords are reset to the config; the OAuth client is kept if `proxy-secrets.js`
|
passwords are reset to the config; the OAuth client is kept if `proxy-secrets.js`
|
||||||
already holds its creds).
|
already holds its creds).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Direct LDAP for legacy apps
|
## Direct LDAP for legacy apps
|
||||||
|
|
||||||
Legacy apps bind LDAP directly over LDAPS:
|
Legacy apps bind LDAP directly over LDAPS:
|
||||||
@@ -131,6 +147,8 @@ ldapsearch -x -H ldaps://<host>:636 \
|
|||||||
Use the `cn=ldapclient` service account (read-only, the bootstrap created it)
|
Use the `cn=ldapclient` service account (read-only, the bootstrap created it)
|
||||||
or the admin DN. Use LDAPS (636), not plain LDAP.
|
or the admin DN. Use LDAPS (636), not plain LDAP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Backups and restore
|
## Backups and restore
|
||||||
|
|
||||||
`./setup.sh` auto-snapshots `./config/` + LDAP + both Redis to `./backups/<ts>/`
|
`./setup.sh` auto-snapshots `./config/` + LDAP + both Redis to `./backups/<ts>/`
|
||||||
@@ -145,6 +163,8 @@ docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \
|
|||||||
-b "<base>" > backup-$(date +%F).ldif
|
-b "<base>" > backup-$(date +%F).ldif
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Next steps
|
## Next steps
|
||||||
|
|
||||||
- Add users / groups in the SSO UI.
|
- Add users / groups in the SSO UI.
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ theta-env composes the two projects but doesn't fork them — both work on their
|
|||||||
own. The submodules in this repo are normal clones; you can also clone them
|
own. The submodules in this repo are normal clones; you can also clone them
|
||||||
directly from GitHub.
|
directly from GitHub.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## SSO Manager alone
|
## SSO Manager alone
|
||||||
|
|
||||||
The all-in-one image (`Dockerfile.openldap`) bundles the app + OpenLDAP + Redis:
|
The all-in-one image (`Dockerfile.openldap`) bundles the app + OpenLDAP + Redis:
|
||||||
@@ -46,6 +48,8 @@ sudo systemctl enable --now sso-manager
|
|||||||
Idempotent — re-run to update. See the SSO Manager
|
Idempotent — re-run to update. See the SSO Manager
|
||||||
[deployment guide](https://theta42.github.io/sso-manager-node/deployment.html).
|
[deployment guide](https://theta42.github.io/sso-manager-node/deployment.html).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Proxy alone
|
## Proxy alone
|
||||||
|
|
||||||
The all-in-one image (`Dockerfile`) bundles OpenResty + the Node app + Redis:
|
The all-in-one image (`Dockerfile`) bundles OpenResty + the Node app + Redis:
|
||||||
@@ -70,6 +74,33 @@ so `app_*` would silently override your file.
|
|||||||
Requires `@simpleworkjs/conf` >= 1.1.0. Full reference:
|
Requires `@simpleworkjs/conf` >= 1.1.0. Full reference:
|
||||||
[proxy deployment docs](https://theta42.github.io/proxy/docker.html).
|
[proxy deployment docs](https://theta42.github.io/proxy/docker.html).
|
||||||
|
|
||||||
|
### The `auth.adminUsers` anti-lockout account
|
||||||
|
|
||||||
|
Both `setup.sh` and `config.example/proxy-secrets.js.example` write
|
||||||
|
`auth.adminUsers: ['proxyadmin2']` into `proxy-secrets.js`. This is a
|
||||||
|
**local, config-driven admin bypass** — the proxy grants full admin rights to
|
||||||
|
any logged-in OIDC user whose username (the `preferred_username` claim from
|
||||||
|
the SSO) matches an entry in `auth.adminUsers`, regardless of their LDAP group
|
||||||
|
membership (see `proxy/nodejs/utils/roles.js`, `resolveEffective()`). It exists
|
||||||
|
so an operator can't lock themselves out of the proxy mgmt UI if the SSO's
|
||||||
|
`app_sso_admin` group is ever misconfigured, deleted, or otherwise broken.
|
||||||
|
|
||||||
|
It is **not** derived from any `setup.env` value, and it does **not** create a
|
||||||
|
user by itself — the name is only a username match. To actually use the
|
||||||
|
bypass, create a user with uid `proxyadmin2` in the SSO (it does not need to
|
||||||
|
be in `app_sso_admin` or any other group) and log in through the proxy as that
|
||||||
|
user.
|
||||||
|
|
||||||
|
To change or disable it, edit `auth.adminUsers` directly in
|
||||||
|
`./config/proxy-secrets.js` after the first `./setup.sh` run (re-running
|
||||||
|
`setup.sh` will not overwrite an existing `proxy-secrets.js`):
|
||||||
|
|
||||||
|
- **Rename** it to a less guessable username: `adminUsers: ['your-break-glass-uid']`.
|
||||||
|
- **Add more** anti-lockout accounts: `adminUsers: ['proxyadmin2', 'another-admin']`.
|
||||||
|
- **Disable** it entirely: `adminUsers: []` (global admin then comes only from
|
||||||
|
`auth.adminGroups` membership — make sure at least one real admin group is
|
||||||
|
reachable before doing this).
|
||||||
|
|
||||||
### Bare metal
|
### Bare metal
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -80,6 +111,8 @@ See the proxy
|
|||||||
[Docker guide](https://theta42.github.io/proxy/docker.html) /
|
[Docker guide](https://theta42.github.io/proxy/docker.html) /
|
||||||
[installation guide](https://theta42.github.io/proxy/installation.html).
|
[installation guide](https://theta42.github.io/proxy/installation.html).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Mixing and matching
|
## Mixing and matching
|
||||||
|
|
||||||
theta-env isn't required to use the two together — the four wiring steps are
|
theta-env isn't required to use the two together — the four wiring steps are
|
||||||
|
|||||||
Reference in New Issue
Block a user