summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorrepliqa <sarzilhossain@proton.me>2025-07-23 14:06:15 +0600
committerrepliqa <sarzilhossain@proton.me>2025-07-23 14:06:15 +0600
commit69acb7a82a68eeb439e55b994281056df52c81b1 (patch)
tree7c6a53694e11511a3014470c213255a503f9c95e
v0.0.1alphaHEADmain
-rw-r--r--.gitignore4
-rw-r--r--.gitmodules0
-rw-r--r--CONTRIBUTING.md76
-rw-r--r--README.md143
-rw-r--r--ansible.cfg9
-rwxr-xr-xfilter_plugins/custom_filters.py16
-rw-r--r--group_vars/all.yaml14
-rw-r--r--inventory.yaml10
-rw-r--r--library/README.md33
-rw-r--r--library/format_output.py39
-rw-r--r--library/hysteria.py66
-rw-r--r--library/ocserv.py80
-rw-r--r--library/sshvpn.py76
-rw-r--r--library/user_expiration.py48
-rw-r--r--library/xray.py104
-rw-r--r--reactance.yaml80
-rw-r--r--roles/base/handlers/main.yaml11
-rw-r--r--roles/base/tasks/add_pubkeys.yaml9
-rw-r--r--roles/base/tasks/base_setup.yaml58
-rw-r--r--roles/base/tasks/main.yaml12
-rw-r--r--roles/base/tasks/pre_execution_checks.yaml11
-rw-r--r--roles/base/tasks/setup_user_expiration.yaml19
-rw-r--r--roles/base/templates/newsyslog.conf.j231
-rw-r--r--roles/base/templates/syslog.conf.j221
-rw-r--r--roles/base/templates/user_expiration_control.py.j299
-rw-r--r--roles/dns/handlers/main.yaml6
-rw-r--r--roles/dns/tasks/check_dns_setup.yaml13
-rw-r--r--roles/dns/tasks/main.yaml3
-rw-r--r--roles/dns/tasks/setup_adblock.yaml66
-rw-r--r--roles/dns/tasks/setup_unbound.yaml44
-rw-r--r--roles/dns/templates/hostname.vether0.j21
-rw-r--r--roles/dns/templates/resolv.conf.j26
-rw-r--r--roles/dns/templates/unbound.conf.j241
-rw-r--r--roles/hysteria/handlers/main.yaml11
-rw-r--r--roles/hysteria/tasks/check_hysteria_exists.yaml21
-rw-r--r--roles/hysteria/tasks/configure_hysteria.yaml51
-rw-r--r--roles/hysteria/tasks/create_users_hysteria.yaml13
-rw-r--r--roles/hysteria/tasks/install_hysteria.yaml64
-rw-r--r--roles/hysteria/tasks/main.yaml7
-rw-r--r--roles/hysteria/tasks/setup_hysteria.yaml17
-rw-r--r--roles/hysteria/templates/config.json.j252
-rw-r--r--roles/hysteria/templates/hysteria.rc.j222
-rw-r--r--roles/ocserv/handlers/main.yml11
-rw-r--r--roles/ocserv/tasks/check_ocserv_exists.yaml16
-rw-r--r--roles/ocserv/tasks/configure_ocserv.yaml40
-rw-r--r--roles/ocserv/tasks/create_users_ocserv.yaml18
-rw-r--r--roles/ocserv/tasks/install_ocserv.yaml63
-rw-r--r--roles/ocserv/tasks/main.yaml3
-rw-r--r--roles/ocserv/tasks/setup_ocserv.yaml8
-rw-r--r--roles/ocserv/templates/ca.tmpl.j28
-rw-r--r--roles/ocserv/templates/crl.tmpl.j22
-rw-r--r--roles/ocserv/templates/ocserv.conf.j248
-rw-r--r--roles/ocserv/templates/ocserv.rc.j214
-rw-r--r--roles/ocserv/templates/server.tmpl.j27
-rw-r--r--roles/sshvpn/handlers/main.yaml6
-rw-r--r--roles/sshvpn/tasks/check_sshvpn_exists.yaml12
-rw-r--r--roles/sshvpn/tasks/create_users_sshvpn.yaml16
-rw-r--r--roles/sshvpn/tasks/main.yaml3
-rw-r--r--roles/sshvpn/tasks/setup_sshvpn.yaml16
-rw-r--r--roles/web/handlers/main.yaml22
-rw-r--r--roles/web/tasks/.setup_sites.yaml.swpbin0 -> 12288 bytes
-rw-r--r--roles/web/tasks/build_hugo_sites.yaml66
-rw-r--r--roles/web/tasks/copy_certs.yaml28
-rw-r--r--roles/web/tasks/main.yaml78
-rw-r--r--roles/web/tasks/setup_auth.yaml12
-rw-r--r--roles/web/tasks/setup_httpd.yaml8
-rw-r--r--roles/web/tasks/template_vars.yaml41
-rw-r--r--roles/web/templates/httpd.conf.j219
-rw-r--r--roles/xray/handlers/main.yaml11
-rw-r--r--roles/xray/tasks/check_xray_exists.yaml21
-rw-r--r--roles/xray/tasks/configure_xray.yaml50
-rw-r--r--roles/xray/tasks/create_users_xray.yaml55
-rw-r--r--roles/xray/tasks/install_xray.yaml100
-rw-r--r--roles/xray/tasks/main.yaml3
-rw-r--r--roles/xray/templates/config.json.j2131
-rw-r--r--roles/xray/templates/xray.rc.j222
-rw-r--r--utils/Dockerfile6
-rw-r--r--utils/drone.star138
-rwxr-xr-xutils/hugo_build.sh8
-rw-r--r--web/archetypes/default.md6
-rw-r--r--web/content.en/_index.md27
-rw-r--r--web/content.en/docs/_index.md5
-rw-r--r--web/content.en/docs/android/_index.md5
-rw-r--r--web/content.en/docs/android/anyconnect.md.j297
-rw-r--r--web/content.en/docs/android/nekobox.md.j290
-rw-r--r--web/content.en/docs/android/nekossh.md.j296
-rw-r--r--web/content.en/docs/windows/_index.md5
-rw-r--r--web/content.en/docs/windows/nekoray.md.j288
-rw-r--r--web/content.en/docs/windows/openconnect.md.j253
-rw-r--r--web/content.fa/_index.md25
-rw-r--r--web/content.fa/docs/_index.md5
-rw-r--r--web/content.fa/docs/android/_index.md5
-rw-r--r--web/content.fa/docs/android/anyconnect.md.j297
-rw-r--r--web/content.fa/docs/android/nekobox.md.j290
-rw-r--r--web/content.fa/docs/android/nekossh.md.j296
-rw-r--r--web/content.fa/docs/windows/_index.md5
-rw-r--r--web/content.fa/docs/windows/nekoray.md.j288
-rw-r--r--web/content.fa/docs/windows/openconnect.md.j253
-rw-r--r--web/hugo.toml.j229
-rw-r--r--web/resources/_gen/assets/scss/book.scss_50fc8c04e12a2f59027287995557ceff.content1
-rw-r--r--web/resources/_gen/assets/scss/book.scss_50fc8c04e12a2f59027287995557ceff.json1
-rw-r--r--web/static/fonts/BNazanin.eotbin0 -> 57856 bytes
-rw-r--r--web/static/fonts/BNazanin.ttfbin0 -> 57656 bytes
-rw-r--r--web/static/fonts/BNazanin.woffbin0 -> 25692 bytes
-rwxr-xr-xweb/static/images/anyconnect-01.pngbin0 -> 83290 bytes
-rwxr-xr-xweb/static/images/anyconnect-02.pngbin0 -> 38121 bytes
-rwxr-xr-xweb/static/images/anyconnect-03.pngbin0 -> 33454 bytes
-rwxr-xr-xweb/static/images/anyconnect-04.pngbin0 -> 100573 bytes
-rwxr-xr-xweb/static/images/anyconnect-05.pngbin0 -> 35508 bytes
-rwxr-xr-xweb/static/images/anyconnect-06.pngbin0 -> 41196 bytes
-rwxr-xr-xweb/static/images/anyconnect-07.pngbin0 -> 38017 bytes
-rwxr-xr-xweb/static/images/anyconnect-08.pngbin0 -> 52350 bytes
-rwxr-xr-xweb/static/images/anyconnect-09.pngbin0 -> 16228 bytes
-rwxr-xr-xweb/static/images/anyconnect-10.pngbin0 -> 22772 bytes
-rwxr-xr-xweb/static/images/anyconnect-11.pngbin0 -> 56838 bytes
-rwxr-xr-xweb/static/images/anyconnect-12.pngbin0 -> 27932 bytes
-rwxr-xr-xweb/static/images/anyconnect-13.pngbin0 -> 22172 bytes
-rwxr-xr-xweb/static/images/anyconnect-14.pngbin0 -> 50920 bytes
-rwxr-xr-xweb/static/images/anyconnect-15.pngbin0 -> 31684 bytes
-rwxr-xr-xweb/static/images/anyconnect-16.pngbin0 -> 14556 bytes
-rwxr-xr-xweb/static/images/anyconnect-17.pngbin0 -> 57558 bytes
-rwxr-xr-xweb/static/images/anyconnect-18.pngbin0 -> 34543 bytes
-rwxr-xr-xweb/static/images/anyconnect-19.pngbin0 -> 36856 bytes
-rwxr-xr-xweb/static/images/nekobox-01.pngbin0 -> 28819 bytes
-rwxr-xr-xweb/static/images/nekobox-02.pngbin0 -> 24957 bytes
-rwxr-xr-xweb/static/images/nekobox-03.pngbin0 -> 20843 bytes
-rwxr-xr-xweb/static/images/nekobox-04.pngbin0 -> 33699 bytes
-rwxr-xr-xweb/static/images/nekobox-05.pngbin0 -> 30678 bytes
-rwxr-xr-xweb/static/images/nekobox-06.pngbin0 -> 34139 bytes
-rwxr-xr-xweb/static/images/nekobox-07.pngbin0 -> 11749 bytes
-rwxr-xr-xweb/static/images/nekobox-08.pngbin0 -> 23623 bytes
-rwxr-xr-xweb/static/images/nekobox-09.pngbin0 -> 18134 bytes
-rwxr-xr-xweb/static/images/nekobox-10.pngbin0 -> 17034 bytes
-rwxr-xr-xweb/static/images/nekobox-12.pngbin0 -> 160376 bytes
-rwxr-xr-xweb/static/images/nekoray-1.pngbin0 -> 9368 bytes
-rwxr-xr-xweb/static/images/nekoray-10.pngbin0 -> 10286 bytes
-rwxr-xr-xweb/static/images/nekoray-11.pngbin0 -> 10280 bytes
-rwxr-xr-xweb/static/images/nekoray-12.pngbin0 -> 67684 bytes
-rwxr-xr-xweb/static/images/nekoray-13.pngbin0 -> 41102 bytes
-rwxr-xr-xweb/static/images/nekoray-2.pngbin0 -> 29204 bytes
-rwxr-xr-xweb/static/images/nekoray-3.pngbin0 -> 39641 bytes
-rwxr-xr-xweb/static/images/nekoray-4.pngbin0 -> 39791 bytes
-rwxr-xr-xweb/static/images/nekoray-5.pngbin0 -> 31338 bytes
-rwxr-xr-xweb/static/images/nekoray-6.pngbin0 -> 40105 bytes
-rwxr-xr-xweb/static/images/nekoray-7.pngbin0 -> 46154 bytes
-rwxr-xr-xweb/static/images/nekoray-8.pngbin0 -> 29514 bytes
-rwxr-xr-xweb/static/images/nekoray-9.pngbin0 -> 10282 bytes
-rwxr-xr-xweb/static/images/nekossh-01.pngbin0 -> 21058 bytes
-rwxr-xr-xweb/static/images/nekossh-02.pngbin0 -> 37307 bytes
-rwxr-xr-xweb/static/images/nekossh-03.pngbin0 -> 39504 bytes
-rwxr-xr-xweb/static/images/nekossh-04.pngbin0 -> 38363 bytes
-rwxr-xr-xweb/static/images/nekossh-05.pngbin0 -> 39561 bytes
-rwxr-xr-xweb/static/images/nekossh-06.pngbin0 -> 17785 bytes
-rwxr-xr-xweb/static/images/windows-oc-01.pngbin0 -> 36798 bytes
-rwxr-xr-xweb/static/images/windows-oc-02.pngbin0 -> 21108 bytes
-rwxr-xr-xweb/static/images/windows-oc-03.pngbin0 -> 26363 bytes
-rwxr-xr-xweb/static/images/windows-oc-04.pngbin0 -> 6582 bytes
-rwxr-xr-xweb/static/images/windows-oc-05.pngbin0 -> 10510 bytes
-rwxr-xr-xweb/static/images/windows-oc-06.pngbin0 -> 11782 bytes
-rwxr-xr-xweb/static/images/windows-oc-08.pngbin0 -> 10988 bytes
-rwxr-xr-xweb/static/nekoboxsetting/Nekobox-Settings.json48
-rw-r--r--web/themes/README.md2
162 files changed, 3634 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..ab2db952
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+.venv
+.ssh_private_key
+filter_plugins/__pycache__
+web/themes/hugo-book
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/.gitmodules
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..4cbb0dc3
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,76 @@
+# Reactance - Developer Manual
+
+## Table of Contents
+- [Description](#description)
+- [Development Environment Setup](#development-environment-setup)
+- [Developing Service Roles](#developing-service-roles)
+- [Developing User Management Modules](#developing-user-management-modules)
+- [Adding User Expiration Functionality](#adding-user-expiration-functionality)
+- [To Do](#to-do)
+
+## Description
+Roles are used for installing, configuring services. Custom ansible modules are used for performing user management on servers. There are separate roles and user management modules for each protocol.
+
+1. Installing, configuring and running the VPN service in a chrooted environment through roles
+2. User management for the VPN service through custom modules and calling them inside roles
+3. Adding user expiration support through custom python script ran on a daily cronjob
+
+## Development Environment Setup
+You need `python3` installed. Make a virtual environment and install ansible in it `python3 -m venv .venv && source .venv/bin/activate && pip3 install ansible`
+
+## Developing Service Roles
+Each role should have the following tasks:
+1. `check_service_exists.yaml` - checks if the service is installed on server, if not, installation task is ran.
+2. `install_service.yaml` - installs the service on the server in a chrooted environment
+3. `configure_service.yaml` - generates server configuration file for the service
+4. `create_users_service.yaml` - runs user management module
+and necessary handlers to restart services, and other things required for the protocol
+
+## Developing User Management Modules
+For each protocol, you need to write a custom module for performing user management on servers.
+
+1. input: each module takes a list of user definitions as input. The list is comprised of `all_users` and `service_users`
+2. user management: the module would then run the necessary functions to perform the user management. For example, reading and updating configuration files or password files.
+3. output: each module should return the list of username:password pairs as output or a message (as in sshvpn) in the following format:
+```json
+{
+ "service name": {
+ "user1": "password1",
+ "user2": "password2"
+ }
+}
+```
+
+## Adding User Expiration Functionality
+All services do not support automatic expiration of users, which is a very needed feature. Reactance however accomplishes that through running a python script as a daily cronjob. The user expiry control script is a part of the base role and templated out during ansible run. The [user_expiration_control.py script](roles/base/templates/user_expiration_control.py.j2) is stored in `roles/base/templates` directory.
+
+The user expiration information is stored in a json file that has the following format:
+```json
+{
+ "date time in unix format": [
+ "user_1",
+ "user_2"
+ ],
+ "another date time in unix format": [
+ "user_1",
+ "user_3"
+ ]
+}
+
+```
+The unix date time is compared with current date, and if it's less than the current date time, the list of users associated to it is added to the list of users to remove. The list is then passed to functions that retrieve the list of previous users, remove users from provided list, saves the final list in configuration/password files.
+
+## Client Website
+At first, install hugo and git on your host machine. Hugo can be installed with `pip3 install hugo`. Follow your OS's documentations on installing git.
+
+The base website is located in `web` directory. For reactance, we are using a Hugo theme called `Book`. To add/modify contents of the pages, update the markdown files in `web/content.<lang_code>/docs/<os>/<client_name>.md.j2`.
+
+**Note: You might notice `hugo serve` under `web/` directory would fail. It is because the credentials and hugo config need to be templated out based on the users. Which is why some files end with .j2. The hugo source code is at first copied for each user, based on the VPN/Proxy services that are enabled for it, built and then copied to the remote server.**
+
+## To-Do
+- [X] single server support
+- [x] user expiration support
+- [X] adding connection packages
+- [X] adding website for clients to download connection packages
+- [ ] cleaning up and refactoring entire codebase
+- [ ] multiple server support
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..ff75b883
--- /dev/null
+++ b/README.md
@@ -0,0 +1,143 @@
+# Reactance - User Manual
+Censorship resistant scalable VPN/Proxy automation for OpenBSD with user management for servers and cloud services.
+
+Supported OpenBSD versions: 7.4 (Tested)
+
+## Table of Contents
+ - [Description](#description)
+ - [Protocols](#protocols)
+ - [Server Definitions](#server-definitions)
+ - [User Definitions](#user-definitions)
+ - [Playbook Execution](#playbook-execution)
+ - [Client Website](#client-website)
+ - [Reading Logs](#reading-logs)
+ - [CI/CD Pipelines](#cicd-pipelines)
+ - [Contributing](#contributing)
+
+## Description
+Reactance is a complete automation to handle installation, user management, credential distribution of censorship-resistant VPN protocols on your openbsd server/cloud service(s) of choice. The server configuration is done through setting inventory variables. User configuration is done through using host and group variables. Read [Server Definitions](#server-definitions) and [User Definitions](#user-definitions) for more info.
+
+Reactance builds websites for users/clients to view documentations and credentials. The htpasswd credential and URL of the website is showed at the end of `web` role at the end of reactance run.
+
+It is also possible to test and deploy the VPN automation through using CI/CD pipelines. Please read [CI/CD Pipelines](#cicd-pipelines) for more info.
+
+The goals of this project are:
+- Automatically setting up a wide range of up-to-date secure VPN services on OpenBSD
+- Automated User Management and Expiration
+- Bypassing Censorship
+- Easy to set up and manage
+
+## Protocols
+These are the protocols currently supported by Reactance. Protocols upper in the list are more preferable for usage because of security and performance.
+
+**NOTE: Hysteria2 is disabled by default. Because the sing-box clients available on the different platforms do not have any mechanism for verifying the proxy server's identity thus there's possibility of MITM attacks. Even if you include Hysteria users and settings in your configuration files, it would be ignored**
+
+|Protocol|Service|Authentication Method|Server Verification Method|Auto DNS Proxying|
+|--|--|--|--|--|
+|Cisco AnyConnect|OpenConnect|Certificate|TBD|Yes|
+|Trojan|Xray core|Password only|XTLS Vision|Yes|
+|VLESS|Xray core|UUID|XTLS Vision|Yes|
+|VMESS|Xray core|UUID|XTLS Vision|Yes|
+|SOCKS|SSH|private key|matching keypairs|No|
+|Hysteria v2|Hysteria v2|Username & Password, Obfuscation|TBD|No|
+
+## Server Definitions
+Servers are defined in inventory.yaml file. There are different groups for different services.
+Supported groups: `all_vpns`, `vless`, `vmess`, `trojan`, `sshvpn`, `hysteria`, `ocserv`
+
+Example: The following config will setup all vpn servers on box1, only ocserv on box2 and box3
+
+```json
+all_vpns:
+ hosts:
+ box1:
+ host: deez.example.com
+
+ocserv:
+ hosts:
+ box2:
+ host: nuts.example.com
+ user: user2
+ ocserv_network: "10.20.30.40/24"
+```
+
+All variables:
+|Name|Description|Default Value|Used Under|
+|--|--|--|--|
+|host|server hostname|(None)|all|
+|port|server ssh port|22|all|
+|private_key_file|path to ssh private key file|.ssh_private_key (required for DroneCI)|all|
+|ocserv_network|network address for ocserv|172.16.16.1/24|all_vpns, ocserv|
+|ocserv_port|port number for openconnect server|4430|all_vpns, ocserv|
+|hysteria_port|port number for hysteria|4435|all_vpns, hysteria|
+|trojan_port|port number for trojan|4436|all_vpns, xray|
+|vless_port|port number for vless|4437|all_vpns, xray|
+|vmess_port|port number for vmess|4438|all_vpns, xray|
+|disable_dns|disable dns and adblock setup|false|all|
+|root_keys|root user ssh public keys|None|all|
+
+## User Definitions
+Users can be set up based on protocol (same users across all servers for same services) or hosts (specific users on specific servers). For user management based on services, write your user definitions in `group_vars/all.yaml`. For user management based on specific hosts, write your user definitions in `host_vars/all.yaml` (group_vars would be overriden for that host).
+
+The parameters for the user definitions are:
+|Parameter Name|Description|Type|Default Value|Importance|
+|--|--|--|--|--|
+|user|username|string|None|required|
+|regen|overwrite existing password|boolean|false|optional|
+|expire|user expiration date (format: yyyy-mm-dd)|string|None|optional|
+
+Supported user lists are: `all_users`, `sshvpn_users`, `vless_users`, `vmess_users`, `trojan_users`, `hysteria_users`, `ocserv_users`
+
+### Configuration Example
+```yaml
+# users set in all_users would be set up for every service
+all_users:
+ - user: foo
+ - user: bar
+vless_users:
+ - user: baz
+ regen: true # will regenerate password
+ expire: 2025-10-13 # will remove user after yyyy-mm-dd
+```
+
+## Playbook Execution
+
+Step 1: It's recommended to run the playbook through a DroneCI pipeline. However if you wish to run it locally from your computer, run the following commands:
+```sh
+apt install rsync # or whatever your package manager is
+python3 -m venv .venv
+source .venv/bin/activate
+pip3 install ansible netaddr
+git submodule add -f https://github.com/alex-shpak/hugo-book web/themes/hugo-book
+```
+
+Step 2: Reactance is ran as the root user. Please copy a public key to your server's `/root/.ssh/authorized_keys` file. Copy the private key and store it on DroneCI under the secret `ssh_private_key`. Or, if you're running reactance manually, store the private key file path under the `private_key_file` parameter in inventory.yaml.
+
+Step 3: To execute the playbook, you can run a manual pipeline build on DroneCI from the Web UI. If you want to execute the playbook manually, simply run:
+```sh
+ansible-playbook reactance.yaml
+```
+
+## Client Website
+The clients can retrieve the VPN credentials and read the docs from the client site that they can access from `http://x.x.x.x/client_name/index.html`. htpasswd based authentication is for authenticating the clients on the sites. The htpasswd credentials along with the URLs are shown at the end of reactance run (web role) or at the end of `setup_web` task in DroneCI.
+The VPN credentials can be retrieved and the docs can be read from the client website. Please read [CONTRIBUTING.md](./CONTRIBUTING.md) to know how to update the site.
+
+## Reading Logs
+How to debug and fix errors with VPN services
+1. Reading logs
+
+|Protocol|Log File|
+|--|--|
+|ocserv|/var/reactance/ocserv/ocserv.log|
+|hysteria|/var/reactance/hysteria/hysteria.log|
+|xray|/var/reactance/xray/logs/{xray_access.log, xray_error.log}|
+|sshvpn|/var/log/authlog|
+
+2. Reading system calls: You can use `ktrace` and `kdump` to read system calls of processes to see if any errors appear.
+
+## CI/CD pipeline
+You can test and deploy vpn services on your server using CI/CD pipelines. As of now, only DroneCI is supported because of its simplicity, flexibility and ease of use. A `utils` folder can be found that contains a Dockerfile and drone starlark configuration for running the drone pipeline. Starlark is used instead of YAML, to make it easier to add/remove services.
+You need to set the drone config path to `utils/drone.star` in the webui and also store the ssh key as a drone secret in `ssh_private_key` variable.
+
+## Contributing
+To contribute to the project, please refer to [CONTRIBUTING.md](./CONTRIBUTING.md)
diff --git a/ansible.cfg b/ansible.cfg
new file mode 100644
index 00000000..1d44baa3
--- /dev/null
+++ b/ansible.cfg
@@ -0,0 +1,9 @@
+[defaults]
+inventory = inventory.yaml
+interpreter_python = /usr/local/bin/python3
+nocows = true
+filter_plugins = filter_plugins
+stdout_callback = debug
+verbosity = 1
+host_key_checking = false
+forks = 50
diff --git a/filter_plugins/custom_filters.py b/filter_plugins/custom_filters.py
new file mode 100755
index 00000000..64054884
--- /dev/null
+++ b/filter_plugins/custom_filters.py
@@ -0,0 +1,16 @@
+#/usr/bin/python3
+
+class FilterModule(object):
+ def filters(self):
+ return {
+ 'format_userpass_output': self.format_userpass_output
+ }
+
+ def format_userpass_output(self, htpasswd_dict, hostname):
+ msg = []
+ msg.append("##################################################################")
+ msg.append("################### CHANGED USERS - HTPASSWD ###################")
+ for user in htpasswd_dict.keys():
+ msg.append(f"{user}: {htpasswd_dict[user]} [LINK: http://{hostname}:80/{user}/index.html]")
+ msg.append("##################################################################")
+ return '\n'.join(msg)
diff --git a/group_vars/all.yaml b/group_vars/all.yaml
new file mode 100644
index 00000000..6f1dff05
--- /dev/null
+++ b/group_vars/all.yaml
@@ -0,0 +1,14 @@
+---
+trojan_users:
+ - user: testtrojanuser
+ regen: true
+ expire: '2025-5-25'
+vless_users:
+ - user: testvlessuser
+ regen: true
+ expire: '2025-5-25'
+vmess_users:
+ - user: testvlessuser
+ regen: true
+ expire: '2025-5-25'
+
diff --git a/inventory.yaml b/inventory.yaml
new file mode 100644
index 00000000..7a8b8be6
--- /dev/null
+++ b/inventory.yaml
@@ -0,0 +1,10 @@
+---
+all_vpns:
+ hosts:
+ deez_nuts:
+ host: legion.opviel.de
+ ocserv_network: "10.20.30.40/16"
+ disable_dns: False
+ root_keys:
+ - "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDTLaXOTj0+At7uO/ctjE/+KqW8gT3PLdvVAA5x+gj1VF+vQr21aolH2su/5ubW+kKf4dQDNBGi3lg1B8ZTKr+5GWwIoV3pJym+wuFO9l8p39ZwkhXKPRxr82P88VMJH49OLDpVx2KC2vyXVG5oLbPLeLvRCvGAesxys895653FQvrFyuQwD5OkhERd0CHfeOKpDusDFCBXgtchB2vyt/URujWo1ioPBzq7Gsbjf4893NF6Gz6uxkJnDlLhYJKbGACfOHevFfUvmT5xilEKGMqs3I2vMWeG82tUxRzS0feuLBMXcJJnhmbbtVN6x620XdrVcMLIfdnScnbE0CSyJOg9kfWZjfHTFmbMf+J+mhZUZMv4GsBMj08wnx/V1z69Yqwszuss3jwUhlbLBZQokHwn3Ay4N2sHuhZQxO8IWTFwobYN69/7f9Po87lJ0t4k5sdXQOgaWMAjID0u9qbXrxUoWpp/FFPJFh92A/VP1OxsSAf3xrBj1YLkkUQ765wu1cL+8orryC+mdLe+PZdun1FTAtvFz4Ley+YsKBd4rvaKTc5ce/Sww+QHWxMc2VsckAz4qAhGlr0PwHHEVPoJhLQSzn/pxgTJznIJ4df9ZmRgFFRy8lNGR6Z2DkDAivaBRNKQn9Rk8rBBDr6KeHMyjOhBGwkpqhiAO0nZe46iK4R9YQ== deez nuts"
+ - "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCk6SNQR5GzjD5GjyZAC7uOWWNAIgfO16JjzishvWqQpy951ZEyJ91Z/0d8d7ZhX44/MEV8YQclUsHZFefl9dT13bL0tOOxi53TnRQEGtFZ1P8pwwHZYV6+GhrWOLrb0UvDF9IYhx3+qwpBtzVPGIMcn93/0zqs6lq81rpaw/XEkNpgHzT+cR0eEFg0hqiiGkoRBgXr9yRNP3YDJ+hncLRyMkCqHFidQdmcMliK1VIi9n3yPK6CsnfDeSk2j0Qeg4MRf2mp+QIPiAVMyO9Fl60/L4mNMHLvgyFHRj5gXfsDBJiagdNzZuxbRs6T2xeMOA1i/kmUvvDa1O8HRk86P1NVrvbEpUYWw4Prj6+fNjlIMFFcwO1CgWqZ0LCoHIUPFE77VrIghWFnhXYfmFP2JMi6gWll+VK8VU4mXEkutKznQ/DO5v2rUxCdjEd80WV/3buLZoF0PhHQaWYFfQfPR4sJQ7VzHv+ryuveWS2AwKaZRzcjEScUCexjAUOKH0K5fiZmR9msvloUvkLRWGAo1OZhsp0KDPkDbkdSApXFGUh56vYG8IVPfFpS3JqM1Gd+CssfOUpMy8lvW4jEefLHqgL2Z1/JqT6jbu66ay5FPtLDRm5dmODz2E88YVYlFLct1JAgu4Rst3zpw2tsqXg5nN7aWsWcfLC86mkd+pkGJerdzQ== deez nuts"
diff --git a/library/README.md b/library/README.md
new file mode 100644
index 00000000..43a1d318
--- /dev/null
+++ b/library/README.md
@@ -0,0 +1,33 @@
+# Custom Modules
+## Table of Contents
+ - [Description](#description)
+ - [Protocols](#protocols)
+ - [ocserv.py](#ocserv.py)
+ - [xray.py](#xray.py)
+ - [sshvpn.py](#sshvpn.py)
+ - [hysteria.py](#hysteria.py)
+
+## Description
+Custom modules for user management for different protcols. Each module takes a list of users as input, writes to configuration or password file, returns a list of usernames and passwords that are printed at the end of playbook run.
+
+ ## Protocols
+### xray.py
+Description: User management module for xray (vless, vmess, trojan)
+Input Parameters:
+- users - all_users + vless_users/vmess_users/trojan_users
+- protocol - vless/vmess/trojan
+
+### ocserv.py
+Description: User management module for ocserv
+Input Parameters:
+- users - all_users + ocserv_users
+
+### hysteria.py
+Description: User management module for hysteria
+Input Parameters:
+- users - all_users + hysteria_users
+
+### sshvpn.py
+Description: User management module for sshvpn
+Input Parameters:
+- users - all_users + sshvpn_users
diff --git a/library/format_output.py b/library/format_output.py
new file mode 100644
index 00000000..6a50e7ce
--- /dev/null
+++ b/library/format_output.py
@@ -0,0 +1,39 @@
+#!/usr/local/bin/python3
+
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+from ansible.module_utils.basic import AnsibleModule
+import json, shlex, os
+from datetime import datetime
+
+EXPIRE_USER_JSON_PATH = "/var/reactance/.user_expiration.json"
+
+def run_module():
+ changed = False
+ module = AnsibleModule(
+ argument_spec=dict(
+ users = dict(type='list', required=True)
+ ),
+ supports_check_mode=True
+ )
+
+ user_pass_list = module.params["users"]
+ msg = """
+#########################
+#### CHANGED USERS ####
+ """
+ for protocol in user_pass_list:
+ msg += f"## {protocol.key}"
+ proto_user_pass_dict = protocol.values()
+ for user in proto_user_pass_dict.keys():
+ msg += f"# {user}: {proto_user_pass_dict[user]}"
+
+ msg += "#########################"
+ module.exit_json(changed=changed, msg=msg)
+
+def main():
+ run_module()
+
+if __name__ == "__main__":
+ main()
diff --git a/library/hysteria.py b/library/hysteria.py
new file mode 100644
index 00000000..99f67871
--- /dev/null
+++ b/library/hysteria.py
@@ -0,0 +1,66 @@
+#!/usr/local/bin/python3
+
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+from ansible.module_utils.basic import AnsibleModule
+import json, shlex, os
+from datetime import datetime
+
+HYSTERIA_CONFIG_FILE = "/var/reactance/hysteria/etc/config.json"
+SALAMANDER_PASSWD_FILE = "/var/reactance/hysteria/salamander_password"
+
+def exec_shell(cmd, module):
+ rc, stdout, stderr= module.run_command(cmd, environ_update={'TERM': 'dumb'})
+ if rc != 0:
+ module.fail_json(stderr)
+ return stdout.rstrip()
+
+def hysteria_get_users():
+ with open(HYSTERIA_CONFIG_FILE, "r") as f:
+ hysteria_config_dict = json.loads(f.read())
+ previous_users = hysteria_config_dict["auth"]["userpass"]
+ return previous_users, hysteria_config_dict
+
+def hysteria_user_control(update_password, module):
+ previous_users, hysteria_config_dict = hysteria_get_users()
+ selected_users = set(update_password.keys())
+ user_pass_dict = {}
+ new_users_dict = {}
+ for user in selected_users:
+ if user in previous_users and not update_password[user]:
+ user_pass_dict[user] = {"hysteria": previous_users[user]}
+ else:
+ user_pass_dict[user] = {"hysteria": exec_shell("openssl rand -hex 32", module)}
+ new_users_dict[user] = user_pass_dict[user]
+
+ with open(HYSTERIA_CONFIG_FILE, "w") as f:
+ hysteria_config_dict["auth"]["userpass"] = user_pass_dict
+ f.write(json.dumps(hysteria_config_dict, indent=1))
+
+ return new_users_dict
+
+def run_module():
+ module = AnsibleModule(
+ argument_spec=dict(
+ users = dict(type='list', required=True)
+ ),
+ supports_check_mode=True
+ )
+ users = module.params["users"]
+ update_password = {}
+
+ for user in users:
+ if 'regen' in user.keys() and user['regen']:
+ update_password[user['user']] = True
+ else:
+ update_password[user['user']] = False
+
+ user_pass_dict = hysteria_user_control(update_password, module)
+ module.exit_json(changed=True, msg=user_pass_dict)
+
+def main():
+ run_module()
+
+if __name__ == "__main__":
+ main()
diff --git a/library/ocserv.py b/library/ocserv.py
new file mode 100644
index 00000000..5ff51adf
--- /dev/null
+++ b/library/ocserv.py
@@ -0,0 +1,80 @@
+#!/usr/local/bin/python3
+
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+from ansible.module_utils.basic import AnsibleModule
+import json, shlex, os
+from datetime import datetime
+
+
+OCSERV_CERTS_DIR = "/var/reactance/ocserv/certs"
+
+def exec_shell(cmd, module):
+ rc, stdout, stderr= module.run_command(cmd, environ_update={'TERM': 'dumb'}, use_unsafe_shell=True)
+ if rc != 0:
+ module.fail_json(stderr)
+ return stdout.rstrip()
+
+def ocserv_get_users():
+ previous_users = [".".join(i.split('.')[:-1]) for i in os.listdir(OCSERV_CERTS_DIR) if i.endswith(".p12")]
+ return previous_users
+
+def ocserv_user_control(update_password, module):
+ previous_users = ocserv_get_users()
+ selected_users = set(update_password.keys())
+ new_users_dict = {}
+
+ # Remove users not in group_vars
+ for user in previous_users:
+ if user not in selected_users or not update_password[user]:
+ # new code goes here - remove user, update crl
+ exec_shell(f"cat {OCSERV_CERTS_DIR}/{user}-cert.pem > {OCSERV_CERTS_DIR}/revoked.pem", module)
+ exec_shell(f"certtool --generate-crl --load-ca-privkey {OCSERV_CERTS_DIR}/ca-key.pem --load-ca-certificate {OCSERV_CERTS_DIR}/ca-cert.pem --load-certificate {OCSERV_CERTS_DIR}/revoked.pem --template {OCSERV_CERTS_DIR}/crl.tmpl --outfile {OCSERV_CERTS_DIR}/crl.pem", module)
+ exec_shell(f"rm {OCSERV_CERTS_DIR}/{user}-cert.pem {OCSERV_CERTS_DIR}/{user}-key.pem {OCSERV_CERTS_DIR}/{user}.p12", module)
+
+ # Add new users or update password of existing users
+ for user in selected_users:
+ if user not in previous_users or update_password[user]:
+ # new code goes here - generate template, certs
+ user_template_contents = f"""
+dn = "cn={user},UID={user}"
+expiration_days = -1
+signing_key
+tls_www_client
+ """
+ user_template_file = os.path.join(OCSERV_CERTS_DIR, f"{user}.tmpl")
+ with open(user_template_file, "w") as f:
+ f.write(user_template_contents)
+ exec_shell(f"certtool --generate-privkey --outfile {OCSERV_CERTS_DIR}/{user}-key.pem", module)
+ exec_shell(f"certtool --generate-certificate --load-privkey {OCSERV_CERTS_DIR}/{user}-key.pem --load-ca-certificate {OCSERV_CERTS_DIR}/ca-cert.pem --load-ca-privkey {OCSERV_CERTS_DIR}/ca-key.pem --template {OCSERV_CERTS_DIR}/{user}.tmpl --outfile {OCSERV_CERTS_DIR}/{user}-cert.pem", module)
+ exec_shell(f"certtool --to-p12 --load-privkey {OCSERV_CERTS_DIR}/{user}-key.pem --pkcs-cipher 3des-pkcs12 --load-certificate {OCSERV_CERTS_DIR}/{user}-cert.pem --outfile {OCSERV_CERTS_DIR}/{user}.p12 --password {user} --p12-name {user} --outder", module)
+ exec_shell(f"rm {user_template_file}", module)
+ new_users_dict[user] = {"ocserv": []} # a hack
+
+ return new_users_dict
+
+def run_module():
+ module = AnsibleModule(
+ argument_spec=dict(
+ users = dict(type='list', required=True)
+ ),
+ supports_check_mode=True
+ )
+ users = module.params["users"]
+ update_password = {}
+
+ for user in users:
+ if 'regen' in user.keys() and user['regen']:
+ update_password[user['user']] = True
+ else:
+ update_password[user['user']] = False
+
+ new_users_dict = ocserv_user_control(update_password, module)
+ module.exit_json(changed=True, msg=new_users_dict)
+
+def main():
+ run_module()
+
+if __name__ == "__main__":
+ main()
diff --git a/library/sshvpn.py b/library/sshvpn.py
new file mode 100644
index 00000000..42c1e60d
--- /dev/null
+++ b/library/sshvpn.py
@@ -0,0 +1,76 @@
+#!/usr/local/bin/python3
+
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+from ansible.module_utils.basic import AnsibleModule
+import json, shlex, os
+from datetime import datetime
+
+SSH_ROOT = "/var/reactance/sshvpn/.ssh"
+AUTHORIZED_KEYS = os.path.join(SSH_ROOT, "authorized_keys")
+
+def exec_shell(cmd, module):
+ # use_unsafe_shell=True so ansible doesn't remove |
+ rc, stdout, stderr= module.run_command(cmd, environ_update={'TERM': 'dumb'}, use_unsafe_shell=True)
+ if rc != 0:
+ module.fail_json(stderr)
+ return stdout.rstrip()
+
+def sshvpn_get_users():
+ previous_users = [".".join(i.split('.')[:-1]) for i in os.listdir(SSH_ROOT) if i.endswith(".pub")]
+ return previous_users
+
+def sshvpn_update_users(update_password, module):
+ previous_users = sshvpn_get_users()
+ new_users_dict = {}
+
+ # Remove users not in new group_vars
+ for user in previous_users:
+ if user not in update_password.keys():
+ exec_shell(f"rm {SSH_ROOT}/{user} {SSH_ROOT}/{user}.pub", module)
+
+ # Update keys for new users or regenerate keys for old users
+ for user in update_password.keys():
+ if user not in previous_users or update_password[user]:
+ exec_shell(f"yes | ssh-keygen -q -t ed25519 -C {user} -N \'\' -f \'{SSH_ROOT}/{user}\'", module)
+ with open(f"{SSH_ROOT}/{user}", "r") as privkey:
+ new_users_dict[user] = {"sshvpn": privkey.read()}
+
+ # Overwrite existing authorized_keys file
+ users_pubkeys = [i for i in os.listdir(SSH_ROOT) if i.endswith(".pub")]
+ with open(AUTHORIZED_KEYS, "w") as f:
+ for user_pubkey in users_pubkeys:
+ user_pubkey_file = os.path.join(SSH_ROOT, user_pubkey)
+ with open(user_pubkey_file, "r") as pkey:
+ f.write(pkey.read())
+
+ # kill running sessions
+ exec_shell(f"pkill -u sshvpn &>/dev/null", module)
+
+ return new_users_dict
+
+def run_module():
+ module = AnsibleModule(
+ argument_spec=dict(
+ users = dict(type='list', required=True)
+ ),
+ supports_check_mode=True
+ )
+ users = module.params["users"]
+ update_password = {}
+
+ for user in users:
+ if 'regen' in user.keys() and user['regen']:
+ update_password[user['user']] = True
+ else:
+ update_password[user['user']] = False
+
+ new_users_dict = sshvpn_update_users(update_password, module)
+ module.exit_json(changed=True, msg=new_users_dict)
+
+def main():
+ run_module()
+
+if __name__ == "__main__":
+ main()
diff --git a/library/user_expiration.py b/library/user_expiration.py
new file mode 100644
index 00000000..2bf88e8f
--- /dev/null
+++ b/library/user_expiration.py
@@ -0,0 +1,48 @@
+#!/usr/local/bin/python3
+
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+from ansible.module_utils.basic import AnsibleModule
+import json, shlex, os
+from datetime import datetime
+
+EXPIRE_USER_JSON_PATH = "/var/reactance/.user_expiration.json"
+
+def run_module():
+ changed = False
+ module = AnsibleModule(
+ argument_spec=dict(
+ users = dict(type='list', required=True)
+ ),
+ supports_check_mode=True
+ )
+
+ users = module.params["users"]
+
+ user_expire_dict = {}
+ if os.path.exists(EXPIRE_USER_JSON_PATH):
+ with open(EXPIRE_USER_JSON_PATH, 'r') as f:
+ user_expire_dict = json.loads(f.read())
+
+ for user in users:
+ if 'expire' in user.keys():
+ changed = True
+ time = str(datetime(*[int(i) for i in user['expire'].split('-')]).timestamp())
+ if time not in user_expire_dict.keys():
+ user_expire_entry = set() # To make sure we don't have duplicates
+ else:
+ user_expire_entry = set(user_expire_dict[time])
+ user_expire_entry.add(user['user'])
+ user_expire_dict[time] = list(user_expire_entry) # JSON can't work with sets
+
+ with open(EXPIRE_USER_JSON_PATH, 'w') as f:
+ f.write(json.dumps(user_expire_dict, indent=1))
+
+ module.exit_json(changed=changed)
+
+def main():
+ run_module()
+
+if __name__ == "__main__":
+ main()
diff --git a/library/xray.py b/library/xray.py
new file mode 100644
index 00000000..ff2d0357
--- /dev/null
+++ b/library/xray.py
@@ -0,0 +1,104 @@
+#!/usr/local/bin/python3
+
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+from ansible.module_utils.basic import AnsibleModule
+import json, shlex, os
+from datetime import datetime
+
+XRAY_CONFIG_PATH = "/var/reactance/xray/etc/config.json"
+VISION_PUBKEY_FILE = "/var/reactance/xray/public_key"
+
+def exec_shell(cmd, module):
+ rc, stdout, stderr = module.run_command(cmd, environ_update={'TERM': 'dumb'})
+ if rc != 0:
+ module.fail_json(stderr)
+ return stdout.rstrip()
+def xray_gen_password(protocol, module):
+ login_method = {'vmess': 'id', 'vless': 'id', 'trojan': 'password'}[protocol]
+ return login_method, exec_shell({'trojan': 'openssl rand -hex 32', 'vless': '/var/reactance/xray/bin/xray uuid', 'vmess': '/var/reactance/xray/bin/xray uuid'}[protocol], module)
+
+def xray_get_users(protocol):
+ with open(XRAY_CONFIG_PATH, "r") as f:
+ xray_config_dict = json.loads(f.read())
+ inbounds = xray_config_dict["inbounds"]
+ protos_users = {}
+ for inbound in inbounds:
+ if inbound['protocol'] == protocol:
+ protos_users[inbound['protocol']] = [j['email'] for j in inbound['settings']['clients']]
+ return protos_users, xray_config_dict
+
+def xray_user_control(update_password, protocol, address, service_port, public_key, module):
+ previous_users, xray_config_dict = xray_get_users(protocol)
+ user_pass_list = []
+ all_users_dict = {}
+ new_users_dict = {}
+
+ # search through all inbound protocools
+ for i, inbound in enumerate(xray_config_dict['inbounds']):
+ if inbound['protocol'] == protocol:
+ previous_users_dict = inbound['settings']['clients']
+ previous_users_list = [i['email'] for i in previous_users_dict]
+ selected_users = set(update_password.keys())
+
+ # keep old passwords
+ for user in previous_users_dict:
+ if user['email'] in selected_users and not update_password[user['email']]:
+ user_pass_list.append(user)
+ all_users_dict[user['email']] = user[{'vmess': 'id', 'vless': 'id', 'trojan': 'password'}[protocol]]
+
+ # generate new passwords
+ for user in selected_users:
+ if user not in all_users_dict.keys():
+ login_method, xray_password = xray_gen_password(protocol, module)
+ new_user = { 'email': user, login_method: xray_password }
+ xray_url = f"{protocol}://{ xray_password }@{ address }:{ service_port }?security=reality&sni=behindthename.com&fp=chrome&pbk={ public_key }"
+ all_users_dict[user] = {protocol: xray_password}
+ if protocol in ["vless", "vmess"]:
+ new_user["flow"] = "xtls-rprx-vision"
+ xray_url += "&flow=xtls-rprx-vision"
+ xray_url += f"#{protocol}_{user}"
+ user_pass_list.append(new_user)
+ new_users_dict[user] = {protocol: xray_url}
+
+ xray_config_dict['inbounds'][i]['settings']['clients'] = user_pass_list
+
+ with open(XRAY_CONFIG_PATH, "w") as f:
+ f.write(json.dumps(xray_config_dict, indent=1))
+
+ return new_users_dict
+
+def run_module():
+ module = AnsibleModule(
+ argument_spec=dict(
+ users = dict(type='list', required=True),
+ protocol = dict(type='str', required=True),
+ address = dict(type='str', required=True),
+ service_port = dict(type='int', required=True),
+ public_key = dict(type='str', required=True)
+ ),
+ supports_check_mode=True
+ )
+
+ users = module.params["users"]
+ protocol = module.params["protocol"]
+ address = module.params["address"]
+ service_port = module.params["service_port"]
+ public_key = module.params["public_key"]
+ update_password = {}
+
+ for user in users:
+ if 'regen' in user.keys() and user['regen']:
+ update_password[user['user']] = True
+ else:
+ update_password[user['user']] = False
+
+ new_users_dict = xray_user_control(update_password, protocol, address, service_port, public_key, module)
+ module.exit_json(changed=True, msg=new_users_dict)
+
+def main():
+ run_module()
+
+if __name__ == "__main__":
+ main()
diff --git a/reactance.yaml b/reactance.yaml
new file mode 100644
index 00000000..58589270
--- /dev/null
+++ b/reactance.yaml
@@ -0,0 +1,80 @@
+---
+- name: initial
+ hosts: all
+ gather_facts: false
+ tasks:
+ - name: "set facts"
+ ansible.builtin.set_fact:
+ ansible_host: "{{ host }}"
+ ansible_port: "{{ port|default(22) }}"
+ ansible_user: "root"
+ ansible_ssh_private_key_file: "{{ private_key_file | default('.ssh_private_key') }}"
+ changed_when: false
+ throttle: 1
+
+ - name: bootstrap
+ ansible.builtin.raw: which python3 || pkg_add python3 || apk add python3 || apt install python3
+ when: bootstrap|default(true)
+ changed_when: false
+ throttle: 1
+
+ - name: gather facts
+ ansible.builtin.setup:
+ throttle: 1
+ tags:
+ - base
+ - web
+ - dns
+ - xray
+ - ocserv
+ - sshvpn
+ - hysteria
+
+- name: "openbsd : apply base role to all"
+ hosts: all
+ roles:
+ - role: base
+ tags:
+ - base
+
+- name: "openbsd : setup ocserv"
+ hosts: ocserv, all_vpns
+ roles:
+ - role: ocserv
+ tags:
+ - ocserv
+
+- name: "openbsd : setup xray vpn"
+ hosts: vless, vmess, trojan, all_vpns
+ roles:
+ - role: xray
+ tags:
+ - xray
+
+- name: "openbsd : setup sshvpn"
+ hosts: sshvpn, all_vpns
+ roles:
+ - role: sshvpn
+ tags:
+ - sshvpn
+
+- name: "openbsd : setup sshvpn"
+ hosts: hysteria, all_vpns
+ roles:
+ - role: hysteria
+ tags:
+ - hysteria
+
+- name: "openbsd : setup dns resolver with adblock and web interface"
+ hosts: all
+ roles:
+ - role: dns
+ tags:
+ - dns
+
+- name: "openbsd : setup web interface for vpn clients"
+ hosts: all
+ roles:
+ - role: web
+ tags:
+ - web
diff --git a/roles/base/handlers/main.yaml b/roles/base/handlers/main.yaml
new file mode 100644
index 00000000..f229852c
--- /dev/null
+++ b/roles/base/handlers/main.yaml
@@ -0,0 +1,11 @@
+---
+- name: restart_notification
+ ansible.builtin.debug:
+ msg: "Restart your box for sysctl.conf and syslog.conf to take effect"
+
+- name: syslogd_restart
+ ansible.builtin.service:
+ name: syslogd
+ state: restarted
+ enabled: true
+
diff --git a/roles/base/tasks/add_pubkeys.yaml b/roles/base/tasks/add_pubkeys.yaml
new file mode 100644
index 00000000..9f0e7c59
--- /dev/null
+++ b/roles/base/tasks/add_pubkeys.yaml
@@ -0,0 +1,9 @@
+---
+- name: "add pubkeys to root user"
+ ansible.builtin.lineinfile:
+ path: /root/.ssh/authorized_keys
+ create: true
+ line: "{{ item | trim }}"
+ search_string: "{{ (item | trim | split(' '))[2:-1]|join(' ') }}"
+ when: "root_keys is defined"
+ loop: "{{ root_keys }}"
diff --git a/roles/base/tasks/base_setup.yaml b/roles/base/tasks/base_setup.yaml
new file mode 100644
index 00000000..f1ed062a
--- /dev/null
+++ b/roles/base/tasks/base_setup.yaml
@@ -0,0 +1,58 @@
+---
+- name: "Create vpns user"
+ ansible.builtin.user:
+ name: _vpn
+ create_home: no
+ comment: Project VPN user
+ state: present
+ shell: /sbin/nologin
+ notify: restart_notification
+
+- name: "Create root directory of vpn services"
+ ansible.builtin.file:
+ path: /var/reactance/
+ state: directory
+ owner: _vpn
+ group: _vpn
+ mode: 0755
+
+- name: "templating out ip forwarding rules in sysctl.conf"
+ ansible.builtin.blockinfile:
+ path: /etc/sysctl.conf
+ create: true
+ backup: true
+ marker: "### REACTANCE - IP Forwarding - {mark} ###"
+ insertafter: "EOF"
+ block: |
+ net.inet.ip.forwarding=1
+ net.inet6.ip6.forwarding=1
+
+- name: "templating out sysctl.conf"
+ ansible.builtin.template:
+ src: "{{ item.src }}"
+ dest: "{{ item.dest }}"
+ mode: '0644'
+ notify: syslogd_restart
+ loop:
+ - src: syslog.conf.j2
+ dest: /etc/syslog.conf
+ - src: newsyslog.conf.j2
+ dest: /etc/newsyslog.conf
+
+
+# openbsd_pkg cant be run parallely otherwise there could be package locks and pipeline would fail
+- name: "install necessary utils"
+ community.general.openbsd_pkg:
+ name:
+ - unzip--
+ - curl--
+ - rsync--
+ - jq--
+ state: present
+ when: inventory_hostname in (groups['vless']|default([]) + groups['vmess']|default([]) + groups['trojan']|default([]) + groups['all_vpns']|default([]))
+
+- name: "tune unbound performance"
+ community.general.openbsd_pkg:
+ name: ripgrep
+ state: present
+ when: not disable_dns|default(False)
diff --git a/roles/base/tasks/main.yaml b/roles/base/tasks/main.yaml
new file mode 100644
index 00000000..5ab9bf45
--- /dev/null
+++ b/roles/base/tasks/main.yaml
@@ -0,0 +1,12 @@
+---
+- name: "add root pubkeys"
+ ansible.builtin.include_tasks: add_pubkeys.yaml
+
+- name: "run pre execution checks"
+ ansible.builtin.include_tasks: pre_execution_checks.yaml
+
+- name: "run base setup"
+ ansible.builtin.include_tasks: base_setup.yaml
+
+- name: "template out user expiration script and cronjob"
+ ansible.builtin.include_tasks: setup_user_expiration.yaml
diff --git a/roles/base/tasks/pre_execution_checks.yaml b/roles/base/tasks/pre_execution_checks.yaml
new file mode 100644
index 00000000..f6a969a7
--- /dev/null
+++ b/roles/base/tasks/pre_execution_checks.yaml
@@ -0,0 +1,11 @@
+---
+- name: "pre execution test : check os"
+ ansible.builtin.fail:
+ msg: "Reactance can only be ran on OpenBSD"
+ when: ansible_facts["os_family"]|lower != "openbsd"
+
+- name: "pre execution test : looking for invalid usernames"
+ ansible.builtin.fail:
+ msg: "Username cannot be 'server' or 'ca'"
+ when: item.user in ["server", "ca"]
+ loop: "{{ all_users|default([]) + ocserv_users|default([]) + vless_users|default([]) + vmess_users|default([]) + trojan_users|default([]) + sshvpn_users|default([]) + hysteria_users|default([]) }}"
diff --git a/roles/base/tasks/setup_user_expiration.yaml b/roles/base/tasks/setup_user_expiration.yaml
new file mode 100644
index 00000000..977977f9
--- /dev/null
+++ b/roles/base/tasks/setup_user_expiration.yaml
@@ -0,0 +1,19 @@
+---
+- name: "Template out user expiration script"
+ ansible.builtin.template:
+ src: user_expiration_control.py.j2
+ dest: /root/.user_expiration_control.py
+ mode: "0400"
+ owner: root
+ group: nogroup
+
+- name: "write user expiration information to file"
+ user_expiration:
+ users: "{{ all_users|default([]) + ocserv_users|default([]) + vless_users|default([]) + vmess_users|default([]) + trojan_users|default([]) + sshvpn_users|default([]) + hysteria_users|default([]) }}"
+
+- name: "setup daily user expiration cronjob"
+ ansible.builtin.cron:
+ name: "daily run user expiration script"
+ user: root
+ job: "python3 /root/.user_expiration_control.py"
+ special_time: daily
diff --git a/roles/base/templates/newsyslog.conf.j2 b/roles/base/templates/newsyslog.conf.j2
new file mode 100644
index 00000000..a8f348d6
--- /dev/null
+++ b/roles/base/templates/newsyslog.conf.j2
@@ -0,0 +1,31 @@
+# $OpenBSD: newsyslog.conf - fsa generated
+#
+# NOTE: SIGHUP has to be sent to syslogd for services whose logs are being written by syslogd
+#
+# logfile_name owner:group mode count size when flags
+/var/cron/log root:wheel 600 3 10 * Z
+/var/log/authlog root:wheel 640 7 * 168 Z
+/var/log/daemon 640 5 300 * Z
+/var/log/lpd-errs 640 7 10 * Z
+/var/log/maillog 640 7 * 24 Z
+/var/log/messages 644 5 300 * Z
+/var/log/secure 600 7 * 168 Z
+/var/log/wtmp 644 7 * $M1D4 B ""
+/var/log/xferlog 640 7 250 * Z
+/var/log/pflog 600 3 250 * ZB "pkill -HUP -u root -U root -t - -x pflogd"
+/var/www/logs/access.log 644 4 * $W0 Z "pkill -USR1 -u root -U root -x httpd"
+/var/www/logs/error.log 644 7 250 * Z "pkill -USR1 -u root -U root -x httpd"
+{# for services whose logs are written by syslogd, we need to restart syslogd instead #}
+{% if inventory_hostname in (groups['xray']|default([])) + (groups['all_vpns']|default([])) %}
+/var/reactance/xray/logs/xray-access.log 640 4 512 * Z "pkill -HUP -u root -U root -x xray"
+/var/reactance/xray/logs/xray-error.log 640 4 512 * Z "pkill -HUP -u root -U root -x xray"
+{% endif %}
+{% if not disable_dns|default(true) %}
+/var/log/unbound.log 600 5 * $W0 Z "pkill -HUP -u root -U root -x syslogd"
+{% endif %}
+{% if inventory_hostname in (groups['ocserv']|default([])) + (groups['all_vpns']|default([])) %}
+/var/log/ocserv.log 600 5 128000 * Z "pkill -HUP -u root -U root -x syslogd"
+{% endif %}
+{% if inventory_hostname in (groups['trojan']|default([])) + (groups['vless']|default([])) + (groups['vmess']|default([])) + (groups['all_vpns']|default([])) %}
+/var/log/relayd.log 600 5 512 * Z "pkill -HUP -u root -U root -x syslogd"
+{% endif %}
diff --git a/roles/base/templates/syslog.conf.j2 b/roles/base/templates/syslog.conf.j2
new file mode 100644
index 00000000..261b6d88
--- /dev/null
+++ b/roles/base/templates/syslog.conf.j2
@@ -0,0 +1,21 @@
+# $OpenBSD: syslog.conf,v 1.20 2016/12/27 13:38:14 jca Exp $
+
+*.notice;auth,authpriv,cron,ftp,kern,lpr,mail,user.none /var/log/messages
+kern.debug;syslog,user.info /var/log/messages
+auth.info /var/log/authlog
+authpriv.debug /var/log/secure
+cron.info /var/cron/log
+{% if inventory_hostname in (groups['ocserv']|default([])) + (groups['all_vpns']|default([])) %}
+!!ocserv
+daemon.* /var/log/ocserv.log
+!*
+{% endif %}
+{% if inventory_hostname in (groups['hysteria']|default([])) + (groups['all_vpns']|default([])) %}
+!!hysteria
+daemon.* /var/log/hysteria.log
+!*
+{% endif %}
+daemon.info /var/log/daemon
+ftp.info /var/log/xferlog
+lpr.debug /var/log/lpd-errs
+mail.info /var/log/maillog
diff --git a/roles/base/templates/user_expiration_control.py.j2 b/roles/base/templates/user_expiration_control.py.j2
new file mode 100644
index 00000000..36551617
--- /dev/null
+++ b/roles/base/templates/user_expiration_control.py.j2
@@ -0,0 +1,99 @@
+#!/usr/local/bin/python3
+
+import json, os, subprocess, shutil
+from datetime import datetime
+
+EXPIRE_USER_JSON_PATH = "/var/reactance/.user_expiration.json"
+EXPIRE_WEB_JSON_PATH = "/var/reactance/.web_expiration.json"
+OCSERV_CONFIG_PATH = "/var/reactance/ocserv/etc/ocserv.passwd"
+HYSTERIA_CONFIG_FILE = "/var/reactance/hysteria/etc/config.json"
+XRAY_CONFIG_PATH = "/var/reactance/xray/etc/config.json"
+SSH_ROOT = "/var/reactance/sshvpn/.ssh"
+AUTHORIZED_KEYS = os.path.join(SSH_ROOT, "authorized_keys")
+
+def ocserv_get_users():
+ ocserv_config_dict = {}
+ if os.path.isfile(OCSERV_CONFIG_PATH):
+ with open(OCSERV_CONFIG_PATH, "r") as f:
+ ocserv_content = f.read()
+ ocserv_config_dict = dict(map(lambda x: x.split(':*:'), list(filter(lambda x: x != '', ocserv_content.split("\n")))))
+ return ocserv_config_dict
+
+def xray_get_users():
+ with open(XRAY_CONFIG_PATH, "r") as f:
+ xray_config_dict = json.loads(f.read())
+ return xray_config_dict
+
+def hysteria_get_users():
+ with open(HYSTERIA_CONFIG_FILE, "r") as f:
+ hysteria_config_dict = json.loads(f.read())
+ return hysteria_config_dict
+
+def sshvpn_get_users():
+ previous_users = [".".join(i.split('.')[:-1]) for i in os.listdir(SSH_ROOT) if i.endswith(".pub")]
+ return previous_users
+
+def ocserv_user_purge(users_to_remove):
+ for user in users_to_remove:
+ subprocess.run(f"ocpasswd -d {user} -c {OCSERV_CONFIG_PATH}", shell=True)
+
+def xray_user_purge(users_to_remove):
+ xray_config_dict = xray_get_users()
+ for i, inbound in enumerate(xray_config_dict['inbounds']):
+ previous_users_list = inbound['settings']['clients']
+ new_users_list = previous_users_list.copy()
+ for user in previous_users_list:
+ if user['email'] in users_to_remove:
+ new_users_list.remove(user)
+ xray_config_dict['inbounds'][i]['settings']['clients'] = new_users_list
+ with open(XRAY_CONFIG_PATH, "w") as f:
+ f.write(json.dumps(xray_config_dict, indent=1))
+
+def hysteria_user_purge(users_to_remove):
+ hysteria_config_dict = hysteria_get_users()
+ previous_users_dict = hysteria_config_dict["auth"]["userpass"]
+ new_users_dict = {}
+ for user in previous_users_dict.keys():
+ if user not in users_to_remove:
+ new_users_dict[user] = previous_users_dict[user]
+ hysteria_config_dict["auth"]["userpass"] = new_users_dict
+ with open(HYSTERIA_CONFIG_FILE, "w") as f:
+ f.write(json.dumps(hysteria_config_dict, indent=1))
+
+def sshvpn_user_purge(users_to_remove):
+ previous_users_list = sshvpn_get_users()
+ for user in previous_users_list:
+ if user in users_to_remove:
+ os.remove(f"{SSH_ROOT}/{user}.pub")
+ os.remove(f"{SSH_ROOT}/{user}")
+
+ # Overwrite existing authorized_key file
+ users_pubkey_files = [os.path.join(SSH_ROOT, i) for i in os.listdir(SSH_ROOT) if i.endswith(".pub")]
+ with open(AUTHORIZED_KEYS, "w") as f:
+ for pubkey_file in users_pubkey_files:
+ with open(pubkey_file, "r") as pkey:
+ f.write(pkey.read())
+
+def main():
+ current_unix_time = datetime.now().timestamp()
+
+ with open(EXPIRE_USER_JSON_PATH, "r") as f:
+ expire_user_dict = json.loads(f.read())
+ for exp in expire_user_dict.keys():
+ if float(exp) <= current_unix_time:
+ users = expire_user_dict[exp]
+
+ xray_user_purge(users)
+ sshvpn_user_purge(users)
+ ocserv_user_purge(users)
+ hysteria_user_purge(users)
+ with open(EXPIRE_USER_JSON_PATH, "w") as f:
+ f.write(json.dumps(expire_user_dict, indent=1))
+
+ with open(EXPIRE_WEB_JSON_PATH, "r") as f:
+ expire_web_dict = json.loads(f.read())
+ for exp in expire_web_dict.keys():
+ shutil.rmtree(f"/var/www/reactance/{exp}")
+
+if __name__ == "__main__":
+ main()
diff --git a/roles/dns/handlers/main.yaml b/roles/dns/handlers/main.yaml
new file mode 100644
index 00000000..cb476298
--- /dev/null
+++ b/roles/dns/handlers/main.yaml
@@ -0,0 +1,6 @@
+---
+- name: restart_unbound
+ ansible.builtin.service:
+ name: unbound
+ state: restarted
+ enabled: true
diff --git a/roles/dns/tasks/check_dns_setup.yaml b/roles/dns/tasks/check_dns_setup.yaml
new file mode 100644
index 00000000..b1fdb500
--- /dev/null
+++ b/roles/dns/tasks/check_dns_setup.yaml
@@ -0,0 +1,13 @@
+---
+- name: "check if adblock.rpz exists"
+ ansible.builtin.stat:
+ path: /var/unbound/db/adblock.rpz
+ register: adblock_rpz
+
+- name: "setup dns resolver (unbound)"
+ ansible.builtin.include_tasks: setup_unbound.yaml
+ when: not adblock_rpz.stat.exists
+
+- name: "setup adblocking"
+ ansible.builtin.include_tasks: setup_adblock.yaml
+ when: not adblock_rpz.stat.exists
diff --git a/roles/dns/tasks/main.yaml b/roles/dns/tasks/main.yaml
new file mode 100644
index 00000000..27978e73
--- /dev/null
+++ b/roles/dns/tasks/main.yaml
@@ -0,0 +1,3 @@
+---
+- name: "check dns resolver setup"
+ ansible.builtin.include_tasks: check_dns_setup.yaml
diff --git a/roles/dns/tasks/setup_adblock.yaml b/roles/dns/tasks/setup_adblock.yaml
new file mode 100644
index 00000000..edf09a7e
--- /dev/null
+++ b/roles/dns/tasks/setup_adblock.yaml
@@ -0,0 +1,66 @@
+---
+- name: "activate unbound control"
+ ansible.builtin.command: unbound-control-setup
+ changed_when: false
+
+- name: "fetch unbound filter script"
+ ansible.builtin.get_url:
+ url: https://geoghegan.ca/pub/unbound-adblock/latest/unbound-adblock.sh
+ dest: /usr/local/bin/unbound-adblock
+ group: bin
+ mode: 755
+ register: adblock_changed
+ # DL fails from time to time, so we retry a couple times
+ until: adblock_changed.state == "file"
+ retries: 10
+ delay: 2
+ ignore_errors: yes
+ notify:
+ - restart_unbound
+
+- name: "create adblock user"
+ ansible.builtin.user:
+ name: _adblock
+ shell: nologin
+ home: /var/empty
+ create_home: false
+
+- name: "add _adblock doas privileges"
+ ansible.builtin.blockinfile:
+ path: /etc/doas.conf
+ create: true
+ backup: true
+ marker: "### REACTANCE - Unbound Adblock - {mark} ###"
+ insertafter: "EOF"
+ block: |
+ permit nopass root
+ permit nopass _adblock cmd /usr/sbin/unbound-control args -q status
+ permit nopass _adblock cmd /usr/sbin/unbound-control args -q flush_zone unbound-adblock
+ permit nopass _adblock cmd /usr/sbin/unbound-control args -q auth_zone_reload unbound-adblock
+
+- name: "create binaries for adblock"
+ ansible.builtin.command: "{{ item }}"
+ loop:
+ - install -m 644 -o _adblock -g wheel /dev/null /var/unbound/db/adblock.rpz
+ - install -d -o root -g wheel -m 755 /var/log/unbound-adblock
+ - install -o _adblock -g wheel -m 640 /dev/null /var/log/unbound-adblock/unbound-adblock.log
+ - install -o _adblock -g wheel -m 640 /dev/null /var/log/unbound-adblock/unbound-adblock.log.0.gz
+ changed_when: false
+ notify: restart_unbound
+
+- name: "restarting adblock (as separate task otherwise cant create rule)"
+ ansible.builtin.service:
+ name: unbound
+ state: restarted
+ enabled: true
+
+- name: "create first ruleset"
+ ansible.builtin.shell: "cd /var/unbound/db && doas -u _adblock /usr/local/bin/unbound-adblock -O openbsd"
+ changed_when: false
+
+- name: "setup daily cronjob"
+ ansible.builtin.cron:
+ name: "update dns blocklist"
+ user: root
+ job: "cd /var/unbound/db && doas -u _adblock /usr/local/bin/unbound-adblock -O openbsd 1> /dev/null"
+ special_time: daily
diff --git a/roles/dns/tasks/setup_unbound.yaml b/roles/dns/tasks/setup_unbound.yaml
new file mode 100644
index 00000000..ec6f123e
--- /dev/null
+++ b/roles/dns/tasks/setup_unbound.yaml
@@ -0,0 +1,44 @@
+---
+- name: "template out configs"
+ ansible.builtin.template:
+ src: "{{ item.src }}"
+ dest: "{{ item.dest }}"
+ loop:
+ - src: unbound.conf.j2
+ dest: /var/unbound/etc/unbound.conf
+ - src: resolv.conf.j2
+ dest: /etc/resolv.conf.j2
+ - src: hostname.vether0.j2
+ dest: /etc/hostname.vether0
+ # we need a separate virtual network interface for binding the dns resolver to since ocserv doesn't creates tunnel interface for each separate connected client, it does not create a primary interface
+
+ # unbound will fail if there's nonexisting interface in config
+- name: "create vether0 interface"
+ ansible.builtin.shell: "sh /etc/netstart vether0"
+ when: inventory_hostname in (groups['ocserv']|default([])) + (groups['all_vpns']|default([]))
+
+- name: "setup log file"
+ ansible.builtin.file:
+ path: /var/log/unbound.log
+ state: touch
+ mode: "0600"
+
+ # pure convenience
+- name: "obsd : dns : symlink it to /etc"
+ ansible.builtin.file:
+ src: /var/unbound/etc/unbound.conf
+ dest: /etc/unbound.conf
+ state: link
+
+- name: "obsd : dns : not exists. generate..."
+ ansible.builtin.command: /usr/sbin/unbound-anchor -a /var/unbound/db/root.key
+ args:
+ creates: /var/unbound/db/root.key
+ failed_when: false
+
+- name: "obsd : dns : get root hints"
+ ansible.builtin.command: ftp -o /var/unbound/etc/root.hints https://www.internic.net/domain/named.root
+ args:
+ creates: /var/unbound/db/root.key
+ notify:
+ - restart_unbound
diff --git a/roles/dns/templates/hostname.vether0.j2 b/roles/dns/templates/hostname.vether0.j2
new file mode 100644
index 00000000..e7ac2c71
--- /dev/null
+++ b/roles/dns/templates/hostname.vether0.j2
@@ -0,0 +1 @@
+inet {{ (ocserv_network|default("172.16.16.0/24"))|ansible.utils.nthhost(2) }}/{{ (ocserv_network|default("172.16.16.0/24")|ipaddr('prefix')) }}
diff --git a/roles/dns/templates/resolv.conf.j2 b/roles/dns/templates/resolv.conf.j2
new file mode 100644
index 00000000..7a0de7f4
--- /dev/null
+++ b/roles/dns/templates/resolv.conf.j2
@@ -0,0 +1,6 @@
+{% if not disable_dns|default(false) %}
+nameserver 127.0.0.1
+{% endif %}
+nameserver 9.9.9.9
+nameserver 149.112.112.112
+lookup file bind
diff --git a/roles/dns/templates/unbound.conf.j2 b/roles/dns/templates/unbound.conf.j2
new file mode 100644
index 00000000..b02df37e
--- /dev/null
+++ b/roles/dns/templates/unbound.conf.j2
@@ -0,0 +1,41 @@
+server:
+ interface: 127.0.0.1
+{% if inventory_hostname in (groups['ocserv']|default([])) + (groups['all_vpns']|default([])) %}
+ interface: {{ (ocserv_network|default("172.16.16.0/24"))|ansible.utils.nthhost(2) }}
+{% endif %}
+ do-ip6: no
+
+ access-control: 0.0.0.0/0 refuse
+ access-control: 127.0.0.0/8 allow
+{% if inventory_hostname in (groups['ocserv']|default([])) + (groups['all_vpns']|default([])) %}
+ access-control: {{ ocserv_network|default("172.16.16.0/24") }} allow
+{% endif %}
+
+ hide-identity: yes
+ hide-version: yes
+
+ auto-trust-anchor-file: "/var/unbound/db/root.key"
+ val-log-level: 2
+ qname-minimisation: yes
+
+ aggressive-nsec: yes
+ verbosity: 1
+ log-queries: no
+ use-caps-for-id: yes
+
+ cache-min-ttl: 3600
+ cache-max-ttl: 86400
+ prefetch: yes
+ unwanted-reply-threshold: 10000
+ do-not-query-localhost: yes
+ val-clean-additional: yes
+ module-config: "respip validator iterator"
+
+remote-control:
+ control-enable: yes
+
+rpz:
+ name: "unbound-adblock"
+ zonefile: "/var/unbound/db/adblock.rpz"
+ rpz-log: no
+ rpz-log-name: "unbound-adblock"
diff --git a/roles/hysteria/handlers/main.yaml b/roles/hysteria/handlers/main.yaml
new file mode 100644
index 00000000..d4227c5e
--- /dev/null
+++ b/roles/hysteria/handlers/main.yaml
@@ -0,0 +1,11 @@
+---
+- name: restart_hysteria
+ ansible.builtin.service:
+ name: hysteria
+ state: restarted
+ enabled: true
+
+- name: remove_hysteria_tempdir
+ ansible.builtin.file:
+ path: "{{ hysteria_tempdir.path }}"
+ state: absent
diff --git a/roles/hysteria/tasks/check_hysteria_exists.yaml b/roles/hysteria/tasks/check_hysteria_exists.yaml
new file mode 100644
index 00000000..3c8f85c7
--- /dev/null
+++ b/roles/hysteria/tasks/check_hysteria_exists.yaml
@@ -0,0 +1,21 @@
+---
+- name: "Check if hysteria is already installed"
+ ansible.builtin.stat:
+ path: /var/reactance/hysteria
+ register: hysteria_directory
+
+- name: "Check if hysteria is configured"
+ ansible.builtin.stat:
+ path: /var/reactance/hysteria/etc/config.json
+ register: hysteria_config
+
+- name: "Install hysteria if directory doesn't exist"
+ ansible.builtin.include_tasks: install_hysteria.yaml
+ when: hysteria_directory.stat.exists == false
+
+- name: "Configure hysteria"
+ ansible.builtin.include_tasks: configure_hysteria.yaml
+ when: hysteria_config.stat.exists == false
+
+- name: "Create hysteria users"
+ ansible.builtin.include_tasks: create_users_hysteria.yaml
diff --git a/roles/hysteria/tasks/configure_hysteria.yaml b/roles/hysteria/tasks/configure_hysteria.yaml
new file mode 100644
index 00000000..3495f336
--- /dev/null
+++ b/roles/hysteria/tasks/configure_hysteria.yaml
@@ -0,0 +1,51 @@
+---
+# check if salamander password file exists
+- name: "check if obfuscation password file exists "
+ ansible.builtin.stat:
+ path: "/var/reactance/hysteria/salamander_password"
+ register: salamander_password_file
+
+# generate salamander password
+- name: "generate salamander password for obfuscation "
+ ansible.builtin.shell: "openssl rand -hex 32"
+ register: random_string
+ when: salamander_password_file.stat.exists != true
+
+# retrieve password from file, if exists
+- name: "use previous password, if it exists"
+ ansible.builtin.set_fact:
+ salamander_password: "{{ lookup('file', '/var/reactance/hysteria/salamander_password') }}"
+ when: salamander_password_file.stat.exists
+
+- name: "set salamander password as var"
+ ansible.builtin.set_fact:
+ salamander_password: "{{ random_string.stdout }}"
+ when: salamander_password_file.stat.exists != true
+
+- name: "write obfuscation password to file"
+ ansible.builtin.copy:
+ content: "{{ salamander_password }}"
+ dest: "/var/reactance/hysteria/salamander_password"
+
+ when: salamander_password_file.stat.exists != true
+
+- name: "template out configs"
+ ansible.builtin.template:
+ src: "{{ item.src }}"
+ dest: "{{ item.dest }}"
+ loop:
+ - src: config.json.j2
+ dest: "/var/reactance/hysteria/etc/config.json"
+ - src: ca.tmpl.j2
+ dest: /var/reactance/hysteria/certs/ca.tmpl
+ - src: server.tmpl.j2
+ dest: /var/reactance/hysteria/certs/server.tmpl
+
+# generate ca, server certs, crl file
+- name: "generate ca, server certs"
+ ansible.builtin.shell: "{{ item }}"
+ loop:
+ - "certtool --generate-privkey --outfile /var/reactance/hysteria/certs/ca-key.pem"
+R - "certtool --generate-self-signed --load-privkey /var/reactance/hysteria/certs/ca-key.pem --template /var/reactance/hysteria/certs/ca.tmpl --outfile /var/reactance/hysteria/certs/ca-cert.pem"
+ - "certtool --generate-privkey --outfile /var/reactance/hysteria/certs/server-key.pem"
+ - "certtool --generate-certificate --load-privkey /var/reactance/hysteria/certs/server-key.pem --load-ca-certificate /var/reactance/hysteria/certs/ca-cert.pem --load-ca-privkey /var/reactance/hysteria/certs/ca-key.pem --template /var/reactance/hysteria/certs/server.tmpl --outfile /var/reactance/hysteria/certs/server-cert.pem"
diff --git a/roles/hysteria/tasks/create_users_hysteria.yaml b/roles/hysteria/tasks/create_users_hysteria.yaml
new file mode 100644
index 00000000..a6881413
--- /dev/null
+++ b/roles/hysteria/tasks/create_users_hysteria.yaml
@@ -0,0 +1,13 @@
+---
+- name: "hysteria user management"
+ hysteria:
+ users: "{{ all_users|default([]) + hysteria_users|default([]) }}"
+ register: hysteria_user_pass_dict
+ no_log: true
+ notify:
+ - restart_hysteria
+
+- name: "add hysteria user password pair to dict"
+ set_fact:
+ user_pass_dict: "{{ user_pass_dict|default({}) | combine(hysteria_user_pass_dict['msg'], recursive=true, list_merge='append') }}"
+ no_log: true
diff --git a/roles/hysteria/tasks/install_hysteria.yaml b/roles/hysteria/tasks/install_hysteria.yaml
new file mode 100644
index 00000000..59dcd7b9
--- /dev/null
+++ b/roles/hysteria/tasks/install_hysteria.yaml
@@ -0,0 +1,64 @@
+---
+- name: "create directory"
+ ansible.builtin.file:
+ path: "{{ item }}"
+ state: directory
+ owner: _vpn
+ group: _vpn
+ mode: 0700
+ loop:
+ - "/var/reactance/hysteria"
+ - "/var/reactance/hysteria/bin"
+ - "/var/reactance/hysteria/etc"
+ - "/var/reactance/hysteria/certs"
+
+- name: "setup log file"
+ ansible.builtin.file:
+ path: /var/log/hysteria.log
+ state: touch
+ mode: "0600"
+ changed_when: false
+
+# this can remain here while hysteria is not being used
+- name: "install necessary utils"
+ community.general.openbsd_pkg:
+ name:
+ - git--
+ - go--
+ - rsync--
+ state: present
+
+- name: "create temporary directory"
+ ansible.builtin.tempfile:
+ state: directory
+ suffix: temp
+ register: hysteria_tempdir
+ notify:
+ - remove_hysteria_tempdir
+
+- name: "clone hysteria"
+ ansible.builtin.shell: "git clone https://github.com/apernet/hysteria.git"
+ args:
+ chdir: "{{ hysteria_tempdir.path }}"
+
+- name: "build hysteria"
+ ansible.builtin.shell: "python3 hyperbole.py build"
+ args:
+ chdir: "{{ hysteria_tempdir.path }}/hysteria"
+
+- name: "install hysteria"
+ ansible.builtin.shell: "{{ item }}"
+ loop:
+ - "install -m 750 -o _vpn -g bin {{ hysteria_tempdir.path }}/hysteria/build/hysteria-openbsd-* /var/reactance/hysteria/bin/hysteria"
+
+# Find the list of dependences through ldd and copy them over
+- name: "copy chroot dependencies"
+ ansible.builtin.shell: "deps=$(ldd /var/reactance/hysteria/bin/hysteria | awk 'FNR > 3 {print $7}'); for dep in $deps; do rsync -av --relative $dep /var/reactance/hysteria; done"
+
+- name: "template out init script"
+ ansible.builtin.template:
+ src: hysteria.rc.j2
+ dest: "{{ hysteria_tempdir.path }}/hysteria.rc"
+
+- name: "install init script"
+ ansible.builtin.shell: "install -m 755 -g bin {{ hysteria_tempdir.path }}/hysteria.rc /etc/rc.d/hysteria"
diff --git a/roles/hysteria/tasks/main.yaml b/roles/hysteria/tasks/main.yaml
new file mode 100644
index 00000000..258c01d7
--- /dev/null
+++ b/roles/hysteria/tasks/main.yaml
@@ -0,0 +1,7 @@
+---
+- name: "ignore hysteria"
+ ansible.builtin.debug:
+ msg: "Hysteria2 sing-box clients do not support any system for verifying server identity and thus Hysteria2 is prone to MITM attacks thus should not be used. The role may not be fully developed. Check README.md for more info."
+
+# - name: "setup hysteria"
+# ansible.builtin.include_tasks: check_hysteria_exists.yaml
diff --git a/roles/hysteria/tasks/setup_hysteria.yaml b/roles/hysteria/tasks/setup_hysteria.yaml
new file mode 100644
index 00000000..64161b43
--- /dev/null
+++ b/roles/hysteria/tasks/setup_hysteria.yaml
@@ -0,0 +1,17 @@
+---
+- name: "create certificate dir"
+ ansible.builtin.file:
+ path: /var/reactance/ocserv/certs/
+ state: directory
+ owner: _vpn
+ group: _vpn
+
+- name: "generate server certs and key"
+ ansible.builtin.shell: "openssl req -x509 -newkey rsa:4096 -keyout /var/reactance/hysteria/certs/server-key.pem -out /var/reactance/hysteria/certs/server-cert.pem -sha256 -days 3650 -nodes -subj '/CN=JohnDane'"
+
+- name: "template out ocserv config"
+ ansible.builtin.template:
+ src: ocserv.conf.j2
+ dest: /var/reactance/ocserv/ocserv.conf
+ notify:
+ - hysteria_start
diff --git a/roles/hysteria/templates/config.json.j2 b/roles/hysteria/templates/config.json.j2
new file mode 100644
index 00000000..40dde2e4
--- /dev/null
+++ b/roles/hysteria/templates/config.json.j2
@@ -0,0 +1,52 @@
+{
+ "listen": "0.0.0.0:{{ hysteria_port | default('4435') }}",
+ "tls": {
+ "cert": "/certs/server-cert.pem",
+ "key": "/certs/server-key.pem"
+ },
+ "disableUDP": false,
+ "bandwidth": {
+ "up": "1 gbps",
+ "down": "1 gbps"
+ },
+ "auth": {
+ "type": "userpass",
+ "userpass": {}
+ },
+ "obfs": {
+ "type": "salamander",
+ "salamander": {
+ "password": "{{ salamander_password }}"
+ }
+ },
+ "resolver": {
+ "type": "udp",
+ "udp": {
+{% if not lookup('vars', 'disable_dns', default=false) %}
+ "addr": "{{ ansible_all_ipv4_addresses[0] }}"
+{% else %}
+ "addr": "9.9.9.9"
+{% endif %}
+ }
+ },
+ "outbounds": [{
+ "name": "direct_out",
+ "type": "direct",
+ "direct": {
+ "mode": "auto"
+ }}],
+ "trafficStats": {
+ "listen": "127.0.0.1:9999",
+ "secret": "secret"
+ },
+ "masquerade": {
+ "type": "string",
+ "string": {
+ "content": "Hello World",
+ "headers": {
+ "content-type": "text/plain"
+ },
+ "statusCode": 200
+ }
+ }
+}
diff --git a/roles/hysteria/templates/hysteria.rc.j2 b/roles/hysteria/templates/hysteria.rc.j2
new file mode 100644
index 00000000..c0aeed29
--- /dev/null
+++ b/roles/hysteria/templates/hysteria.rc.j2
@@ -0,0 +1,22 @@
+#!/bin/ksh
+#
+# $OpenBSD: hysteria
+
+chroot_dir=/var/reactance/hysteria
+chroot_user=_vpn
+daemon_class=daemon
+daemon=/bin/hysteria
+daemon_flags="server -c /etc/config.json"
+daemon_user=root
+daemon_logger=daemon.info
+
+. /etc/rc.d/rc.subr
+
+rc_start() {
+ rc_exec "chroot -u $chroot_user $chroot_dir $daemon $daemon_flags"
+}
+
+rc_bg=YES
+rc_reload=NO
+
+rc_cmd $1
diff --git a/roles/ocserv/handlers/main.yml b/roles/ocserv/handlers/main.yml
new file mode 100644
index 00000000..dcc18f2c
--- /dev/null
+++ b/roles/ocserv/handlers/main.yml
@@ -0,0 +1,11 @@
+---
+- name: restart_ocserv
+ ansible.builtin.service:
+ name: ocserv
+ state: restarted
+ enabled: true
+
+- name: remove_ocserv_tempdir
+ ansible.builtin.file:
+ path: "{{ ocserv_tempdir.path }}"
+ state: absent
diff --git a/roles/ocserv/tasks/check_ocserv_exists.yaml b/roles/ocserv/tasks/check_ocserv_exists.yaml
new file mode 100644
index 00000000..bb4734ce
--- /dev/null
+++ b/roles/ocserv/tasks/check_ocserv_exists.yaml
@@ -0,0 +1,16 @@
+---
+- name: "Check if ocserv is already installed"
+ ansible.builtin.stat:
+ path: /var/reactance/ocserv
+ register: ocserv_directory
+
+- name: "Install ocserv if directory doesn't exist"
+ ansible.builtin.include_tasks: install_ocserv.yaml
+ when: ocserv_directory.stat.exists == false
+
+- name: "Configure ocserv"
+ ansible.builtin.include_tasks: configure_ocserv.yaml
+ when: ocserv_directory.stat.exists == false
+
+- name: "Create ocserv users"
+ ansible.builtin.include_tasks: create_users_ocserv.yaml
diff --git a/roles/ocserv/tasks/configure_ocserv.yaml b/roles/ocserv/tasks/configure_ocserv.yaml
new file mode 100644
index 00000000..53e771c0
--- /dev/null
+++ b/roles/ocserv/tasks/configure_ocserv.yaml
@@ -0,0 +1,40 @@
+---
+- name: "template out config"
+ ansible.builtin.template:
+ src: "{{ item.src }}"
+ dest: "{{ item.dest }}"
+ loop:
+ - src: ocserv.conf.j2
+ dest: /var/reactance/ocserv/ocserv.conf
+ - src: ca.tmpl.j2
+ dest: /var/reactance/ocserv/certs/ca.tmpl
+ - src: server.tmpl.j2
+ dest: /var/reactance/ocserv/certs/server.tmpl
+ - src: crl.tmpl.j2
+ dest: /var/reactance/ocserv/certs/crl.tmpl
+
+# generate ca, server certs, crl file
+- name: "generate ca, server certs"
+ ansible.builtin.shell: "{{ item }}"
+ loop:
+ - "certtool --generate-privkey --outfile /var/reactance/ocserv/certs/ca-key.pem"
+ - "certtool --generate-self-signed --load-privkey /var/reactance/ocserv/certs/ca-key.pem --template /var/reactance/ocserv/certs/ca.tmpl --outfile /var/reactance/ocserv/certs/ca-cert.pem"
+ - "certtool --generate-privkey --outfile /var/reactance/ocserv/certs/server-key.pem"
+ - "certtool --generate-certificate --load-privkey /var/reactance/ocserv/certs/server-key.pem --load-ca-certificate /var/reactance/ocserv/certs/ca-cert.pem --load-ca-privkey /var/reactance/ocserv/certs/ca-key.pem --template /var/reactance/ocserv/certs/server.tmpl --outfile /var/reactance/ocserv/certs/server-cert.pem"
+ - "certtool --generate-crl --load-ca-privkey /var/reactance/ocserv/certs/ca-key.pem --load-ca-certificate /var/reactance/ocserv/certs/ca-cert.pem --template /var/reactance/ocserv/certs/crl.tmpl --outfile /var/reactance/ocserv/certs/crl.pem"
+ no_log: true
+
+- name: "template out nat rules in pf.conf"
+ ansible.builtin.blockinfile:
+ path: /etc/pf.conf
+ create: true
+ backup: true
+ marker: "### REACTANCE - Ocserv NAT - {mark} ###"
+ insertafter: "EOF"
+ block: |
+ match out on {{ ansible_default_ipv4.interface|default(ansible_all_ipv4_addresses[0]) }} from {{ ocserv_network | default("172.16.16.0/24") }} to any nat-to ({{ ansible_default_ipv4.interface|default(ansible_all_ipv4_addresses[0]) }})
+ match in on {{ ansible_default_ipv4.interface|default(ansible_all_ipv4_addresses[0]) }} from any to {{ ocserv_network | default("172.16.16.0/24") }} nat-to ({{ ansible_default_ipv4.interface|default(ansible_all_ipv4_addresses[0]) }})
+# default(ansible_all_ipv4_addresses[0]) is added, in case a default route doesn't exist
+
+- name: "generate public, private key pair"
+ ansible.builtin.shell: "openssl req -x509 -newkey rsa:4096 -keyout /var/reactance/ocserv/certs/server-key.pem -out /var/reactance/ocserv/certs/server-cert.pem -sha256 -days 3650 -nodes -subj /CN=example &>/dev/null"
diff --git a/roles/ocserv/tasks/create_users_ocserv.yaml b/roles/ocserv/tasks/create_users_ocserv.yaml
new file mode 100644
index 00000000..12748e7a
--- /dev/null
+++ b/roles/ocserv/tasks/create_users_ocserv.yaml
@@ -0,0 +1,18 @@
+---
+- name: "ocserv user management"
+ ocserv:
+ users: "{{ all_users|default([]) + ocserv_users|default([]) }}"
+ notify:
+ - restart_ocserv
+# no_log: true
+ register: ocserv_user_pass_dict
+
+- name: "make temp dir"
+ ansible.builtin.file:
+ path: /var/reactance/.temp/
+ state: directory
+
+- name: "add ocserv user password pair to dict"
+ ansible.builtin.copy:
+ content: "{{ ocserv_user_pass_dict['msg']|default({}) | to_json }}"
+ dest: /var/reactance/.temp/ocserv_user_pass_dict
diff --git a/roles/ocserv/tasks/install_ocserv.yaml b/roles/ocserv/tasks/install_ocserv.yaml
new file mode 100644
index 00000000..a31a2ee5
--- /dev/null
+++ b/roles/ocserv/tasks/install_ocserv.yaml
@@ -0,0 +1,63 @@
+---
+# ocserv has built-in chroot functionality
+
+# it's fine here, other roles won't be running any pkg_add
+- name: "install ocserv"
+ community.general.openbsd_pkg:
+ name: ocserv--
+ state: present
+
+- name: "create directory"
+ ansible.builtin.file:
+ path: "{{ item }}"
+ state: directory
+ owner: _vpn
+ group: _vpn
+ mode: 0700
+ loop:
+ - /var/reactance/ocserv
+ - /var/reactance/ocserv/run
+ - /var/reactance/ocserv/certs
+
+- name: "create log file"
+ ansible.builtin.file:
+ path: /var/log/ocserv.log
+ state: touch
+ mode: "0600"
+ changed_when: false
+
+- name: "create temporary directory"
+ ansible.builtin.tempfile:
+ state: directory
+ suffix: temp
+ register: ocserv_tempdir
+ notify:
+ - remove_ocserv_tempdir
+
+- name: "template out config"
+ ansible.builtin.template:
+ src: ocserv.conf.j2
+ dest: /var/reactance/ocserv/ocserv.conf
+
+- name: "template out init script"
+ ansible.builtin.template:
+ src: ocserv.rc.j2
+ dest: "{{ ocserv_tempdir.path }}/ocserv.rc"
+
+# will fail without it
+- name: "copy ocserv-worker"
+ ansible.builtin.copy:
+ owner: _vpn
+ group: _vpn
+ remote_src: true
+ src: /usr/local/sbin/ocserv-worker
+ dest: /var/reactance/ocserv/ocserv-worker
+ mode: 0770
+
+- name: "install init script"
+ ansible.builtin.shell: "install -m 755 -g bin {{ ocserv_tempdir.path }}/ocserv.rc /etc/rc.d/ocserv && rm -rf /var/reactance/ocserv/ocserv.rc"
+
+- name: "remove /etc/ocserv (we are using /var/reactance/ocserv)"
+ ansible.builtin.file:
+ path: /etc/ocserv
+ state: absent
diff --git a/roles/ocserv/tasks/main.yaml b/roles/ocserv/tasks/main.yaml
new file mode 100644
index 00000000..4ed25a48
--- /dev/null
+++ b/roles/ocserv/tasks/main.yaml
@@ -0,0 +1,3 @@
+---
+- name: "setup ocserv"
+ include_tasks: check_ocserv_exists.yaml
diff --git a/roles/ocserv/tasks/setup_ocserv.yaml b/roles/ocserv/tasks/setup_ocserv.yaml
new file mode 100644
index 00000000..d2a9bb59
--- /dev/null
+++ b/roles/ocserv/tasks/setup_ocserv.yaml
@@ -0,0 +1,8 @@
+---
+- name: "generate server cert and key"
+ ansible.builtin.shell: "openssl req -x509 -newkey rsa:4096 -keyout /var/reactance/ocserv/certs/server-key.pem -out /var/reactance/ocserv/certs/server-cert.pem -sha256 -days 3650 -nodes -subj '/CN=JohnDane'"
+
+- name: "template out ocserv config"
+ ansible.builtin.template:
+ src: ocserv.conf.j2
+ dest: /var/reactance/ocserv/ocserv.conf
diff --git a/roles/ocserv/templates/ca.tmpl.j2 b/roles/ocserv/templates/ca.tmpl.j2
new file mode 100644
index 00000000..c595f0c3
--- /dev/null
+++ b/roles/ocserv/templates/ca.tmpl.j2
@@ -0,0 +1,8 @@
+cn = "VPN CA"
+organization = "Big Corp"
+serial = 1
+expiration_days = -1
+ca
+signing_key
+cert_signing_key
+crl_signing_key
diff --git a/roles/ocserv/templates/crl.tmpl.j2 b/roles/ocserv/templates/crl.tmpl.j2
new file mode 100644
index 00000000..b70745fd
--- /dev/null
+++ b/roles/ocserv/templates/crl.tmpl.j2
@@ -0,0 +1,2 @@
+crl_next_update = 365
+crl_number = 1
diff --git a/roles/ocserv/templates/ocserv.conf.j2 b/roles/ocserv/templates/ocserv.conf.j2
new file mode 100644
index 00000000..4f722487
--- /dev/null
+++ b/roles/ocserv/templates/ocserv.conf.j2
@@ -0,0 +1,48 @@
+chroot-dir = /var/reactance/ocserv
+auth = "certificate"
+tcp-port = {{ ocserv_port | default("4430") }}
+run-as-user = _vpn
+run-as-group = _vpn
+
+socket-file = run/ocserv-socket
+server-cert = /var/reactance/ocserv/certs/server-cert.pem
+server-key = /var/reactance/ocserv/certs/server-key.pem
+ca-cert = /var/reactance/ocserv/certs/ca-cert.pem
+crl = /var/reactance/ocserv/certs/crl.pem
+
+max-clients = 10000
+max-same-clients = 2
+rate-limit-ms = 100
+server-stats-reset-time = 604800
+keepalive = 32400
+dpd = 90
+mobile-dpd = 1800
+switch-to-tcp-timeout = 25
+try-mtu-discovery = false
+cert-user-oid = 0.9.2342.19200300.100.1.1
+compression = true
+tls-priorities = "NORMAL:%SERVER_PRECEDENCE:%COMPAT:-VERS-SSL3.0:-VERS-TLS1.0:-VERS-TLS1.1"
+auth-timeout = 240
+min-reauth-time = 300
+max-ban-score = 80
+ban-reset-time = 1200
+cookie-timeout = 300
+deny-roaming = false
+rekey-time = 172800
+rekey-method = ssl
+use-occtl = true
+pid-file = /var/reactance/ocserv/run/ocserv.pid
+log-level = 3
+device = vpns
+predictable-ips = true
+ipv4-network = {{ ocserv_network | default("172.16.16.0/24") }}
+tunnel-all-dns = true
+{% if not lookup('vars', 'disable_dns', default=false) %}
+dns = {{ (ocserv_network|default("172.16.16.0/24"))|ansible.utils.nthhost(2) }}
+{% else %}
+dns = 9.9.9.9
+{% endif %}
+ping-leases = false
+route = default
+cisco-client-compat = true
+max-ban-score = 20
diff --git a/roles/ocserv/templates/ocserv.rc.j2 b/roles/ocserv/templates/ocserv.rc.j2
new file mode 100644
index 00000000..f68a06ff
--- /dev/null
+++ b/roles/ocserv/templates/ocserv.rc.j2
@@ -0,0 +1,14 @@
+#!/bin/ksh
+# $OpenBSD: ocserv
+daemon="/usr/local/sbin/ocserv"
+daemon_flags="-c /var/reactance/ocserv/ocserv.conf"
+
+. /etc/rc.d/rc.subr
+
+pexp="ocserv: ocserv-main"
+
+rc_pre() {
+ /usr/bin/install -d -o _vpn /var/reactance/ocserv/run/
+}
+
+rc_cmd $1
diff --git a/roles/ocserv/templates/server.tmpl.j2 b/roles/ocserv/templates/server.tmpl.j2
new file mode 100644
index 00000000..f5eb7b66
--- /dev/null
+++ b/roles/ocserv/templates/server.tmpl.j2
@@ -0,0 +1,7 @@
+cn = "VPN server"
+ip_address = "{{ ansible_default_ipv4.address|default(ansible_all_ipv4_addresses[0]) }}"
+organization = "MyCompany"
+expiration_days = -1
+signing_key
+encryption_key
+tls_www_server
diff --git a/roles/sshvpn/handlers/main.yaml b/roles/sshvpn/handlers/main.yaml
new file mode 100644
index 00000000..cd12bec6
--- /dev/null
+++ b/roles/sshvpn/handlers/main.yaml
@@ -0,0 +1,6 @@
+---
+- name: restart_ssh
+ ansible.builtin.service:
+ name: sshd
+ state: restarted
+ enabled: yes
diff --git a/roles/sshvpn/tasks/check_sshvpn_exists.yaml b/roles/sshvpn/tasks/check_sshvpn_exists.yaml
new file mode 100644
index 00000000..3939f2d8
--- /dev/null
+++ b/roles/sshvpn/tasks/check_sshvpn_exists.yaml
@@ -0,0 +1,12 @@
+---
+- name: "Check if sshvpn is already installed"
+ ansible.builtin.stat:
+ path: /home/sshvpn
+ register: sshvpn_directory
+
+- name: "Setup sshvpn if directory doesn't exist"
+ ansible.builtin.include_tasks: setup_sshvpn.yaml
+ when: sshvpn_directory.stat.exists == false
+
+- name: "Create sshvpn users"
+ ansible.builtin.include_tasks: create_users_sshvpn.yaml
diff --git a/roles/sshvpn/tasks/create_users_sshvpn.yaml b/roles/sshvpn/tasks/create_users_sshvpn.yaml
new file mode 100644
index 00000000..f4b0ed68
--- /dev/null
+++ b/roles/sshvpn/tasks/create_users_sshvpn.yaml
@@ -0,0 +1,16 @@
+---
+- name: "sshvpn user management"
+ sshvpn:
+ users: "{{ all_users|default([]) + sshvpn_users|default([]) }}"
+ register: sshvpn_user_pass_dict
+ #no_log: true
+
+- name: "make temp dir"
+ ansible.builtin.file:
+ path: /var/reactance/.temp/
+ state: directory
+
+- name: "add sshvpn user password pair to dict"
+ ansible.builtin.copy:
+ content: "{{ sshvpn_user_pass_dict['msg']|default({}) | to_json }}"
+ dest: /var/reactance/.temp/sshvpn_user_pass_dict
diff --git a/roles/sshvpn/tasks/main.yaml b/roles/sshvpn/tasks/main.yaml
new file mode 100644
index 00000000..78a241af
--- /dev/null
+++ b/roles/sshvpn/tasks/main.yaml
@@ -0,0 +1,3 @@
+---
+- name: "Setup sshvpn"
+ ansible.builtin.include_tasks: check_sshvpn_exists.yaml
diff --git a/roles/sshvpn/tasks/setup_sshvpn.yaml b/roles/sshvpn/tasks/setup_sshvpn.yaml
new file mode 100644
index 00000000..20504e57
--- /dev/null
+++ b/roles/sshvpn/tasks/setup_sshvpn.yaml
@@ -0,0 +1,16 @@
+---
+- name: "Create sshvpns user"
+ ansible.builtin.user:
+ name: sshvpn
+ shell: /sbin/nologin
+ home: /var/reactance/sshvpn
+
+- name: "Create .ssh directory (if not exists)"
+ ansible.builtin.file:
+ path: /var/reactance/sshvpn/.ssh
+ owner: sshvpn
+ group: sshvpn
+ mode: 0700
+
+- name: "Run user management script"
+ include_tasks: create_users_sshvpn.yaml
diff --git a/roles/web/handlers/main.yaml b/roles/web/handlers/main.yaml
new file mode 100644
index 00000000..85096548
--- /dev/null
+++ b/roles/web/handlers/main.yaml
@@ -0,0 +1,22 @@
+---
+- name: restart_httpd
+ ansible.builtin.service:
+ name: httpd
+ state: restarted
+ enabled: true
+
+- name: notification_restart
+ ansible.builtin.debug:
+ msg: "Restart your box once reactance run is complete"
+
+- name: show_links
+ ansible.builtin.debug:
+ msg: "{{ }}"
+- name: show_htpasswd_passwords
+ ansible.builtin.debug:
+ msg: "{{ htpasswd_passwords | format_userpass_output(ansible_default_ipv4.address|default(ansible_all_ipv4_addresses[0])) }}"
+
+- name: cleanup_temp_dir
+ ansible.builtin.file:
+ path: /var/reactance/.temp
+ state: absent
diff --git a/roles/web/tasks/.setup_sites.yaml.swp b/roles/web/tasks/.setup_sites.yaml.swp
new file mode 100644
index 00000000..0534a7f0
--- /dev/null
+++ b/roles/web/tasks/.setup_sites.yaml.swp
Binary files differ
diff --git a/roles/web/tasks/build_hugo_sites.yaml b/roles/web/tasks/build_hugo_sites.yaml
new file mode 100644
index 00000000..f3852133
--- /dev/null
+++ b/roles/web/tasks/build_hugo_sites.yaml
@@ -0,0 +1,66 @@
+---
+- name: "make build directory"
+ delegate_to: localhost
+ ansible.builtin.file:
+ path: "{{ item }}"
+ state: directory
+ loop:
+ - "{{ inventory_dir }}/.hugo_sites_build"
+ - "{{ inventory_dir }}/.built_sites"
+
+- name: "copy specific folders to hugo_tmp for each user"
+ delegate_to: localhost
+ ansible.builtin.shell: "rsync -avz {{ inventory_dir }}/web/ {{ inventory_dir }}/.hugo_sites_build/{{ item }} {{ '--exclude=anyconnect.md' if 'ocserv' not in user_pass_dict[item] }} {{'--exclude=openconnect.md' if 'ocserv' not in user_pass_dict[item] }} {{'--exclude=nekobox.md' if ['trojan','vmess','vless']|intersect(user_pass_dict[item]) == {} }} {{'--exclude=nekoray.md' if ['trojan','vmess','vless']|intersect(user_pass_dict[item]) == {} }} {{'--exclude=nekossh.md' if 'sshvpn' not in user_pass_dict[item] }}"
+ loop: "{{ user_pass_dict.keys() }}"
+
+- name: "template out hugo.toml"
+ delegate_to: localhost
+ ansible.builtin.template:
+ src: "{{ inventory_dir }}/.hugo_sites_build/{{ item }}/hugo.toml.j2"
+ dest: "{{ inventory_dir }}/.hugo_sites_build/{{ item }}/hugo.toml"
+ loop: "{{ user_pass_dict.keys() }}"
+
+- name: "template out vars"
+ ansible.builtin.include_tasks: template_vars.yaml
+ loop:
+ - content.en
+ - content.fa
+ loop_control:
+ loop_var: content_dir
+
+- name: "copy hugo_build.sh to temp dir"
+ delegate_to: localhost
+ ansible.builtin.copy:
+ src: "{{ inventory_dir }}/utils/hugo_build.sh"
+ dest: ".hugo_sites_build/hugo_build.sh"
+
+- name: "build hugo sites"
+ delegate_to: localhost
+ ansible.builtin.shell: "sh {{ inventory_dir }}/.hugo_sites_build/hugo_build.sh"
+
+- name: "copy sites"
+ ansible.posix.synchronize:
+ src: "{{ inventory_dir }}/.built_sites/" # this will only copy contents
+ dest: /var/www/reactance
+
+- name: "copy images"
+ ansible.posix.synchronize:
+ src: "{{ inventory_dir }}/web/static/images/"
+ dest: /var/www/reactance/images
+
+- name: "charge dir permissions"
+ ansible.builtin.file:
+ dest: /var/www/reactance
+ owner: www
+ group: daemon
+ mode: 0755
+ recurse: yes
+
+- name: "remove build directory"
+ delegate_to: localhost
+ ansible.builtin.file:
+ path: "{{ item }}"
+ state: absent
+ loop:
+ - "{{ inventory_dir }}/.hugo_sites_build"
+ - "{{ inventory_dir }}/.built_sites"
diff --git a/roles/web/tasks/copy_certs.yaml b/roles/web/tasks/copy_certs.yaml
new file mode 100644
index 00000000..ad670ff3
--- /dev/null
+++ b/roles/web/tasks/copy_certs.yaml
@@ -0,0 +1,28 @@
+---
+- name: "copy ocserv cert"
+ ansible.builtin.copy:
+ remote_src: true
+ src: "/var/reactance/ocserv/certs/{{ item }}-cert.pem"
+ dest: "/var/www/reactance/{{ item }}/{{ item }}-User-Certificate.pem"
+ owner: www
+ group: daemon
+ loop: "{{ (ocserv_user_pass_dict_contents.content|default('e30K')|b64decode|from_json).keys()}}"
+
+- name: "copy ocserv key"
+ ansible.builtin.copy:
+ remote_src: true
+ src: "/var/reactance/ocserv/certs/{{ item }}-key.pem"
+ dest: "/var/www/reactance/{{ item }}/{{ item }}-User-Key.pem"
+ owner: www
+ group: daemon
+ loop: "{{ (ocserv_user_pass_dict_contents.content|default('e30K')|b64decode|from_json).keys()}}"
+
+- name: "copy ocserv p12 cert"
+ ansible.builtin.copy:
+ remote_src: true
+ src: "/var/reactance/ocserv/certs/{{ item }}.p12"
+ dest: "/var/www/reactance/{{ item }}/{{ item }}-Certificate-Android.p12"
+ owner: www
+ group: daemon
+ loop: "{{ (ocserv_user_pass_dict_contents.content|default('e30K')|b64decode|from_json).keys()}}"
+
diff --git a/roles/web/tasks/main.yaml b/roles/web/tasks/main.yaml
new file mode 100644
index 00000000..e541df09
--- /dev/null
+++ b/roles/web/tasks/main.yaml
@@ -0,0 +1,78 @@
+---
+
+- name: "check if files exist"
+ ansible.builtin.stat:
+ path: "{{ item }}"
+ register: check_pass_stats
+ loop:
+ - /var/reactance/.temp/ocserv_user_pass_dict
+ - /var/reactance/.temp/xray_user_pass_dict
+ - /var/reactance/.temp/sshvpn_user_pass_dict
+
+- name: "slurp ocserv creds"
+ ansible.builtin.slurp:
+ src: /var/reactance/.temp/ocserv_user_pass_dict
+ register: ocserv_user_pass_dict_contents
+ when: check_pass_stats.results[0].stat.exists
+
+- name: "slurp xray creds"
+ ansible.builtin.slurp:
+ src: /var/reactance/.temp/xray_user_pass_dict
+ register: xray_user_pass_dict_contents
+ when: check_pass_stats.results[1].stat.exists
+
+- name: "slurp sshvpn creds"
+ ansible.builtin.slurp:
+ src: /var/reactance/.temp/sshvpn_user_pass_dict
+ register: sshvpn_user_pass_dict_contents
+ when: check_pass_stats.results[2].stat.exists
+
+- name: "combine dicts"
+ ansible.builtin.set_fact:
+ user_pass_dict: "{{ (ocserv_user_pass_dict_contents.content|default('e30K')|b64decode|from_json)|combine(xray_user_pass_dict_contents.content|default('e30K')|b64decode|from_json, sshvpn_user_pass_dict_contents.content|default('e30K')|b64decode|from_json, recursive=true, list_merge='append') }}"
+ notify:
+ - cleanup_temp_dir
+
+- name: "get salamander public key"
+ ansible.builtin.slurp:
+ path: "/var/reactance/xray/xray_public_key"
+ register: xray_pub_key_b64e
+ when: check_pass_stats.results[1].stat.exists
+
+- name: "register salamander public key"
+ ansible.builtin.set_fact:
+ xray_public_key: "{{ xray_pub_key_b64e.content|b64decode }}"
+ when: check_pass_stats.results[1].stat.exists
+
+- name: "build and copy sites"
+ ansible.builtin.include_tasks: build_hugo_sites.yaml
+
+- name: "copy certificates and keys"
+ ansible.builtin.include_tasks: copy_certs.yaml
+
+- name: "setup httpd"
+ ansible.builtin.include_tasks: setup_httpd.yaml
+
+- name: "setup htpasswd auth"
+ ansible.builtin.include_tasks: setup_auth.yaml
+
+- name: "store web expiration date"
+ ansible.builtin.set_fact:
+ web_exp_dict: "{{ web_exp_dict|default({}) | combine({item: ansible_facts.date_time.epoch|int + 86400 }) }}"
+ loop: "{{ user_pass_dict.keys() }}"
+
+- name: "check if web_expiration.json exists"
+ ansible.builtin.stat:
+ path: /var/reactance/.web_expiration.json
+ register: web_exp_stat
+
+- name: "slurp previous web_expiration.json contents"
+ ansible.builtin.slurp:
+ path: /var/reactance/.web_expiration.json
+ when: web_exp_stat.stat.exists
+ register: web_exp_e64
+
+- name: "write it to file"
+ ansible.builtin.copy:
+ content: "{{ web_exp_dict|default({})|combine(web_exp_e64.content|default('e30K')|b64decode|from_json) | to_json }}"
+ dest: /var/reactance/.web_expiration.json
diff --git a/roles/web/tasks/setup_auth.yaml b/roles/web/tasks/setup_auth.yaml
new file mode 100644
index 00000000..edcd83ef
--- /dev/null
+++ b/roles/web/tasks/setup_auth.yaml
@@ -0,0 +1,12 @@
+---
+- name: "generate arbitrary passwords for htpasswd"
+ ansible.builtin.set_fact:
+ htpasswd_passwords: "{{ htpasswd_passwords|default({}) | combine({ item: lookup('community.general.random_string', length=14, base64=true) }) }}"
+ loop: "{{ user_pass_dict.keys() }}"
+
+- name: "generate htpasswd"
+ ansible.builtin.shell: " echo {{item}}:{{htpasswd_passwords[item]}} | htpasswd -I /var/www/reactance/{{ item }}/.htpasswd && chown www /var/www/reactance/{{ item }}/.htpasswd && chmod 700 /var/www/reactance/{{ item }}/.htpasswd"
+ loop: "{{ user_pass_dict.keys() }}"
+ notify:
+ - show_htpasswd_passwords
+ - restart_httpd
diff --git a/roles/web/tasks/setup_httpd.yaml b/roles/web/tasks/setup_httpd.yaml
new file mode 100644
index 00000000..2bb9e880
--- /dev/null
+++ b/roles/web/tasks/setup_httpd.yaml
@@ -0,0 +1,8 @@
+---
+- name: "template out httpd.conf"
+ ansible.builtin.template:
+ src: httpd.conf.j2
+ dest: /etc/httpd.conf
+ notify:
+ - restart_httpd
+
diff --git a/roles/web/tasks/template_vars.yaml b/roles/web/tasks/template_vars.yaml
new file mode 100644
index 00000000..ba4423b4
--- /dev/null
+++ b/roles/web/tasks/template_vars.yaml
@@ -0,0 +1,41 @@
+---
+- name: "template out anyconnect.md"
+ delegate_to: localhost
+ ansible.builtin.template:
+ src: "{{ inventory_dir }}/.hugo_sites_build/{{ item }}/{{ content_dir }}/docs/android/anyconnect.md.j2"
+ dest: "{{ inventory_dir }}/.hugo_sites_build/{{ item }}/{{ content_dir }}/docs/android/anyconnect.md"
+ loop: "{{ (ocserv_user_pass_dict_contents.content|default('e30K')|b64decode|from_json).keys()}}"
+
+- name: "template out openconnect.md"
+ delegate_to: localhost
+ ansible.builtin.template:
+ src: "{{ inventory_dir }}/.hugo_sites_build/{{ item }}/{{ content_dir }}/docs/windows/openconnect.md.j2"
+ dest: "{{ inventory_dir }}/.hugo_sites_build/{{ item }}/{{ content_dir }}/docs/windows/openconnect.md"
+ loop: "{{ (ocserv_user_pass_dict_contents.content|default('e30K')|b64decode|from_json).keys() }}"
+
+- name: "template out nekobox.md"
+ delegate_to: localhost
+ ansible.builtin.template:
+ src: "{{ inventory_dir }}/.hugo_sites_build/{{ item }}/{{ content_dir }}/docs/android/nekobox.md.j2"
+ dest: "{{ inventory_dir }}/.hugo_sites_build/{{ item }}/{{ content_dir }}/docs/android/nekobox.md"
+ loop: "{{ users.keys() }}"
+ vars:
+ users: "{{ xray_user_pass_dict_contents.content|default('e30K')|b64decode|from_json}}"
+
+- name: "template out nekoray.md"
+ delegate_to: localhost
+ ansible.builtin.template:
+ src: "{{ inventory_dir }}/.hugo_sites_build/{{ item }}/{{ content_dir }}/docs/windows/nekoray.md.j2"
+ dest: "{{ inventory_dir }}/.hugo_sites_build/{{ item }}/{{ content_dir }}/docs/windows/nekoray.md"
+ loop: "{{ users.keys() }}"
+ vars:
+ users: "{{ xray_user_pass_dict_contents.content|default('e30K')|b64decode|from_json}}"
+
+- name: "template out nekossh.md"
+ delegate_to: localhost
+ ansible.builtin.template:
+ src: "{{ inventory_dir }}/.hugo_sites_build/{{ item }}/{{ content_dir }}/docs/android/nekossh.md.j2"
+ dest: "{{ inventory_dir }}/.hugo_sites_build/{{ item }}/{{ content_dir }}/docs/android/nekossh.md"
+ loop: "{{ users.keys() }}"
+ vars:
+ users: "{{ sshvpn_user_pass_dict_contents.content|default('e30K')|b64decode|from_json}}"
diff --git a/roles/web/templates/httpd.conf.j2 b/roles/web/templates/httpd.conf.j2
new file mode 100644
index 00000000..ab26722c
--- /dev/null
+++ b/roles/web/templates/httpd.conf.j2
@@ -0,0 +1,19 @@
+server "default" {
+ listen on * port 80
+ location "/" {
+ block drop
+ }
+ location "/images/*" {
+ root "/reactance/images"
+ request strip 1
+ }
+
+{% for uname in user_pass_dict.keys() %}
+ location "/{{ uname }}/*" {
+ root "/reactance/{{ uname }}"
+ request strip 1
+ directory auto index
+ authenticate with "/reactance/{{ uname }}/.htpasswd"
+ }
+{% endfor %}
+}
diff --git a/roles/xray/handlers/main.yaml b/roles/xray/handlers/main.yaml
new file mode 100644
index 00000000..63ee84c8
--- /dev/null
+++ b/roles/xray/handlers/main.yaml
@@ -0,0 +1,11 @@
+---
+- name: restart_xray
+ ansible.builtin.service:
+ name: xray
+ state: restarted
+ enabled: true
+
+- name: remove_xray_tempdir
+ ansible.builtin.file:
+ path: "{{ xray_tempdir.path }}"
+ state: absent
diff --git a/roles/xray/tasks/check_xray_exists.yaml b/roles/xray/tasks/check_xray_exists.yaml
new file mode 100644
index 00000000..c0fdc525
--- /dev/null
+++ b/roles/xray/tasks/check_xray_exists.yaml
@@ -0,0 +1,21 @@
+---
+- name: "Check if xray is already installed"
+ ansible.builtin.stat:
+ path: /var/reactance/xray
+ register: xray_directory
+
+- name: "Check if xray is configured"
+ ansible.builtin.stat:
+ path: /var/reactance/xray/etc/config.json
+ register: xray_config
+
+- name: "Install xray if directory doesn't exist"
+ ansible.builtin.include_tasks: install_xray.yaml
+ when: xray_directory.stat.exists == false
+
+- name: "Configure xray"
+ ansible.builtin.include_tasks: configure_xray.yaml
+ when: xray_config.stat.exists == false
+
+- name: "Create xray users"
+ ansible.builtin.include_tasks: create_users_xray.yaml
diff --git a/roles/xray/tasks/configure_xray.yaml b/roles/xray/tasks/configure_xray.yaml
new file mode 100644
index 00000000..4f8cf5e6
--- /dev/null
+++ b/roles/xray/tasks/configure_xray.yaml
@@ -0,0 +1,50 @@
+---
+# generate keypair, needed for config
+- name: "generate private, public keypair for xtls-reality"
+ ansible.builtin.shell: "/var/reactance/xray/bin/xray x25519 | awk '{ print $3 }' | tr '\n' ','"
+ register: keypair
+
+- name: "set private key as var"
+ ansible.builtin.set_fact:
+ xray_private_key: "{{ (keypair.stdout | split(',')).0 }}"
+ xray_public_key: "{{ (keypair.stdout | split(',')).1 }}"
+
+- name: "write public key to file"
+ ansible.builtin.copy:
+ content: "{{ xray_public_key }}"
+ dest: "/var/reactance/xray/xray_public_key"
+
+- name: "write private key to file"
+ ansible.builtin.copy:
+ content: "{{ xray_private_key }}"
+ dest: "/var/reactance/xray/xray_private_key"
+
+- name: "template out config and init script"
+ ansible.builtin.template:
+ src: config.json.j2
+ dest: "/var/reactance/xray/etc/config.json"
+
+# xray is chrooted and has their own mechanism for logging, which is why it needs to be separatly linked later
+- name: "touch xray log files"
+ ansible.builtin.file:
+ path: "{{ item }}"
+ state: touch
+ mode: "0700"
+ owner: _vpn
+ group: _vpn
+ loop:
+ - "/var/reactance/xray/logs/xray-access.log"
+ - "/var/reactance/xray/logs/xray-error.log"
+
+# purely for convenience
+- name: "link log files to /var/log/xray"
+ ansible.builtin.file:
+ src: "/var/reactance/xray/logs/{{ item }}"
+ dest: "/var/log/xray/{{ item }}"
+ state: link
+ mode: "0700"
+ owner: _vpn
+ group: _vpn
+ loop:
+ - xray-access.log
+ - xray-error.log
diff --git a/roles/xray/tasks/create_users_xray.yaml b/roles/xray/tasks/create_users_xray.yaml
new file mode 100644
index 00000000..b56cb0fa
--- /dev/null
+++ b/roles/xray/tasks/create_users_xray.yaml
@@ -0,0 +1,55 @@
+---
+
+- name: "get salamaner public key"
+ ansible.builtin.slurp:
+ path: "/var/reactance/xray/xray_public_key"
+ register: xray_pub_key_b64e
+
+- name: "vless user management"
+ xray:
+ users: "{{ all_users|default([]) + vless_users|default([]) }}"
+ protocol: vless
+ address: "{{ ansible_default_ipv4.interface|default(ansible_all_ipv4_addresses[0]) }}"
+ service_port: "{{ vless_port|default(4437) }}"
+ public_key: "{{ xray_pub_key_b64e.content|b64decode }}"
+ when: inventory_hostname in (groups['vless']|default([])) + (groups['all_vpns']|default([]))
+ register: vless_user_pass_dict
+ # no_log: true
+ notify:
+ - restart_xray
+
+- name: "vmess user management"
+ xray:
+ users: "{{ all_users|default([]) + vmess_users|default([]) }}"
+ protocol: vmess
+ address: "{{ ansible_default_ipv4.interface|default(ansible_all_ipv4_addresses[0]) }}"
+ service_port: "{{ vless_port|default(4437) }}"
+ public_key: "{{ xray_pub_key_b64e.content|b64decode }}"
+ when: inventory_hostname in (groups['vmess']|default([])) + (groups['all_vpns']|default([]))
+ register: vmess_user_pass_dict
+ no_log: true
+ notify:
+ - restart_xray
+
+- name: "trojan user management"
+ xray:
+ users: "{{ all_users|default([]) + trojan_users|default([]) }}"
+ protocol: trojan
+ address: "{{ ansible_default_ipv4.interface|default(ansible_all_ipv4_addresses[0]) }}"
+ service_port: "{{ vless_port|default(4437) }}"
+ public_key: "{{ xray_pub_key_b64e.content|b64decode }}"
+ when: inventory_hostname in (groups['trojan']|default([])) + (groups['all_vpns']|default([]))
+ register: trojan_user_pass_dict
+ no_log: true
+ notify:
+ - restart_xray
+
+- name: "make temp dir"
+ ansible.builtin.file:
+ path: /var/reactance/.temp/
+ state: directory
+
+- name: "add ocserv user password pair to dict"
+ ansible.builtin.copy:
+ content: "{{ (trojan_user_pass_dict['msg']|default({}) | combine(vmess_user_pass_dict['msg']|default({}), vless_user_pass_dict['msg']|default({}), recursive=true, list_merge='append')) | to_json }}"
+ dest: /var/reactance/.temp/xray_user_pass_dict
diff --git a/roles/xray/tasks/install_xray.yaml b/roles/xray/tasks/install_xray.yaml
new file mode 100644
index 00000000..d4889531
--- /dev/null
+++ b/roles/xray/tasks/install_xray.yaml
@@ -0,0 +1,100 @@
+---
+- name: "create directory"
+ ansible.builtin.file:
+ path: "{{ item }}"
+ state: directory
+ owner: _vpn
+ group: _vpn
+ mode: 0700
+ loop:
+ - "/var/reactance/xray"
+ - "/var/reactance/xray/bin"
+ - "/var/reactance/xray/etc"
+ - "/var/reactance/xray/logs"
+ - "/var/log/xray"
+
+- name: "install golang to build xray"
+ community.general.openbsd_pkg:
+ name: go--
+ state: present
+ register: installed_go
+
+- name: "log var"
+ ansible.builtin.debug:
+ msg: "{{ is_installed }}"
+
+- name: "create temporary directory"
+ ansible.builtin.tempfile:
+ state: directory
+ suffix: temp
+ register: xray_tempdir
+ notify:
+ - remove_xray_tempdir
+
+- name: "get latest version"
+ ansible.builtin.shell: 'curl --silent "https://api.github.com/repos/XTLS/Xray-core/releases/latest" | jq -r .tag_name'
+ register: xray_latest_version
+
+- name: "download latest source"
+ ansible.builtin.get_url:
+ url: "https://github.com/XTLS/Xray-core/releases/download/{{ xray_latest_version.stdout }}/{{ xray_latest_version.stdout }}.zip"
+ dest: "{{ xray_tempdir.path }}/source.zip"
+
+- name: "additionally download latest version (for geoip.dat, geosite.dat)"
+ ansible.builtin.get_url:
+ url: "https://github.com/XTLS/Xray-core/releases/download/{{ xray_latest_version.stdout }}/Xray-openbsd-64.zip"
+ dest: "{{ xray_tempdir.path }}/xray.zip"
+
+- name: "unzip xray"
+ ansible.builtin.unarchive:
+ src: "{{ xray_tempdir.path }}/xray.zip"
+ dest: "{{ xray_tempdir.path }}"
+ remote_src: yes
+
+- name: "unzip xray source"
+ ansible.builtin.unarchive:
+ src: "{{ xray_tempdir.path }}/xray.source"
+ dest: "{{ xray_tempdir.path }}/source"
+ remote_src: yes
+
+- name: "build xray from source"
+ ansible.builtin.shell: "CGO_ENABLED=0 go build -o xray -trimpath -buildvcs=false -ldflags=\"-s -w -buildid= -X 'xray.buf.readv='\" ./main"
+ args:
+ chdir: "{{ xray_tempdir.path }}/source"
+
+- name: "template out init script"
+ ansible.builtin.template:
+ src: "{{ item.file_src }}"
+ dest: "{{ item.file_dest }}"
+ loop:
+ - file_src: xray.rc.j2
+ file_dest: "{{ xray_tempdir.path }}/xray.rc"
+
+- name: "install xray"
+ ansible.builtin.shell: "{{ item }}"
+ loop:
+ - "install -m 700 -o _vpn -g bin {{ xray_tempdir.path }}/source/xray /var/reactance/xray/bin/xray"
+ - "install -m 755 -o _vpn -g bin {{ xray_tempdir.path }}/xray.rc /etc/rc.d/xray"
+ - "install -m 700 -o _vpn -g bin {{ xray_tempdir.path }}/geoip.dat /var/reactance/xray/bin/geoip.dat"
+ - "install -m 700 -o _vpn -g bin {{ xray_tempdir.path }}/geosite.dat /var/reactance/xray/bin/geosite.dat"
+
+- name: "copy chroot dependencies"
+ ansible.builtin.shell: "deps=$(ldd /var/reactance/xray/bin/xray | awk 'FNR > 3 {print $7}'); for dep in $deps; do rsync -av --relative $dep /var/reactance/xray; done"
+
+# xray will fail without these two files
+- name: "copy hosts and resolv.conf"
+ ansible.builtin.copy:
+ remote_src: true
+ src: "{{ item.src }}"
+ dest: "{{ item.dest }}"
+ loop:
+ - src: /etc/hosts
+ dest: /var/reactance/xray/etc/hosts
+ - src: /etc/resolv.conf
+ dest: /var/reactance/xray/etc/resolv.conf
+
+# - name: "uninstall golang (if wasn't installed in past)"
+# community.general.openbsd_pkg:
+# name: go--
+# state: present
+
diff --git a/roles/xray/tasks/main.yaml b/roles/xray/tasks/main.yaml
new file mode 100644
index 00000000..422b2bb5
--- /dev/null
+++ b/roles/xray/tasks/main.yaml
@@ -0,0 +1,3 @@
+---
+- name: "setup xray vpn"
+ ansible.builtin.include_tasks: check_xray_exists.yaml
diff --git a/roles/xray/templates/config.json.j2 b/roles/xray/templates/config.json.j2
new file mode 100644
index 00000000..35e78b75
--- /dev/null
+++ b/roles/xray/templates/config.json.j2
@@ -0,0 +1,131 @@
+{
+ "log": {
+ "loglevel": "debug",
+ "access": "/logs/xray-access.log",
+ "error": "/logs/xray-error.log",
+ "dnsLog": false
+ },
+ "stats": {},
+ "api": {
+ "tag": "api",
+ "services": [
+ "StatsService"
+ ]
+ },
+ "policy": {
+ "levels": {
+ "0": {
+ "statsUserUplink": true,
+ "statsUserDownlink": true
+ }
+ }
+ },
+ "inbounds": [
+{% if inventory_hostname in (groups['trojan']|default([])) + (groups['all_vpns']|default([])) %}
+ {
+ "listen": "0.0.0.0",
+ "port": {{ trojan_port|default(4436) }},
+ "protocol": "trojan",
+ "settings": {
+ "clients": []
+ },
+ "streamSettings": {
+ "network": "tcp",
+ "security": "reality",
+ "realitySettings": {
+ "show": false,
+ "dest": "behindthename.com:443",
+ "serverNames": [
+ "behindthename.com",
+ "www.behindthename.com"
+ ],
+ "xver": 0,
+ "privateKey": "{{ xray_private_key }}",
+ "maxTimeDiff": 0,
+ "shortIds": [""]
+ }
+ }
+ },
+{% endif %}
+{% if inventory_hostname in (groups['vless']|default([])) + (groups['all_vpns']|default([])) %}
+ {
+ "listen": "0.0.0.0",
+ "port": {{ vless_port|default(4437) }},
+ "protocol": "vless",
+ "settings": {
+ "decryption": "none",
+ "clients": []
+ },
+ "streamSettings": {
+ "network": "tcp",
+ "security": "reality",
+ "realitySettings": {
+ "show": false,
+ "dest": "behindthename.com:443",
+ "serverNames": [
+ "behindthename.com",
+ "www.behindthename.com"
+ ],
+ "xver": 0,
+ "privateKey": "{{ xray_private_key }}",
+ "maxTimeDiff": 0,
+ "shortIds": [""]
+ }
+ }
+ },
+{% endif %}
+{% if inventory_hostname in (groups['vmess']|default([])) + (groups['all_vpns']|default([])) %}
+ {
+ "listen": "0.0.0.0",
+ "port": {{ vmess_port|default(4438) }},
+ "protocol": "vmess",
+ "settings": {
+ "clients": []
+ },
+ "streamSettings": {
+ "network": "tcp",
+ "security": "reality",
+ "realitySettings": {
+ "show": false,
+ "dest": "behindthename.com:443",
+ "serverNames": [
+ "behindthename.com",
+ "www.behindthename.com"
+ ],
+ "xver": 0,
+ "privateKey": "{{ xray_private_key }}",
+ "maxTimeDiff": 0,
+ "shortIds": [""]
+ }
+ }
+ },
+{% endif %}
+ {
+ "listen": "127.0.0.1",
+ "port": 10085,
+ "protocol": "dokodemo-door",
+ "settings": {
+ "address": "127.0.0.1"
+ },
+ "tag": "api"
+ }
+ ],
+ "outbounds": [
+ {
+ "protocol": "freedom",
+ "tag": "direct"
+ }
+ ],
+ "routing": {
+ "rules": [
+ {
+ "inboundTag": [
+ "api"
+ ],
+ "outboundTag": "api",
+ "type": "field"
+ }
+ ]
+ }
+}
+
diff --git a/roles/xray/templates/xray.rc.j2 b/roles/xray/templates/xray.rc.j2
new file mode 100644
index 00000000..fe658fbc
--- /dev/null
+++ b/roles/xray/templates/xray.rc.j2
@@ -0,0 +1,22 @@
+#!/bin/ksh
+#
+# $OpenBSD: xray
+
+chroot_dir=/var/reactance/xray
+chroot_user=_vpn
+daemon_class=daemon
+daemon=/bin/xray
+daemon_flags="run -c /etc/config.json"
+daemon_user=root
+
+. /etc/rc.d/rc.subr
+
+rc_start() {
+ rc_exec "chroot -u $chroot_user $chroot_dir $daemon $daemon_flags"
+}
+
+rc_bg=YES
+rc_reload=NO
+
+rc_cmd $1
+
diff --git a/utils/Dockerfile b/utils/Dockerfile
new file mode 100644
index 00000000..e0407998
--- /dev/null
+++ b/utils/Dockerfile
@@ -0,0 +1,6 @@
+FROM alpine:latest
+
+RUN apk add --no-cache ansible py3-netaddr openssh-client rsync
+RUN apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community hugo
+
+CMD ["ansible-playbook", "--version"]
diff --git a/utils/drone.star b/utils/drone.star
new file mode 100644
index 00000000..db0fbaae
--- /dev/null
+++ b/utils/drone.star
@@ -0,0 +1,138 @@
+# starlark is used instead of more readable YAML because protocols will be added/removed in future.
+# you need to set the drone config path to `utils/drone.star` in the webui and also store the ssh key as a drone secret in `ssh_private_key` variable.
+# run custom build with force_rebuild parameter set to true to rebuild and override images on registry
+
+def main(ctx):
+
+ protocols = [
+ 'xray',
+ 'hysteria',
+ 'ocserv',
+ 'sshvpn'
+ ]
+
+ pipelines = [
+ pipeline_1(),
+ pipeline_2(protocols)
+ ]
+
+ return pipelines
+
+def pipeline_1():
+ steps = []
+
+ # step 1: check if image exists on remote registry
+ steps.append({
+ "name": "check_image",
+ "image": "alpine:latest",
+ "commands": [
+ ' wget http://registry.opviel.de/v2/_catalog -O - | grep -q "alpine_ansible_hugo" && [ "$force_rebuild" != "true" ] && echo -n "\nBUILD SKIPPED" && exit 78 || exit 0'
+ ],
+ "trigger": {"branch": "master"}
+ })
+
+ # step 2: if doesn't exist, build and publish image to registry
+ steps.append({
+ "name": "publish_on_registry",
+ "image": "plugins/docker",
+ "settings": {
+ "repo": "registry.opviel.de/alpine_ansible_hugo",
+ "dockerfile": "utils/Dockerfile",
+ "registry": "registry.opviel.de",
+ "tags": ["latest"],
+ "insecure": "true",
+ "purge": "true",
+ "compress": "true"
+ }
+ })
+
+ return {
+ "kind": "pipeline",
+ "type": "docker",
+ "name": "Build and Publish Image",
+ "platform": { "arch": "arm64" },
+ "steps": steps,
+ "trigger": {"branch": "master" }
+ }
+
+
+def pipeline_2(protocols):
+
+ environment_vars = {
+ "SSH_PRIVATE_KEY": {
+ "from_secret": "ssh_private_key"
+ }
+ }
+
+ steps = []
+
+ # step 1: export ssh private key to file
+ steps.append({
+ "name": "export_ssh_key",
+ "image": "alpine",
+ "commands": [
+ 'echo "$SSH_PRIVATE_KEY" > .ssh_private_key',
+ "chmod 600 .ssh_private_key"
+ ],
+ "environment": environment_vars
+ })
+
+ # step 2: add theme
+ steps.append({
+ "name": "git_add_theme",
+ "image": "alpine/git",
+ "commands": [
+ "git submodule add -f https://github.com/alex-shpak/hugo-book web/themes/hugo-book"
+ ],
+ "environment": environment_vars
+ })
+
+ steps.append({
+ "name": "setup_base",
+ "image": "registry.opviel.de:80/alpine_ansible_hugo:latest",
+ "commands": [
+ "/usr/bin/ansible-playbook reactance.yaml -t base"
+ ],
+ "depends_on": ["export_ssh_key"]
+ })
+
+ # step 3: run pipeline
+ web_deps = ["export_ssh_key", "setup_base", "git_add_theme"]
+ for protocol in protocols:
+ steps.append({
+ "name": "setup_{}".format(protocol),
+ "image": "registry.opviel.de:80/alpine_ansible_hugo:latest",
+ "commands": [
+ "/usr/bin/ansible-playbook reactance.yaml -t {}".format(protocol)
+ ],
+ "depends_on": ["export_ssh_key", "setup_base"]
+ })
+
+ web_deps.append("setup_{}".format(protocol))
+ steps.append({
+ "name": "setup_dns",
+ "image": "registry.opviel.de:80/alpine_ansible_hugo:latest",
+ "commands": [
+ "/usr/bin/ansible-playbook reactance.yaml -t dns"
+ ],
+ "depends_on": ["export_ssh_key", "setup_base"]
+ })
+
+ steps.append({
+ "name": "setup_web",
+ "image": "registry.opviel.de:80/alpine_ansible_hugo:latest",
+ "commands": [
+ "/usr/bin/ansible-playbook reactance.yaml -t web"
+ ],
+ "depends_on": web_deps
+ })
+
+ return {
+ "kind": "pipeline",
+ "type": "docker",
+ "name": "Execute Playbook",
+ "platform": { "arch": "arm64" },
+ "steps": steps,
+ "depends_on": ["Build and Publish Image"],
+ "trigger": {"branch": "master"}
+ }
diff --git a/utils/hugo_build.sh b/utils/hugo_build.sh
new file mode 100755
index 00000000..ef344209
--- /dev/null
+++ b/utils/hugo_build.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+
+for d in .hugo_sites_build/*; do
+ [ -f "$d" ] && continue
+ uname=$(echo $d | rev | cut -d / -f 1 | rev)
+ cd $d && hugo && cd ../..
+ mv $d/public .built_sites/$uname
+done;
diff --git a/web/archetypes/default.md b/web/archetypes/default.md
new file mode 100644
index 00000000..00e77bd7
--- /dev/null
+++ b/web/archetypes/default.md
@@ -0,0 +1,6 @@
+---
+title: "{{ replace .Name "-" " " | title }}"
+date: {{ .Date }}
+draft: true
+---
+
diff --git a/web/content.en/_index.md b/web/content.en/_index.md
new file mode 100644
index 00000000..39d79381
--- /dev/null
+++ b/web/content.en/_index.md
@@ -0,0 +1,27 @@
+---
+title: Introduction
+type: docs
+---
+
+# Reactance
+
+## Motivation
+{{< hint info >}}
+In psychology, reactance is an unpleasant motivational reaction to offers, persons, rules, or regulations that threaten or eliminate specific behavioral freedoms.
+{{< /hint >}}
+
+Reactance helps you bypass government's internet censorships. It itself is not a VPN or proxy protocol but it's an automation that sets up finely selected proxy and VPN servers that can bypass these restrictions.
+
+## Getting Started
+
+Reactance doesn't have a client of its own. Instead, we provide documentations and credentials for you to know how to connect to the proxy and VPN servers through specific clients. The clients are organized based on OSes. Simply click on the client names on the left navigation bar based on your Operating System to know how to connect to the servers.
+
+{{< hint warning >}}
+**Note:** Please setup the VPN connections within 24 hour period because the website will be deleted after 24 hours and you wouldn't be able to access it.
+{{< /hint >}}
+
+{{< hint danger >}}
+A VPN is not a ticket to freedom!
+All it does is exchange one country's censorship policies for another one's.
+Please refrain from criminal activities, whether they be western-ideological or real like torrenting and piracy, to ensure the service's continued existence free from trouble.
+{{< /hint >}} \ No newline at end of file
diff --git a/web/content.en/docs/_index.md b/web/content.en/docs/_index.md
new file mode 100644
index 00000000..dfd18748
--- /dev/null
+++ b/web/content.en/docs/_index.md
@@ -0,0 +1,5 @@
+---
+bookFlatsection: true
+title: Introduction
+type: docs
+---
diff --git a/web/content.en/docs/android/_index.md b/web/content.en/docs/android/_index.md
new file mode 100644
index 00000000..669d6c8b
--- /dev/null
+++ b/web/content.en/docs/android/_index.md
@@ -0,0 +1,5 @@
+---
+bookFlatSection: true
+title: Android Clients
+weight: 2
+---
diff --git a/web/content.en/docs/android/anyconnect.md.j2 b/web/content.en/docs/android/anyconnect.md.j2
new file mode 100644
index 00000000..c3a97a6e
--- /dev/null
+++ b/web/content.en/docs/android/anyconnect.md.j2
@@ -0,0 +1,97 @@
+---
+title: AnyConnect
+type: docs
+---
+
+# AnyConnect
+
+1. Install the android **Cisco AnyConnect** app from Google PlayStore. Click on the button below and it will take you to its Google Play Store page.
+
+{{ '{{<' }} button href="https://play.google.com/store/apps/details?id=com.cisco.anyconnect.vpn.android.avf&hl=en&gl=US" {{ '>}}' }}**Download Cisco AnyConnect from Google Play Store**{{ '{{<' }} /button {{ '>}}' }}
+
+2. Download your certificate file by clicking the button below:
+
+{{ '{{<' }} button href="/{{item}}/{{item}}-Certificate-Android.p12" {{ '>}}' }}**Download client .P12 certificate file**{{ '{{<' }} /button {{ '>}}' }}
+
+3. Launch the app.
+
+4. Click on **OK** to close the intial terms of service notice.
+
+![AnyConnect-01](/images/anyconnect-01.png)
+
+5. Grant it the relevant permissions (Access to your **notifications**, in this case).
+
+![AnyConnect-02](/images/anyconnect-02.png)
+
+6. Click on the menu button on top right corner and go to **“Settings”**.
+
+![AnyConnect-03](/images/anyconnect-03.png)
+
+7. Uncheck the second option that says **“Block Untrusted Servers”**.
+
+![AnyConnect-04](/images/anyconnect-04.png)
+
+8. Click on the arrow icon on top left corner to go back to the home menu of the app.
+
+9. In the middle of the screen, click on **“Connections”**.
+
+![AnyConnect-05](/images/anyconnect-05.png)
+
+10. Click on the + icon at the bottom of the screen.
+
+![AnyConnect-06](/images/anyconnect-06.png)
+
+11. In the next window:
+
+ 1. Enter any name you want.
+
+ 2. Enter the following server address and port: **{{ ansible_all_ipv4_addresses[0] }}:{{ ocserv_port | default(4430) }}**
+
+ 3. Once you entered the server address, click on **“Advanced Preferences”**.
+
+![AnyConnect-07](/images/anyconnect-07.png)
+
+12. Click on the first option that says **“Certificate”**.
+
+![AnyConnect-08](/images/anyconnect-08.png)
+
+13. Click on the **“Import”** button at the bottom of the screeen.
+
+![AnyConnect-09](/images/anyconnect-09.png)
+
+14. Click on **“File System”**. It should open your file browser app.
+
+![AnyConnect-10](/images/anyconnect-10.png)
+
+15. Find the **.p12 certificate** file that you downloaded in step 2.
+
+![AnyConnect-11](/images/anyconnect-11.png)
+
+16. It will ask for a password. The password is: **{{ item }}**
+
+![AnyConnect-12](/images/anyconnect-12.png)
+
+17. Once the certificate is imported, click on it.
+
+![AnyConnect-13](/images/anyconnect-13.png)
+
+18. Click on **“Done”** at the bottom of the screen, and click on **“Done”** again.
+
+![AnyConnect-14](/images/anyconnect-14.png)
+![AnyConnect-15](/images/anyconnect-15.png)
+
+19. Click on the arrow icon at the top left corner.
+
+![AnyConnect-16](/images/anyconnect-16.png)
+
+20. **Turn on** the VPN by clicking on the VPN toggle.
+
+![AnyConnect-17](/images/anyconnect-18.png)
+
+21. Click on **“Continue”**.
+
+![AnyConnect-18](/images/anyconnect-17.png)
+
+22. And that's it! You can check your IP location to ensure that you are connected to the VPN and it's working properly.
+
+![AnyConnect-19](/images/anyconnect-19.png) \ No newline at end of file
diff --git a/web/content.en/docs/android/nekobox.md.j2 b/web/content.en/docs/android/nekobox.md.j2
new file mode 100644
index 00000000..450635c9
--- /dev/null
+++ b/web/content.en/docs/android/nekobox.md.j2
@@ -0,0 +1,90 @@
+---
+title: NekoBox (V2Ray)
+type: docs
+weight: 1
+---
+
+# NekoBox
+
+In this tutorial you will learn how to use NekoBox VPN client on android devices.
+
+NekoBox is a VPN client supporting a variety of VPN protocols, and we will be using this handy client to use our main VPN protocols on android devices, including VLess, VMess, and Trojan.
+
+## How to install and use NekoBox:
+
+1. Go to NekoBox's GitHub page by clicking the button below. Download the APK that is compatible with your device. (In most cases, arm64-v8a version is compatible unless you have an old smartphone.)
+
+{{ '{{<' }} button href="https://github.com/MatsuriDayo/NekoBoxForAndroid/releases/tag/1.2.9" {{ '>}}' }}**NekoBox's GitHub Page**{{ '{{<' }} /button {{ '>}}' }}
+
+![Nekobox-01](/images/nekobox-12.png)
+
+{{ '{{<' }} hint danger {{ '>}}' }}
+**Warning**
+The version of Nekobox that you can download from Google Play Store is not developed by the official developer of NekoBox, **please do not use that application.**
+{{ '{{<' }} /hint {{ '>}}' }}
+
+2. Install the application and open it.
+
+3. Grant it the permission to send you notifications (if asked).
+
+![Nekobox-02](/images/nekobox-01.png)
+
+4. In the **“VpnService Policy”**, click on YES.
+
+![Nekobox-03](/images/nekobox-02.png)
+
+5. Download the NekoBox Settings file by clicking on the button below.
+
+{{ '{{<' }} button href="/nekoboxsetting/Nekobox-Settings.json" {{ '>}}' }}**Download NekoBox Settings**{{ '{{<' }} /button {{ '>}}' }}
+
+6. Open the **dashboard** by clicking on the menu icon on the top left corner of the interface and **“Tools”**.
+
+![Nekobox-04](/images/nekobox-03.png)
+
+7. Go to the **“Backup”** tab and click on **“Import from file”** button.
+
+![Nekobox-05](/images/nekobox-04.png)
+
+8. Select the setting file that you downloaded from **step 5**, then click on **“IMPORT”**.
+
+![Nekobox-06](/images/nekobox-05.png)
+![Nekobox-07](/images/nekobox-06.png)
+
+9. If done **properly**, the UI should then turn **blue** and you should be brought back to the main screen of the app.
+
+10. Copy the VPN URL of your of the protocol of your choice (**VMess**, **VLess** and/or **Trojan**) from below:
+
+{% if users[item].vless is defined %}
+```
+{{ users[item].vless }}
+```
+{% endif %}
+
+{% if users[item].vmess is defined %}
+```
+{{ users[item].vmess }}
+```
+{% endif %}
+
+{% if users[item].trojan is defined %}
+```
+{{ users[item].trojan }}
+```
+{% endif %}
+
+11. In the app, click on the + icon on at the top of the interface, and select **“Import from Clipboard”**. (Note: If it's imported successfully but the VPN profle doesn't appear in the list of your VPN profiles, close the app and open it again, and it will appear in the list.)
+
+![Nekobox-08](/images/nekobox-07.png)
+![Nekobox-09](/images/nekobox-08.png)
+
+12. Click on the VPN profile that was just added to select it. If you have multiple VPN profiles, the one that has a **highlight** on its left side is the one that is selected.
+
+13. Click on the **button** at the bottom of the screen to connect to the VPN profile.
+
+![Nekobox-10](/images/nekobox-09.png)
+
+14. To check the latency of the VPN profile, click on the tab that says **“Connected, tap to check connection”**. It will tell you the latency in milliseconds.
+
+![Nekobox-11](/images/nekobox-10.png)
+
+15. And that's it! You can check your IP location to ensure that you are connected to the VPN and it's working properly.
diff --git a/web/content.en/docs/android/nekossh.md.j2 b/web/content.en/docs/android/nekossh.md.j2
new file mode 100644
index 00000000..181b0520
--- /dev/null
+++ b/web/content.en/docs/android/nekossh.md.j2
@@ -0,0 +1,96 @@
+---
+title: SecureShell (SSH)
+type: docs
+weight: 1
+---
+
+# SecureShell (SSH) on NekoBox
+
+In this tutorial you will learn how to make use of the SSH VPN service using NekoBox.
+
+NekoBox is a VPN client supporting a variety of VPN protocols, and we will be using this handy client to use SecureShell (SSH) VPN protocol.
+
+**Please note that SSH is meant to be used as a last resort option in case the other methods don't work.**
+
+## How to install and use NekoBox for SSH VPN:
+
+1. Go to NekoBox's GitHub page by clicking the link below. Download the APK that is compatible with your device. (In most cases, arm64-v8a version is compatible unless you have an old smartphone.)
+
+{{ '{{<' }} button href="https://github.com/MatsuriDayo/NekoBoxForAndroid/releases/tag/1.2.9" {{ '>}}' }}**NekoBox's GitHub Page**{{ '{{<' }} /button {{ '>}}' }}
+
+![NekoSSH-01](/images/nekobox-12.png)
+
+{{ '{{<' }} hint danger {{ '>}}' }}
+**Warning**
+The version of Nekobox that you can download from Google Play Store is not developed by the official developer of NekoBox, **please do not use that application.**
+{{ '{{<' }} /hint {{ '>}}' }}
+
+2. Install the application and open it.
+
+3. Grant it the permission to send you notifications (if asked).
+
+![NekoSSH-02](/images/nekobox-01.png)
+
+4. In the **“VpnService Policy”**, click on YES.
+
+![NekoSSH-03](/images/nekobox-02.png)
+
+5. Download the NekoBox Settings file by clicking on the link below.
+
+[**Download NekoBox Settings**](/nekoboxsettings/Nekobox-Settings.json)
+
+6. Open the **dashboard** by clicking on the menu icon on the top left corner of the interface and **“Tools”**.
+
+![NekoSSH-04](/images/nekobox-03.png)
+
+7. Go to the **“Backup”** tab and click on **“Import from file”** button.
+
+![NekoSSH-05](/images/nekobox-04.png)
+
+8. Select the setting file that you downloaded from **step 5**, then click on **“IMPORT”**.
+
+![NekoSSH-06](/images/nekobox-05.png)
+![NekoSSH-07](/images/nekobox-06.png)
+
+9. If done **properly**, the UI should then turn **blue** and you should be brought back to the main screen of the app.
+
+10. In the app, click on the + icon on at the top of the interface, and select **"Manual"**, then click on **"SSH"**
+
+![NekoSSH-08](/images/nekobox-07.png)
+![NekoSSH-09](/images/nekossh-01.png)
+![NekoSSH-10](/images/nekossh-02.png)
+
+11. In the next menu, click on the option that says **"Authentication Type"** and change it to **"Public Key".**
+
+![NekoSSH-11](/images/nekossh-03.png)
+![NekoSSH-12](/images/nekossh-04.png)
+
+12. Once you've done the preivous step, **copy** and **paste** the following values in their respective fields.
+
+ 1. Any name that you want.
+
+ 2. {{ ansible_all_ipv4_addresses[0] }}
+
+ 3. **sshvpn**
+
+ 4. Copy and paste the entire box below and paste it in field **#4**.
+
+```bash
+{{ users[item].sshvpn }}
+```
+
+![NekoSSH-13](/images/nekossh-05.png)
+
+13. Once done, click on the checkmark button at top right corner. (Note: If it's imported successfully but the VPN profle doesn't appear in the list of your VPN profiles, close the app and open it again, and it will appear in the list.)
+
+14. Click on the VPN profile that was just added to select it. If you have multiple VPN profiles, the one that has a **highlight** on its left side is the one that is selected.
+
+13. Click on the **button** at the bottom of the screen to connect to the VPN profile.
+
+![NekoSSH-14](/images/nekossh-06.png)
+
+14. To check the latency of the VPN profile, click on the tab that says **“Connected, tap to check connection”**. It will tell you the latency in milliseconds.
+
+![NekoSSH-15](/images/nekobox-10.png)
+
+15. And that's it! You can check your IP location to ensure that you are connected to the VPN and it's working properly.
diff --git a/web/content.en/docs/windows/_index.md b/web/content.en/docs/windows/_index.md
new file mode 100644
index 00000000..fa3a83c1
--- /dev/null
+++ b/web/content.en/docs/windows/_index.md
@@ -0,0 +1,5 @@
+---
+bookFlatSection: true
+title: Windows Clients
+weight: 1
+---
diff --git a/web/content.en/docs/windows/nekoray.md.j2 b/web/content.en/docs/windows/nekoray.md.j2
new file mode 100644
index 00000000..a9f533cf
--- /dev/null
+++ b/web/content.en/docs/windows/nekoray.md.j2
@@ -0,0 +1,88 @@
+---
+title: NekoRay (V2Ray)
+type: docs
+---
+
+# NekoRay
+
+In this tutorial you will learn how to use NekoRay VPN client on Windows devices.
+
+NekoRay is a VPN client supporting a variety of VPN protocols, and we will be using this handy client to use our main VPN protocols on desktop computers, including VLess, VMess, and Trojan.
+
+## How to use NekoRay:
+
+1. Go to NekoRay's GitHub page by clicking the button below. Click on the link that is highlighted in the screenshot below and download NekoRay. It doesn't need installation, just extract it somewhere in your computer (it doesn't matter where but preferably somewhere easy to find).
+
+{{ '{{<' }} button href="https://github.com/MatsuriDayo/nekoray/releases" {{ '>}}' }}**NekoRay's GitHub Page**{{ '{{<' }} /button {{ '>}}' }}
+
+![Nekoray-01](/images/nekoray-12.png)
+
+2. Go to the folder where you extracted NekoRay and launch “nekoray” **as administrator**.
+
+![Nekoray-02](/images/nekoray-13.png)
+
+3. Click on the button that says “sing-box”
+
+![Nekoray-03](/images/nekoray-1.png)
+
+4. You will see the interface of the program in front of you. Click on the “Program” button at the top left corner.
+
+![Nekoray-04](/images/nekoray-2.png)
+
+5. Click on “Remember last profile”.
+
+![Nekoray-05](/images/nekoray-3.png)
+
+6. Copy the VPN URL of your of the protocol of your choice (VMess, VLess and/or Trojan) from below and then click on the “Program” button. This time, click on “Add profile from clipboard”.
+
+{% if users[item].vless is defined %}
+```
+{{ users[item].vless }}
+```
+{% endif %}
+
+{% if users[item].vmess is defined %}
+```
+{{ users[item].vmess }}
+```
+{% endif %}
+
+{% if users[item].trojan is defined %}
+```
+{{ users[item].trojan }}
+```
+{% endif %}
+
+![Nekoray-06](/images/nekoray-4.png)
+
+7. You will see that the VPN profile will appear in the middle. Right click on the VPN profile and choose “Start” (or click on the VPN profile and press Enter)
+
+![Nekoray-07](/images/nekoray-6.png)
+
+8. You will see a checkmark appear next to the VPN profile and the text turns blue. This happens when a VPN profile is selected. To select another profile, repeat the previous step (Step 5) on another profile.
+
+9. In order to turn on the VPN and access the internet, you will need to click on either of the two VPN modes: “System Proxy” and “Tun Mode”. Click on “Tun Mode”. This will tunnel all of your computer's internet traffic through the VPN, this includes all the programs. (You need to run the program as Administrator in order to use “Tun Mode”. If you haven't done so, you will be asked whether or not you want to run the program as Administrator.).
+
+![Nekoray-08](/images/nekoray-7.png)
+
+10. After clicking on “Tun Mode”, you will notice that a red dot appears on the system tray icon of NekoRay. This is to indicate that “Tun Mode” is on.
+
+![Nekoray-09](/images/nekoray-11.png)
+
+11. To turn the VPN off, simply click on the “Tun Mode” checkbox again. Do remember to turn off the VPN when you're done using it. Do not turn your PC off or close the program while the VPN is connected.
+
+12. And that's it! You can check your IP location to ensure that you are connected to the VPN and it's working properly.
+
+## Other notes
+
+1. You can turn the VPN on or off via the system tray icon. Right click on the system tray icon and go to system proxy and then choose either mode.
+
+![Nekoray-10](/images/nekoray-8.png)
+
+2. “System Proxy” mode activates the VPN via a proxy. This mode only works for browsers or web-clients (such as Discord's desktop app) and it will not work on any software is not browser-based (such as online games).
+
+3. In case you forget to turn the VPN off before turning off your PC or closing the program, you might run into some connection issues. This can be solved by launching NekoRay and turning off the VPN mode that you previously selected.
+
+4. In case you decide to change the program's settings and the client stops working, delete NekoRay's folder and download it again, and repeat the steps at the beginning of this tutorial.
+
+5. If you wish to test the latency of the VPN profile, right click on the profile you want, go to “Current Select” and click on “Url Test”. The number that appears under “Test results” column is the latency. Alternatively, you can click on the bottom left of the program where the name of the selected profile is displayed and it will also tell you what the latency is.
diff --git a/web/content.en/docs/windows/openconnect.md.j2 b/web/content.en/docs/windows/openconnect.md.j2
new file mode 100644
index 00000000..a63e3f70
--- /dev/null
+++ b/web/content.en/docs/windows/openconnect.md.j2
@@ -0,0 +1,53 @@
+---
+title: OpenConnect
+type: docs
+---
+
+# OpenConnect
+
+## How to install and use OpenConnect client on Windows devices:
+
+1. Go to the OpenConnect client's GitLab page by clicking the button below. Click on the link that is marked in the screenshot below. Installation is straightforward and very easy.
+
+{{ '{{<' }} button href="https://gitlab.com/openconnect/openconnect-gui/-/releases" {{ '>}}' }}**OpenConnect client GitLab Page**{{ '{{<' }} /button {{ '>}}' }}
+
+![Openconnect-01](/images/windows-oc-01.png)
+
+2. It should prompt you to install a distributable and a network adapter, give it permission to do so.
+
+3. Open the client, click on the gear icon in the middle, then click on **“New profile (Advanced)”**
+
+![Openconnect-02](/images/windows-oc-02.png)
+
+4. Download the two files that are provided below:
+
+ 1. **User Certificate:** [Click to download User Certificate](/{{item}}/{{item}}-User-Certificate.pem)
+
+ 2. **User Key:** [Click to download User Key](/{{item}}/{{item}}-User-Key.pem)
+
+5. Do the following in the given order:
+
+ 1. Enter a **name** (any name you want)
+
+ 2. Copy the following and paste it in the server address field: **{{ ansible_all_ipv4_addresses[0] }}:{{ ocserv_port | default(4430) }}**
+
+ 3. Click on the button next to the **“User Certificated”** field, and choose the User Certificate file that you downloaded from **step 4**.
+
+ 4. Click on the button next to the **“User Key”** field, and choose the User Key file that you downloaded from **step 4**.
+
+![Openconnect-03](/images/windows-oc-03.png)
+![Openconnect-04](/images/windows-oc-04.png)
+
+6. Click save, then click on the big button in the middle saying **"connect"**.
+
+![Openconnect-05](/images/windows-oc-05.png)
+
+7. You'll be prompted to confirm the information regarding the destination address. Click on **“Accurate information”**.
+
+![Openconnect-06](/images/windows-oc-06.png)
+
+8. To disconnect the VPN, simply click on the **Disconnect** button in the main interface.
+
+![Openconnect-07](/images/windows-oc-08.png)
+
+9. And that's it! You can check your IP location to ensure that you are connected to the VPN and it's working properly.
diff --git a/web/content.fa/_index.md b/web/content.fa/_index.md
new file mode 100644
index 00000000..c105868a
--- /dev/null
+++ b/web/content.fa/_index.md
@@ -0,0 +1,25 @@
+---
+title: معرفی
+type: docs
+---
+# راکتانس
+
+## درباره سرویس
+{{< hint info >}}
+واژه راکتانس (Reactance) در روانشناسی به معنای هرگونه واکنش ناخوشایند به پیشنهادات، افراد، قوانین و مقرراتی است که آزادی‌های فردی و رفتاری را تهدید یا سلب می‌کند.
+{{< /hint >}}
+راکتانس به شما کمک خواهد کرد سانسور اینترنتی که توسط دولت‌ها تحمیل می‌شود را دور بزنید. راکتانس خود پروتکل وی پی ان نیست، در واقع یک سیستم اتوماسیون شده از سرورها و پروتکل‌های دست‌چین شده است که به شما کمک خواهد کرد محدودیت‌های اینترنت را دور بزنید.
+
+## راه اندازی
+
+راکتانس، نرم افزار و کلاینت وی پی ان خود را ندارد. در عوض، برای شما آموزش و سایر موارد مورد نیاز برای اتصال به نرم افزارهای موجود را فراهم می‌کند. دسته بندی نرم افزارها بر اساس سیستم عامل است. در سمت راست صفحه، روی نام نرم افزار یا اپلیکیشن مد نظر کلیک کنید تا آموزش اتصال به وی پی ان از طریق آن برای شما نمایش داده شود.
+
+{{< hint warning >}}
+**نکته مهم:** لطفاً تا ۲۴ ساعت بعد از دسترسی به این سایت، وی پی ان را طبق آموزش‌های ارائه شده راه اندازی کنید. این سایت موقت است و به صورت خودکار بعد از ۲۴ ساعت حذف خواهد شد و دسترسی شما به آن قطع خواهد شد.
+{{< /hint >}}
+
+{{< hint danger >}}
+استفاده از وی پی ان، بلیط یک طرفه به آزادی نیست!
+تنها کار یک وی پی ان، معاوضه یک سانسور با سانسور دیگر است.
+لطفاً از جستجو و انجام فعالیت‌های غیرقانونی، مانند دانلود کردن تورنت یا جستجو کردن محتوای غیرقانونی خودداری کنید تا این سرویس بدون مشکل و اختلال برقرار بماند.
+{{< /hint >}}
diff --git a/web/content.fa/docs/_index.md b/web/content.fa/docs/_index.md
new file mode 100644
index 00000000..dfd18748
--- /dev/null
+++ b/web/content.fa/docs/_index.md
@@ -0,0 +1,5 @@
+---
+bookFlatsection: true
+title: Introduction
+type: docs
+---
diff --git a/web/content.fa/docs/android/_index.md b/web/content.fa/docs/android/_index.md
new file mode 100644
index 00000000..310fbdd8
--- /dev/null
+++ b/web/content.fa/docs/android/_index.md
@@ -0,0 +1,5 @@
+---
+bookFlatSection: true
+title: اندروید
+weight: 2
+---
diff --git a/web/content.fa/docs/android/anyconnect.md.j2 b/web/content.fa/docs/android/anyconnect.md.j2
new file mode 100644
index 00000000..97e9e426
--- /dev/null
+++ b/web/content.fa/docs/android/anyconnect.md.j2
@@ -0,0 +1,97 @@
+---
+title: AnyConnect
+type: docs
+---
+
+# انی کانکت (AnyConnect)
+
+۱. برای استفاده از پروتکل AnyConnect، نیاز است که اپلیکیشن **Cisco AnyConnect** از گوگل پلی استور دانلود شود. روی دکمه زیر کلیک کنید تا به صفحه گوگل پلی استور اپلیکیشن بروید.
+
+{{ '{{<' }} button href="https://play.google.com/store/apps/details?id=com.cisco.anyconnect.vpn.android.avf&hl=en&gl=US" {{ '>}}' }}**دانلود اپلیکیشن Cisco AnyConnect از گوگل پلی استور**{{ '{{<' }} /button {{ '>}}' }}
+
+۲. برای دانلود فایل Certificate روی دکمه زیر کلیک کنید:
+
+{{ '{{<' }} button href="/{{item}}/{{item}}-Certificate-Android.p12" {{ '>}}' }}**دانلود فایل Certificate با پسوند .P12**{{ '{{<' }} /button {{ '>}}' }}
+
+۳. اپلیکیشن را اجرا کنید.
+
+۴. روی **OK** کلیک کنید تا پیام اولیه اپلیکیشن بسته شود.
+
+![AnyConnect-01](/images/anyconnect-01.png)
+
+۵. دسترسی‌های مورد نیاز را به اپلیکیشن بدهید. (در این مورد، تنها دسترسی به **اعلان‌ها** نیاز است.)
+
+![AnyConnect-02](/images/anyconnect-02.png)
+
+۶. در بالا سمت راست صفحه، روی سه نقطه بزنید و سپس **Settings** را انتخاب کنید.
+
+![AnyConnect-03](/images/anyconnect-03.png)
+
+۷. تیک گزینه دوم که می‌گوید **Block Untrusted Servers** را حذف کنید.
+
+![AnyConnect-04](/images/anyconnect-04.png)
+
+۸. روی علامت فلش در بالا سمت راست صفحه بزنید تا به منوی اصلی برنامه برگردید.
+
+۹. در وسط صفحه، روی گزینه **Connections** بزنید.
+
+![AnyConnect-05](/images/anyconnect-05.png)
+
+۱۰. در پایین صفحه، روی علامت مثبت بزنید.
+
+![AnyConnect-06](/images/anyconnect-06.png)
+
+۱۱. در صفحه بعدی، موارد زیر را طبق عکس به ترتیب انجام دهید:
+
+1. اسم دلخواه خودتان
+
+2. آدرس سرور و پورت رو به رو: **{{ ansible_all_ipv4_addresses[0] }}:{{ ocserv_port | default(4430) }}**
+
+3. سپس، روی گزینه **Advanced Preferences** بزنید.
+
+![AnyConnect-07](/images/anyconnect-07.png)
+
+۱۲. روی گزینه اول که **Certificate** نام دارد بزنید.
+
+![AnyConnect-08](/images/anyconnect-08.png)
+
+۱۳. روی گزینه **Import** در پایین صفحه بزنید.
+
+![AnyConnect-09](/images/anyconnect-09.png)
+
+۱۴. روی گزینه **File System** بزنید. پس از آن، اپلیکیشن مدیریت فایل‌های گوشی شما باز می‌شود.
+
+![AnyConnect-10](/images/anyconnect-10.png)
+
+۱۵. فایلی که در قدم دوم دانلود کردید (با پسوند .p12) را انتخاب کنید.
+
+![AnyConnect-11](/images/anyconnect-11.png)
+
+۱۶. پس از انتخاب کردن فایل، از شما می‌خواهد که پسورد وارد کنید. عبارت رو به رو را وارد کنید: **{{ item }}**
+
+![AnyConnect-12](/images/anyconnect-12.png)
+
+۱۷. پس از وارد کردن فایل Certificate، آن را انتخاب کنید.
+
+![AnyConnect-13](/images/anyconnect-13.png)
+
+۱۸. در پایین صفحه، روی گزینه **Done** بزنید و پس از آن بار دیگر **Done** را بزنید.
+
+![AnyConnect-14](/images/anyconnect-14.png)
+![AnyConnect-15](/images/anyconnect-15.png)
+
+۱۹. روی علامت فلش در بالا سمت چپ صفحه بزنید.
+
+![AnyConnect-16](/images/anyconnect-16.png)
+
+۲۰. در صفحه اصلی، روی **دکمه روشن کردن** بزنید.
+
+![AnyConnect-17](/images/anyconnect-18.png)
+
+۲۱. گزینه **Continue** را انتخاب کنید.
+
+![AnyConnect-18](/images/anyconnect-17.png)
+
+۲۲. تمام! حال به VPN وصل شده‌اید. می‌توانید با استفاده از سایت‌های بررسی IP، لوکیشن خود را بررسی و از اتصال به VPN اطمینان حاصل کنید.
+
+![AnyConnect-19](/images/anyconnect-19.png)
diff --git a/web/content.fa/docs/android/nekobox.md.j2 b/web/content.fa/docs/android/nekobox.md.j2
new file mode 100644
index 00000000..ab275ec9
--- /dev/null
+++ b/web/content.fa/docs/android/nekobox.md.j2
@@ -0,0 +1,90 @@
+---
+title: NekoBox (V2Ray)
+type: docs
+weight: 1
+---
+
+# نِکوباکس (NekoBox)
+
+در این آموزش یاد خواهید گرفت که چگونه از اپلیکیشن نِکوباکس روی دستگاه‌های اندرویدی استفاده کنید.
+
+اپلیکیشن نِکوباکس از پروتکل‌های زیادی پشتیبانی می‌کند، و از این اپلیکیشن برای اتصال به پروتکل‌های اصلی‌مان روی دستگاه‌های اندرویدی استفاده خواهیم کرد. پروتکل‌هایی که مورد استفاده قرار خواهند گرفت عبارتند از: VLess، VMess و Trojan.
+
+## نحوه نصب و استفاده از نِکوباکس:
+
+۱. روی دکمه زیر کلیک کنید تا به صفحه گیت‌هاب نِکوباکس بروید. فایل APK که با گوشی شما سازگار است را دانلود کنید. (در اکثر موارد، نسخه **arm64-v8a** با گوشی‌تان سازگار است، مگر این که دستگاه شما قدیمی باشد که در آن صورت نسخه **armeabi-v7a** را نصب کنید.)
+
+{{ '{{<' }} button href="https://github.com/MatsuriDayo/NekoBoxForAndroid/releases/tag/1.2.9" {{ '>}}' }}**صفحه گیت‌هاب نِکوباکس**{{ '{{<' }} /button {{ '>}}' }}
+
+![Nekobox-01](/images/nekobox-12.png)
+
+{{ '{{<' }} hint danger {{ '>}}' }}
+**اخطار**
+اپلیکیشن نِکوباکس که در گوگل پلی استور است توسط خود سازنده‌های اصلی نِکوباکس ارائه نشده است. **از استفاده از آن نسخه خودداری کنید.**
+{{ '{{<' }} /hint {{ '>}}' }}
+
+۲. اپلیکیشن را نصب و اجرا کنید.
+
+۳. در صورت لزوم، دسترسی‌های مورد نیاز را به اپلیکیشن بدهید (دسترسی به **اعلان‌ها**)
+
+![Nekobox-02](/images/nekobox-01.png)
+
+۴. در پیام بعدی، روی **YES** کلیک کنید.
+
+![Nekobox-03](/images/nekobox-02.png)
+
+۵. روی دکمه زیر کلیک کنید و فایل تنظیمات نِکوباکس را دانلود کنید.
+
+{{ '{{<' }} button href="/nekoboxsetting/Nekobox-Settings.json" {{ '>}}' }}**دانلود تنظیمات نِکوباکس**{{ '{{<' }} /button {{ '>}}' }}
+
+۶. در بالا سمت چپ، روی دکمه سه خط بزنید تا پنل **تنظیمات** باز شود. سپس، روی **Tools** بزنید.
+
+![Nekobox-04](/images/nekobox-03.png)
+
+۷. به قسمت **Backup** بروید و گزینه **Import from file** را انتخاب کنید.
+
+![Nekobox-05](/images/nekobox-04.png)
+
+۸. فایلی که در قدم **شماره 5** دانلود کردید را انتخاب کنید و سپس روی **IMPORT** بزنید.
+
+![Nekobox-06](/images/nekobox-05.png)
+![Nekobox-07](/images/nekobox-06.png)
+
+۹. اگر قدم قبلی را **درست** انجام داده باشید، رنگ محیط کاربری به **آبی** تغییر می‌کند و به صفحه اصلی اپلیکیشن برمی‌گردید.
+
+۱۰. لینک پروتکل مورد نظرتان را از قسمت پایین کپی کنید.
+
+{% if users[item].vless is defined %}
+```
+{{ users[item].vless }}
+```
+{% endif %}
+
+{% if users[item].vmess is defined %}
+```
+{{ users[item].vmess }}
+```
+{% endif %}
+
+{% if users[item].trojan is defined %}
+```
+{{ users[item].trojan }}
+```
+{% endif %}
+
+۱۱. در اپلیکیشن، روی علامت مثبت در بالای صحفه بزنید و **Import from Clipboard** را انتخاب کنید. (**نکته: ** اگر که لینک با موفقیت وارد برنامه شد ولی در صفحه اصلی برنامه چیزی ظاهر نشد، برنامه را ببندید و دوباره باز کنید تا در لیست ظاهر شود.)
+
+![Nekobox-08](/images/nekobox-07.png)
+![Nekobox-09](/images/nekobox-08.png)
+
+۱۲. روی VPN که به تازگی ایجاد شد بزنید تا انتخاب شود. اگر چندین پروفایل در لیست دارید، آن که در کنارش نوار رنگی است انتخاب شده است.
+
+۱۳. روی **دکمه** پایین صفحه بزنید تا به VPN وصل شوید.
+
+![Nekobox-10](/images/nekobox-09.png)
+
+۱۴. برای بررسی سرعت و تاخیر، در پایین صفحه روی نوشته **Connected, Tap to check connection** بزنید تا میزان تاخیر به میلی ثانیه به شما نشان داده شود.
+
+![Nekobox-11](/images/nekobox-10.png)
+
+۱۵. تمام! حال به VPN وصل شده‌اید. می‌توانید با استفاده از سایت‌های بررسی IP، لوکیشن خود را بررسی و از اتصال به VPN اطمینان حاصل کنید.
diff --git a/web/content.fa/docs/android/nekossh.md.j2 b/web/content.fa/docs/android/nekossh.md.j2
new file mode 100644
index 00000000..439fdf43
--- /dev/null
+++ b/web/content.fa/docs/android/nekossh.md.j2
@@ -0,0 +1,96 @@
+---
+title: SecureShell (SSH)
+type: docs
+weight: 1
+---
+
+# پروتکل SecureShell (SSH)
+
+در این آموزش یاد خواهید گرفت که چگونه از نِکوباکس برای به کارگیری پروتکل SSH استفاده کنید.
+
+اپلیکیشن نِکوباکس از پروتکل‌های زیادی پشتیبانی می‌کند، و از این اپلیکیشن برای اتصال به پروتکل SSH استفاده خواهیم کرد.
+
+**توجه داشته باشید که این پروتکل تنها در صورتی ارائه می‌شود که سایر پروتکل و متودها برای شما قابل استفاده نباشد.**
+
+## نحوه نصب و استفاده از نِکوباکس برای استفاده از پروتکل SSH:
+
+۱. روی دکمه زیر کلیک کنید تا به صفحه گیت‌هاب نِکوباکس بروید. فایل APK که با گوشی شما سازگار است را دانلود کنید. (در اکثر موارد، نسخه **arm64-v8a** با گوشی‌تان سازگار است، مگر این که دستگاه شما قدیمی باشد که در آن صورت نسخه **armeabi-v7a** را نصب کنید.)
+
+{{ '{{<' }} button href="https://github.com/MatsuriDayo/NekoBoxForAndroid/releases/tag/1.2.9" {{ '>}}' }}**صفحه گیت‌هاب نِکوباکس**{{ '{{<' }} /button {{ '>}}' }}
+
+![Nekobox-01](/images/nekobox-12.png)
+
+{{ '{{<' }} hint danger {{ '>}}' }}
+**اخطار**
+اپلیکیشن نِکوباکس که در گوگل پلی استور است توسط خود سازنده‌های اصلی نِکوباکس ارائه نشده است. **از استفاده از آن نسخه خودداری کنید.**
+{{ '{{<' }} /hint {{ '>}}' }}
+
+۲. اپلیکیشن را نصب و اجرا کنید.
+
+۳. در صورت لزوم، دسترسی‌های مورد نیاز را به اپلیکیشن بدهید (دسترسی به **اعلان‌ها**)
+
+![Nekobox-02](/images/nekobox-01.png)
+
+۴. در پیام بعدی، روی **YES** کلیک کنید.
+
+![Nekobox-03](/images/nekobox-02.png)
+
+۵. روی دکمه زیر کلیک کنید و فایل تنظیمات نِکوباکس را دانلود کنید.
+
+{{ '{{<' }} button href="/nekoboxsetting/Nekobox-Settings.json" {{ '>}}' }}**دانلود تنظیمات نِکوباکس**{{ '{{<' }} /button {{ '>}}' }}
+
+۶. در بالا سمت چپ، روی دکمه سه خط بزنید تا پنل **تنظیمات** باز شود. سپس، روی **Tools** بزنید.
+
+![Nekobox-04](/images/nekobox-03.png)
+
+۷. به قسمت **Backup** بروید و گزینه **Import from file** را انتخاب کنید.
+
+![Nekobox-05](/images/nekobox-04.png)
+
+۸. فایلی که در قدم **شماره 5** دانلود کردید را انتخاب کنید و سپس روی **IMPORT** بزنید.
+
+![Nekobox-06](/images/nekobox-05.png)
+![Nekobox-07](/images/nekobox-06.png)
+
+۹. اگر قدم قبلی را **درست** انجام داده باشید، رنگ محیط کاربری به **آبی** تغییر می‌کند و به صفحه اصلی اپلیکیشن برمی‌گردید.
+
+۱۰. در صفحه اصلی، روی گزینه مثبت در بالای صفحه بزنید و سپس **Manual** را انتخاب کنید. پس از آن، **SSH** را انتخاب کنید.
+
+![NekoSSH-08](/images/nekobox-07.png)
+![NekoSSH-09](/images/nekossh-01.png)
+![NekoSSH-10](/images/nekossh-02.png)
+
+۱۱. در صفحه‌ای که باز می‌شود، روی گزینه **Authentication Type** بزنید و آن را به **Public Key** تغییر دهید.
+
+![NekoSSH-11](/images/nekossh-03.png)
+![NekoSSH-12](/images/nekossh-04.png)
+
+۱۲. حال موارد زیر را مطابق عکس در جاهای مربوطه کپی کنید.
+
+۱. نام دلخواه خودتان
+
+۲. {{ ansible_all_ipv4_addresses[0] }}
+
+۳. **sshvpn**
+
+۴. محتوای باکس زیر را به طور کامل کپی کنید و در قسمت **Private key** وارد کنید.
+
+```bash
+{{ users[item].sshvpn }}
+```
+
+![NekoSSH-13](/images/nekossh-05.png)
+
+۱۳. پس از وارد کردن موارد قبلی، روی گزینه تیک در بالا سمت چپ بزنید. (نکته، اگر که لینک با موفقیت وارد برنامه شد ولی در صفحه اصلی برنامه چیزی ظاهر نشد، برنامه را ببندید و دوباره باز کنید تا در لیست ظاهر شود.)
+
+۱۴. روی VPN که به تازگی ایجاد شد بزنید تا انتخاب شود. اگر چندین پروفایل در لیست دارید، آن که در کنارش نوار رنگی است انتخاب شده است.
+
+۱۵. روی **دکمه** پایین صفحه بزنید تا به VPN وصل شوید.
+
+![NekoSSH-14](/images/nekossh-06.png)
+
+۱۶. برای بررسی سرعت و تاخیر، در پایین صفحه روی نوشته **Connected, Tap to check connection** بزنید تا میزان تاخیر به میلی ثانیه به شما نشان داده شود.
+
+![NekoSSH-15](/images/nekobox-10.png)
+
+۱۷. تمام! حال به VPN وصل شده‌اید. می‌توانید با استفاده از سایت‌های بررسی IP، لوکیشن خود را بررسی و از اتصال به VPN اطمینان حاصل کنید.
diff --git a/web/content.fa/docs/windows/_index.md b/web/content.fa/docs/windows/_index.md
new file mode 100644
index 00000000..ea6b32a0
--- /dev/null
+++ b/web/content.fa/docs/windows/_index.md
@@ -0,0 +1,5 @@
+---
+bookFlatSection: true
+title: ویندوز
+weight: 1
+---
diff --git a/web/content.fa/docs/windows/nekoray.md.j2 b/web/content.fa/docs/windows/nekoray.md.j2
new file mode 100644
index 00000000..1c479346
--- /dev/null
+++ b/web/content.fa/docs/windows/nekoray.md.j2
@@ -0,0 +1,88 @@
+---
+title: NekoRay (V2Ray)
+type: docs
+---
+
+# نِکو رِی (NekoRay)
+
+در این آموزش یاد خواهید گرفت که چگونه از اپلیکیشن نِکو رِی در ویندوز استفاده کنید.
+
+اپلیکیشن نِکو رِی از پروتکل‌های زیادی پشتیبانی می‌کند، و از این اپلیکیشن برای اتصال به پروتکل‌های اصلی‌مان در ویندوز استفاده خواهیم کرد. پروتکل‌هایی که مورد استفاده قرار خواهند گرفت عبارتند از: VLess، VMess و Trojan.
+
+## نحوه نصب و استفاده از نِکو رِی:
+
+۱. روی دکمه زیر کلیک کنید تا به صفحه گیت‌هاب NekoRay بروید. بر لینکی که در اسکرین شات زیر دور آن خط کشیده شده است کلیک کنید. برنامه نیاز به نصب کردن ندارد و فقط کافیست آن را در جایی در ویندوز استخراج کنید. (فرقی ندارد در کجای ویندوز، ولی ترجیحاً جایی که قابل پیدا کردن باشد.)
+
+{{ '{{<' }} button href="https://github.com/MatsuriDayo/nekoray/releases" {{ '>}}' }}**صفحه گیت‌هاب نِکو رِی**{{ '{{<' }} /button {{ '>}}' }}
+
+![Nekoray-01](/images/nekoray-12.png)
+
+۲. به جایی که برنامه را استخراج کردید بروید و فایل **nekoray** را اجرا کنید. (به صورت Run as Administrator)
+
+![Nekoray-02](/images/nekoray-13.png)
+
+۳. روی دکمه **sing-box** کلیک کنید.
+
+![Nekoray-03](/images/nekoray-1.png)
+
+۴. صفحه اصلی برنامه برای شما باز خواهد شد. روی دکمه **Program** در بالا سمت چپ کلیک کنید.
+
+![Nekoray-04](/images/nekoray-2.png)
+
+۵. روی **Remember my last profile** کلیک کنید.
+
+![Nekoray-05](/images/nekoray-3.png)
+
+۶. لینک پروتکل مورد نظرتان را از باکس پایین کپی کنید و بار دیگر روی دکمه **Program** کلیک کنید. این بار، گزینه **Add profile from clipboard** را انتخاب کنید.
+
+{% if users[item].vless is defined %}
+```
+{{ users[item].vless }}
+```
+{% endif %}
+
+{% if users[item].vmess is defined %}
+```
+{{ users[item].vmess }}
+```
+{% endif %}
+
+{% if users[item].trojan is defined %}
+```
+{{ users[item].trojan }}
+```
+{% endif %}
+
+![Nekoray-06](/images/nekoray-4.png)
+
+۷. با انجام قدم قبلی، در لیست VPNهای برنامه، آن که کپی کرده بودید ظاهر می‌شود. روی آن کلیک راست کنید و **Start** را انتخاب کنید (یا پس از انتخاب آن، کلید Enter را فشار دهید.)
+
+![Nekoray-07](/images/nekoray-6.png)
+
+۸. پس از انجام قدم قبلی، یک تیک کنار پروفایل VPN ظاهر می‌شود و متن آن آبی می‌شود. اگر چند پروفایل دارید، قدم قبلی را روی آن‌ها اجرا کنید.
+
+۹. برای استفاده از VPN و دسترسی به اینترنت، باید روی یکی از دو گزینه System Proxy**** یا **Tun mode** کلیک کنید. تیک گزینه **Tun mode** را فعال کنید تا تمام ترافیک سیستم از بتواند از VPN استفاده کند.
+
+![Nekoray-08](/images/nekoray-7.png)
+
+۱۰. پس از روشن کردن حالت Tun Mode، نقطه قرمزی روی آیکون نِکو رِی در پایین سمت راست ویندوز (آیکون Tray) ظاهر می‌شود که به معنی روشن بودن این حالت است.
+
+![Nekoray-09](/images/nekoray-11.png)
+
+۱۱. برای خاموش کردن VPN، تنها کافیست تیک گزینه **Tun mode** را بردارید. حتماً پس از **اتمام استفاده‌تان** از VPN آن را خاموش کنید. **در حین فعال بودن VPN، برنامه را نبندید و سیستم را خاموش نکنید.**
+
+۱۲. تمام! حال به VPN وصل شده‌اید. می‌توانید با استفاده از سایت‌های بررسی IP، لوکیشن خود را بررسی و از اتصال به VPN اطمینان حاصل کنید.
+
+## سایر نکات
+
+۱. می‌توان برنامه را از طریق آیکون **Tray** نیز خاموش و روشن کرد. روی آیکون آن در گوشه پایین سمت راست کلیک راست کنید و در قسمت **System proxy**، حالت مورد نظرتان را انتخاب کنید. (همان کاری که در قدم شماره 9 انجام دادید.)
+
+![Nekoray-10](/images/nekoray-8.png)
+
+۲. حالت **System Proxy**، به صورت پروکسی برای شما VPN را فعال می‌کند. به این صورت که تنها اپلیکیشن‌های تحت وب و مرورگرها قابلیت استفاده از آن را دارند و روی سایر اپلیکیشن‌های ویندوز (برای مثال بازی‌های آنلاین) تاثیری نخواهد داشت.
+
+۳. در صورتی که فراموش کنید قبل از بستن برنامه یا خاموش کردن سیستم، VPN را خاموش کنید، برای اتصال به اینترنت به مشکل بر خواهید خورد. برای رفع آن، بار دیگر برنامه را اجرا کنید و حالت VPN که انتخاب کرده بودید را غیر فعال کنید.
+
+۴. در صورتی که تنظیمات برنامه را دستکاری کردید و برنامه دیگر کار نکرد، پوشه NekoRay را کامل پاک کنید و از ابتدا آموزش را دنبال کنید.
+
+۵. در صورتی که تمایل به تست سرعت و تاخیر VPN داشتید، می‌توانید روی گوشه پایین سمت چپ برنامه کلیک کنید (جایی که نام پروفایل انتخاب شده نوشته شده است) تا تاخیر به میلی ثانیه به شما نمایش داده شود.
diff --git a/web/content.fa/docs/windows/openconnect.md.j2 b/web/content.fa/docs/windows/openconnect.md.j2
new file mode 100644
index 00000000..4d8e66f2
--- /dev/null
+++ b/web/content.fa/docs/windows/openconnect.md.j2
@@ -0,0 +1,53 @@
+---
+title: OpenConnect
+type: docs
+---
+
+# اوپن کانکت (OpenConnect)
+
+## نحوه نصب و استفاده از اوپن کانکت در ویندوز
+
+۱. بر دکمه زیر کلیک کنید تا وارد صفحه گیت‌لَب اوپن کانکت شوید. روی لینکی که در اسکرین‌شات زیر علامت زده شده است کلیک کنید. نصب آن آسان و بدون دردسر است.
+
+{{ '{{<' }} button href="https://gitlab.com/openconnect/openconnect-gui/-/releases" {{ '>}}' }}**صفحه گیت‌لَب اوپن کانکت**{{ '{{<' }} /button {{ '>}}' }}
+
+![Openconnect-01](/images/windows-oc-01.png)
+
+۲. در حین نصب، اجازه نصب **فایل‌های مورد نیاز** و **آداپتور شبکه** را به آن بدهید.
+
+۳. برنامه را اجرا کنید. روی علامت چرخ دنده کلیک کنید و گزینه **New Profile (Advanced)** را انتخاب کنید.
+
+![Openconnect-02](/images/windows-oc-02.png)
+
+۴. دو فایل زیر را دانلود کنید.
+
+1. فایل **User Certificate:** [برای دانلود User Certificate کلیک کنید](/{{item}}/{{item}}-User-Certificate.pem)
+
+2. فایل **User Key:** [برای دانلود User Key کلیک کنید](/{{item}}/{{item}}-User-Key.pem)
+
+۵. به ترتیب، مراحل زیر را انجام دهید:
+
+1. نام دلخواه خود را وارد کنید.
+
+2. آی پی و پورت رو به رو را در قسمت **شماره 2** وارد کنید: **{{ ansible_all_ipv4_addresses[0] }}:{{ ocserv_port | default(4430) }}**
+
+3. روی دکمه کنار قسمت **User Certificate** کلیک کنید و فایل **User Certificate** که در **قدم شماره ۴** دانلود کردید را انتخاب کنید.
+
+4. روی دکمه کنار قسمت **User Key** کلیک کنید و فایل **User Certificate** که در **قدم شماره ۴** دانلود کردید را انتخاب کنید.
+
+![Openconnect-03](/images/windows-oc-03.png)
+![Openconnect-04](/images/windows-oc-04.png)
+
+۶. روی **Save** کلیک کنید، و در صفحه اصلی روی دکمه **Connect** در وسط صفحه کلیک کنید.
+
+![Openconnect-05](/images/windows-oc-05.png)
+
+۷. در پیام بعدی، روی دکمه **Accurate Information** کلیک کنید.
+
+![Openconnect-06](/images/windows-oc-06.png)
+
+۸. برای خاموش کردن VPN، روی دکمه **Disconnect** کلیک کنید.
+
+![Openconnect-07](/images/windows-oc-08.png)
+
+۹. تمام! حال به VPN وصل شده‌اید. می‌توانید با استفاده از سایت‌های بررسی IP، لوکیشن خود را بررسی و از اتصال به VPN اطمینان حاصل کنید.
diff --git a/web/hugo.toml.j2 b/web/hugo.toml.j2
new file mode 100644
index 00000000..1587763b
--- /dev/null
+++ b/web/hugo.toml.j2
@@ -0,0 +1,29 @@
+baseURL = 'http://{{ ansible_all_ipv4_addresses[0] }}/{{ item }}'
+languageCode = 'en-us'
+title = 'Reactance VPN'
+theme = 'hugo-book'
+
+[params]
+ # (Optional, default light) Sets color theme: light, dark or auto.
+ # Theme 'auto' switches between dark and light modes based on browser/os preferences
+ BookTheme = 'light'
+ BookRepo = 'https://github.com/sarzilhossain'
+
+[languages]
+[languages.en]
+ contentDir = 'content.en'
+ disabled = false
+ languageCode = 'en-US'
+ languageDirection = 'ltr'
+ languageName = 'English'
+ title = 'Reactance VPN'
+ weight = 1
+
+[languages.fa]
+ contentDir = 'content.fa'
+ disabled = false
+ languageCode = 'fa-IR'
+ languageDirection = 'rtl'
+ languageName = 'فارسی'
+ title = 'راکتانس وی پی ان'
+ weight = 2
diff --git a/web/resources/_gen/assets/scss/book.scss_50fc8c04e12a2f59027287995557ceff.content b/web/resources/_gen/assets/scss/book.scss_50fc8c04e12a2f59027287995557ceff.content
new file mode 100644
index 00000000..50c6fede
--- /dev/null
+++ b/web/resources/_gen/assets/scss/book.scss_50fc8c04e12a2f59027287995557ceff.content
@@ -0,0 +1 @@
+@charset "UTF-8";:root{--gray-100:#f8f9fa;--gray-200:#e9ecef;--gray-500:#adb5bd;--color-link:#0055bb;--color-visited-link:#8440f1;--body-background:white;--body-font-color:black;--icon-filter:none;--hint-color-info:#6bf;--hint-color-warning:#fd6;--hint-color-danger:#f66}/*!normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css*/html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}.flex{display:flex}.flex-auto{flex:auto}.flex-even{flex:1 1}.flex-wrap{flex-wrap:wrap}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.align-center{align-items:center}.mx-auto{margin:0 auto}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.hidden{display:none}input.toggle{height:0;width:0;overflow:hidden;opacity:0;position:absolute}.clearfix::after{content:"";display:table;clear:both}html{font-size:16px;scroll-behavior:smooth;touch-action:manipulation}body{min-width:20rem;color:var(--body-font-color);background:var(--body-background);letter-spacing:.33px;font-weight:400;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;box-sizing:border-box}body *{box-sizing:inherit}h1,h2,h3,h4,h5{font-weight:400}a{text-decoration:none;color:var(--color-link)}img{vertical-align:baseline}:focus{outline-style:auto;outline-color:currentColor;outline-color:-webkit-focus-ring-color}aside nav ul{padding:0;margin:0;list-style:none}aside nav ul li{margin:1em 0;position:relative}aside nav ul a{display:block}aside nav ul a:hover{opacity:.5}aside nav ul ul{padding-inline-start:1rem}ul.pagination{display:flex;justify-content:center;list-style-type:none;padding-inline-start:0}ul.pagination .page-item a{padding:1rem}.container{max-width:80rem;margin:0 auto}.book-icon{filter:var(--icon-filter)}.book-brand{margin-top:0;margin-bottom:1rem}.book-brand img{height:1.5em;width:1.5em;margin-inline-end:.5rem}.book-menu{flex:0 0 16rem;font-size:.875rem}.book-menu .book-menu-content{width:16rem;padding:1rem;background:var(--body-background);position:fixed;top:0;bottom:0;overflow-x:hidden;overflow-y:auto}.book-menu a,.book-menu label{color:inherit;cursor:pointer;word-wrap:break-word}.book-menu a.active{color:var(--color-link)}.book-menu input.toggle+label+ul{display:none}.book-menu input.toggle:checked+label+ul{display:block}.book-menu input.toggle+label::after{content:"▸"}.book-menu input.toggle:checked+label::after{content:"▾"}body[dir=rtl] .book-menu input.toggle+label::after{content:"◂"}body[dir=rtl] .book-menu input.toggle:checked+label::after{content:"▾"}.book-section-flat{margin:2rem 0}.book-section-flat>a,.book-section-flat>span,.book-section-flat>label{font-weight:bolder}.book-section-flat>ul{padding-inline-start:0}.book-page{min-width:20rem;flex-grow:1;padding:1rem}.book-post{margin-bottom:3rem}.book-header{display:none;margin-bottom:1rem}.book-header label{line-height:0}.book-header img.book-icon{height:1.5em;width:1.5em}.book-search{position:relative;margin:1rem 0;border-bottom:1px solid transparent}.book-search input{width:100%;padding:.5rem;border:0;border-radius:.25rem;background:var(--gray-100);color:var(--body-font-color)}.book-search input:required+.book-search-spinner{display:block}.book-search .book-search-spinner{position:absolute;top:0;margin:.5rem;margin-inline-start:calc(100% - 1.5rem);width:1rem;height:1rem;border:1px solid transparent;border-top-color:var(--body-font-color);border-radius:50%;animation:spin 1s ease infinite}@keyframes spin{100%{transform:rotate(360deg)}}.book-search small{opacity:.5}.book-toc{flex:0 0 16rem;font-size:.75rem}.book-toc .book-toc-content{width:16rem;padding:1rem;position:fixed;top:0;bottom:0;overflow-x:hidden;overflow-y:auto}.book-toc img{height:1em;width:1em}.book-toc nav>ul>li:first-child{margin-top:0}.book-footer{padding-top:1rem;font-size:.875rem}.book-footer img{height:1em;width:1em;margin-inline-end:.5rem}.book-comments{margin-top:1rem}.book-languages{margin-block-end:2rem}.book-languages .book-icon{height:1em;width:1em;margin-inline-end:.5em}.book-languages ul{padding-inline-start:1.5em}.book-menu-content,.book-toc-content,.book-page,.book-header aside,.markdown{transition:.2s ease-in-out;transition-property:transform,margin,opacity,visibility;will-change:transform,margin,opacity}@media screen and (max-width:56rem){#menu-control,#toc-control{display:inline}.book-menu{visibility:hidden;margin-inline-start:-16rem;font-size:16px;z-index:1}.book-toc{display:none}.book-header{display:block}#menu-control:focus~main label[for=menu-control]{outline-style:auto;outline-color:currentColor;outline-color:-webkit-focus-ring-color}#menu-control:checked~main .book-menu{visibility:initial}#menu-control:checked~main .book-menu .book-menu-content{transform:translateX(16rem);box-shadow:0 0 .5rem rgba(0,0,0,.1)}#menu-control:checked~main .book-page{opacity:.25}#menu-control:checked~main .book-menu-overlay{display:block;position:absolute;top:0;bottom:0;left:0;right:0}#toc-control:focus~main label[for=toc-control]{outline-style:auto;outline-color:currentColor;outline-color:-webkit-focus-ring-color}#toc-control:checked~main .book-header aside{display:block}body[dir=rtl] #menu-control:checked~main .book-menu .book-menu-content{transform:translateX(-16rem)}}@media screen and (min-width:80rem){.book-page,.book-menu .book-menu-content,.book-toc .book-toc-content{padding:2rem 1rem}}@font-face{font-family:roboto;font-style:normal;font-weight:400;font-display:swap;src:local(""),url(fonts/roboto-v27-latin-regular.woff2)format("woff2"),url(fonts/roboto-v27-latin-regular.woff)format("woff")}@font-face{font-family:roboto;font-style:normal;font-weight:700;font-display:swap;src:local(""),url(fonts/roboto-v27-latin-700.woff2)format("woff2"),url(fonts/roboto-v27-latin-700.woff)format("woff")}@font-face{font-family:roboto mono;font-style:normal;font-weight:400;font-display:swap;src:local(""),url(fonts/roboto-mono-v13-latin-regular.woff2)format("woff2"),url(fonts/roboto-mono-v13-latin-regular.woff)format("woff")}body{font-family:roboto,sans-serif}code{font-family:roboto mono,monospace}@media print{.book-menu,.book-footer,.book-toc{display:none}.book-header,.book-header aside{display:block}main{display:block!important}}.markdown{line-height:1.6}.markdown>:first-child{margin-top:0}.markdown h1,.markdown h2,.markdown h3,.markdown h4,.markdown h5,.markdown h6{font-weight:400;line-height:1;margin-top:1.5em;margin-bottom:1rem}.markdown h1 a.anchor,.markdown h2 a.anchor,.markdown h3 a.anchor,.markdown h4 a.anchor,.markdown h5 a.anchor,.markdown h6 a.anchor{opacity:0;font-size:.75em;vertical-align:middle;text-decoration:none}.markdown h1:hover a.anchor,.markdown h1 a.anchor:focus,.markdown h2:hover a.anchor,.markdown h2 a.anchor:focus,.markdown h3:hover a.anchor,.markdown h3 a.anchor:focus,.markdown h4:hover a.anchor,.markdown h4 a.anchor:focus,.markdown h5:hover a.anchor,.markdown h5 a.anchor:focus,.markdown h6:hover a.anchor,.markdown h6 a.anchor:focus{opacity:initial}.markdown h4,.markdown h5,.markdown h6{font-weight:bolder}.markdown h5{font-size:.875em}.markdown h6{font-size:.75em}.markdown b,.markdown optgroup,.markdown strong{font-weight:bolder}.markdown a{text-decoration:none}.markdown a:hover{text-decoration:underline}.markdown a:visited{color:var(--color-visited-link)}.markdown img{max-width:100%;height:auto}.markdown code{direction:ltr;unicode-bidi:embed;padding:0 .25rem;background:var(--gray-200);border-radius:.25rem;font-size:.875em}.markdown pre{direction:ltr;unicode-bidi:embed;padding:1rem;background:var(--gray-100);border-radius:.25rem;overflow-x:auto}.markdown pre code{padding:0;background:0 0}.markdown p{word-wrap:break-word}.markdown blockquote{margin:1rem 0;padding:.5rem 1rem .5rem .75rem;border-inline-start:.25rem solid var(--gray-200);border-radius:.25rem}.markdown blockquote :first-child{margin-top:0}.markdown blockquote :last-child{margin-bottom:0}.markdown table{overflow:auto;display:block;border-spacing:0;border-collapse:collapse;margin-top:1rem;margin-bottom:1rem}.markdown table tr th,.markdown table tr td{padding:.5rem 1rem;border:1px solid var(--gray-200)}.markdown table tr:nth-child(2n){background:var(--gray-100)}.markdown hr{height:1px;border:none;background:var(--gray-200)}.markdown ul,.markdown ol{padding-inline-start:2rem;word-wrap:break-word}.markdown dl dt{font-weight:bolder;margin-top:1rem}.markdown dl dd{margin-inline-start:0;margin-bottom:1rem}.markdown .highlight{direction:ltr;unicode-bidi:embed}.markdown .highlight table tr td:nth-child(1) pre{margin:0;padding-inline-end:0}.markdown .highlight table tr td:nth-child(2) pre{margin:0;padding-inline-start:0}.markdown details{padding:1rem;border:1px solid var(--gray-200);border-radius:.25rem}.markdown details summary{line-height:1;padding:1rem;margin:-1rem;cursor:pointer}.markdown details[open] summary{margin-bottom:0}.markdown figure{margin:1rem 0}.markdown figure figcaption p{margin-top:0}.markdown-inner>:first-child{margin-top:0}.markdown-inner>:last-child{margin-bottom:0}.markdown .book-expand{margin-top:1rem;margin-bottom:1rem;border:1px solid var(--gray-200);border-radius:.25rem;overflow:hidden}.markdown .book-expand .book-expand-head{background:var(--gray-100);padding:.5rem 1rem;cursor:pointer}.markdown .book-expand .book-expand-content{display:none;padding:1rem}.markdown .book-expand input[type=checkbox]:checked+.book-expand-content{display:block}.markdown .book-tabs{margin-top:1rem;margin-bottom:1rem;border:1px solid var(--gray-200);border-radius:.25rem;overflow:hidden;display:flex;flex-wrap:wrap}.markdown .book-tabs label{display:inline-block;padding:.5rem 1rem;border-bottom:1px transparent;cursor:pointer}.markdown .book-tabs .book-tabs-content{order:999;width:100%;border-top:1px solid var(--gray-100);padding:1rem;display:none}.markdown .book-tabs input[type=radio]:checked+label{border-bottom:1px solid var(--color-link)}.markdown .book-tabs input[type=radio]:checked+label+.book-tabs-content{display:block}.markdown .book-tabs input[type=radio]:focus+label{outline-style:auto;outline-color:currentColor;outline-color:-webkit-focus-ring-color}.markdown .book-columns{margin-left:-1rem;margin-right:-1rem}.markdown .book-columns>div{margin:1rem 0;min-width:10rem;padding:0 1rem}.markdown a.book-btn{display:inline-block;font-size:.875rem;color:var(--color-link);line-height:2rem;padding:0 1rem;border:1px solid var(--color-link);border-radius:.25rem;cursor:pointer}.markdown a.book-btn:hover{text-decoration:none}.markdown .book-hint.info{border-color:#6bf;background-color:rgba(102,187,255,.1)}.markdown .book-hint.warning{border-color:#fd6;background-color:rgba(255,221,102,.1)}.markdown .book-hint.danger{border-color:#f66;background-color:rgba(255,102,102,.1)} \ No newline at end of file
diff --git a/web/resources/_gen/assets/scss/book.scss_50fc8c04e12a2f59027287995557ceff.json b/web/resources/_gen/assets/scss/book.scss_50fc8c04e12a2f59027287995557ceff.json
new file mode 100644
index 00000000..9d04b972
--- /dev/null
+++ b/web/resources/_gen/assets/scss/book.scss_50fc8c04e12a2f59027287995557ceff.json
@@ -0,0 +1 @@
+{"Target":"book.min.309b7ed028807cdb68d8d61e26d609f48369c098dbf5e4d8c0dcf4cdf49feafc.css","MediaType":"text/css","Data":{"Integrity":"sha256-MJt+0CiAfNto2NYeJtYJ9INpwJjb9eTYwNz0zfSf6vw="}} \ No newline at end of file
diff --git a/web/static/fonts/BNazanin.eot b/web/static/fonts/BNazanin.eot
new file mode 100644
index 00000000..d811f6bf
--- /dev/null
+++ b/web/static/fonts/BNazanin.eot
Binary files differ
diff --git a/web/static/fonts/BNazanin.ttf b/web/static/fonts/BNazanin.ttf
new file mode 100644
index 00000000..870801a7
--- /dev/null
+++ b/web/static/fonts/BNazanin.ttf
Binary files differ
diff --git a/web/static/fonts/BNazanin.woff b/web/static/fonts/BNazanin.woff
new file mode 100644
index 00000000..37914028
--- /dev/null
+++ b/web/static/fonts/BNazanin.woff
Binary files differ
diff --git a/web/static/images/anyconnect-01.png b/web/static/images/anyconnect-01.png
new file mode 100755
index 00000000..48f91fdc
--- /dev/null
+++ b/web/static/images/anyconnect-01.png
Binary files differ
diff --git a/web/static/images/anyconnect-02.png b/web/static/images/anyconnect-02.png
new file mode 100755
index 00000000..e9a26db5
--- /dev/null
+++ b/web/static/images/anyconnect-02.png
Binary files differ
diff --git a/web/static/images/anyconnect-03.png b/web/static/images/anyconnect-03.png
new file mode 100755
index 00000000..000251b6
--- /dev/null
+++ b/web/static/images/anyconnect-03.png
Binary files differ
diff --git a/web/static/images/anyconnect-04.png b/web/static/images/anyconnect-04.png
new file mode 100755
index 00000000..b236fbaa
--- /dev/null
+++ b/web/static/images/anyconnect-04.png
Binary files differ
diff --git a/web/static/images/anyconnect-05.png b/web/static/images/anyconnect-05.png
new file mode 100755
index 00000000..b58187d5
--- /dev/null
+++ b/web/static/images/anyconnect-05.png
Binary files differ
diff --git a/web/static/images/anyconnect-06.png b/web/static/images/anyconnect-06.png
new file mode 100755
index 00000000..f2a3ea1f
--- /dev/null
+++ b/web/static/images/anyconnect-06.png
Binary files differ
diff --git a/web/static/images/anyconnect-07.png b/web/static/images/anyconnect-07.png
new file mode 100755
index 00000000..de42eb54
--- /dev/null
+++ b/web/static/images/anyconnect-07.png
Binary files differ
diff --git a/web/static/images/anyconnect-08.png b/web/static/images/anyconnect-08.png
new file mode 100755
index 00000000..4e42cf42
--- /dev/null
+++ b/web/static/images/anyconnect-08.png
Binary files differ
diff --git a/web/static/images/anyconnect-09.png b/web/static/images/anyconnect-09.png
new file mode 100755
index 00000000..d49d8b16
--- /dev/null
+++ b/web/static/images/anyconnect-09.png
Binary files differ
diff --git a/web/static/images/anyconnect-10.png b/web/static/images/anyconnect-10.png
new file mode 100755
index 00000000..62caeaba
--- /dev/null
+++ b/web/static/images/anyconnect-10.png
Binary files differ
diff --git a/web/static/images/anyconnect-11.png b/web/static/images/anyconnect-11.png
new file mode 100755
index 00000000..b2b255c2
--- /dev/null
+++ b/web/static/images/anyconnect-11.png
Binary files differ
diff --git a/web/static/images/anyconnect-12.png b/web/static/images/anyconnect-12.png
new file mode 100755
index 00000000..383a3006
--- /dev/null
+++ b/web/static/images/anyconnect-12.png
Binary files differ
diff --git a/web/static/images/anyconnect-13.png b/web/static/images/anyconnect-13.png
new file mode 100755
index 00000000..6939a5dd
--- /dev/null
+++ b/web/static/images/anyconnect-13.png
Binary files differ
diff --git a/web/static/images/anyconnect-14.png b/web/static/images/anyconnect-14.png
new file mode 100755
index 00000000..ddd750a1
--- /dev/null
+++ b/web/static/images/anyconnect-14.png
Binary files differ
diff --git a/web/static/images/anyconnect-15.png b/web/static/images/anyconnect-15.png
new file mode 100755
index 00000000..70bacf83
--- /dev/null
+++ b/web/static/images/anyconnect-15.png
Binary files differ
diff --git a/web/static/images/anyconnect-16.png b/web/static/images/anyconnect-16.png
new file mode 100755
index 00000000..8e628f77
--- /dev/null
+++ b/web/static/images/anyconnect-16.png
Binary files differ
diff --git a/web/static/images/anyconnect-17.png b/web/static/images/anyconnect-17.png
new file mode 100755
index 00000000..587e8b11
--- /dev/null
+++ b/web/static/images/anyconnect-17.png
Binary files differ
diff --git a/web/static/images/anyconnect-18.png b/web/static/images/anyconnect-18.png
new file mode 100755
index 00000000..441e1b27
--- /dev/null
+++ b/web/static/images/anyconnect-18.png
Binary files differ
diff --git a/web/static/images/anyconnect-19.png b/web/static/images/anyconnect-19.png
new file mode 100755
index 00000000..7e554cd3
--- /dev/null
+++ b/web/static/images/anyconnect-19.png
Binary files differ
diff --git a/web/static/images/nekobox-01.png b/web/static/images/nekobox-01.png
new file mode 100755
index 00000000..4692485d
--- /dev/null
+++ b/web/static/images/nekobox-01.png
Binary files differ
diff --git a/web/static/images/nekobox-02.png b/web/static/images/nekobox-02.png
new file mode 100755
index 00000000..69543ac3
--- /dev/null
+++ b/web/static/images/nekobox-02.png
Binary files differ
diff --git a/web/static/images/nekobox-03.png b/web/static/images/nekobox-03.png
new file mode 100755
index 00000000..91441a3d
--- /dev/null
+++ b/web/static/images/nekobox-03.png
Binary files differ
diff --git a/web/static/images/nekobox-04.png b/web/static/images/nekobox-04.png
new file mode 100755
index 00000000..60b7a7bd
--- /dev/null
+++ b/web/static/images/nekobox-04.png
Binary files differ
diff --git a/web/static/images/nekobox-05.png b/web/static/images/nekobox-05.png
new file mode 100755
index 00000000..9e17c476
--- /dev/null
+++ b/web/static/images/nekobox-05.png
Binary files differ
diff --git a/web/static/images/nekobox-06.png b/web/static/images/nekobox-06.png
new file mode 100755
index 00000000..4b29f087
--- /dev/null
+++ b/web/static/images/nekobox-06.png
Binary files differ
diff --git a/web/static/images/nekobox-07.png b/web/static/images/nekobox-07.png
new file mode 100755
index 00000000..cad2568d
--- /dev/null
+++ b/web/static/images/nekobox-07.png
Binary files differ
diff --git a/web/static/images/nekobox-08.png b/web/static/images/nekobox-08.png
new file mode 100755
index 00000000..43ee068d
--- /dev/null
+++ b/web/static/images/nekobox-08.png
Binary files differ
diff --git a/web/static/images/nekobox-09.png b/web/static/images/nekobox-09.png
new file mode 100755
index 00000000..d79ad037
--- /dev/null
+++ b/web/static/images/nekobox-09.png
Binary files differ
diff --git a/web/static/images/nekobox-10.png b/web/static/images/nekobox-10.png
new file mode 100755
index 00000000..03595f43
--- /dev/null
+++ b/web/static/images/nekobox-10.png
Binary files differ
diff --git a/web/static/images/nekobox-12.png b/web/static/images/nekobox-12.png
new file mode 100755
index 00000000..b7d82fbb
--- /dev/null
+++ b/web/static/images/nekobox-12.png
Binary files differ
diff --git a/web/static/images/nekoray-1.png b/web/static/images/nekoray-1.png
new file mode 100755
index 00000000..45fd3250
--- /dev/null
+++ b/web/static/images/nekoray-1.png
Binary files differ
diff --git a/web/static/images/nekoray-10.png b/web/static/images/nekoray-10.png
new file mode 100755
index 00000000..f0bab47c
--- /dev/null
+++ b/web/static/images/nekoray-10.png
Binary files differ
diff --git a/web/static/images/nekoray-11.png b/web/static/images/nekoray-11.png
new file mode 100755
index 00000000..9f35f4f1
--- /dev/null
+++ b/web/static/images/nekoray-11.png
Binary files differ
diff --git a/web/static/images/nekoray-12.png b/web/static/images/nekoray-12.png
new file mode 100755
index 00000000..e5e7e6ef
--- /dev/null
+++ b/web/static/images/nekoray-12.png
Binary files differ
diff --git a/web/static/images/nekoray-13.png b/web/static/images/nekoray-13.png
new file mode 100755
index 00000000..3844df42
--- /dev/null
+++ b/web/static/images/nekoray-13.png
Binary files differ
diff --git a/web/static/images/nekoray-2.png b/web/static/images/nekoray-2.png
new file mode 100755
index 00000000..e151a334
--- /dev/null
+++ b/web/static/images/nekoray-2.png
Binary files differ
diff --git a/web/static/images/nekoray-3.png b/web/static/images/nekoray-3.png
new file mode 100755
index 00000000..91e6b503
--- /dev/null
+++ b/web/static/images/nekoray-3.png
Binary files differ
diff --git a/web/static/images/nekoray-4.png b/web/static/images/nekoray-4.png
new file mode 100755
index 00000000..e02bbd09
--- /dev/null
+++ b/web/static/images/nekoray-4.png
Binary files differ
diff --git a/web/static/images/nekoray-5.png b/web/static/images/nekoray-5.png
new file mode 100755
index 00000000..d6271046
--- /dev/null
+++ b/web/static/images/nekoray-5.png
Binary files differ
diff --git a/web/static/images/nekoray-6.png b/web/static/images/nekoray-6.png
new file mode 100755
index 00000000..9f3e8a9d
--- /dev/null
+++ b/web/static/images/nekoray-6.png
Binary files differ
diff --git a/web/static/images/nekoray-7.png b/web/static/images/nekoray-7.png
new file mode 100755
index 00000000..e3cc596d
--- /dev/null
+++ b/web/static/images/nekoray-7.png
Binary files differ
diff --git a/web/static/images/nekoray-8.png b/web/static/images/nekoray-8.png
new file mode 100755
index 00000000..1592cd22
--- /dev/null
+++ b/web/static/images/nekoray-8.png
Binary files differ
diff --git a/web/static/images/nekoray-9.png b/web/static/images/nekoray-9.png
new file mode 100755
index 00000000..7b01c385
--- /dev/null
+++ b/web/static/images/nekoray-9.png
Binary files differ
diff --git a/web/static/images/nekossh-01.png b/web/static/images/nekossh-01.png
new file mode 100755
index 00000000..0d9d8c5f
--- /dev/null
+++ b/web/static/images/nekossh-01.png
Binary files differ
diff --git a/web/static/images/nekossh-02.png b/web/static/images/nekossh-02.png
new file mode 100755
index 00000000..b6f7820a
--- /dev/null
+++ b/web/static/images/nekossh-02.png
Binary files differ
diff --git a/web/static/images/nekossh-03.png b/web/static/images/nekossh-03.png
new file mode 100755
index 00000000..2387d9b1
--- /dev/null
+++ b/web/static/images/nekossh-03.png
Binary files differ
diff --git a/web/static/images/nekossh-04.png b/web/static/images/nekossh-04.png
new file mode 100755
index 00000000..6c5621c2
--- /dev/null
+++ b/web/static/images/nekossh-04.png
Binary files differ
diff --git a/web/static/images/nekossh-05.png b/web/static/images/nekossh-05.png
new file mode 100755
index 00000000..73237a1b
--- /dev/null
+++ b/web/static/images/nekossh-05.png
Binary files differ
diff --git a/web/static/images/nekossh-06.png b/web/static/images/nekossh-06.png
new file mode 100755
index 00000000..4218817d
--- /dev/null
+++ b/web/static/images/nekossh-06.png
Binary files differ
diff --git a/web/static/images/windows-oc-01.png b/web/static/images/windows-oc-01.png
new file mode 100755
index 00000000..1c8ed90b
--- /dev/null
+++ b/web/static/images/windows-oc-01.png
Binary files differ
diff --git a/web/static/images/windows-oc-02.png b/web/static/images/windows-oc-02.png
new file mode 100755
index 00000000..345b1617
--- /dev/null
+++ b/web/static/images/windows-oc-02.png
Binary files differ
diff --git a/web/static/images/windows-oc-03.png b/web/static/images/windows-oc-03.png
new file mode 100755
index 00000000..3e7a067e
--- /dev/null
+++ b/web/static/images/windows-oc-03.png
Binary files differ
diff --git a/web/static/images/windows-oc-04.png b/web/static/images/windows-oc-04.png
new file mode 100755
index 00000000..70453c7a
--- /dev/null
+++ b/web/static/images/windows-oc-04.png
Binary files differ
diff --git a/web/static/images/windows-oc-05.png b/web/static/images/windows-oc-05.png
new file mode 100755
index 00000000..88f11038
--- /dev/null
+++ b/web/static/images/windows-oc-05.png
Binary files differ
diff --git a/web/static/images/windows-oc-06.png b/web/static/images/windows-oc-06.png
new file mode 100755
index 00000000..79765600
--- /dev/null
+++ b/web/static/images/windows-oc-06.png
Binary files differ
diff --git a/web/static/images/windows-oc-08.png b/web/static/images/windows-oc-08.png
new file mode 100755
index 00000000..baa51cd3
--- /dev/null
+++ b/web/static/images/windows-oc-08.png
Binary files differ
diff --git a/web/static/nekoboxsetting/Nekobox-Settings.json b/web/static/nekoboxsetting/Nekobox-Settings.json
new file mode 100755
index 00000000..a4b1a366
--- /dev/null
+++ b/web/static/nekoboxsetting/Nekobox-Settings.json
@@ -0,0 +1,48 @@
+{
+ "version": 1,
+ "settings": [
+ "CQAAAG0AaQB4AGUAZABQAG8AcgB0AAAABQAAAAQAAAAyMDgw",
+ "DAAAAHAAbwByAHQATABvAGMAYQBsAEQAbgBzAAAAAAAFAAAABAAAADY0NTA",
+ "DQAAAGkAcwBBAHUAdABvAEMAbwBuAG4AZQBjAHQAAAABAAAAAQAAAAAAAAA",
+ "CgAAAG4AaQBnAGgAdABUAGgAZQBtAGUAAAAAAAUAAAABAAAAMAAAAA",
+ "CwAAAHMAZQByAHYAaQBjAGUATQBvAGQAZQAAAAUAAAADAAAAdnBuAA",
+ "EQAAAHQAdQBuAEkAbQBwAGwAZQBtAGUAbgB0AGEAdABpAG8AbgAAAAUAAAABAAAAMgAAAA",
+ "DQAAAHMAcABlAGUAZABJAG4AdABlAHIAdgBhAGwAAAAFAAAABAAAADEwMDA",
+ "GAAAAHAAcgBvAGYAaQBsAGUAVAByAGEAZgBmAGkAYwBTAHQAYQB0AGkAcwB0AGkAYwBzAAAAAAABAAAAAQAAAAEAAAA",
+ "FwAAAHMAaABvAHcARwByAG8AdQBwAEkAbgBOAG8AdABpAGYAaQBjAGEAdABpAG8AbgAAAAEAAAABAAAAAAAAAA",
+ "EQAAAGEAbAB3AGEAeQBzAFMAaABvAHcAQQBkAGQAcgBlAHMAcwAAAAEAAAABAAAAAAAAAA",
+ "DgAAAG0AZQB0AGUAcgBlAGQATgBlAHQAdwBvAHIAawAAAAAAAQAAAAEAAAAAAAAA",
+ "DwAAAHMAaABvAHcARABpAHIAZQBjAHQAUwBwAGUAZQBkAAAAAQAAAAEAAAABAAAA",
+ "CAAAAGwAbwBnAEwAZQB2AGUAbAAAAAAABQAAAAEAAAAwAAAA",
+ "CQAAAHAAcgBvAHgAeQBBAHAAcABzAAAAAQAAAAEAAAAAAAAA",
+ "CQAAAGIAeQBwAGEAcwBzAEwAYQBuAAAAAQAAAAEAAAAAAAAA",
+ "DwAAAGIAeQBwAGEAcwBzAEwAYQBuAEkAbgBDAG8AcgBlAAAAAQAAAAEAAAAAAAAA",
+ "DwAAAHQAcgBhAGYAZgBpAGMAUwBuAGkAZgBmAGkAbgBnAAAABQAAAAEAAAAxAAAA",
+ "EgAAAHIAZQBzAG8AbAB2AGUARABlAHMAdABpAG4AYQB0AGkAbwBuAAAAAAABAAAAAQAAAAAAAAA",
+ "CAAAAGkAcAB2ADYATQBvAGQAZQAAAAAABQAAAAEAAAAwAAAA",
+ "DQAAAHIAdQBsAGUAcwBQAHIAbwB2AGkAZABlAHIAAAAFAAAAAQAAADAAAAA",
+ "AwAAAG0AdQB4AAAABgAAAAAAAAA",
+ "BwAAAG0AdQB4AFQAeQBwAGUAAAAFAAAAAQAAADAAAAA",
+ "DgAAAG0AdQB4AEMAbwBuAGMAdQByAHIAZQBuAGMAeQAAAAAABQAAAAEAAAA4AAAA",
+ "GgAAAGQAbwBtAGEAaQBuAF8AcwB0AHIAYQB0AGUAZwB5AF8AZgBvAHIAXwByAGUAbQBvAHQAZQAAAAAABQAAAAQAAABhdXRv",
+ "CQAAAGQAaQByAGUAYwB0AEQAbgBzAAAABQAAAAUAAABsb2NhbAAAAA",
+ "GgAAAGQAbwBtAGEAaQBuAF8AcwB0AHIAYQB0AGUAZwB5AF8AZgBvAHIAXwBkAGkAcgBlAGMAdAAAAAAABQAAAAQAAABhdXRv",
+ "GgAAAGQAbwBtAGEAaQBuAF8AcwB0AHIAYQB0AGUAZwB5AF8AZgBvAHIAXwBzAGUAcgB2AGUAcgAAAAAABQAAAAQAAABhdXRv",
+ "EAAAAGUAbgBhAGIAbABlAEQAbgBzAFIAbwB1AHQAaQBuAGcAAAAAAAEAAAABAAAAAQAAAA",
+ "DwAAAGEAcABwAGUAbgBkAEgAdAB0AHAAUAByAG8AeAB5AAAAAQAAAAEAAAAAAAAA",
+ "CwAAAGEAbABsAG8AdwBBAGMAYwBlAHMAcwAAAAEAAAABAAAAAAAAAA",
+ "EQAAAGMAbwBuAG4AZQBjAHQAaQBvAG4AVABlAHMAdABVAFIATAAAAAUAAAAZAAAAaHR0cDovL2NwLmNsb3VkZmxhcmUuY29tLwAAAA",
+ "DwAAAGEAYwBxAHUAaQByAGUAVwBhAGsAZQBMAG8AYwBrAAAAAQAAAAEAAAAAAAAA",
+ "DgAAAGUAbgBhAGIAbABlAEMAbABhAHMAaABBAFAASQAAAAAAAQAAAAEAAAAAAAAA",
+ "FAAAAHQAYwBwAEsAZQBlAHAAQQBsAGkAdgBlAEkAbgB0AGUAcgB2AGEAbAAAAAAABQAAAAIAAAAxNQAA",
+ "DQAAAGEAcABwAFQATABTAFYAZQByAHMAaQBvAG4AAAAFAAAAAwAAADEuMgA",
+ "DQAAAHMAaABvAHcAQgBvAHQAdABvAG0AQgBhAHIAAAABAAAAAQAAAAAAAAA",
+ "AwAAAG0AdAB1AAAABQAAAAQAAAAxNTAw",
+ "DQAAAGUAbgBhAGIAbABlAEYAYQBrAGUARABuAHMAAAABAAAAAQAAAAEAAAA",
+ "CQAAAHIAZQBtAG8AdABlAEQAbgBzAAAABQAAAAkAAAAxMjcuMC4wLjEAAAA",
+ "CAAAAGEAcABwAFQAaABlAG0AZQAAAAAABAAAAAgAAAAAAAAAAAAABw",
+ "CQAAAHAAcgBvAGYAaQBsAGUASQBkAAAABAAAAAgAAAAAAAAAAAAAAw",
+ "DgAAAHAAcgBvAGYAaQBsAGUAQwB1AHIAcgBlAG4AdAAAAAAABAAAAAgAAAAAAAAAAAAAAw",
+ "DAAAAHAAcgBvAGYAaQBsAGUARwByAG8AdQBwAAAAAAAEAAAACAAAAAAAAAAAAAAB"
+ ]
+} \ No newline at end of file
diff --git a/web/themes/README.md b/web/themes/README.md
new file mode 100644
index 00000000..193ac6a2
--- /dev/null
+++ b/web/themes/README.md
@@ -0,0 +1,2 @@
+# This is where themes will go
+(This file exists so the directory is not empty, otherwise it won't be added to git)