├── .github └── workflows │ └── docker-image.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── __init__.py ├── api │ ├── __init__.py │ └── endpoints.py ├── static │ ├── custom.css │ ├── custom.js │ ├── custom.min.js │ ├── nginx.ico │ ├── nginx.png │ ├── semantic.min.css │ ├── semantic.min.js │ └── themes │ │ └── default │ │ └── assets │ │ ├── fonts │ │ ├── brand-icons.eot │ │ ├── brand-icons.svg │ │ ├── brand-icons.ttf │ │ ├── brand-icons.woff │ │ ├── brand-icons.woff2 │ │ ├── icons.eot │ │ ├── icons.otf │ │ ├── icons.svg │ │ ├── icons.ttf │ │ ├── icons.woff │ │ ├── icons.woff2 │ │ ├── outline-icons.eot │ │ ├── outline-icons.svg │ │ ├── outline-icons.ttf │ │ ├── outline-icons.woff │ │ └── outline-icons.woff2 │ │ └── images │ │ └── flags.png ├── templates │ ├── config.html │ ├── domain.html │ ├── domains.html │ ├── index.html │ └── new_domain.j2 └── ui │ ├── __init__.py │ └── views.py ├── config.py ├── docker-compose.yml ├── requirements.txt └── wsgi.py /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: [ "master", "0.1", "0.2" ] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Clone repository 12 | uses: actions/checkout@v2 13 | 14 | - name: Build the Docker image 15 | uses: docker/build-push-action@v1.1.0 16 | with: 17 | username: ${{ secrets.DOCKER_USERNAME }} 18 | password: ${{ secrets.DOCKER_PASSWORD }} 19 | repository: schenkd/nginx-ui 20 | tag_with_ref: true 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### VirtualEnv template 3 | # Virtualenv 4 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 5 | .Python 6 | [Bb]in 7 | [Ii]nclude 8 | [Ll]ib 9 | [Ll]ib64 10 | [Ll]ocal 11 | [Ss]cripts 12 | pyvenv.cfg 13 | .venv 14 | pip-selfcheck.json 15 | 16 | ### macOS template 17 | # General 18 | .DS_Store 19 | .AppleDouble 20 | .LSOverride 21 | 22 | # Icon must end with two \r 23 | Icon 24 | 25 | # Thumbnails 26 | ._* 27 | 28 | # Files that might appear in the root of a volume 29 | .DocumentRevisions-V100 30 | .fseventsd 31 | .Spotlight-V100 32 | .TemporaryItems 33 | .Trashes 34 | .VolumeIcon.icns 35 | .com.apple.timemachine.donotpresent 36 | 37 | # Directories potentially created on remote AFP share 38 | .AppleDB 39 | .AppleDesktop 40 | Network Trash Folder 41 | Temporary Items 42 | .apdisk 43 | 44 | ### JetBrains template 45 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 46 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 47 | 48 | # User-specific stuff 49 | .idea/**/workspace.xml 50 | .idea/**/tasks.xml 51 | .idea/**/usage.statistics.xml 52 | .idea/**/dictionaries 53 | .idea/**/shelf 54 | 55 | # Generated files 56 | .idea/**/contentModel.xml 57 | 58 | # Sensitive or high-churn files 59 | .idea/**/dataSources/ 60 | .idea/**/dataSources.ids 61 | .idea/**/dataSources.local.xml 62 | .idea/**/sqlDataSources.xml 63 | .idea/**/dynamic.xml 64 | .idea/**/uiDesigner.xml 65 | .idea/**/dbnavigator.xml 66 | 67 | # Gradle 68 | .idea/**/gradle.xml 69 | .idea/**/libraries 70 | 71 | # Gradle and Maven with auto-import 72 | # When using Gradle or Maven with auto-import, you should exclude module files, 73 | # since they will be recreated, and may cause churn. Uncomment if using 74 | # auto-import. 75 | # .idea/modules.xml 76 | # .idea/*.iml 77 | # .idea/modules 78 | # *.iml 79 | # *.ipr 80 | 81 | # CMake 82 | cmake-build-*/ 83 | 84 | # Mongo Explorer plugin 85 | .idea/**/mongoSettings.xml 86 | 87 | # File-based project format 88 | *.iws 89 | 90 | # IntelliJ 91 | out/ 92 | 93 | # mpeltonen/sbt-idea plugin 94 | .idea_modules/ 95 | 96 | # JIRA plugin 97 | atlassian-ide-plugin.xml 98 | 99 | # Cursive Clojure plugin 100 | .idea/replstate.xml 101 | 102 | # Crashlytics plugin (for Android Studio and IntelliJ) 103 | com_crashlytics_export_strings.xml 104 | crashlytics.properties 105 | crashlytics-build.properties 106 | fabric.properties 107 | 108 | # Editor-based Rest Client 109 | .idea/httpRequests 110 | 111 | # Android studio 3.1+ serialized cache file 112 | .idea/caches/build_file_checksums.ser 113 | 114 | ### Python template 115 | # Byte-compiled / optimized / DLL files 116 | __pycache__/ 117 | *.py[cod] 118 | *$py.class 119 | 120 | # C extensions 121 | *.so 122 | 123 | # Distribution / packaging 124 | build/ 125 | develop-eggs/ 126 | dist/ 127 | downloads/ 128 | eggs/ 129 | .eggs/ 130 | lib/ 131 | lib64/ 132 | parts/ 133 | sdist/ 134 | var/ 135 | wheels/ 136 | pip-wheel-metadata/ 137 | share/python-wheels/ 138 | *.egg-info/ 139 | .installed.cfg 140 | *.egg 141 | MANIFEST 142 | 143 | # PyInstaller 144 | # Usually these files are written by a python script from a template 145 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 146 | *.manifest 147 | *.spec 148 | 149 | # Installer logs 150 | pip-log.txt 151 | pip-delete-this-directory.txt 152 | 153 | # Unit test / coverage reports 154 | htmlcov/ 155 | .tox/ 156 | .nox/ 157 | .coverage 158 | .coverage.* 159 | .cache 160 | nosetests.xml 161 | coverage.xml 162 | *.cover 163 | .hypothesis/ 164 | .pytest_cache/ 165 | 166 | # Translations 167 | *.mo 168 | *.pot 169 | 170 | # Django stuff: 171 | *.log 172 | local_settings.py 173 | db.sqlite3 174 | db.sqlite3-journal 175 | 176 | # Flask stuff: 177 | instance/ 178 | .webassets-cache 179 | 180 | # Scrapy stuff: 181 | .scrapy 182 | 183 | # Sphinx documentation 184 | docs/_build/ 185 | 186 | # PyBuilder 187 | target/ 188 | 189 | # Jupyter Notebook 190 | .ipynb_checkpoints 191 | 192 | # IPython 193 | profile_default/ 194 | ipython_config.py 195 | 196 | # pyenv 197 | .python-version 198 | 199 | # pipenv 200 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 201 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 202 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 203 | # install all needed dependencies. 204 | #Pipfile.lock 205 | 206 | # celery beat schedule file 207 | celerybeat-schedule 208 | 209 | # SageMath parsed files 210 | *.sage.py 211 | 212 | # Environments 213 | .env 214 | env/ 215 | venv/ 216 | ENV/ 217 | env.bak/ 218 | venv.bak/ 219 | 220 | # Spyder project settings 221 | .spyderproject 222 | .spyproject 223 | 224 | # Rope project settings 225 | .ropeproject 226 | 227 | # mkdocs documentation 228 | /site 229 | 230 | # mypy 231 | .mypy_cache/ 232 | .dmypy.json 233 | dmypy.json 234 | 235 | # Pyre type checker 236 | .pyre/ 237 | 238 | ### VisualStudioCode template 239 | .vscode/* 240 | !.vscode/settings.json 241 | !.vscode/tasks.json 242 | !.vscode/launch.json 243 | !.vscode/extensions.json 244 | 245 | ### Linux template 246 | *~ 247 | 248 | # temporary files which can be created if a process still has a handle open of a deleted file 249 | .fuse_hidden* 250 | 251 | # KDE directory preferences 252 | .directory 253 | 254 | # Linux trash folder which might appear on any partition or disk 255 | .Trash-* 256 | 257 | # .nfs files are created when an open file is removed but is still being accessed 258 | .nfs* 259 | ### VirtualEnv template 260 | # Virtualenv 261 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 262 | 263 | ### macOS template 264 | # General 265 | 266 | # Icon must end with two \r 267 | 268 | # Thumbnails 269 | 270 | # Files that might appear in the root of a volume 271 | 272 | # Directories potentially created on remote AFP share 273 | 274 | ### JetBrains template 275 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 276 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 277 | 278 | # User-specific stuff 279 | 280 | # Generated files 281 | 282 | # Sensitive or high-churn files 283 | 284 | # Gradle 285 | 286 | # Gradle and Maven with auto-import 287 | # When using Gradle or Maven with auto-import, you should exclude module files, 288 | # since they will be recreated, and may cause churn. Uncomment if using 289 | # auto-import. 290 | # .idea/modules.xml 291 | # .idea/*.iml 292 | # .idea/modules 293 | # *.iml 294 | # *.ipr 295 | 296 | # CMake 297 | 298 | # Mongo Explorer plugin 299 | 300 | # File-based project format 301 | 302 | # IntelliJ 303 | 304 | # mpeltonen/sbt-idea plugin 305 | 306 | # JIRA plugin 307 | 308 | # Cursive Clojure plugin 309 | 310 | # Crashlytics plugin (for Android Studio and IntelliJ) 311 | 312 | # Editor-based Rest Client 313 | 314 | # Android studio 3.1+ serialized cache file 315 | 316 | ### Node template 317 | # Logs 318 | logs 319 | npm-debug.log* 320 | yarn-debug.log* 321 | yarn-error.log* 322 | lerna-debug.log* 323 | 324 | # Diagnostic reports (https://nodejs.org/api/report.html) 325 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 326 | 327 | # Runtime data 328 | pids 329 | *.pid 330 | *.seed 331 | *.pid.lock 332 | 333 | # Directory for instrumented libs generated by jscoverage/JSCover 334 | lib-cov 335 | 336 | # Coverage directory used by tools like istanbul 337 | coverage 338 | *.lcov 339 | 340 | # nyc test coverage 341 | .nyc_output 342 | 343 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 344 | .grunt 345 | 346 | # Bower dependency directory (https://bower.io/) 347 | bower_components 348 | 349 | # node-waf configuration 350 | .lock-wscript 351 | 352 | # Compiled binary addons (https://nodejs.org/api/addons.html) 353 | build/Release 354 | 355 | # Dependency directories 356 | node_modules/ 357 | jspm_packages/ 358 | 359 | # TypeScript v1 declaration files 360 | typings/ 361 | 362 | # TypeScript cache 363 | *.tsbuildinfo 364 | 365 | # Optional npm cache directory 366 | .npm 367 | 368 | # Optional eslint cache 369 | .eslintcache 370 | 371 | # Optional REPL history 372 | .node_repl_history 373 | 374 | # Output of 'npm pack' 375 | *.tgz 376 | 377 | # Yarn Integrity file 378 | .yarn-integrity 379 | 380 | # dotenv environment variables file 381 | .env.test 382 | 383 | # parcel-bundler cache (https://parceljs.org/) 384 | 385 | # next.js build output 386 | .next 387 | 388 | # nuxt.js build output 389 | .nuxt 390 | 391 | # vuepress build output 392 | .vuepress/dist 393 | 394 | # Serverless directories 395 | .serverless/ 396 | 397 | # FuseBox cache 398 | .fusebox/ 399 | 400 | # DynamoDB Local files 401 | .dynamodb/ 402 | 403 | ### Python template 404 | # Byte-compiled / optimized / DLL files 405 | 406 | # C extensions 407 | 408 | # Distribution / packaging 409 | 410 | # PyInstaller 411 | # Usually these files are written by a python script from a template 412 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 413 | 414 | # Installer logs 415 | 416 | # Unit test / coverage reports 417 | 418 | # Translations 419 | 420 | # Django stuff: 421 | 422 | # Flask stuff: 423 | 424 | # Scrapy stuff: 425 | 426 | # Sphinx documentation 427 | 428 | # PyBuilder 429 | 430 | # Jupyter Notebook 431 | 432 | # IPython 433 | 434 | # pyenv 435 | 436 | # pipenv 437 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 438 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 439 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 440 | # install all needed dependencies. 441 | #Pipfile.lock 442 | 443 | # celery beat schedule file 444 | 445 | # SageMath parsed files 446 | 447 | # Environments 448 | 449 | # Spyder project settings 450 | 451 | # Rope project settings 452 | 453 | # mkdocs documentation 454 | 455 | # mypy 456 | 457 | # Pyre type checker 458 | 459 | ### VisualStudioCode template 460 | 461 | ### Linux template 462 | 463 | # temporary files which can be created if a process still has a handle open of a deleted file 464 | 465 | # KDE directory preferences 466 | 467 | # Linux trash folder which might appear on any partition or disk 468 | 469 | # .nfs files are created when an open file is removed but is still being accessed 470 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-alpine 2 | 3 | ADD requirements.txt . 4 | 5 | RUN apk add python3-dev build-base linux-headers pcre-dev && pip install --no-cache-dir -r requirements.txt 6 | 7 | # adding application files 8 | ADD . /webapp 9 | 10 | # configure path /webapp to HOME-dir 11 | ENV HOME /webapp 12 | WORKDIR /webapp 13 | 14 | ENTRYPOINT ["uwsgi"] 15 | CMD ["--http", "0.0.0.0:8080", "--wsgi-file", "wsgi.py", "--callable", "app", "--processes", "1", "--threads", "8"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 David Schenk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nginx ui 2 | 3 | ![Docker Image CI](https://github.com/schenkd/nginx-ui/workflows/Docker%20Image%20CI/badge.svg) 4 | 5 | ![Image of Nginx UI](https://i.ibb.co/XXcfsDp/Bildschirmfoto-2020-06-20-um-18-40-27.png) 6 | 7 | Table of Contents 8 | 9 | - [nginx ui](#nginx-ui) 10 | - [Introduction](#introduction) 11 | - [Setup](#setup) 12 | - [Example](#example) 13 | - [Docker](#docker) 14 | - [UI](#ui) 15 | - [Authentication](#authentication) 16 | - [Configure the auth file](#configure-the-auth-file) 17 | - [Configure nginx](#configure-nginx) 18 | 19 | ## Introduction 20 | 21 | We use nginx in our company lab environment. It often happens that my 22 | colleagues have developed an application that is now deployed in our Stage 23 | or Prod environment. To make this application accessible nginx has to be 24 | adapted. Most of the time my colleagues don't have the permission to access 25 | the server and change the configuration files and since I don't feel like 26 | doing this for everyone anymore I thought a UI could help us all. If you 27 | feel the same way I wish you a lot of fun with the application and I am 28 | looking forward to your feedback, change requests or even a star. 29 | 30 | ## Setup 31 | 32 | Containerization is now state of the art and therefore the application is 33 | delivered in a container. 34 | 35 | ### Example 36 | 37 | - `-d` run as deamon in background 38 | - `--restart=always` restart on crash or server reboot 39 | - `--name nginxui` give the container a name 40 | - `-v /etc/nginx:/etc/nginx` map the hosts nginx directory into the container 41 | - `-p 8080:8080` map host port 8080 to docker container port 8080 42 | 43 | ```bash 44 | docker run -d --restart=always --name nginxui -v /etc/nginx:/etc/nginx -p 8080:8080 schenkd/nginx-ui:latest 45 | ``` 46 | 47 | ### Docker 48 | 49 | Repository @ [DockerHub](https://hub.docker.com/r/schenkd/nginx-ui) 50 | 51 | Docker Compose excerpt 52 | 53 | ```yaml 54 | # Docker Compose excerpt 55 | services: 56 | nginx-ui: 57 | image: schenkd/nginx-ui:latest 58 | ports: 59 | - 8080:8080 60 | volumes: 61 | - nginx:/etc/nginx 62 | ``` 63 | 64 | ## UI 65 | 66 | ![Image of Nginx UI](https://i.ibb.co/qNgBRrt/Bildschirmfoto-2020-06-21-um-10-01-46.png) 67 | 68 | With the menu item Main Config the Nginx specific configuration files 69 | can be extracted and updated. These are dynamically read from the Nginx 70 | directory. If a file has been added manually, it is immediately integrated 71 | into the Nginx UI Main Config menu item. 72 | 73 | ![Image of Nginx UI](https://i.ibb.co/j85XKM6/Bildschirmfoto-2020-06-21-um-10-01-58.png) 74 | 75 | Adding a domain opens an exclusive editing window for the configuration 76 | file. This can be applied, deleted and enabled/disabled. 77 | 78 | ## Authentication 79 | 80 | [BasicAuth with nginx](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/) 81 | 82 | In general, this app does not come with authentication. However, it is easy to setup basic auth to restrict unwanted access. 83 | Here is how this can be done when using nginx. 84 | 85 | ### Configure the auth file 86 | 87 | 1. Verify that `apache2-utils` (Debian, Ubuntu) or `httpd-tools` (RHEL/CentOS/Oracle Linux) is installed 88 | 2. Run the htpasswd utility to create a new user and set a passwort. 89 | - Make sure, that the directory exists 90 | - Remove the `-c` flag, if you have created a user before, since it creates the inital user/passwort file 91 | - `sudo htpasswd -c /etc/apache2/.htpasswd user1` 92 | 93 | ### Configure nginx 94 | 95 | The following example adds basic auth to our nginxui app running in a docker container with a mapped port 8080. 96 | In this case, it will be accessible via nginx.mydomain.com 97 | 98 | ```none 99 | server { 100 | server_name nginx.mydomain.com; 101 | 102 | location / { 103 | proxy_pass http://127.0.0.1:8080/; 104 | } 105 | 106 | auth_basic "nginxui secured"; 107 | auth_basic_user_file /etc/apache2/.htpasswd; 108 | 109 | # [...] ommited ssl configuration 110 | } 111 | ``` 112 | 113 | 1. Add above nginx conf to your `/etc/nginx/my.conf` file 114 | 2. Run `nginx -t` to make sure, that your config is valid 115 | 3. Run `systemctl restart nginx` (or equivalent) to restart your nginx and apply the new settings 116 | 4. Your nginx ui is now accessible at nginx.mydomain.com and will correctly prompt for basic auth 117 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from config import config 3 | from flask_moment import Moment 4 | 5 | 6 | moment = Moment() 7 | 8 | 9 | def create_app(config_name): 10 | app = Flask(__name__) 11 | app.config.from_object(config[config_name]) 12 | 13 | config[config_name].init_app(app) 14 | moment.init_app(app) 15 | 16 | from app.ui import ui as ui_blueprint 17 | app.register_blueprint(ui_blueprint) 18 | 19 | from app.api import api as api_blueprint 20 | app.register_blueprint(api_blueprint, url_prefix='/api') 21 | 22 | return app 23 | -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | api = Blueprint('api', __name__) 4 | 5 | from . import endpoints 6 | -------------------------------------------------------------------------------- /app/api/endpoints.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import io 3 | import os 4 | import flask 5 | 6 | from app.api import api 7 | 8 | 9 | @api.route('/config/', methods=['GET']) 10 | def get_config(name: str): 11 | """ 12 | Reads the file with the corresponding name that was passed. 13 | 14 | :param name: Configuration file name 15 | :type name: str 16 | 17 | :return: Rendered HTML document with content of the configuration file. 18 | :rtype: str 19 | """ 20 | nginx_path = flask.current_app.config['NGINX_PATH'] 21 | 22 | with io.open(os.path.join(nginx_path, name), 'r') as f: 23 | _file = f.read() 24 | 25 | return flask.render_template('config.html', name=name, file=_file), 200 26 | 27 | 28 | @api.route('/config/', methods=['POST']) 29 | def post_config(name: str): 30 | """ 31 | Accepts the customized configuration and saves it in the configuration file with the supplied name. 32 | 33 | :param name: Configuration file name 34 | :type name: str 35 | 36 | :return: 37 | :rtype: werkzeug.wrappers.Response 38 | """ 39 | content = flask.request.get_json() 40 | nginx_path = flask.current_app.config['NGINX_PATH'] 41 | 42 | with io.open(os.path.join(nginx_path, name), 'w') as f: 43 | f.write(content['file']) 44 | 45 | return flask.make_response({'success': True}), 200 46 | 47 | 48 | @api.route('/domains', methods=['GET']) 49 | def get_domains(): 50 | """ 51 | Reads all files from the configuration file directory and checks the state of the site configuration. 52 | 53 | :return: Rendered HTML document with the domains 54 | :rtype: str 55 | """ 56 | config_path = flask.current_app.config['CONFIG_PATH'] 57 | sites_available = [] 58 | sites_enabled = [] 59 | 60 | for _ in os.listdir(config_path): 61 | 62 | if os.path.isfile(os.path.join(config_path, _)): 63 | domain, state = _.rsplit('.', 1) 64 | 65 | if state == 'conf': 66 | time = datetime.datetime.fromtimestamp(os.path.getmtime(os.path.join(config_path, _))) 67 | 68 | sites_available.append({ 69 | 'name': domain, 70 | 'time': time 71 | }) 72 | sites_enabled.append(domain) 73 | elif state == 'disabled': 74 | time = datetime.datetime.fromtimestamp(os.path.getmtime(os.path.join(config_path, _))) 75 | 76 | sites_available.append({ 77 | 'name': domain.rsplit('.', 1)[0], 78 | 'time': time 79 | }) 80 | 81 | # sort sites by name 82 | sites_available = sorted(sites_available, key=lambda _: _['name']) 83 | return flask.render_template('domains.html', sites_available=sites_available, sites_enabled=sites_enabled), 200 84 | 85 | 86 | @api.route('/domain/', methods=['GET']) 87 | def get_domain(name: str): 88 | """ 89 | Takes the name of the domain configuration file and 90 | returns a rendered HTML with the current configuration of the domain. 91 | 92 | :param name: The domain name that corresponds to the name of the file. 93 | :type name: str 94 | 95 | :return: Rendered HTML document with the domain 96 | :rtype: str 97 | """ 98 | config_path = flask.current_app.config['CONFIG_PATH'] 99 | _file = '' 100 | enabled = True 101 | 102 | for _ in os.listdir(config_path): 103 | 104 | if os.path.isfile(os.path.join(config_path, _)): 105 | if _.startswith(name): 106 | domain, state = _.rsplit('.', 1) 107 | 108 | if state == 'disabled': 109 | enabled = False 110 | 111 | with io.open(os.path.join(config_path, _), 'r') as f: 112 | _file = f.read() 113 | 114 | break 115 | 116 | return flask.render_template('domain.html', name=name, file=_file, enabled=enabled), 200 117 | 118 | 119 | @api.route('/domain/', methods=['POST']) 120 | def post_domain(name: str): 121 | """ 122 | Creates the configuration file of the domain. 123 | 124 | :param name: The domain name that corresponds to the name of the file. 125 | :type name: str 126 | 127 | :return: Returns a status about the success or failure of the action. 128 | """ 129 | config_path = flask.current_app.config['CONFIG_PATH'] 130 | new_domain = flask.render_template('new_domain.j2', name=name) 131 | name = name + '.conf.disabled' 132 | 133 | try: 134 | with io.open(os.path.join(config_path, name), 'w') as f: 135 | f.write(new_domain) 136 | 137 | response = flask.jsonify({'success': True}), 201 138 | except Exception as ex: 139 | response = flask.jsonify({'success': False, 'error_msg': ex}), 500 140 | 141 | return response 142 | 143 | 144 | @api.route('/domain/', methods=['DELETE']) 145 | def delete_domain(name: str): 146 | """ 147 | Deletes the configuration file of the corresponding domain. 148 | 149 | :param name: The domain name that corresponds to the name of the file. 150 | :type name: str 151 | 152 | :return: Returns a status about the success or failure of the action. 153 | """ 154 | config_path = flask.current_app.config['CONFIG_PATH'] 155 | removed = False 156 | 157 | for _ in os.listdir(config_path): 158 | 159 | if os.path.isfile(os.path.join(config_path, _)): 160 | if _.startswith(name): 161 | os.remove(os.path.join(config_path, _)) 162 | removed = not os.path.exists(os.path.join(config_path, _)) 163 | break 164 | 165 | if removed: 166 | return flask.jsonify({'success': True}), 200 167 | else: 168 | return flask.jsonify({'success': False}), 400 169 | 170 | 171 | @api.route('/domain/', methods=['PUT']) 172 | def put_domain(name: str): 173 | """ 174 | Updates the configuration file with the corresponding domain name. 175 | 176 | :param name: The domain name that corresponds to the name of the file. 177 | :type name: str 178 | 179 | :return: Returns a status about the success or failure of the action. 180 | """ 181 | content = flask.request.get_json() 182 | config_path = flask.current_app.config['CONFIG_PATH'] 183 | 184 | for _ in os.listdir(config_path): 185 | 186 | if os.path.isfile(os.path.join(config_path, _)): 187 | if _.startswith(name): 188 | with io.open(os.path.join(config_path, _), 'w') as f: 189 | f.write(content['file']) 190 | 191 | return flask.make_response({'success': True}), 200 192 | 193 | 194 | @api.route('/domain//enable', methods=['POST']) 195 | def enable_domain(name: str): 196 | """ 197 | Activates the domain in Nginx so that the configuration is applied. 198 | 199 | :param name: The domain name that corresponds to the name of the file. 200 | :type name: str 201 | 202 | :return: Returns a status about the success or failure of the action. 203 | """ 204 | content = flask.request.get_json() 205 | config_path = flask.current_app.config['CONFIG_PATH'] 206 | 207 | for _ in os.listdir(config_path): 208 | 209 | if os.path.isfile(os.path.join(config_path, _)): 210 | if _.startswith(name): 211 | if content['enable']: 212 | new_filename, disable = _.rsplit('.', 1) 213 | os.rename(os.path.join(config_path, _), os.path.join(config_path, new_filename)) 214 | else: 215 | os.rename(os.path.join(config_path, _), os.path.join(config_path, _ + '.disabled')) 216 | 217 | return flask.make_response({'success': True}), 200 218 | -------------------------------------------------------------------------------- /app/static/custom.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Source+Code+Pro'); 2 | 3 | textarea { 4 | margin: 0; 5 | border-radius: 0; 6 | } 7 | 8 | .code { 9 | font-size: 17px; 10 | font-weight: 200; 11 | font-family: 'Source Code Pro', monospace; 12 | } 13 | 14 | #main-container { 15 | margin-top: 5em; 16 | } 17 | #domain { 18 | display: none; 19 | } 20 | 21 | @media only screen and (max-width: 666px) { 22 | [class*="mobile hidden"], 23 | [class*="tablet only"]:not(.mobile), 24 | [class*="computer only"]:not(.mobile), 25 | [class*="large monitor only"]:not(.mobile), 26 | [class*="widescreen monitor only"]:not(.mobile), 27 | [class*="or lower hidden"] { 28 | display: none !important; 29 | } 30 | } -------------------------------------------------------------------------------- /app/static/custom.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | $('.ui.dropdown').dropdown(); 3 | 4 | $('.config.item').click(function() { 5 | var name = $(this).html(); 6 | load_config(name); 7 | }); 8 | 9 | $('#domains').click(function() { load_domains() }); 10 | 11 | load_domains(); 12 | 13 | }); 14 | 15 | function load_domains() { 16 | $.when(fetch_html('api/domains')).then(function() { 17 | $('#domain').hide(); 18 | $('#domain_cards').fadeIn(); 19 | }); 20 | } 21 | 22 | function add_domain() { 23 | var name = $('#add_domain').val(); 24 | $('#add_domain').val(''); 25 | 26 | $.ajax({ 27 | type: 'POST', 28 | url: '/api/domain/' + name, 29 | statusCode: { 30 | 201: function() { fetch_domain(name) } 31 | } 32 | }); 33 | 34 | } 35 | 36 | function enable_domain(name, enable) { 37 | 38 | $.ajax({ 39 | type: 'POST', 40 | url: '/api/domain/' + name + '/enable', 41 | contentType: 'application/json; charset=utf-8', 42 | dataType: 'json', 43 | data: JSON.stringify({ 44 | enable: enable 45 | }), 46 | statusCode: { 47 | 200: function() { fetch_domain(name); } 48 | } 49 | }); 50 | 51 | } 52 | 53 | function update_domain(name) { 54 | var _file = $('#file-content').val(); 55 | $('#dimmer').addClass('active'); 56 | 57 | $.ajax({ 58 | type: 'PUT', 59 | url: '/api/domain/' + name, 60 | contentType: 'application/json; charset=utf-8', 61 | dataType: 'json', 62 | data: JSON.stringify({ 63 | file: _file 64 | }), 65 | statusCode: { 66 | 200: function() { setTimeout(function(){ fetch_domain(name) }, 400) } 67 | } 68 | }); 69 | 70 | } 71 | 72 | function fetch_domain(name) { 73 | 74 | fetch('api/domain/' + name) 75 | .then(function(response) { 76 | response.text().then(function(text) { 77 | $('#domain').html(text).fadeIn(); 78 | $('#domain_cards').hide(); 79 | }); 80 | }) 81 | .catch(function(error) { 82 | console.error(error); 83 | }); 84 | 85 | } 86 | 87 | function remove_domain(name) { 88 | 89 | $.ajax({ 90 | type: 'DELETE', 91 | url: '/api/domain/' + name, 92 | statusCode: { 93 | 200: function() { 94 | load_domains(); 95 | }, 96 | 400: function() { 97 | alert('Deleting not possible'); 98 | } 99 | } 100 | }); 101 | 102 | } 103 | 104 | function fetch_html(url) { 105 | 106 | fetch(url) 107 | .then(function(response) { 108 | response.text().then(function(text) { 109 | $('#content').html(text); 110 | }); 111 | }) 112 | .catch(function(error) { 113 | console.error(error); 114 | }); 115 | 116 | } 117 | 118 | function update_config(name) { 119 | var _file = $('#file-content').val(); 120 | $('#dimmer').addClass('active'); 121 | 122 | $.ajax({ 123 | type: 'POST', 124 | url: '/api/config/' + name, 125 | contentType: 'application/json; charset=utf-8', 126 | dataType: 'json', 127 | data: JSON.stringify({ 128 | file: _file 129 | }), 130 | statusCode: { 131 | 200: function() { 132 | 133 | setTimeout(function() { 134 | $('#dimmer').removeClass('active'); 135 | }, 450); 136 | 137 | } 138 | } 139 | }); 140 | 141 | } 142 | 143 | function load_config(name) { 144 | 145 | fetch('api/config/' + name) 146 | .then(function(response) { 147 | response.text().then(function(text) { 148 | $('#content').html(text); 149 | }); 150 | }) 151 | .catch(function(error) { 152 | console.error(error); 153 | }); 154 | 155 | } 156 | -------------------------------------------------------------------------------- /app/static/custom.min.js: -------------------------------------------------------------------------------- 1 | function load_domains(){$.when(fetch_html("api/domains")).then(function(){$("#domain").hide(),$("#domain_cards").fadeIn()})}function add_domain(){var n=$("#add_domain").val();$("#add_domain").val(""),$.ajax({type:"POST",url:"/api/domain/"+n,statusCode:{201:function(){fetch_domain(n)}}})}function enable_domain(n,t){$.ajax({type:"POST",url:"/api/domain/"+n+"/enable",contentType:"application/json; charset=utf-8",dataType:"json",data:JSON.stringify({enable:t}),statusCode:{200:function(){fetch_domain(n)}}})}function update_domain(n){var t=$("#file-content").val();$("#dimmer").addClass("active"),$.ajax({type:"PUT",url:"/api/domain/"+n,contentType:"application/json; charset=utf-8",dataType:"json",data:JSON.stringify({file:t}),statusCode:{200:function(){setTimeout(function(){fetch_domain(n)},400)}}})}function fetch_domain(n){fetch("api/domain/"+n).then(function(n){n.text().then(function(n){$("#domain").html(n).fadeIn(),$("#domain_cards").hide()})}).catch(function(n){console.error(n)})}function remove_domain(n){$.ajax({type:"DELETE",url:"/api/domain/"+n,statusCode:{200:function(){load_domains()},400:function(){alert("Deleting not possible")}}})}function fetch_html(n){fetch(n).then(function(n){n.text().then(function(n){$("#content").html(n)})}).catch(function(n){console.error(n)})}function update_config(n){var t=$("#file-content").val();$("#dimmer").addClass("active"),$.ajax({type:"POST",url:"/api/config/"+n,contentType:"application/json; charset=utf-8",dataType:"json",data:JSON.stringify({file:t}),statusCode:{200:function(){setTimeout(function(){$("#dimmer").removeClass("active")},450)}}})}function load_config(n){fetch("api/config/"+n).then(function(n){n.text().then(function(n){$("#content").html(n)})}).catch(function(n){console.error(n)})}$(document).ready(function(){$(".ui.dropdown").dropdown(),$(".config.item").click(function(){load_config($(this).html())}),$("#domains").click(function(){load_domains()}),load_domains()}); -------------------------------------------------------------------------------- /app/static/nginx.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schenkd/nginx-ui/8c12f54d381aa6dee6e8bd683036beaaddc96eca/app/static/nginx.ico -------------------------------------------------------------------------------- /app/static/nginx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schenkd/nginx-ui/8c12f54d381aa6dee6e8bd683036beaaddc96eca/app/static/nginx.png -------------------------------------------------------------------------------- /app/static/themes/default/assets/fonts/brand-icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schenkd/nginx-ui/8c12f54d381aa6dee6e8bd683036beaaddc96eca/app/static/themes/default/assets/fonts/brand-icons.eot -------------------------------------------------------------------------------- /app/static/themes/default/assets/fonts/brand-icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schenkd/nginx-ui/8c12f54d381aa6dee6e8bd683036beaaddc96eca/app/static/themes/default/assets/fonts/brand-icons.ttf -------------------------------------------------------------------------------- /app/static/themes/default/assets/fonts/brand-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schenkd/nginx-ui/8c12f54d381aa6dee6e8bd683036beaaddc96eca/app/static/themes/default/assets/fonts/brand-icons.woff -------------------------------------------------------------------------------- /app/static/themes/default/assets/fonts/brand-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schenkd/nginx-ui/8c12f54d381aa6dee6e8bd683036beaaddc96eca/app/static/themes/default/assets/fonts/brand-icons.woff2 -------------------------------------------------------------------------------- /app/static/themes/default/assets/fonts/icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schenkd/nginx-ui/8c12f54d381aa6dee6e8bd683036beaaddc96eca/app/static/themes/default/assets/fonts/icons.eot -------------------------------------------------------------------------------- /app/static/themes/default/assets/fonts/icons.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schenkd/nginx-ui/8c12f54d381aa6dee6e8bd683036beaaddc96eca/app/static/themes/default/assets/fonts/icons.otf -------------------------------------------------------------------------------- /app/static/themes/default/assets/fonts/icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schenkd/nginx-ui/8c12f54d381aa6dee6e8bd683036beaaddc96eca/app/static/themes/default/assets/fonts/icons.ttf -------------------------------------------------------------------------------- /app/static/themes/default/assets/fonts/icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schenkd/nginx-ui/8c12f54d381aa6dee6e8bd683036beaaddc96eca/app/static/themes/default/assets/fonts/icons.woff -------------------------------------------------------------------------------- /app/static/themes/default/assets/fonts/icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schenkd/nginx-ui/8c12f54d381aa6dee6e8bd683036beaaddc96eca/app/static/themes/default/assets/fonts/icons.woff2 -------------------------------------------------------------------------------- /app/static/themes/default/assets/fonts/outline-icons.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schenkd/nginx-ui/8c12f54d381aa6dee6e8bd683036beaaddc96eca/app/static/themes/default/assets/fonts/outline-icons.eot -------------------------------------------------------------------------------- /app/static/themes/default/assets/fonts/outline-icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | 19 | 22 | 25 | 28 | 31 | 34 | 37 | 40 | 43 | 46 | 49 | 52 | 55 | 58 | 61 | 64 | 67 | 70 | 73 | 76 | 79 | 82 | 85 | 88 | 91 | 94 | 97 | 100 | 103 | 106 | 109 | 112 | 115 | 118 | 121 | 124 | 127 | 130 | 133 | 136 | 139 | 142 | 145 | 148 | 151 | 154 | 157 | 160 | 163 | 166 | 169 | 172 | 175 | 178 | 181 | 184 | 187 | 190 | 193 | 196 | 199 | 202 | 205 | 208 | 211 | 214 | 217 | 220 | 223 | 226 | 229 | 232 | 235 | 238 | 241 | 244 | 247 | 250 | 253 | 256 | 259 | 262 | 265 | 268 | 271 | 274 | 277 | 280 | 283 | 286 | 289 | 292 | 295 | 298 | 301 | 304 | 307 | 310 | 313 | 316 | 319 | 322 | 325 | 328 | 331 | 334 | 337 | 340 | 343 | 346 | 349 | 352 | 355 | 358 | 361 | 364 | 365 | 366 | 367 | -------------------------------------------------------------------------------- /app/static/themes/default/assets/fonts/outline-icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schenkd/nginx-ui/8c12f54d381aa6dee6e8bd683036beaaddc96eca/app/static/themes/default/assets/fonts/outline-icons.ttf -------------------------------------------------------------------------------- /app/static/themes/default/assets/fonts/outline-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schenkd/nginx-ui/8c12f54d381aa6dee6e8bd683036beaaddc96eca/app/static/themes/default/assets/fonts/outline-icons.woff -------------------------------------------------------------------------------- /app/static/themes/default/assets/fonts/outline-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schenkd/nginx-ui/8c12f54d381aa6dee6e8bd683036beaaddc96eca/app/static/themes/default/assets/fonts/outline-icons.woff2 -------------------------------------------------------------------------------- /app/static/themes/default/assets/images/flags.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schenkd/nginx-ui/8c12f54d381aa6dee6e8bd683036beaaddc96eca/app/static/themes/default/assets/images/flags.png -------------------------------------------------------------------------------- /app/templates/config.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |

{{ name }}

6 |
7 | 8 |
9 | 13 |
14 | 15 |
16 |
17 | 18 |
19 |
20 | 21 |
22 | 23 |
24 |
25 |
26 |
27 | 28 |
29 | 30 |
31 | 32 |
33 |
-------------------------------------------------------------------------------- /app/templates/domain.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

{{ name }}

5 |
6 | 7 |
8 |
9 | 12 | 13 | 16 | 17 | {% if enabled %} 18 | 21 | {% else %} 22 | 25 | {% endif %} 26 |
27 |
28 |
29 |
30 | 31 |
32 | 33 |
34 | 35 |
36 |
37 |
38 |
39 |
40 | 41 |
42 |
43 | 44 |
45 |
46 | -------------------------------------------------------------------------------- /app/templates/domains.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | {% if sites_available %} 5 | {% for domain in sites_available %} 6 |
7 |
8 | 9 | 10 | {% if domain['name'] in sites_enabled %} 11 | 12 | {% else %} 13 | 14 | {% endif %} 15 | {{ domain['name'] }} 16 | 17 | 18 |
19 | Updated {{ moment(domain['time']).fromNow() }} 20 |
21 | 22 |
23 |
24 | {% endfor %} 25 | 26 | {% endif %} 27 |
28 | 29 |
30 | 31 |
32 | 33 |
-------------------------------------------------------------------------------- /app/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Nginx UI 10 | 11 | 12 | 13 | {{ moment.include_moment() }} 14 | 15 | 16 | 17 | 18 | 19 | 20 | 55 | 56 |
57 | 58 |
59 | 60 |
61 | 62 | 63 | -------------------------------------------------------------------------------- /app/templates/new_domain.j2: -------------------------------------------------------------------------------- 1 | { 2 | listen 80; 3 | server_name {{ name }}, 4 | access_log logs/{{ name }}.access.log main; 5 | 6 | location / { 7 | proxy_pass http://127.0.0.1:8080; 8 | } 9 | 10 | } -------------------------------------------------------------------------------- /app/ui/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | ui = Blueprint('ui', __name__) 4 | 5 | from . import views 6 | -------------------------------------------------------------------------------- /app/ui/views.py: -------------------------------------------------------------------------------- 1 | from app.ui import ui 2 | import flask 3 | import os 4 | 5 | 6 | @ui.route('/', methods=['GET']) 7 | def index(): 8 | """ 9 | Delivers the home page of Nginx UI. 10 | 11 | :return: Rendered HTML document. 12 | :rtype: str 13 | """ 14 | nginx_path = flask.current_app.config['NGINX_PATH'] 15 | config = [f for f in os.listdir(nginx_path) if os.path.isfile(os.path.join(nginx_path, f))] 16 | return flask.render_template('index.html', config=config) 17 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class Config(object): 5 | SECRET_KEY = os.urandom(64).hex() 6 | 7 | NGINX_PATH = '/etc/nginx' 8 | CONFIG_PATH = os.path.join(NGINX_PATH, 'conf.d') 9 | 10 | @staticmethod 11 | def init_app(app): 12 | pass 13 | 14 | 15 | class DevConfig(Config): 16 | DEBUG = True 17 | 18 | 19 | class WorkingConfig(Config): 20 | DEBUG = False 21 | 22 | 23 | config = { 24 | 'dev': DevConfig, 25 | 'default': WorkingConfig 26 | } 27 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | nginx-ui: 4 | container_name: nginx-ui 5 | build: . 6 | image: nginx-ui:latest 7 | ports: 8 | - 8080:8080 9 | volumes: 10 | - nginx:/etc/nginx 11 | 12 | nginx: 13 | container_name: nginx 14 | image: nginx:1.18.0-alpine 15 | ports: 16 | - 80:80 17 | volumes: 18 | - nginx:/etc/nginx 19 | 20 | volumes: 21 | nginx: -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click==7.1.2 2 | Flask==1.1.2 3 | Flask-Moment==0.9.0 4 | itsdangerous==1.1.0 5 | Jinja2==2.11.3 6 | MarkupSafe==1.1.1 7 | pytz==2020.1 8 | uWSGI==2.0.18 9 | Werkzeug==1.0.1 10 | -------------------------------------------------------------------------------- /wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | from app import create_app 3 | 4 | 5 | app = create_app(os.getenv('FLASK_CONFIG') or 'default') 6 | --------------------------------------------------------------------------------