├── .gitignore ├── LICENSE.txt ├── README.md ├── setup.cfg ├── setup.py └── vhostm └── vhostm.py /.gitignore: -------------------------------------------------------------------------------- 1 | // Artifacts 2 | .* 3 | !*.gitignore 4 | 5 | // Emacs artifacts 6 | *~ 7 | \#*\# 8 | *_flymake.* 9 | 10 | // Python artifacts 11 | *.pyc 12 | *.egg-info* -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Phil Eaton 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vhostm 2 | 3 | This python3 script facilitates the creation and deletion of nginx virtual servers 4 | and hosts file entries. 5 | 6 | ### Motivation 7 | 8 | It is annoying to set up new virtual servers and hosts file entries by hand 9 | every time I take on a new client. 10 | 11 | ## Install 12 | 13 | ### To install from pypi 14 | 15 | ```bash 16 | sudo pip install vhostm 17 | ``` 18 | 19 | ### To install for development 20 | 21 | ```bash 22 | git clone git@github.com:eatonphil/vhostm 23 | cd vhostm 24 | pyvenv .env 25 | . .env/bin/activate 26 | pip install -e ./ 27 | ``` 28 | 29 | ## Usage 30 | 31 | Vhostm differs slightly from spinup and provides a much more useful interface 32 | for viewing existing vhosts. 33 | 34 | ### Setup 35 | 36 | The following defaults are used: 37 | 38 | ```json 39 | { 40 | "nginx_conf_dir": "/etc/nginx/sites-enabled", 41 | "hosts_file": "/etc/hosts", 42 | "vhosts_file": "/etc/vhostm/vhosts.conf" 43 | } 44 | ``` 45 | 46 | To override any of these settings per user, copy the json with the settings 47 | you wish to override into ~/.vhostm.conf and change the value of the key. 48 | 49 | For instance, on FreeBSD, the config (~/.vhostm.conf) may look like this: 50 | 51 | ```json 52 | { 53 | "nginx_conf_dir": "/usr/local/etc/nginx/conf.d", 54 | "vhosts_file": "/usr/local/etc/vhostm/vhosts.conf" 55 | } 56 | ``` 57 | 58 | You may also override either of these per command by using the flags 59 | (--nginx_conf_dir, --vhosts_file, --hosts_file). 60 | 61 | ### List 62 | 63 | ```bash 64 | sudo vhostm list 65 | ``` 66 | 67 | ### Create 68 | 69 | This one-liner creates a new vhost at `/etc/nginx/sites-enabled/mysite.com` 70 | that forwards mysite.com to localhost:3000 and serves static files from 71 | ./static. Additionally, this adds an entry in your hosts file so mysite.com 72 | points to localhost. 73 | 74 | ```bash 75 | sudo vhostm add -d mysite.com -p 3000 -s ./static 76 | ``` 77 | 78 | ### Delete 79 | 80 | This one-line deletes the previously created config file and removes the 81 | entry from the hosts file. 82 | 83 | ```bash 84 | sudo vhostm del -d mysite.com 85 | ``` 86 | 87 | ## Alternative template file 88 | 89 | If you would like to provide an alternative template for the nginx 90 | config, you can override the default by using the --nginx_template_file 91 | flag or assigning the "nginx_template_file" in the ~/.vhostm.conf file. 92 | 93 | Use the default template in vhostm/vhostm.py as an example. 94 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="vhostm", 5 | packages=["vhostm"], 6 | version="1.1", 7 | description="Manage nginx virtual servers and hosts file entries.", 8 | author="Phil Eaton", 9 | author_email="me@eatonphil.com", 10 | url="https://github.com/eatonphil/vhostm", 11 | download_url="https://github.com/eatonphil/vhostm/tarball/1.1", 12 | keywords=["nginx", "virtual", "hosts"], 13 | install_requires=[ 14 | "jinja2>=2.8" 15 | ], 16 | entry_points={ 17 | "console_scripts": [ 18 | "vhostm = vhostm.vhostm:main" 19 | ] 20 | } 21 | ) 22 | -------------------------------------------------------------------------------- /vhostm/vhostm.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import os 4 | import subprocess 5 | from os import makedirs, remove 6 | from os.path import abspath, join, exists, dirname 7 | 8 | from jinja2 import Template 9 | 10 | CMDS = ["list", "add", "del", "gen"] 11 | 12 | VHOSTM_CONFIG = ".vhostm.conf" 13 | 14 | DEFAULT_NGINX_TEMPLATE = """ 15 | upstream {{domain}} { 16 | server {{address}}:{{port}}; 17 | } 18 | 19 | server { 20 | listen 80; 21 | listen [::]:80; 22 | server_name {{domain}}; 23 | 24 | location / { 25 | proxy_set_header Host $host; 26 | proxy_set_header X-Real-IP $remote_addr; 27 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 28 | proxy_set_header X-Forwarded-Proto $scheme; 29 | 30 | proxy_read_timeout 90; 31 | 32 | proxy_pass http://{{domain}}; 33 | } 34 | 35 | {% if static_root %} 36 | # Media: images, icons, video, audio, HTC 37 | location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc|mst|otf|ttf|woff)$ { 38 | root {{static_root}}; 39 | # expires 1M; 40 | access_log off; 41 | add_header Cache-Control "public"; 42 | } 43 | 44 | # CSS and Javascript 45 | location ~* \.(?:css|js)$ { 46 | root {{static_root}}; 47 | # expires 1y; 48 | access_log off; 49 | add_header Cache-Control "public"; 50 | } 51 | {% endif %} 52 | } 53 | """ 54 | 55 | 56 | class Config(object): 57 | def __init__(self, vhosts_file, hosts_file, 58 | nginx_conf_dir, nginx_template): 59 | self.vhosts_file = vhosts_file 60 | self.hosts_file = hosts_file 61 | self.nginx_conf_dir = nginx_conf_dir 62 | self.nginx_template = nginx_template 63 | 64 | for d in [dirname(vhosts_file), dirname(hosts_file), nginx_conf_dir]: 65 | if not exists(d): 66 | makedirs(d) 67 | 68 | 69 | class Vhost(object): 70 | def __init__(self, domain, port, static_root=None, address="127.0.0.1"): 71 | self.domain = domain 72 | self.port = port 73 | self.static_root = static_root 74 | self.address = address 75 | 76 | def write(self): 77 | if self.port is None: 78 | exit("Cannot write vhost without port") 79 | 80 | static_root = self.static_root if self.static_root else "" 81 | return json.dumps(dict( 82 | domain=self.domain, 83 | port=self.port, 84 | static_root=static_root, 85 | address=self.address)) 86 | 87 | @staticmethod 88 | def header(): 89 | return "Domain | Address:Port | Static Root" 90 | 91 | @classmethod 92 | def read(cls, vhost_str): 93 | vhost = json.loads(vhost_str) 94 | domain = vhost["domain"] 95 | port = vhost["port"] 96 | static_root = vhost["static_root"] 97 | address = vhost["address"] 98 | return cls(domain, 99 | port, 100 | static_root if static_root != "" else None, 101 | address) 102 | 103 | def __str__(self): 104 | return "{} | {}:{} | {}".format( 105 | self.domain, 106 | self.address, 107 | self.port, 108 | self.static_root if self.static_root else "") 109 | 110 | def __eq__(self, other): 111 | return self.domain == other.domain 112 | 113 | 114 | def vhostm_gen(config): 115 | try: 116 | with open(config.vhosts_file, "r") as f: 117 | vhosts = json.load(f) 118 | except: 119 | vhosts = {"vhosts": []} 120 | 121 | hosts_file = "" 122 | try: 123 | with open(config.hosts_file) as f: 124 | hosts_file = f.read() 125 | except FileNotFoundError: 126 | pass 127 | 128 | hosts = "" 129 | for vhost_str in vhosts["vhosts"]: 130 | vhost = Vhost.read(vhost_str) 131 | 132 | hosts += "{}\t{}\n".format(vhost.address, vhost.domain) 133 | 134 | # Write nginx config file 135 | with open(join(config.nginx_conf_dir, vhost.domain), "w+") as f: 136 | template = Template(config.nginx_template) 137 | f.write(template.render(domain=vhost.domain, 138 | port=vhost.port, 139 | address=vhost.address, 140 | static_root=vhost.static_root)) 141 | 142 | with open(config.hosts_file, "w+") as f: 143 | if "#{%block vhostm_hosts%}\n" not in hosts_file: 144 | hosts_file += "\n#{%block vhostm_hosts%}\n#{%endblock%}\n" 145 | 146 | template = Template(hosts_file) 147 | 148 | def swap_vhostm_hosts(*args, **kwargs): 149 | yield "\n#{%block vhostm_hosts%}\n" + hosts + "#{%endblock%}\n" 150 | 151 | template.blocks["vhostm_hosts"] = swap_vhostm_hosts 152 | hosts_file = template.render() 153 | f.write(hosts_file) 154 | 155 | assert(subprocess.call(["nginx", "-t"]) == 0) 156 | 157 | assert(subprocess.call(["service", "nginx", "reload"]) == 0) 158 | 159 | 160 | def vhostm_add(config, vhost): 161 | try: 162 | with open(config.vhosts_file, "r") as f: 163 | vhosts = json.load(f) 164 | except: 165 | vhosts = {"vhosts": []} 166 | 167 | for vhost_str in vhosts["vhosts"]: 168 | _vhost = Vhost.read(vhost_str) 169 | if _vhost == vhost: 170 | exit("Unable to override existing vhost {}".format( 171 | vhost.domain)) 172 | 173 | vhosts["vhosts"].append(vhost.write()) 174 | 175 | with open(config.vhosts_file, "w+") as f: 176 | json.dump(vhosts, f) 177 | 178 | vhostm_gen(config) 179 | 180 | 181 | def vhostm_del(config, vhost): 182 | try: 183 | with open(config.vhosts_file) as f: 184 | vhosts = json.load(f) 185 | except: 186 | exit("Cannot delete vhost from vhosts_file that does not exist") 187 | 188 | vhosts_dict = {"vhosts": []} 189 | for vhost_str in vhosts["vhosts"]: 190 | _vhost = Vhost.read(vhost_str) 191 | 192 | if vhost == _vhost: 193 | # Remove nginx config file 194 | remove(join(config.nginx_conf_dir, vhost.domain)) 195 | else: 196 | vhosts_dict["vhosts"].append(vhost_str) 197 | 198 | with open(config.vhosts_file, "w") as f: 199 | json.dump(vhosts_dict, f) 200 | 201 | vhostm_gen(config) 202 | 203 | 204 | def vhostm_list(config): 205 | try: 206 | with open(config.vhosts_file) as f: 207 | vhosts = json.load(f) 208 | except: 209 | vhosts = {"vhosts": []} 210 | 211 | print(Vhost.header()) 212 | for vhost_str in vhosts["vhosts"]: 213 | vhost = Vhost.read(vhost_str) 214 | if vhost is not None: 215 | print(vhost) 216 | 217 | 218 | def get_user_root(): 219 | sudo_user = os.getenv("SUDO_USER") 220 | user_config = "/home/{}".format(sudo_user) 221 | if sudo_user == "root": 222 | user_config = "/root" 223 | 224 | return user_config 225 | 226 | 227 | def get_args(): 228 | parser = argparse.ArgumentParser() 229 | 230 | hosts_file = "/etc/hosts" 231 | nginx_conf_dir = "/etc/nginx/sites-enabled" 232 | vhosts_file = "/etc/vhostm/vhosts.conf" 233 | 234 | nginx_template = DEFAULT_NGINX_TEMPLATE 235 | nginx_template_file = None 236 | 237 | try: 238 | user_root = get_user_root() 239 | with open(abspath(join(user_root, VHOSTM_CONFIG))) as f: 240 | config = json.load(f) 241 | hosts_file = config.get("hosts_file", 242 | hosts_file) 243 | nginx_conf_dir = config.get("nginx_conf_dir", 244 | nginx_conf_dir) 245 | vhosts_file = config.get("vhosts_file", 246 | vhosts_file) 247 | nginx_template_file = config.get("nginx_template_file") 248 | except IOError: 249 | pass 250 | 251 | cmd_help = ("must supply command. Options are [{}]" 252 | "").format(", ".join(CMDS)) 253 | parser.add_argument("cmd", help=cmd_help, type=str) 254 | 255 | vhosts_file_help = ("must supply the location of a vhosts_file here or in" 256 | "~/.vhostm.conf") 257 | parser.add_argument("--vhosts_file", 258 | help=vhosts_file_help, type=str, default=None) 259 | 260 | vhost_file_help = "the location of a vhost config file" 261 | parser.add_argument("-f", "--vhost_file", 262 | help=vhost_file_help, type=str, 263 | default="./.vhost.conf") 264 | 265 | nginx_template_file_help = "the location of the nginx template file" 266 | parser.add_argument("--nginx_template_file", 267 | help=nginx_template_file_help, type=str, default=None) 268 | 269 | hosts_file_help = "the location of the hosts file" 270 | parser.add_argument("--hosts_file", 271 | help=hosts_file_help, type=str, default=None) 272 | 273 | nginx_conf_dir_help = "the nginx configuration directory" 274 | parser.add_argument("--nginx_conf_dir", 275 | help=nginx_conf_dir_help, type=str, default=None) 276 | 277 | domain_help = "the domain to be added or deleted" 278 | parser.add_argument("-d", "--domain", 279 | help=domain_help, type=str) 280 | 281 | port_help = "the port to attach the domain to" 282 | parser.add_argument("-p", "--port", 283 | help=port_help, type=str) 284 | 285 | static_root_help = "the static root to serve files from" 286 | parser.add_argument("-s", "--static_root", 287 | help=static_root_help, type=str, default=None) 288 | 289 | address_help = "the address to forward to (127.0.0.1, 0.0.0.0)" 290 | parser.add_argument("-a", "--address", 291 | help=address_help, type=str, default="127.0.0.1") 292 | 293 | args = parser.parse_args() 294 | 295 | if args.vhosts_file is None: 296 | args.vhosts_file = vhosts_file 297 | 298 | if args.hosts_file is None: 299 | args.hosts_file = hosts_file 300 | 301 | if args.nginx_conf_dir is None: 302 | args.nginx_conf_dir = nginx_conf_dir 303 | 304 | if args.nginx_template_file is None: 305 | args.nginx_template_file = nginx_template_file 306 | 307 | if args.nginx_template_file is not None: 308 | with open(abspath(nginx_template_file)) as f: 309 | nginx_template = f.read() 310 | 311 | setattr(args, "nginx_template", nginx_template) 312 | 313 | if args.static_root: 314 | args.static_root = abspath(args.static_root) 315 | 316 | return args 317 | 318 | 319 | def main(): 320 | args = get_args() 321 | if args.cmd not in CMDS: 322 | exit("cmd must be one of [{}]".format(", ".join(CMDS))) 323 | 324 | config = Config(abspath(args.vhosts_file), 325 | abspath(args.hosts_file), 326 | abspath(args.nginx_conf_dir), 327 | args.nginx_template) 328 | 329 | cmd_functions = [vhostm_list, vhostm_add, vhostm_del, vhostm_gen] 330 | for i, cmd in enumerate(CMDS): 331 | vhost = None 332 | if args.vhost_file is not None: 333 | try: 334 | with open(args.vhost_file) as f: 335 | vhost = Vhost.read(f) 336 | except IOError: 337 | pass 338 | 339 | if args.domain is not None: 340 | vhost = Vhost(args.domain, 341 | args.port, 342 | args.static_root, 343 | args.address) 344 | 345 | if args.cmd == cmd: 346 | kwargs = {} 347 | if vhost is not None: 348 | kwargs["vhost"] = vhost 349 | try: 350 | cmd_functions[i](config, **kwargs) 351 | except TypeError as e: 352 | print(e) 353 | exit("Missing required arguments to {}".format(cmd)) 354 | 355 | 356 | if __name__ == "__main__": 357 | main() 358 | --------------------------------------------------------------------------------