Merge master
diff --git a/containers/vpn/Dockerfile b/containers/vpn/Dockerfile
new file mode 100644
index 0000000..8ae8484
--- /dev/null
+++ b/containers/vpn/Dockerfile
@@ -0,0 +1,12 @@
+FROM       xosproject/xos-synchronizer-openstack
+
+RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y \
+    openvpn
+
+# for OpenVPN
+RUN mkdir -p /opt/openvpn
+RUN chmod 777 /opt/openvpn
+RUN git clone https://github.com/OpenVPN/easy-rsa.git /opt/openvpn
+RUN git -C /opt/openvpn pull origin master
+RUN echo 'set_var EASYRSA	"/opt/openvpn/easyrsa3"' | tee /opt/openvpn/vars
+RUN echo 'set_var EASYRSA_BATCH	"true"' | tee -a /opt/openvpn/vars
diff --git a/containers/vpn/Makefile b/containers/vpn/Makefile
new file mode 100644
index 0000000..6004e76
--- /dev/null
+++ b/containers/vpn/Makefile
@@ -0,0 +1,18 @@
+IMAGE_NAME:=xosproject/xos-vpn
+CONTAINER_NAME:=xos-synchronizer
+NO_DOCKER_CACHE?=true
+
+.PHONY: build
+build: ; sudo docker build --no-cache=${NO_DOCKER_CACHE} --rm -t ${IMAGE_NAME} .
+
+.PHONY: run
+run: ; sudo docker run -d --name ${CONTAINER_NAME} -v /usr/local/share/ca-certificates:/usr/local/share/ca-certificates:ro ${IMAGE_NAME}
+
+.PHONY: stop
+stop: ; sudo docker stop ${CONTAINER_NAME}
+
+.PHONY: rm
+rm: ; sudo docker rm ${CONTAINER_NAME}
+
+.PHONY: rmi
+rmi: ; docker rmi ${IMAGE_NAME}
diff --git a/containers/vpn/conf/ansible-hosts b/containers/vpn/conf/ansible-hosts
new file mode 100644
index 0000000..0dd74f1
--- /dev/null
+++ b/containers/vpn/conf/ansible-hosts
@@ -0,0 +1,2 @@
+[localhost]
+127.0.0.1
diff --git a/containers/vpn/conf/synchronizer.conf b/containers/vpn/conf/synchronizer.conf
new file mode 100644
index 0000000..2131a25
--- /dev/null
+++ b/containers/vpn/conf/synchronizer.conf
@@ -0,0 +1,9 @@
+[supervisord]
+logfile=/var/log/supervisord.log ; (main log file;default $CWD/supervisord.log)
+pidfile=/var/run/supervisord.pid ; (supervisord pidfile;default supervisord.pid)
+nodaemon=true
+
+[program:synchronizer]
+command=python /opt/xos/synchronizers/openstack/xos-synchronizer.py
+stderr_logfile=/var/log/supervisor/synchronizer.err.log
+stdout_logfile=/var/log/supervisor/synchronizer.out.log
diff --git a/views/ngXosViews/vpnDashboard/.bowerrc b/views/ngXosViews/vpnDashboard/.bowerrc
new file mode 100644
index 0000000..e491038
--- /dev/null
+++ b/views/ngXosViews/vpnDashboard/.bowerrc
@@ -0,0 +1,3 @@
+{
+  "directory": "src/vendor/"
+}
\ No newline at end of file
diff --git a/views/ngXosViews/vpnDashboard/.eslintrc b/views/ngXosViews/vpnDashboard/.eslintrc
new file mode 100644
index 0000000..c852748
--- /dev/null
+++ b/views/ngXosViews/vpnDashboard/.eslintrc
@@ -0,0 +1,42 @@
+{
+    "ecmaFeatures": {
+        "blockBindings": true,
+        "forOf": true,
+        "destructuring": true,
+        "arrowFunctions": true,
+        "templateStrings": true
+    },
+    "env": { 
+        "browser": true,
+        "node": true,
+        "es6": true
+    },
+    "plugins": [
+        //"angular"
+    ],
+    "rules": {
+        "quotes": [2, "single"],
+        "camelcase": [1, {"properties": "always"}],
+        "no-underscore-dangle": 1,
+        "eqeqeq": [2, "smart"],
+        "no-alert": 1,
+        "key-spacing": [1, { "beforeColon": false, "afterColon": true }],
+        "indent": [2, 2],
+        "no-irregular-whitespace": 1,
+        "eol-last": 0,
+        "max-nested-callbacks": [2, 4],
+        "comma-spacing": [1, {"before": false, "after": true}],
+        "no-trailing-spaces": [1, { skipBlankLines: true }],
+        "no-unused-vars": [1, {"vars": "all", "args": "after-used"}],
+        "new-cap": 0,
+
+        //"angular/ng_module_name": [2, '/^xos\.*[a-z]*$/'],
+        //"angular/ng_controller_name": [2, '/^[a-z].*Ctrl$/'],
+        //"angular/ng_service_name": [2, '/^[A-Z].*Service$/'],
+        //"angular/ng_directive_name": [2, '/^[a-z]+[[A-Z].*]*$/'],
+        //"angular/ng_di": [0, "function or array"]
+    },
+    "globals" :{
+        "angular": true
+    } 
+}
\ No newline at end of file
diff --git a/views/ngXosViews/vpnDashboard/.gitignore b/views/ngXosViews/vpnDashboard/.gitignore
new file mode 100644
index 0000000..567aee4
--- /dev/null
+++ b/views/ngXosViews/vpnDashboard/.gitignore
@@ -0,0 +1,6 @@
+dist/
+src/vendor
+.tmp
+node_modules
+npm-debug.log
+dist/
\ No newline at end of file
diff --git a/views/ngXosViews/vpnDashboard/bower.json b/views/ngXosViews/vpnDashboard/bower.json
new file mode 100644
index 0000000..b7c354e
--- /dev/null
+++ b/views/ngXosViews/vpnDashboard/bower.json
@@ -0,0 +1,29 @@
+{
+  "name": "xos-vpnDashboard",
+  "version": "0.0.0",
+  "authors": [
+    "Jeremy Mowery <jermowery@email.arizona.edu>"
+  ],
+  "description": "The vpnDashboard view",
+  "license": "MIT",
+  "ignore": [
+    "**/.*",
+    "node_modules",
+    "bower_components",
+    "static/js/vendor/",
+    "test",
+    "tests"
+  ],
+  "dependencies": {
+  },
+  "devDependencies": {
+    "jquery": "2.1.4",
+    "angular-mocks": "1.4.7",
+    "angular": "1.4.7",
+    "angular-ui-router": "0.2.15",
+    "angular-cookies": "1.4.7",
+    "angular-resource": "1.4.7",
+    "ng-lodash": "0.3.0",
+    "bootstrap-css": "2.3.2"
+  }
+}
diff --git a/views/ngXosViews/vpnDashboard/env/default.js b/views/ngXosViews/vpnDashboard/env/default.js
new file mode 100644
index 0000000..5b198ec
--- /dev/null
+++ b/views/ngXosViews/vpnDashboard/env/default.js
@@ -0,0 +1,13 @@
+// This is a default configuration for your development environment.
+// You can duplicate this configuration for any of your Backend Environments.
+// Different configurations are loaded setting a NODE_ENV variable that contain the config file name.
+// `NODE_ENV=local npm start`
+//
+// If xoscsrftoken or xossessionid are not specified the browser value are used
+// (works only for local environment as both application are served on the same domain)
+
+module.exports = {
+  host: '',
+  xoscsrftoken: '',
+  xossessionid: ''
+};
diff --git a/views/ngXosViews/vpnDashboard/gulp/build.js b/views/ngXosViews/vpnDashboard/gulp/build.js
new file mode 100644
index 0000000..b7de9e5
--- /dev/null
+++ b/views/ngXosViews/vpnDashboard/gulp/build.js
@@ -0,0 +1,150 @@
+'use strict';
+
+// BUILD
+//
+// The only purpose of this gulpfile is to build a XOS view and copy the correct files into
+// .html => dashboards
+// .js (minified and concat) => static/js
+//
+// The template are parsed and added to js with angular $templateCache
+
+var gulp = require('gulp');
+var ngAnnotate = require('gulp-ng-annotate');
+var uglify = require('gulp-uglify');
+var templateCache = require('gulp-angular-templatecache');
+var runSequence = require('run-sequence');
+var concat = require('gulp-concat');
+var del = require('del');
+var wiredep = require('wiredep');
+var angularFilesort = require('gulp-angular-filesort');
+var _ = require('lodash');
+var eslint = require('gulp-eslint');
+var inject = require('gulp-inject');
+var rename = require('gulp-rename');
+var replace = require('gulp-replace');
+var postcss = require('gulp-postcss');
+var autoprefixer = require('autoprefixer');
+var mqpacker = require('css-mqpacker');
+var csswring = require('csswring');
+
+var TEMPLATE_FOOTER = `}]);
+angular.module('xos.vpnDashboard').run(function($location){$location.path('/')});
+angular.bootstrap(angular.element('#xosVpnDashboard'), ['xos.vpnDashboard']);`;
+
+module.exports = function(options){
+
+  // delete previous builded file
+  gulp.task('clean', function(){
+    return del(
+      [options.dashboards + 'xosVpnDashboard.html'],
+      {force: true}
+    );
+  });
+
+  // minify css
+  gulp.task('css', function () {
+    var processors = [
+      autoprefixer({browsers: ['last 1 version']}),
+      mqpacker,
+      csswring
+    ];
+
+    gulp.src([
+      `${options.css}**/*.css`,
+      `!${options.css}dev.css`
+    ])
+    .pipe(postcss(processors))
+    .pipe(gulp.dest(options.tmp + '/css/'));
+  });
+
+  gulp.task('copyCss', ['css'], function(){
+    return gulp.src([`${options.tmp}/css/*.css`])
+    .pipe(concat('xosVpnDashboard.css'))
+    .pipe(gulp.dest(options.static + 'css/'))
+  });
+
+  // compile and minify scripts
+  gulp.task('scripts', function() {
+    return gulp.src([
+      options.tmp + '**/*.js'
+    ])
+    .pipe(ngAnnotate())
+    .pipe(angularFilesort())
+    .pipe(concat('xosVpnDashboard.js'))
+    .pipe(uglify())
+    .pipe(gulp.dest(options.static + 'js/'));
+  });
+
+  // set templates in cache
+  gulp.task('templates', function(){
+    return gulp.src('./src/templates/*.html')
+      .pipe(templateCache({
+        module: 'xos.vpnDashboard',
+        root: 'templates/',
+        templateFooter: TEMPLATE_FOOTER
+      }))
+      .pipe(gulp.dest(options.tmp));
+  });
+
+  // copy html index to Django Folder
+  gulp.task('copyHtml', ['clean'], function(){
+    return gulp.src(options.src + 'index.html')
+      // remove dev dependencies from html
+      .pipe(replace(/<!-- bower:css -->(\n.*)*\n<!-- endbower --><!-- endcss -->/, ''))
+      .pipe(replace(/<!-- bower:js -->(\n.*)*\n<!-- endbower --><!-- endjs -->/, ''))
+      .pipe(replace(/ng-app=".*"\s/, ''))
+      // rewriting css path
+      // .pipe(replace(/(<link.*">)/, ''))
+      // injecting minified files
+      .pipe(
+        inject(
+          gulp.src([
+            options.static + 'js/vendor/xosVpnDashboardVendor.js',
+            options.static + 'js/xosVpnDashboard.js',
+            options.static + 'css/xosVpnDashboard.css'
+          ]),
+          {ignorePath: '/../../../xos/core/xoslib'}
+        )
+      )
+      .pipe(rename('xosVpnDashboard.html'))
+      .pipe(gulp.dest(options.dashboards));
+  });
+
+  // minify vendor js files
+  gulp.task('wiredep', function(){
+    var bowerDeps = wiredep().js;
+    if(!bowerDeps){
+      return;
+    }
+
+    // remove angular (it's already loaded)
+    _.remove(bowerDeps, function(dep){
+      return dep.indexOf('angular/angular.js') !== -1;
+    });
+
+    return gulp.src(bowerDeps)
+      .pipe(concat('xosVpnDashboardVendor.js'))
+      .pipe(uglify())
+      .pipe(gulp.dest(options.static + 'js/vendor/'));
+  });
+
+  gulp.task('lint', function () {
+    return gulp.src(['src/js/**/*.js'])
+      .pipe(eslint())
+      .pipe(eslint.format())
+      .pipe(eslint.failAfterError());
+  });
+
+  gulp.task('build', function() {
+    runSequence(
+      'lint',
+      'templates',
+      'babel',
+      'scripts',
+      'wiredep',
+      'copyHtml',
+      'copyCss',
+      'cleanTmp'
+    );
+  });
+};
diff --git a/views/ngXosViews/vpnDashboard/gulp/server.js b/views/ngXosViews/vpnDashboard/gulp/server.js
new file mode 100644
index 0000000..7605294
--- /dev/null
+++ b/views/ngXosViews/vpnDashboard/gulp/server.js
@@ -0,0 +1,146 @@
+'use strict';
+
+var gulp = require('gulp');
+var browserSync = require('browser-sync').create();
+var inject = require('gulp-inject');
+var runSequence = require('run-sequence');
+var angularFilesort = require('gulp-angular-filesort');
+var babel = require('gulp-babel');
+var wiredep = require('wiredep').stream;
+var httpProxy = require('http-proxy');
+var del = require('del');
+
+const environment = process.env.NODE_ENV;
+
+if (environment){
+  var conf = require(`../env/${environment}.js`);
+}
+else{
+  var conf = require('../env/default.js')
+}
+
+var proxy = httpProxy.createProxyServer({
+  target: conf.host || 'http://0.0.0.0:9999'
+});
+
+
+proxy.on('error', function(error, req, res) {
+  res.writeHead(500, {
+    'Content-Type': 'text/plain'
+  });
+
+  console.error('[Proxy]', error);
+});
+
+module.exports = function(options){
+
+  // open in browser with sync and proxy to 0.0.0.0
+  gulp.task('browser', function() {
+    browserSync.init({
+      // reloadDelay: 500,
+      // logLevel: 'debug',
+      // logConnections: true,
+      startPath: '#/',
+      snippetOptions: {
+        rule: {
+          match: /<!-- browserSync -->/i
+        }
+      },
+      server: {
+        baseDir: options.src,
+        routes: {
+          '/api': options.api,
+          '/xosHelpers/src': options.helpers
+        },
+        middleware: function(req, res, next){
+          if(
+            req.url.indexOf('/xos/') !== -1 ||
+            req.url.indexOf('/xoslib/') !== -1 ||
+            req.url.indexOf('/hpcapi/') !== -1
+          ){
+            if(conf.xoscsrftoken && conf.xossessionid){
+              req.headers.cookie = `xoscsrftoken=${conf.xoscsrftoken}; xossessionid=${conf.xossessionid}`;
+              req.headers['x-csrftoken'] = conf.xoscsrftoken;
+            }
+            proxy.web(req, res);
+          }
+          else{
+            next();
+          }
+        }
+      }
+    });
+
+    gulp.watch(options.src + 'js/**/*.js', ['js-watch']);
+    gulp.watch(options.src + 'vendor/**/*.js', ['bower'], function(){
+      browserSync.reload();
+    });
+    gulp.watch(options.src + '**/*.html', function(){
+      browserSync.reload();
+    });
+  });
+
+  // transpile js with sourceMaps
+  gulp.task('babel', function(){
+    return gulp.src(options.scripts + '**/*.js')
+      .pipe(babel({sourceMaps: true}))
+      .pipe(gulp.dest(options.tmp));
+  });
+
+  // inject scripts
+  gulp.task('injectScript', ['cleanTmp', 'babel'], function(){
+    return gulp.src(options.src + 'index.html')
+      .pipe(
+        inject(
+          gulp.src([
+            options.tmp + '**/*.js',
+            options.api + '*.js',
+            options.helpers + '**/*.js'
+          ])
+          .pipe(angularFilesort()),
+          {
+            ignorePath: [options.src, '/../../ngXosLib']
+          }
+        )
+      )
+      .pipe(gulp.dest(options.src));
+  });
+
+  // inject CSS
+  gulp.task('injectCss', function(){
+    return gulp.src(options.src + 'index.html')
+      .pipe(
+        inject(
+          gulp.src(options.src + 'css/*.css'),
+          {
+            ignorePath: [options.src]
+          }
+          )
+        )
+      .pipe(gulp.dest(options.src));
+  });
+
+  // inject bower dependencies with wiredep
+  gulp.task('bower', function () {
+    return gulp.src(options.src + 'index.html')
+    .pipe(wiredep({devDependencies: true}))
+    .pipe(gulp.dest(options.src));
+  });
+
+  gulp.task('js-watch', ['injectScript'], function(){
+    browserSync.reload();
+  });
+
+  gulp.task('cleanTmp', function(){
+    return del([options.tmp + '**/*']);
+  });
+
+  gulp.task('serve', function() {
+    runSequence(
+      'bower',
+      'injectScript',
+      'injectCss',
+      ['browser']
+    );
+  });
+};
diff --git a/views/ngXosViews/vpnDashboard/gulpfile.js b/views/ngXosViews/vpnDashboard/gulpfile.js
new file mode 100644
index 0000000..a3523ee
--- /dev/null
+++ b/views/ngXosViews/vpnDashboard/gulpfile.js
@@ -0,0 +1,25 @@
+'use strict';
+
+var gulp = require('gulp');
+var wrench = require('wrench');
+
+var options = {
+  src: 'src/',
+  css: 'src/css/',
+  scripts: 'src/js/',
+  tmp: 'src/.tmp',
+  dist: 'dist/',
+  api: '../../ngXosLib/api/',
+  helpers: '../../ngXosLib/xosHelpers/src/',
+  static: '../../../xos/core/xoslib/static/', // this is the django static folder
+  dashboards: '../../../xos/core/xoslib/dashboards/' // this is the django html folder
+};
+
+wrench.readdirSyncRecursive('./gulp')
+.map(function(file) {
+  require('./gulp/' + file)(options);
+});
+
+gulp.task('default', function () {
+  gulp.start('build');
+});
diff --git a/views/ngXosViews/vpnDashboard/karma.conf.js b/views/ngXosViews/vpnDashboard/karma.conf.js
new file mode 100644
index 0000000..dbd344a
--- /dev/null
+++ b/views/ngXosViews/vpnDashboard/karma.conf.js
@@ -0,0 +1,89 @@
+// Karma configuration
+// Generated on Tue Oct 06 2015 09:27:10 GMT+0000 (UTC)
+
+/* eslint indent: [2,2], quotes: [2, "single"]*/
+
+/*eslint-disable*/
+var wiredep = require('wiredep');
+var path = require('path');
+
+var bowerComponents = wiredep( {devDependencies: true} )[ 'js' ].map(function( file ){
+  return path.relative(process.cwd(), file);
+});
+
+module.exports = function(config) {
+/*eslint-enable*/
+  config.set({
+
+    // base path that will be used to resolve all patterns (eg. files, exclude)
+    basePath: '',
+
+
+    // frameworks to use
+    // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
+    frameworks: ['jasmine'],
+
+
+    // list of files / patterns to load in the browser
+    files: bowerComponents.concat([
+      'src/css/**/*.css',
+      '../../static/js/xosApi.js',
+      '../../static/js/vendor/ngXosHelpers.js',
+      'src/js/**/*.js',
+      'spec/**/*.mock.js',
+      'spec/**/*.test.js',
+      'src/**/*.html'
+    ]),
+
+
+    // list of files to exclude
+    exclude: [
+    ],
+
+
+    // preprocess matching files before serving them to the browser
+    // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
+    preprocessors: {
+      'src/js/**/*.js': ['babel'],
+      'spec/**/*.test.js': ['babel'],
+      'src/**/*.html': ['ng-html2js']
+    },
+
+    ngHtml2JsPreprocessor: {
+      stripPrefix: 'src/', //strip the src path from template url (http://stackoverflow.com/questions/22869668/karma-unexpected-request-when-testing-angular-directive-even-with-ng-html2js)
+      moduleName: 'templates' // define the template module name
+    },
+
+    // test results reporter to use
+    // possible values: 'dots', 'progress'
+    // available reporters: https://npmjs.org/browse/keyword/karma-reporter
+    reporters: ['mocha'],
+
+
+    // web server port
+    port: 9876,
+
+
+    // enable / disable colors in the output (reporters and logs)
+    colors: true,
+
+
+    // level of logging
+    // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
+    logLevel: config.LOG_INFO,
+
+
+    // enable / disable watching file and executing tests whenever any file changes
+    autoWatch: true,
+
+
+    // start these browsers
+    // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
+    browsers: ['PhantomJS'],
+
+
+    // Continuous Integration mode
+    // if true, Karma captures browsers, runs the tests and exits
+    singleRun: false
+  });
+};
diff --git a/views/ngXosViews/vpnDashboard/package.json b/views/ngXosViews/vpnDashboard/package.json
new file mode 100644
index 0000000..a4d9e6f
--- /dev/null
+++ b/views/ngXosViews/vpnDashboard/package.json
@@ -0,0 +1,45 @@
+{
+  "name": "xos-vpnDashboard",
+  "version": "1.0.0",
+  "description": "Angular Application for XOS, created with generator-xos",
+  "scripts": {
+    "prestart": "npm install && bower install",
+    "start": "gulp serve",
+    "prebuild": "npm install && bower install",
+    "build": "gulp",
+    "test": "karma start",
+    "lint": "eslint src/js/"
+  },
+  "keywords": [
+    "XOS",
+    "Angular",
+    "XOSlib"
+  ],
+  "author": "Jeremy Mowery",
+  "license": "MIT",
+  "dependencies": {},
+  "devDependencies": {
+    "browser-sync": "^2.9.11",
+    "del": "^2.0.2",
+    "gulp": "^3.9.0",
+    "gulp-angular-filesort": "^1.1.1",
+    "gulp-angular-templatecache": "^1.8.0",
+    "gulp-babel": "^5.3.0",
+    "gulp-concat": "^2.6.0",
+    "gulp-inject": "^3.0.0",
+    "gulp-minify-html": "^1.0.4",
+    "gulp-rename": "^1.2.2",
+    "gulp-replace": "^0.5.4",
+    "gulp-uglify": "^1.4.2",
+    "http-proxy": "^1.12.0",
+    "proxy-middleware": "^0.15.0",
+    "run-sequence": "^1.1.4",
+    "wiredep": "^3.0.0-beta",
+    "wrench": "^1.5.8",
+    "gulp-ng-annotate": "^1.1.0",
+    "lodash": "^3.10.1",
+    "eslint": "^1.8.0",
+    "eslint-plugin-angular": "linkmesrl/eslint-plugin-angular",
+    "gulp-eslint": "^1.0.0"
+  }
+}
diff --git a/views/ngXosViews/vpnDashboard/spec/sample.test.js b/views/ngXosViews/vpnDashboard/spec/sample.test.js
new file mode 100644
index 0000000..ad20c13
--- /dev/null
+++ b/views/ngXosViews/vpnDashboard/spec/sample.test.js
@@ -0,0 +1,37 @@
+'use strict';
+
+describe('The User List', () => {
+  
+  var scope, element, isolatedScope, httpBackend;
+
+  beforeEach(module('xos.vpnDashboard'));
+  beforeEach(module('templates'));
+
+  beforeEach(inject(function($httpBackend, $compile, $rootScope){
+    
+    httpBackend = $httpBackend;
+    // Setting up mock request
+    $httpBackend.expectGET('/xos/users/?no_hyperlinks=1').respond([
+      {
+        email: 'jermowery@email.arizona.edu',
+        firstname: 'Jeremy',
+        lastname: 'Mowery' 
+      }
+    ]);
+  
+    scope = $rootScope.$new();
+    element = angular.element('<users-list></users-list>');
+    $compile(element)(scope);
+    scope.$digest();
+    isolatedScope = element.isolateScope().vm;
+  }));
+
+  it('should load 1 users', () => {
+    httpBackend.flush();
+    expect(isolatedScope.users.length).toBe(1);
+    expect(isolatedScope.users[0].email).toEqual('jermowery@email.arizona.edu');
+    expect(isolatedScope.users[0].firstname).toEqual('Jeremy');
+    expect(isolatedScope.users[0].lastname).toEqual('Mowery');
+  });
+
+});
\ No newline at end of file
diff --git a/views/ngXosViews/vpnDashboard/src/css/vpnDashboard.css b/views/ngXosViews/vpnDashboard/src/css/vpnDashboard.css
new file mode 100644
index 0000000..116051f
--- /dev/null
+++ b/views/ngXosViews/vpnDashboard/src/css/vpnDashboard.css
@@ -0,0 +1,14 @@
+#xosVpnDashboard{
+  width: 70%;
+  margin: auto;
+}
+.vpn-row {
+    display: table-row;
+}
+.vpn-cell {
+    display: table-cell;
+    padding: 5px;
+}
+.vpn-header {
+    font-weight: bold;
+}
diff --git a/views/ngXosViews/vpnDashboard/src/index.html b/views/ngXosViews/vpnDashboard/src/index.html
new file mode 100644
index 0000000..07f1017
--- /dev/null
+++ b/views/ngXosViews/vpnDashboard/src/index.html
@@ -0,0 +1,34 @@
+<!-- browserSync -->
+<!-- bower:css -->
+<link rel="stylesheet" href="vendor/bootstrap-css/css/bootstrap.css" />
+<!-- endbower --><!-- endcss -->
+<!-- inject:css -->
+<link rel="stylesheet" href="/css/vpnDashboard.css">
+<!-- endinject -->
+
+<div ng-app="xos.vpnDashboard" id="xosVpnDashboard">
+    <div ui-view></div>
+</div>
+
+<!-- bower:js -->
+<script src="vendor/jquery/dist/jquery.js"></script>
+<script src="vendor/angular/angular.js"></script>
+<script src="vendor/angular-mocks/angular-mocks.js"></script>
+<script src="vendor/angular-ui-router/release/angular-ui-router.js"></script>
+<script src="vendor/angular-cookies/angular-cookies.js"></script>
+<script src="vendor/angular-resource/angular-resource.js"></script>
+<script src="vendor/ng-lodash/build/ng-lodash.js"></script>
+<script src="vendor/bootstrap-css/js/bootstrap.js"></script>
+<!-- endbower --><!-- endjs -->
+<!-- inject:js -->
+<script src="/xosHelpers/src/xosHelpers.module.js"></script>
+<script src="/xosHelpers/src/ui_components/table/table.component.js"></script>
+<script src="/xosHelpers/src/ui_components/ui-components.module.js"></script>
+<script src="/xosHelpers/src/services/noHyperlinks.interceptor.js"></script>
+<script src="/xosHelpers/src/services/csrfToken.interceptor.js"></script>
+<script src="/xosHelpers/src/services/api.services.js"></script>
+<script src="/api/ng-xoslib.js"></script>
+<script src="/api/ng-xos.js"></script>
+<script src="/api/ng-hpcapi.js"></script>
+<script src="/.tmp/main.js"></script>
+<!-- endinject -->
diff --git a/views/ngXosViews/vpnDashboard/src/js/main.js b/views/ngXosViews/vpnDashboard/src/js/main.js
new file mode 100644
index 0000000..233051a
--- /dev/null
+++ b/views/ngXosViews/vpnDashboard/src/js/main.js
@@ -0,0 +1,61 @@
+'use strict';
+
+angular.module('xos.vpnDashboard', [
+  'ngResource',
+  'ngCookies',
+  'ngLodash',
+  'ui.router',
+  'xos.helpers'
+])
+.config(($stateProvider) => {
+  $stateProvider
+  .state('vpnList', {
+    url: '/',
+    template: '<vpn-list></vpn-list>'
+  });
+})
+.config(($compileProvider) => {
+  $compileProvider.aHrefSanitizationWhitelist(
+    /^\s*(https?|ftp|mailto|tel|file|blob):/);
+})
+.service('Vpn', function($http, $q){
+
+  this.getVpnTenants = () => {
+    let deferred = $q.defer();
+
+    $http.get('/xoslib/vpntenant/')
+    .then((res) => {
+      deferred.resolve(res.data)
+    })
+    .catch((e) => {
+      deferred.reject(e);
+    });
+
+    return deferred.promise;
+  }
+})
+.config(function($httpProvider){
+  $httpProvider.interceptors.push('NoHyperlinks');
+})
+.directive('vpnList', function(){
+  return {
+    restrict: 'E',
+    scope: {},
+    bindToController: true,
+    controllerAs: 'vm',
+    templateUrl: 'templates/vpn-list.tpl.html',
+    controller: function(Vpn){
+      Vpn.getVpnTenants()
+      .then((vpns) => {
+        this.vpns = vpns;
+        for (var i = 0; i < this.vpns.length; i++) {
+          var blob = new Blob([this.vpns[i].script_text], {type: 'text/plain'});
+          this.vpns[i].script_text = (window.URL || window.webkitURL).createObjectURL( blob );
+        }
+      })
+      .catch((e) => {
+        throw new Error(e);
+      });
+    }
+  };
+});
diff --git a/views/ngXosViews/vpnDashboard/src/templates/vpn-list.tpl.html b/views/ngXosViews/vpnDashboard/src/templates/vpn-list.tpl.html
new file mode 100644
index 0000000..0c7635f
--- /dev/null
+++ b/views/ngXosViews/vpnDashboard/src/templates/vpn-list.tpl.html
@@ -0,0 +1,19 @@
+<div style="display: table;">
+  <div class="vpn-row">
+    <h1 class="vpn-cell">VPN List</h1>
+  </div>
+  <div class="vpn-row">
+    <div class="vpn-cell vpn-header">ID</div>
+    <div class="vpn-cell vpn-header">VPN Network</div>
+    <div class="vpn-cell vpn-header">VPN Subnet</div>
+    <div class="vpn-cell vpn-header">Script Link</div>
+  </div>
+  <div class="vpn-row" ng-repeat="vpn in vm.vpns">
+    <div class="vpn-cell">{{ vpn.id }}</div>
+    <div class="vpn-cell">{{ vpn.server_network }}</div>
+    <div class="vpn-cell">{{ vpn.vpn_subnet }}</div>
+    <div class="vpn-cell">
+      <a download="connect-{{ vpn.id }}.vpn" ng-href="{{ vpn.script_text }}">Script</a>
+    </div>
+  </div>
+</div>
diff --git a/views/npm-debug.log b/views/npm-debug.log
new file mode 100644
index 0000000..38c9da9
--- /dev/null
+++ b/views/npm-debug.log
@@ -0,0 +1,20 @@
+0 info it worked if it ends with ok
+1 verbose cli [ '/usr/bin/nodejs', '/usr/bin/npm', 'start' ]
+2 info using npm@3.6.0
+3 info using node@v5.7.0
+4 verbose stack Error: ENOENT: no such file or directory, open '/home/jeremy/xos/views/package.json'
+4 verbose stack     at Error (native)
+5 verbose cwd /home/jeremy/xos/views
+6 error Linux 4.2.0-19-generic
+7 error argv "/usr/bin/nodejs" "/usr/bin/npm" "start"
+8 error node v5.7.0
+9 error npm  v3.6.0
+10 error path /home/jeremy/xos/views/package.json
+11 error code ENOENT
+12 error errno -2
+13 error syscall open
+14 error enoent ENOENT: no such file or directory, open '/home/jeremy/xos/views/package.json'
+15 error enoent ENOENT: no such file or directory, open '/home/jeremy/xos/views/package.json'
+15 error enoent This is most likely not a problem with npm itself
+15 error enoent and is related to npm not being able to find a file.
+16 verbose exit [ -2, true ]
diff --git a/xos/configurations/vpn/Makefile b/xos/configurations/vpn/Makefile
new file mode 100644
index 0000000..0ec4a35
--- /dev/null
+++ b/xos/configurations/vpn/Makefile
@@ -0,0 +1,56 @@
+MYIP:=$(shell hostname -i)
+
+cloudlab: common_cloudlab xos
+
+devstack: upgrade_pkgs common_devstack xos
+
+xos:
+	sudo MYIP=$(MYIP) docker-compose up -d
+	bash ../common/wait_for_xos.sh
+	sudo MYIP=$(MYIP) docker-compose run xos python /opt/xos/tosca/run.py padmin@vicci.org /opt/xos/configurations/common/base.yaml
+	sudo MYIP=$(MYIP) docker-compose run xos python /opt/xos/tosca/run.py padmin@vicci.org /root/setup/nodes.yaml
+
+containers:
+	cd ../../../containers/xos; make devel
+	cd ../../../containers/synchronizer; make
+	cd ../../../containers/vpn; make
+
+common_cloudlab:
+	make -C ../common -f Makefile.cloudlab
+
+common_devstack:
+	make -C ../common -f Makefile.devstack
+
+stop:
+	sudo MYIP=$(MYIP) docker-compose stop
+
+showlogs:
+	sudo MYIP=$(MYIP) docker-compose logs
+
+rm: stop
+	sudo MYIP=$(MYIP) docker-compose rm
+
+ps:
+	sudo MYIP=$(MYIP) docker-compose ps
+
+enter-xos:
+	sudo docker exec -it devel_xos_1 bash
+
+enter-synchronizer:
+	sudo docker exec -it devel_xos_synchronizer_openstack_1 bash
+
+upgrade_pkgs:
+	sudo pip install httpie --upgrade
+
+rebuild_xos:
+	make -C ../../../containers/xos devel
+
+rebuild_synchronizer:
+	make -C ../../../containers/synchronizer
+
+cleanup_docker: rm
+	sudo docker rm -v $(docker ps -a -q -f status=exited) || true
+	docker rm -v $(docker ps -a -q -f status=exited) || true
+	sudo docker rmi $(docker images -qf "dangling=true") || true
+	socker rmi $(docker images -qf "dangling=true") || true
+	sudo docker run -v /var/run/docker.sock:/var/run/docker.sock -v /var/lib/docker:/var/lib/docker --rm martin/docker-cleanup-volumes || true
diff --git a/xos/configurations/vpn/docker-compose.yml b/xos/configurations/vpn/docker-compose.yml
new file mode 100644
index 0000000..273e8c6
--- /dev/null
+++ b/xos/configurations/vpn/docker-compose.yml
@@ -0,0 +1,62 @@
+xos_db:
+    image: xosproject/xos-postgres
+    expose:
+        - "5432"
+
+xos_synchronizer_openstack:
+    image: xosproject/xos-synchronizer-openstack
+    command: bash -c "sleep 120; python /opt/xos/synchronizers/openstack/xos-synchronizer.py"
+    labels:
+        org.xosproject.kind: synchronizer
+        org.xosproject.target: openstack
+    links:
+        - xos_db
+    extra_hosts:
+        - ctl:${MYIP}
+    volumes:
+        - ../common/xos_common_config:/opt/xos/xos_configuration/xos_common_config:ro
+        - ./images:/opt/xos/images:ro
+
+xos_synchronizer_vpn:
+    image: xosproject/xos-vpn
+    command: bash -c "sleep 120 ; python /opt/xos/synchronizers/vpn/vpn-synchronizer.py -C /opt/xos/synchronizers/vpn/vpn_config"
+    labels:
+        org.xosproject.kind: synchronizer
+        org.xosproject.target: vpn
+    links:
+        - xos_db
+    extra_hosts:
+        - ctl:${MYIP}
+    volumes:
+        - ../setup/id_rsa:/opt/xos/synchronizers/vpn/vpn_private_key:ro  # private key
+    volumes_from:
+        - xos_synchronizer_vpn_data:rw
+
+xos_synchronizer_vpn_data:
+    image: xosproject/xos-vpn
+    links:
+        - xos_db
+    extra_hosts:
+        - ctl:${MYIP}
+    volumes:
+        - /opt/openvpn
+
+# FUTURE
+#xos_swarm_synchronizer:
+#    image: xosproject/xos-swarm-synchronizer
+#    labels:
+#        org.xosproject.kind: synchronizer
+#        org.xosproject.target: swarm
+
+xos:
+    image: xosproject/xos-vpn
+    command: python /opt/xos/manage.py runserver 0.0.0.0:8000 --insecure --makemigrations
+    ports:
+        - "9999:8000"
+    links:
+        - xos_db
+    volumes:
+      - ../setup:/root/setup:ro
+      - ../common/xos_common_config:/opt/xos/xos_configuration/xos_common_config:ro
+    volumes_from:
+      - xos_synchronizer_vpn_data:rw
diff --git a/xos/core/admin.py b/xos/core/admin.py
index f14710b..827a2e1 100644
--- a/xos/core/admin.py
+++ b/xos/core/admin.py
@@ -1,8 +1,6 @@
 import threading
 from cgi import escape as html_escape
 
-from core.models import *
-from core.models import Site
 from django import forms
 from django.contrib import admin, messages
 from django.contrib.admin.widgets import (AdminTextareaWidget,
@@ -21,6 +19,9 @@
 from django.utils.html import conditional_escape, format_html
 from django.utils.safestring import mark_safe
 from django.utils.text import capfirst
+
+from core.models import *
+from core.models import Site
 from openstack.manager import OpenStackManager
 from suit.widgets import LinkedSelect
 
@@ -977,6 +978,24 @@
                       )
 
 
+class TenantRoleAdmin(XOSBaseAdmin):
+    """Admin for TenantRoles."""
+    model = TenantRole
+    fields = ('role',)
+
+
+class TenantPrivilegeInline(XOSTabularInline):
+    """Inline for adding a TenantPrivilege to a Tenant."""
+    model = TenantPrivilege
+    extra = 0
+    suit_classes = 'suit-tab suit-tab-tenantprivileges'
+    fields = ['backend_status_icon', 'user', 'role', 'tenant']
+    readonly_fields = ('backend_status_icon', )
+
+    def queryset(self, request):
+        return TenantPrivilege.select_by_user(request.user)
+
+
 class ProviderTenantInline(XOSTabularInline):
     model = CoarseTenant
     fields = ['provider_service', 'subscriber_service', 'connect_method']
@@ -2404,5 +2423,6 @@
     admin.site.register(Flavor, FlavorAdmin)
     admin.site.register(TenantRoot, TenantRootAdmin)
     admin.site.register(TenantRootRole, TenantRootRoleAdmin)
+    admin.site.register(TenantRole, TenantRoleAdmin)
     admin.site.register(TenantAttribute, TenantAttributeAdmin)
     admin.site.register(AddressPool, AddressPoolAdmin)
diff --git a/xos/core/fixtures/core_initial_data.json b/xos/core/fixtures/core_initial_data.json
index 86658bb..e4f86f9 100644
--- a/xos/core/fixtures/core_initial_data.json
+++ b/xos/core/fixtures/core_initial_data.json
@@ -65,6 +65,34 @@
 },
 {
     "fields": {
+        "updated": "2015-02-17T22:06:38.620Z",
+        "policed": null,
+        "created": "2015-02-17T22:06:38.620Z",
+        "deleted": false,
+        "backend_register": "{}",
+        "role": "admin",
+        "backend_status": "0 - Provisioning in progress",
+        "enacted": null
+    },
+    "model": "core.tenantrole",
+    "pk": 1
+},
+{
+    "fields": {
+        "updated": "2015-02-17T22:06:38.620Z",
+        "policed": null,
+        "created": "2015-02-17T22:06:38.620Z",
+        "deleted": false,
+        "backend_register": "{}",
+        "role": "access",
+        "backend_status": "0 - Provisioning in progress",
+        "enacted": null
+    },
+    "model": "core.tenantrole",
+    "pk": 2
+},
+{
+    "fields": {
         "accessControl": "allow all",
         "updated": "2015-02-17T22:06:37.789Z",
         "policed": null,
diff --git a/xos/core/migrations/0001_initial.py b/xos/core/migrations/0001_initial.py
index b2e5d00..88955a5 100644
--- a/xos/core/migrations/0001_initial.py
+++ b/xos/core/migrations/0001_initial.py
@@ -1,15 +1,16 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
+import django.utils.timezone
+from django.conf import settings
+from django.db import migrations, models
+
 import core.models.instance
 import core.models.network
 import core.models.serviceclass
-import django.utils.timezone
 import encrypted_fields.fields
 import geoposition.fields
 import timezones.fields
-from django.conf import settings
-from django.db import migrations, models
 
 
 class Migration(migrations.Migration):
@@ -1628,6 +1629,52 @@
             },
             bases=(models.Model,),
         ),
+        migrations.CreateModel(
+            name='TenantPrivilege',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID',
+                                        serialize=False, auto_created=True, primary_key=True)),
+                ('created', models.DateTimeField(
+                    default=django.utils.timezone.now, auto_now_add=True)),
+                ('updated', models.DateTimeField(
+                    default=django.utils.timezone.now, auto_now=True)),
+                ('enacted', models.DateTimeField(
+                    default=None, null=True, blank=True)),
+                ('policed', models.DateTimeField(
+                    default=None, null=True, blank=True)),
+                ('backend_status', models.CharField(
+                    default=b'Provisioning in progress', max_length=140)),
+                ('deleted', models.BooleanField(default=False)),
+            ],
+            options={
+                'abstract': False,
+            },
+            bases=(models.Model,),
+        ),
+        migrations.CreateModel(
+            name='TenantRole',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID',
+                                        serialize=False, auto_created=True, primary_key=True)),
+                ('created', models.DateTimeField(
+                    default=django.utils.timezone.now, auto_now_add=True)),
+                ('updated', models.DateTimeField(
+                    default=django.utils.timezone.now, auto_now=True)),
+                ('enacted', models.DateTimeField(
+                    default=None, null=True, blank=True)),
+                ('policed', models.DateTimeField(
+                    default=None, null=True, blank=True)),
+                ('backend_status', models.CharField(
+                    default=b'Provisioning in progress', max_length=140)),
+                ('deleted', models.BooleanField(default=False)),
+                ('role', models.CharField(unique=True, max_length=30,
+                                          choices=[(b'admin', b'Admin'), (b'access', b'Access')])),
+            ],
+            options={
+                'abstract': False,
+            },
+            bases=(models.Model,),
+        ),
         migrations.AddField(
             model_name='sliceprivilege',
             name='role',
diff --git a/xos/core/models/__init__.py b/xos/core/models/__init__.py
index 6fad0f1..6b5826a 100644
--- a/xos/core/models/__init__.py
+++ b/xos/core/models/__init__.py
@@ -1,7 +1,7 @@
 from .plcorebase import PlCoreBase,PlCoreBaseManager,PlCoreBaseDeletionManager,PlModelMixIn
 from .project import Project
 from .singletonmodel import SingletonModel
-from .service import Service, Tenant, TenantWithContainer, CoarseTenant, ServicePrivilege, TenantRoot, TenantRootPrivilege, TenantRootRole, Subscriber, Provider
+from .service import Service, Tenant, TenantWithContainer, CoarseTenant, ServicePrivilege, TenantRoot, TenantRootPrivilege, TenantRootRole, TenantPrivilege, TenantRole, Subscriber, Provider
 from .service import ServiceAttribute, TenantAttribute, ServiceRole
 from .tag import Tag
 from .role import Role
@@ -29,4 +29,3 @@
 from .network import Network, NetworkParameterType, NetworkParameter, Port, NetworkTemplate, Router, NetworkSlice, ControllerNetwork, AddressPool
 from .billing import Account, Invoice, Charge, UsableObject, Payment
 from .program import Program
-
diff --git a/xos/core/models/service.py b/xos/core/models/service.py
index 641c5ae..eb584a5 100644
--- a/xos/core/models/service.py
+++ b/xos/core/models/service.py
@@ -1,9 +1,10 @@
 import json
 from operator import attrgetter
 
+from django.db import models
+
 from core.models import PlCoreBase, PlCoreBaseManager, SingletonModel
 from core.models.plcorebase import StrippedCharField
-from django.db import models
 from xos.exceptions import *
 
 COARSE_KIND = "coarse"
@@ -892,3 +893,52 @@
                     [trp.id for trp in cls.objects.filter(tenant_root=priv.tenant_root)])
 
             return cls.objects.filter(id__in=trp_ids)
+
+
+class TenantRole(PlCoreBase):
+    """A TenantRole option."""
+    ROLE_CHOICES = (('admin', 'Admin'), ('access', 'Access'))
+    role = StrippedCharField(choices=ROLE_CHOICES, unique=True, max_length=30)
+
+    def __unicode__(self): return u'%s' % (self.role)
+
+
+class TenantPrivilege(PlCoreBase):
+    """"A TenantPrivilege which defines how users can access a particular Tenant.
+
+    Attributes:
+        id (models.AutoField): The ID of the privilege.
+        user (models.ForeignKey): A Foreign Key to the a User.
+        tenant (models.ForeignKey): A ForeignKey to the Tenant.
+        role (models.ForeignKey): A ForeignKey to the TenantRole.
+    """
+    id = models.AutoField(primary_key=True)
+    user = models.ForeignKey('User', related_name="tenantprivileges")
+    tenant = models.ForeignKey('Tenant', related_name="tenantprivileges")
+    role = models.ForeignKey('TenantRole', related_name="tenantprivileges")
+
+    def __unicode__(self): return u'%s %s %s' % (
+        self.tenant, self.user, self.role)
+
+    def save(self, *args, **kwds):
+        if not self.user.is_active:
+            raise PermissionDenied, "Cannot modify role(s) of a disabled user"
+        super(TenantPrivilege, self).save(*args, **kwds)
+
+    def can_update(self, user):
+        return user.can_update_tenant_privilege(self)
+
+    @classmethod
+    def select_by_user(cls, user):
+        if user.is_admin:
+            return cls.objects.all()
+        else:
+            # User can see his own privilege
+            trp_ids = [trp.id for trp in cls.objects.filter(user=user)]
+
+            # A tenant admin can see the TenantPrivileges for their Tenants
+            for priv in cls.objects.filter(user=user, role__role="admin"):
+                trp_ids.extend(
+                    [trp.id for trp in cls.objects.filter(tenant=priv.tenant)])
+
+            return cls.objects.filter(id__in=trp_ids)
diff --git a/xos/core/models/user.py b/xos/core/models/user.py
index f1d73d2..20f463c 100644
--- a/xos/core/models/user.py
+++ b/xos/core/models/user.py
@@ -5,10 +5,6 @@
 from collections import defaultdict
 from operator import attrgetter, itemgetter
 
-import synchronizers.model_policy
-from core.middleware import get_request
-from core.models import DashboardView, PlCoreBase, PlModelMixIn, Site
-from core.models.plcorebase import StrippedCharField
 from django.contrib.auth.models import AbstractBaseUser, BaseUserManager
 from django.core.exceptions import PermissionDenied
 from django.core.mail import EmailMultiAlternatives
@@ -16,6 +12,11 @@
 from django.db.models import F, Q
 from django.forms.models import model_to_dict
 from django.utils import timezone
+
+import synchronizers.model_policy
+from core.middleware import get_request
+from core.models import DashboardView, PlCoreBase, PlModelMixIn, Site
+from core.models.plcorebase import StrippedCharField
 from timezones.fields import TimeZoneField
 
 # ------ from plcorebase.py ------
@@ -294,7 +295,7 @@
 
     def can_update_root(self):
         """
-        Return True if user has root (global) write access. 
+        Return True if user has root (global) write access.
         """
         if self.is_readonly:
             return False
@@ -356,9 +357,21 @@
             return True
         return False
 
+    def can_update_tenant(self, tenant, allow=[]):
+        from core.models.service import Tenant, TenantPrivilege
+        if self.can_update_root():
+            return True
+        if TenantPrivilege.objects.filter(
+                tenant=tenant, user=self, role__role__in=['admin', 'Admin'] + allow):
+            return True
+        return False
+
     def can_update_tenant_root_privilege(self, tenant_root_privilege, allow=[]):
         return self.can_update_tenant_root(tenant_root_privilege.tenant_root, allow)
 
+    def can_update_tenant_privilege(self, tenant_privilege, allow=[]):
+        return self.can_update_tenant(tenant_privilege.tenant, allow)
+
     def get_readable_objects(self, filter_by=None):
         """ Returns a list of objects that the user is allowed to read. """
         from core.models import Deployment, Flavor, Image, Network, NetworkTemplate, Node, PlModelMixIn, Site, Slice, SliceTag, Instance, Tag, User, DeploymentPrivilege, SitePrivilege, SlicePrivilege
@@ -374,15 +387,15 @@
         return readable_objects
 
     def get_permissions(self, filter_by=None):
-        """ Return a list of objects for which the user has read or read/write 
-        access. The object will be an instance of a django model object. 
+        """ Return a list of objects for which the user has read or read/write
+        access. The object will be an instance of a django model object.
         Permissions will be either 'r' or 'rw'.
 
         e.g.
         [{'object': django_object_instance, 'permissions': 'rw'}, ...]
 
         Returns:
-          list of dicts  
+          list of dicts
 
         """
         from core.models import Deployment, Flavor, Image, Network, NetworkTemplate, Node, PlModelMixIn, Site, Slice, SliceTag, Instance, Tag, User, DeploymentPrivilege, SitePrivilege, SlicePrivilege
diff --git a/xos/core/xoslib/dashboards/xosVpnDashboard.html b/xos/core/xoslib/dashboards/xosVpnDashboard.html
new file mode 100644
index 0000000..b63559e
--- /dev/null
+++ b/xos/core/xoslib/dashboards/xosVpnDashboard.html
@@ -0,0 +1,14 @@
+<!-- browserSync -->
+
+<!-- inject:css -->
+<link rel="stylesheet" href="/static/css/xosVpnDashboard.css">
+<!-- endinject -->
+
+<div id="xosVpnDashboard">
+    <div ui-view></div>
+</div>
+
+
+<!-- inject:js -->
+<script src="/static/js/xosVpnDashboard.js"></script>
+<!-- endinject -->
diff --git a/xos/core/xoslib/methods/vpnview.py b/xos/core/xoslib/methods/vpnview.py
new file mode 100644
index 0000000..bf8231d
--- /dev/null
+++ b/xos/core/xoslib/methods/vpnview.py
@@ -0,0 +1,83 @@
+import jinja2
+from core.models import TenantPrivilege
+from plus import PlusSerializerMixin
+from rest_framework import serializers
+from services.vpn.models import VPNService, VPNTenant
+from xos.apibase import XOSListCreateAPIView
+
+if hasattr(serializers, "ReadOnlyField"):
+    # rest_framework 3.x
+    ReadOnlyField = serializers.ReadOnlyField
+else:
+    # rest_framework 2.x
+    ReadOnlyField = serializers.Field
+
+
+def get_default_vpn_service():
+    vpn_services = VPNService.get_service_objects().all()
+    if vpn_services:
+        return vpn_services[0].id
+    return None
+
+
+class VPNTenantSerializer(serializers.ModelSerializer, PlusSerializerMixin):
+    """A Serializer for the VPNTenant that has the minimum information required for clients.
+
+    Attributes:
+        id (ReadOnlyField): The ID of VPNTenant.
+        server_network (ReadOnlyField): The network of the VPN.
+        vpn_subnet (ReadOnlyField): The subnet of the VPN.
+        script_text (SerializerMethodField): The text of the script for the client to use to
+            connect.
+    """
+    id = ReadOnlyField()
+    server_network = ReadOnlyField()
+    vpn_subnet = ReadOnlyField()
+    script_text = serializers.SerializerMethodField()
+
+    class Meta:
+        model = VPNTenant
+        fields = ('id', 'service_specific_attribute', 'vpn_subnet',
+                  'server_network', 'script_text')
+
+    def get_script_text(self, obj):
+        """Gets the text of the client script for the requesting user.
+
+        Parameters:
+            obj (services.vpn.models.VPNTenant): The VPNTenant to connect to.
+
+        Returns:
+            str: The client script as a str.
+        """
+        env = jinja2.Environment(loader=jinja2.FileSystemLoader("/opt/xos/services/vpn/templates"))
+        template = env.get_template("connect.vpn.j2")
+        client_name = self.context['request'].user.email + "-" + str(obj.id)
+        remote_ids = list(obj.failover_server_ids)
+        remote_ids.insert(0, obj.id)
+        remotes = VPNTenant.get_tenant_objects().filter(pk__in=remote_ids)
+        pki_dir = VPNService.get_pki_dir(obj)
+        fields = {"client_name": client_name,
+                  "remotes": remotes,
+                  "is_persistent": obj.is_persistent,
+                  "ca_crt": obj.get_ca_crt(pki_dir),
+                  "client_crt": obj.get_client_cert(client_name, pki_dir),
+                  "client_key": obj.get_client_key(client_name, pki_dir)
+                 }
+        return template.render(fields)
+
+
+class VPNTenantList(XOSListCreateAPIView):
+    """Class that provides a list of VPNTenants that the user has permission to access."""
+    serializer_class = VPNTenantSerializer
+    method_kind = "list"
+    method_name = "vpntenant"
+
+    def get_queryset(self):
+        # Get every privilege for this user
+        tenants_privs = TenantPrivilege.objects.all().filter(
+            user=self.request.user)
+        vpn_tenants = []
+        for priv in tenants_privs:
+            vpn_tenants.append(
+                VPNTenant.get_tenant_objects().filter(pk=priv.tenant.pk)[0])
+        return vpn_tenants
diff --git a/xos/core/xoslib/static/css/xosVpnDashboard.css b/xos/core/xoslib/static/css/xosVpnDashboard.css
new file mode 100644
index 0000000..d5a8ed4
--- /dev/null
+++ b/xos/core/xoslib/static/css/xosVpnDashboard.css
@@ -0,0 +1 @@
+#xosVpnDashboard{width:70%;margin:auto}.vpn-row{display:table-row}.vpn-cell{display:table-cell;padding:5px}.vpn-header{font-weight:700}
\ No newline at end of file
diff --git a/xos/core/xoslib/static/js/xosVpnDashboard.js b/xos/core/xoslib/static/js/xosVpnDashboard.js
new file mode 100644
index 0000000..7077f40
--- /dev/null
+++ b/xos/core/xoslib/static/js/xosVpnDashboard.js
@@ -0,0 +1 @@
+"use strict";angular.module("xos.vpnDashboard",["ngResource","ngCookies","ngLodash","ui.router","xos.helpers"]).config(["$stateProvider",function(n){n.state("vpnList",{url:"/",template:"<vpn-list></vpn-list>"})}]).config(["$compileProvider",function(n){n.aHrefSanitizationWhitelist(/^\s*(https?|ftp|mailto|tel|file|blob):/)}]).service("Vpn",["$http","$q",function(n,t){this.getVpnTenants=function(){var e=t.defer();return n.get("/xoslib/vpntenant/").then(function(n){e.resolve(n.data)})["catch"](function(n){e.reject(n)}),e.promise}}]).config(["$httpProvider",function(n){n.interceptors.push("NoHyperlinks")}]).directive("vpnList",function(){return{restrict:"E",scope:{},bindToController:!0,controllerAs:"vm",templateUrl:"templates/vpn-list.tpl.html",controller:["Vpn",function(n){var t=this;n.getVpnTenants().then(function(n){t.vpns=n;for(var e=0;e<t.vpns.length;e++){var i=new Blob([t.vpns[e].script_text],{type:"text/plain"});t.vpns[e].script_text=(window.URL||window.webkitURL).createObjectURL(i)}})["catch"](function(n){throw new Error(n)})}]}}),angular.module("xos.vpnDashboard").run(["$templateCache",function(n){n.put("templates/vpn-list.tpl.html",'<div style="display: table;">\n  <div class="vpn-row">\n    <h1 class="vpn-cell">VPN List</h1>\n  </div>\n  <div class="vpn-row">\n    <div class="vpn-cell vpn-header">ID</div>\n    <div class="vpn-cell vpn-header">VPN Network</div>\n    <div class="vpn-cell vpn-header">VPN Subnet</div>\n    <div class="vpn-cell vpn-header">Script Link</div>\n  </div>\n  <div class="vpn-row" ng-repeat="vpn in vm.vpns">\n    <div class="vpn-cell">{{ vpn.id }}</div>\n    <div class="vpn-cell">{{ vpn.server_network }}</div>\n    <div class="vpn-cell">{{ vpn.vpn_subnet }}</div>\n    <div class="vpn-cell">\n      <a download="connect-{{ vpn.id }}.vpn" ng-href="{{ vpn.script_text }}">Script</a>\n    </div>\n  </div>\n</div>\n')}]),angular.module("xos.vpnDashboard").run(["$location",function(n){n.path("/")}]),angular.bootstrap(angular.element("#xosVpnDashboard"),["xos.vpnDashboard"]);
\ No newline at end of file
diff --git a/xos/services/vpn/__init__.py b/xos/services/vpn/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/xos/services/vpn/__init__.py
diff --git a/xos/services/vpn/admin.py b/xos/services/vpn/admin.py
new file mode 100644
index 0000000..63040f9
--- /dev/null
+++ b/xos/services/vpn/admin.py
@@ -0,0 +1,234 @@
+from django import forms
+from django.contrib import admin
+
+from core.admin import ReadOnlyAwareAdmin, SliceInline, TenantPrivilegeInline
+from core.middleware import get_request
+from core.models import User
+from services.vpn.models import VPN_KIND, VPNService, VPNTenant
+from xos.exceptions import XOSValidationError
+
+
+class VPNServiceForm(forms.ModelForm):
+
+    exposed_ports = forms.CharField(required=True)
+
+    def __init__(self, *args, **kwargs):
+        super(VPNServiceForm, self).__init__(*args, **kwargs)
+
+        if self.instance:
+            self.fields['exposed_ports'].initial = (
+                self.instance.exposed_ports_str)
+
+    def save(self, commit=True):
+        self.instance.exposed_ports = self.cleaned_data['exposed_ports']
+        return super(VPNServiceForm, self).save(commit=commit)
+
+    def clean_exposed_ports(self):
+        exposed_ports = self.cleaned_data['exposed_ports']
+        self.instance.exposed_ports_str = exposed_ports
+        port_mapping = {"udp": [], "tcp": []}
+        parts = exposed_ports.split(",")
+        for part in parts:
+            part = part.strip()
+            if "/" in part:
+                (protocol, ports) = part.split("/", 1)
+            elif " " in part:
+                (protocol, ports) = part.split(None, 1)
+            else:
+                raise XOSValidationError(
+                    'malformed port specifier %s, format example: ' +
+                    '"tcp 123, tcp 201:206, udp 333"' % part)
+
+            protocol = protocol.strip()
+            ports = ports.strip()
+
+            if not (protocol in ["udp", "tcp"]):
+                raise XOSValidationError('unknown protocol %s' % protocol)
+
+            if "-" in ports:
+                port_mapping[protocol].extend(
+                    self.parse_port_range(ports, "-"))
+            elif ":" in ports:
+                port_mapping[protocol].extend(
+                    self.parse_port_range(ports, ":"))
+            else:
+                port_mapping[protocol].append(int(ports))
+
+        return port_mapping
+
+    def parse_port_range(self, port_str, split_str):
+        (first, last) = port_str.split(split_str)
+        first = int(first.strip())
+        last = int(last.strip())
+        return list(range(first, last))
+
+    class Meta:
+        model = VPNService
+
+
+class VPNServiceAdmin(ReadOnlyAwareAdmin):
+    """Defines the admin for the VPNService."""
+    model = VPNService
+    form = VPNServiceForm
+    verbose_name = "VPN Service"
+
+    list_display = ("backend_status_icon", "name", "enabled")
+
+    list_display_links = ('backend_status_icon', 'name', )
+
+    fieldsets = [(None, {'fields': ['backend_status_text', 'name', 'enabled',
+                                    'versionNumber', 'description', "view_url",
+                                    'exposed_ports'],
+                         'classes':['suit-tab suit-tab-general']})]
+
+    readonly_fields = ('backend_status_text', )
+
+    inlines = [SliceInline]
+
+    extracontext_registered_admins = True
+
+    user_readonly_fields = ["name", "enabled", "versionNumber", "description"]
+
+    suit_form_tabs = (('general', 'VPN Service Details'),
+                      ('administration', 'Tenants'),
+                      ('slices', 'Slices'),)
+
+    suit_form_includes = (('vpnserviceadmin.html',
+                           'top',
+                           'administration'),)
+
+    def queryset(self, request):
+        return VPNService.get_service_objects_by_user(request.user)
+
+
+class VPNTenantForm(forms.ModelForm):
+    """The form used to create and edit a VPNTenant.
+
+    Attributes:
+        creator (forms.ModelChoiceField): The XOS user that created this
+            tenant.
+        server_network (forms.GenericIPAddressField): The IP address of the VPN network.
+        vpn_subnet (forms.GenericIPAddressField): The subnet used by the VPN network.
+        is_persistent (forms.BooleanField): Determines if this Tenant keeps
+            this connection alive through failures.
+        clients_can_see_each_other (forms.BooleanField): Determines if the clients on the VPN can
+            communicate with each other.
+        failover_servers (forms.ModelMultipleChoiceField): The other VPNTenants to use as failover
+            servers.
+        protocol (forms.ChoiceField): The protocol to use.
+        use_ca_from (forms.ModelChoiceField): Another VPNTenant to use the CA of, this is a very
+            hacky way to let VPNs have the same clients.
+    """
+    creator = forms.ModelChoiceField(queryset=User.objects.all())
+    server_network = forms.GenericIPAddressField(
+        protocol="IPv4", required=True)
+    vpn_subnet = forms.GenericIPAddressField(protocol="IPv4", required=True)
+    is_persistent = forms.BooleanField(required=False)
+    clients_can_see_each_other = forms.BooleanField(required=False)
+    failover_servers = forms.ModelMultipleChoiceField(
+        required=False, queryset=VPNTenant.get_tenant_objects())
+    protocol = forms.ChoiceField(required=True, choices=[
+        ("tcp", "tcp"), ("udp", "udp")])
+    use_ca_from = forms.ModelChoiceField(
+        queryset=VPNTenant.get_tenant_objects(), required=False)
+
+    def __init__(self, *args, **kwargs):
+        super(VPNTenantForm, self).__init__(*args, **kwargs)
+        self.fields['kind'].widget.attrs['readonly'] = True
+        self.fields['failover_servers'].widget.attrs['rows'] = 300
+        self.fields[
+            'provider_service'].queryset = (
+                VPNService.get_service_objects().all())
+
+        self.fields['kind'].initial = VPN_KIND
+
+        if self.instance:
+            self.fields['creator'].initial = self.instance.creator
+            self.fields['vpn_subnet'].initial = self.instance.vpn_subnet
+            self.fields[
+                'server_network'].initial = self.instance.server_network
+            self.fields[
+                'clients_can_see_each_other'].initial = (
+                    self.instance.clients_can_see_each_other)
+            self.fields['is_persistent'].initial = self.instance.is_persistent
+            self.initial['protocol'] = self.instance.protocol
+            self.fields['failover_servers'].queryset = (
+                VPNTenant.get_tenant_objects().exclude(pk=self.instance.pk))
+            self.initial['failover_servers'] = VPNTenant.get_tenant_objects().filter(
+                pk__in=self.instance.failover_server_ids)
+            self.fields['use_ca_from'].queryset = (
+                VPNTenant.get_tenant_objects().exclude(pk=self.instance.pk))
+            if (self.instance.use_ca_from_id):
+                self.initial['use_ca_from'] = (
+                    VPNTenant.get_tenant_objects().filter(pk=self.instance.use_ca_from_id)[0])
+
+        if (not self.instance) or (not self.instance.pk):
+            self.fields['creator'].initial = get_request().user
+            self.fields['vpn_subnet'].initial = "255.255.255.0"
+            self.fields['server_network'].initial = "10.66.77.0"
+            self.fields['clients_can_see_each_other'].initial = True
+            self.fields['is_persistent'].initial = True
+            self.fields['failover_servers'].queryset = (
+                VPNTenant.get_tenant_objects())
+            if VPNService.get_service_objects().exists():
+                self.fields["provider_service"].initial = (
+                    VPNService.get_service_objects().all()[0])
+
+    def save(self, commit=True):
+        self.instance.creator = self.cleaned_data.get("creator")
+        self.instance.is_persistent = self.cleaned_data.get('is_persistent')
+        self.instance.vpn_subnet = self.cleaned_data.get("vpn_subnet")
+        self.instance.server_network = self.cleaned_data.get('server_network')
+        self.instance.clients_can_see_each_other = self.cleaned_data.get(
+            'clients_can_see_each_other')
+
+        self.instance.failover_server_ids = [
+            tenant.id for tenant in self.cleaned_data.get('failover_servers')]
+
+        # Do not aquire a new port number if the protocol hasn't changed
+        if ((not self.instance.protocol) or
+                (self.instance.protocol != self.cleaned_data.get("protocol"))):
+            self.instance.protocol = self.cleaned_data.get("protocol")
+            self.instance.port_number = (
+                self.instance.provider_service.get_next_available_port(
+                    self.instance.protocol))
+
+        if (self.cleaned_data.get('use_ca_from')):
+            self.instance.use_ca_from_id = self.cleaned_data.get(
+                'use_ca_from').id
+        else:
+            self.instance.use_ca_from_id = None
+
+        return super(VPNTenantForm, self).save(commit=commit)
+
+    class Meta:
+        model = VPNTenant
+
+
+class VPNTenantAdmin(ReadOnlyAwareAdmin):
+    verbose_name = "VPN Tenant Admin"
+    list_display = ('id', 'backend_status_icon', 'instance',
+                    'server_network', 'vpn_subnet')
+    list_display_links = ('id', 'backend_status_icon',
+                          'instance', 'server_network', 'vpn_subnet')
+    fieldsets = [(None, {'fields': ['backend_status_text', 'kind',
+                                    'provider_service', 'instance', 'creator',
+                                    'server_network', 'vpn_subnet',
+                                    'is_persistent', 'use_ca_from',
+                                    'clients_can_see_each_other',
+                                    'failover_servers', "protocol"],
+                         'classes': ['suit-tab suit-tab-general']})]
+    readonly_fields = ('backend_status_text', 'instance')
+    form = VPNTenantForm
+    inlines = [TenantPrivilegeInline]
+
+    suit_form_tabs = (('general', 'Details'),
+                      ('tenantprivileges', 'Privileges'))
+
+    def queryset(self, request):
+        return VPNTenant.get_tenant_objects_by_user(request.user)
+
+
+# Associate the admin forms with the models.
+admin.site.register(VPNService, VPNServiceAdmin)
+admin.site.register(VPNTenant, VPNTenantAdmin)
diff --git a/xos/services/vpn/models.py b/xos/services/vpn/models.py
new file mode 100644
index 0000000..66b1bf8
--- /dev/null
+++ b/xos/services/vpn/models.py
@@ -0,0 +1,316 @@
+from subprocess import PIPE, Popen
+
+from django.db import transaction
+
+from core.models import Service, TenantWithContainer
+from xos.exceptions import XOSConfigurationError, XOSValidationError
+
+VPN_KIND = "vpn"
+
+
+class VPNService(Service):
+    """Defines the Service for creating VPN servers."""
+    KIND = VPN_KIND
+    OPENVPN_PREFIX = "/opt/openvpn/"
+    """The location of the openvpn EASY RSA files and PKIs."""
+    SERVER_PREFIX = OPENVPN_PREFIX + "server-"
+    """The prefix for server PKIs."""
+    VARS = OPENVPN_PREFIX + "vars"
+    """The location of the vars file with information for using EASY RSA."""
+    EASYRSA_LOC = OPENVPN_PREFIX + "easyrsa3/easyrsa"
+    """The location of the EASY RSA binary."""
+    EASYRSA_COMMAND_PREFIX = EASYRSA_LOC + " --vars=" + VARS
+    """Prefix for EASY RSA commands."""
+
+    @classmethod
+    def execute_easyrsa_command(cls, pki_dir, command):
+        """Executes the given EASY RSA command using the given PKI.
+
+        Parameters:
+            pki_dir (str): The directory for the pki to execute the command on.
+            command (str): The command to execute using ESAY RSA.
+        """
+        full_command = (
+            VPNService.EASYRSA_COMMAND_PREFIX + " --pki-dir=" +
+            pki_dir + " " + command)
+        proc = Popen(
+            full_command, shell=True, stdout=PIPE, stderr=PIPE
+        )
+        (stdout, stderr) = proc.communicate()
+        if (proc.returncode != 0):
+            raise XOSConfigurationError(
+                full_command + " failed with standard out:" + str(stdout) +
+                " and stderr: " + str(stderr))
+
+    @classmethod
+    def get_pki_dir(cls, tenant):
+        """Gets the directory of the PKI for the given tenant.
+
+        Parameters:
+            tenant (services.vpn.models.VPNTenant): The tenant to get the PKI directory for.
+
+        Returns:
+            str: The pki directory for the tenant.
+        """
+        return VPNService.SERVER_PREFIX + str(tenant.id)
+
+    class Meta:
+        proxy = True
+        # The name used to find this service, all directories are named this
+        app_label = "vpn"
+        verbose_name = "VPN Service"
+
+    default_attributes = {'exposed_ports': None,
+                          'exposed_ports_str': None}
+
+    @property
+    def exposed_ports(self):
+        """Mapping[str, list(str)]: maps protocols to a list of ports for that protocol."""
+        return self.get_attribute("exposed_ports",
+                                  self.default_attributes["exposed_ports"])
+
+    @exposed_ports.setter
+    def exposed_ports(self, value):
+        self.set_attribute("exposed_ports", value)
+
+    @property
+    def exposed_ports_str(self):
+        """str: a raw str representing the exposed ports."""
+        return self.get_attribute("exposed_ports_str",
+                                  self.default_attributes["exposed_ports_str"])
+
+    @exposed_ports_str.setter
+    def exposed_ports_str(self, value):
+        self.set_attribute("exposed_ports_str", value)
+
+    def get_next_available_port(self, protocol):
+        """Gets the next free port for the given protocol.
+
+        Parameters:
+            protocol (str): The protocol to get a port for, must be tcp or udp.
+
+        Returns:
+            int: a port number.
+
+        Raises:
+            xos.exceptions.XOSValidationError: If there the protocol is not udp or tcp.
+            xos.exceptions.XOSValidationError: If there are no available ports for the protocol.
+        """
+        if protocol != "udp" and protocol != "tcp":
+            raise XOSValidationError("Port protocol must be udp or tcp")
+        if not self.exposed_ports[protocol]:
+            raise XOSValidationError(
+                "No availble ports for protocol: " + protocol)
+        tenants = [
+            tenant for tenant in VPNTenant.get_tenant_objects().all()
+            if tenant.protocol == protocol]
+        port_numbers = self.exposed_ports[protocol]
+        for port_number in port_numbers:
+            if (
+                len([
+                    tenant for tenant in tenants
+                    if tenant.port_number == port_number]) == 0):
+                return port_number
+
+
+class VPNTenant(TenantWithContainer):
+    """Defines the Tenant for creating VPN servers."""
+
+    class Meta:
+        proxy = True
+        verbose_name = "VPN Tenant"
+
+    KIND = VPN_KIND
+
+    sync_attributes = ("nat_ip", "nat_mac",)
+
+    default_attributes = {'vpn_subnet': None,
+                          'server_network': None,
+                          'clients_can_see_each_other': True,
+                          'is_persistent': True,
+                          'port': None,
+                          'use_ca_from_id': None,
+                          'failover_server_ids': list(),
+                          'protocol': None}
+
+    def __init__(self, *args, **kwargs):
+        vpn_services = VPNService.get_service_objects().all()
+        if vpn_services:
+            self._meta.get_field(
+                "provider_service").default = vpn_services[0].id
+        super(VPNTenant, self).__init__(*args, **kwargs)
+
+    def save(self, *args, **kwargs):
+        super(VPNTenant, self).save(*args, **kwargs)
+        model_policy_vpn_tenant(self.pk)
+
+    def delete(self, *args, **kwargs):
+        self.cleanup_container()
+        super(VPNTenant, self).delete(*args, **kwargs)
+
+    @property
+    def protocol(self):
+        """str: The protocol that this tenant is listening on."""
+        return self.get_attribute(
+            "protocol", self.default_attributes["protocol"])
+
+    @protocol.setter
+    def protocol(self, value):
+        self.set_attribute("protocol", value)
+
+    @property
+    def use_ca_from_id(self):
+        """int: The ID of VPNTenant to use to obtain a CA."""
+        return self.get_attribute(
+            "use_ca_from_id", self.default_attributes["use_ca_from_id"])
+
+    @use_ca_from_id.setter
+    def use_ca_from_id(self, value):
+        self.set_attribute("use_ca_from_id", value)
+
+    @property
+    def addresses(self):
+        """Mapping[str, str]: The ip, mac address, and subnet of the NAT
+            network of this Tenant."""
+        if (not self.id) or (not self.instance):
+            return {}
+
+        addresses = {}
+        for ns in self.instance.ports.all():
+            if "nat" in ns.network.name.lower():
+                addresses["ip"] = ns.ip
+                addresses["mac"] = ns.mac
+                break
+
+        return addresses
+
+    # This getter is necessary because nat_ip is a sync_attribute
+    @property
+    def nat_ip(self):
+        """str: The IP of this Tenant on the NAT network."""
+        return self.addresses.get("ip", None)
+
+    # This getter is necessary because nat_mac is a sync_attribute
+    @property
+    def nat_mac(self):
+        """str: The MAC address of this Tenant on the NAT network."""
+        return self.addresses.get("mac", None)
+
+    @property
+    def server_network(self):
+        """str: The IP address of the server on the VPN."""
+        return self.get_attribute(
+            'server_network',
+            self.default_attributes['server_network'])
+
+    @server_network.setter
+    def server_network(self, value):
+        self.set_attribute("server_network", value)
+
+    @property
+    def vpn_subnet(self):
+        """str: The IP address of the client on the VPN."""
+        return self.get_attribute(
+            'vpn_subnet',
+            self.default_attributes['vpn_subnet'])
+
+    @vpn_subnet.setter
+    def vpn_subnet(self, value):
+        self.set_attribute("vpn_subnet", value)
+
+    @property
+    def is_persistent(self):
+        """bool: True if the VPN connection is persistence, false otherwise."""
+        return self.get_attribute(
+            "is_persistent",
+            self.default_attributes['is_persistent'])
+
+    @is_persistent.setter
+    def is_persistent(self, value):
+        self.set_attribute("is_persistent", value)
+
+    @property
+    def failover_server_ids(self):
+        """list(int): The IDs of the VPNTenants to use as failover servers."""
+        return self.get_attribute(
+            "failover_server_ids", self.default_attributes["failover_server_ids"])
+
+    @failover_server_ids.setter
+    def failover_server_ids(self, value):
+        self.set_attribute("failover_server_ids", value)
+
+    @property
+    def clients_can_see_each_other(self):
+        """bool: True if the client can see the subnet of the server, false
+            otherwise."""
+        return self.get_attribute(
+            "clients_can_see_each_other",
+            self.default_attributes['clients_can_see_each_other'])
+
+    @clients_can_see_each_other.setter
+    def clients_can_see_each_other(self, value):
+        self.set_attribute("clients_can_see_each_other", value)
+
+    @property
+    def port_number(self):
+        """int: the integer representing the port number for this server"""
+        return self.get_attribute("port", self.default_attributes['port'])
+
+    @port_number.setter
+    def port_number(self, value):
+        self.set_attribute("port", value)
+
+    def get_ca_crt(self, pki_dir):
+        """Gets the lines fo the ca.crt file for this VPNTenant.
+
+        Parameters:
+            pki_dir (str): The PKI directory to look in.
+
+        Returns:
+            list(str): The lines of the ca.crt file for this VPNTenant.
+        """
+        with open(pki_dir + "/ca.crt", 'r') as f:
+            return f.readlines()
+
+    def get_client_cert(self, client_name, pki_dir):
+        """Gets the lines fo the crt file for a client.
+
+        Parameters:
+            pki_dir (str): The PKI directory to look in.
+            client_name (str): The client name to use.
+
+        Returns:
+            list(str): The lines of the crt file for the client.
+        """
+        with open(pki_dir + "/issued/" + client_name + ".crt", 'r') as f:
+            return f.readlines()
+
+    def get_client_key(self, client_name, pki_dir):
+        """Gets the lines fo the key file for a client.
+
+        Parameters:
+            pki_dir (str): The PKI directory to look in.
+            client_name (str): The client name to use.
+
+        Returns:
+            list(str): The lines of the key file for the client.
+        """
+        with open(pki_dir + "/private/" + client_name + ".key", 'r') as f:
+            return f.readlines()
+
+
+def model_policy_vpn_tenant(pk):
+    """Manages the container for the VPN Tenant.
+
+    Parameters
+        pk (int): The ID of this VPNTenant.
+    """
+    # This section of code is atomic to prevent race conditions
+    with transaction.atomic():
+        # We find all of the tenants that are waiting to update
+        tenant = VPNTenant.objects.select_for_update().filter(pk=pk)
+        if not tenant:
+            return
+        # Since this code is atomic it is safe to always use the first tenant
+        tenant = tenant[0]
+        tenant.manage_container()
diff --git a/xos/services/vpn/templates/connect.vpn.j2 b/xos/services/vpn/templates/connect.vpn.j2
new file mode 100644
index 0000000..4ce9894
--- /dev/null
+++ b/xos/services/vpn/templates/connect.vpn.j2
@@ -0,0 +1,24 @@
+#! /bin/bash
+# This file autogenerated by VPNTenant.
+# It contains a script used to generate the OPENVPN client files.
+printf "%b" "client
+dev tun
+remote-cert-tls server
+resolv-retry 60
+nobind
+ca ca.crt
+cert {{ client_name }}.crt
+key {{ client_name }}.key
+verb 3
+{% for tenant in remotes %}remote {{ tenant.nat_ip }} {{ tenant.port_number }} {{ tenant.protocol }}{% endfor %}
+{% if is_persistent %}
+persist-tun
+persist-key
+{% endif %}
+" > client.conf
+printf "%b" "{% for line in ca_crt %}{{ line }}{% endfor %}" > ca.crt
+printf "%b" "{% for line in client_crt %}{{ line }}{% endfor %}" > {{ client_name }}.crt
+printf "%b" "{% for line in client_key %}{{ line }}{% endfor %}" > {{ client_name }}.key
+apt-get update
+apt-get install openvpn -y
+openvpn client.conf
diff --git a/xos/services/vpn/templates/vpnserviceadmin.html b/xos/services/vpn/templates/vpnserviceadmin.html
new file mode 100644
index 0000000..d983771
--- /dev/null
+++ b/xos/services/vpn/templates/vpnserviceadmin.html
@@ -0,0 +1,10 @@
+<!-- Template used to for the button leading to the HelloWorldTenantComplete form. -->
+<div class = "left-nav">
+  <ul>
+    <li>
+      <a href="/admin/vpn/vpntenant/">
+        VPN Tenants
+      </a>
+    </li>
+  </ul>
+</div>
diff --git a/xos/synchronizers/vpn/__init__.py b/xos/synchronizers/vpn/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/xos/synchronizers/vpn/__init__.py
diff --git a/xos/synchronizers/vpn/model-deps b/xos/synchronizers/vpn/model-deps
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/xos/synchronizers/vpn/model-deps
@@ -0,0 +1 @@
+{}
diff --git a/xos/synchronizers/vpn/run.sh b/xos/synchronizers/vpn/run.sh
new file mode 100755
index 0000000..9a2e69b
--- /dev/null
+++ b/xos/synchronizers/vpn/run.sh
@@ -0,0 +1,2 @@
+export XOS_DIR=/opt/xos
+python vpn-synchronizer.py  -C $XOS_DIR/synchronizers/vpn/vpn_config
diff --git a/xos/synchronizers/vpn/steps/__init__.py b/xos/synchronizers/vpn/steps/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/xos/synchronizers/vpn/steps/__init__.py
diff --git a/xos/synchronizers/vpn/steps/roles/openvpn/handlers/main.yml b/xos/synchronizers/vpn/steps/roles/openvpn/handlers/main.yml
new file mode 100644
index 0000000..8725e29
--- /dev/null
+++ b/xos/synchronizers/vpn/steps/roles/openvpn/handlers/main.yml
@@ -0,0 +1,4 @@
+---
+
+- name: restart openvpn
+  shell: (kill -9 $(cat {{ pki_dir }}/pid) || true) && (openvpn {{ pki_dir }}/server.conf &)
diff --git a/xos/synchronizers/vpn/steps/roles/openvpn/tasks/main.yml b/xos/synchronizers/vpn/steps/roles/openvpn/tasks/main.yml
new file mode 100644
index 0000000..47093b2
--- /dev/null
+++ b/xos/synchronizers/vpn/steps/roles/openvpn/tasks/main.yml
@@ -0,0 +1,38 @@
+---
+
+- name: install openvpn
+  apt: name=openvpn state=present update_cache=yes
+
+- name: make sure /opt/openvpn exists
+  file: path=/opt/openvpn state=directory
+
+- name: make sure directory for this server exists
+  file: path={{ pki_dir }} state=directory
+
+- name: get server key
+  copy: src={{ pki_dir }}/private/server.key dest={{ pki_dir }}/server.key
+  notify:
+  - restart openvpn
+
+- name: get server crt
+  copy: src={{ pki_dir }}/issued/server.crt dest={{ pki_dir }}/server.crt
+  notify:
+  - restart openvpn
+
+- name: get ca crt
+  copy: src={{ pki_dir }}/ca.crt dest={{ pki_dir }}/ca.crt
+  notify:
+  - restart openvpn
+
+- name: get crl
+  copy: src={{ pki_dir }}/crl.pem dest={{ pki_dir }}/crl.pem
+
+- name: get dh
+  copy: src={{ pki_dir }}/dh.pem dest={{ pki_dir }}/dh.pem
+  notify:
+  - restart openvpn
+
+- name: write config
+  template: src=server.conf.j2 dest={{ pki_dir }}/server.conf owner=root group=root
+  notify:
+  - restart openvpn
diff --git a/xos/synchronizers/vpn/steps/roles/openvpn/templates/server.conf.j2 b/xos/synchronizers/vpn/steps/roles/openvpn/templates/server.conf.j2
new file mode 100644
index 0000000..3930d28
--- /dev/null
+++ b/xos/synchronizers/vpn/steps/roles/openvpn/templates/server.conf.j2
@@ -0,0 +1,24 @@
+# This file autogenerated by VPNTenant synchronizer
+# It contains the OPENVPN config file for the server
+script-security 3 system
+port {{ port_number }}
+proto {{ protocol }}
+dev tun
+writepid {{ pki_dir }}/pid
+ca {{ pki_dir }}/ca.crt
+cert {{ pki_dir }}/server.crt
+key {{ pki_dir }}/server.key
+dh {{ pki_dir }}/dh.pem
+crl-verify {{ pki_dir }}/crl.pem
+server {{ server_network }} {{ vpn_subnet }}
+ifconfig-pool-persist {{ pki_dir }}/ipp.txt
+status {{ pki_dir }}/openvpn-status.log
+verb 3
+{% if is_persistent %}
+keepalive 10 60
+persist-tun
+persist-key
+{% endif %}
+{% if clients_can_see_each_other %}
+client-to-client
+{% endif %}
diff --git a/xos/synchronizers/vpn/steps/sync_tenantprivilege.py b/xos/synchronizers/vpn/steps/sync_tenantprivilege.py
new file mode 100644
index 0000000..81e5910
--- /dev/null
+++ b/xos/synchronizers/vpn/steps/sync_tenantprivilege.py
@@ -0,0 +1,79 @@
+import os
+import sys
+
+from core.models import TenantPrivilege
+from services.vpn.models import VPN_KIND, VPNService, VPNTenant
+from synchronizers.base.syncstep import DeferredException, SyncStep
+
+parentdir = os.path.join(os.path.dirname(__file__), "..")
+sys.path.insert(0, parentdir)
+
+
+class SyncTenantPrivilege(SyncStep):
+    """Class for syncing a TenantPrivilege for a VPNTenant.
+
+    This SyncStep isolates the updated TenantPrivileges that are for VPNTenants and performs
+    actions if the TenantPrivilege has been added or deleted. For added privileges a new client
+    certificate and key are made, signed with the ca.crt file used by this VPNTenant. For deleted
+    privileges the client certificate is revoked and the files associated are deleted. In both
+    cases the associated VPNTenant is saved causing the VPNTenant synchronizer to run.
+    """
+    provides = [TenantPrivilege]
+    observes = TenantPrivilege
+    requested_interval = 0
+
+    def fetch_pending(self, deleted):
+        privs = super(SyncTenantPrivilege, self).fetch_pending(deleted)
+        # Get only the TenantPrivileges that relate to VPNTenants
+        privs = [priv for priv in privs if priv.tenant.kind == VPN_KIND]
+        return privs
+
+    def sync_record(self, record):
+        if (not record.tenant.id):
+            raise DeferredException("Privilege waiting on VPN Tenant ID")
+        certificate = self.get_certificate_name(record)
+        tenant = VPNTenant.get_tenant_objects().filter(pk=record.tenant.id)[0]
+        if (not tenant):
+            raise DeferredException("Privilege waiting on VPN Tenant")
+        # Only add a certificate if ones does not yet exist
+        pki_dir = VPNService.get_pki_dir(tenant)
+        if (not os.path.isfile(pki_dir + "/issued/" + certificate + ".crt")):
+            VPNService.execute_easyrsa_command(
+                pki_dir, "build-client-full " + certificate + " nopass")
+            tenant.save()
+        record.save()
+
+    def delete_record(self, record):
+        if (not record.tenant.id):
+            return
+        certificate = self.get_certificate_name(record)
+        tenant = VPNTenant.get_tenant_objects().filter(pk=record.tenant.id)[0]
+        if (not tenant):
+            return
+        # If the client has already been reovked don't do it again
+        pki_dir = VPNService.get_pki_dir(tenant)
+        if (os.path.isfile(pki_dir + "/issued/" + certificate + ".crt")):
+            VPNService.execute_easyrsa_command(
+                pki_dir, "revoke " + certificate)
+            # Revoking a client cert does not delete any of the files
+            # to make sure that we can add this user again we need to
+            # delete all of the files created by easyrsa
+            os.remove(pki_dir + "/issued/" + certificate + ".crt")
+            os.remove(pki_dir + "/private/" + certificate + ".key")
+            os.remove(pki_dir + "/reqs/" + certificate + ".req")
+            tenant.save()
+
+        record.delete()
+
+    def get_certificate_name(self, tenant_privilege):
+        """Gets the name of a certificate for the given TenantPrivilege
+
+        Parameters:
+            tenant_privilege (core.models.TenantPrivilege): The TenantPrivilege to use to generate
+                the certificate name.
+
+        Returns:
+            str: The certificate name.
+        """
+        return (str(tenant_privilege.user.email) +
+                "-" + str(tenant_privilege.tenant.id))
diff --git a/xos/synchronizers/vpn/steps/sync_vpntenant.py b/xos/synchronizers/vpn/steps/sync_vpntenant.py
new file mode 100644
index 0000000..586ffb2
--- /dev/null
+++ b/xos/synchronizers/vpn/steps/sync_vpntenant.py
@@ -0,0 +1,75 @@
+import os
+import shutil
+import sys
+
+from django.db.models import F, Q
+
+from services.vpn.models import VPNService, VPNTenant
+from synchronizers.base.SyncInstanceUsingAnsible import \
+    SyncInstanceUsingAnsible
+
+parentdir = os.path.join(os.path.dirname(__file__), "..")
+sys.path.insert(0, parentdir)
+
+
+class SyncVPNTenant(SyncInstanceUsingAnsible):
+    """Class for syncing a VPNTenant using Ansible.
+
+    This SyncStep creates any necessary files for the VPNTenant using ESAY RSA and then runs the
+    Ansible template to start the server on an instance.
+    """
+    provides = [VPNTenant]
+    observes = VPNTenant
+    requested_interval = 0
+    template_name = "sync_vpntenant.yaml"
+    service_key_name = "/opt/xos/synchronizers/vpn/vpn_private_key"
+
+    def fetch_pending(self, deleted):
+        if (not deleted):
+            objs = VPNTenant.get_tenant_objects().filter(
+                Q(enacted__lt=F('updated')) |
+                Q(enacted=None), Q(lazy_blocked=False))
+        else:
+            objs = VPNTenant.get_deleted_tenant_objects()
+
+        return objs
+
+    def get_extra_attributes(self, tenant):
+        return {"is_persistent": tenant.is_persistent,
+                "vpn_subnet": tenant.vpn_subnet,
+                "server_network": tenant.server_network,
+                "clients_can_see_each_other": (
+                    tenant.clients_can_see_each_other),
+                "port_number": tenant.port_number,
+                "protocol": tenant.protocol,
+                "pki_dir": VPNService.get_pki_dir(tenant)
+                }
+
+    def sync_fields(self, o, fields):
+        pki_dir = VPNService.get_pki_dir(o)
+
+        if (not os.path.isdir(pki_dir)):
+            VPNService.execute_easyrsa_command(pki_dir, "init-pki")
+            VPNService.execute_easyrsa_command(
+                pki_dir, "--req-cn=XOS build-ca nopass")
+
+        # Very hacky way to handle VPNs that need to share CAs
+        if (o.use_ca_from_id):
+            tenant = VPNTenant.get_tenant_objects().filter(
+                pk=o.use_ca_from_id)[0]
+            other_pki_dir = VPNService.get_pki_dir(tenant)
+            shutil.copy2(other_pki_dir + "/ca.crt", pki_dir)
+            shutil.copy2(other_pki_dir + "/private/ca.key",
+                         pki_dir + "/private")
+
+        # If the server has to be built then we need to build it
+        if (not os.path.isfile(pki_dir + "/issued/server.crt")):
+            VPNService.execute_easyrsa_command(
+                pki_dir, "build-server-full server nopass")
+            VPNService.execute_easyrsa_command(pki_dir, "gen-dh")
+
+        # Get the most recent list of revoked clients
+        VPNService.execute_easyrsa_command(pki_dir, "gen-crl")
+
+        # Super runs the playbook
+        super(SyncVPNTenant, self).sync_fields(o, fields)
diff --git a/xos/synchronizers/vpn/steps/sync_vpntenant.yaml b/xos/synchronizers/vpn/steps/sync_vpntenant.yaml
new file mode 100644
index 0000000..e36f51b
--- /dev/null
+++ b/xos/synchronizers/vpn/steps/sync_vpntenant.yaml
@@ -0,0 +1,17 @@
+---
+- hosts: {{ instance_name }}
+  gather_facts: False
+  connection: ssh
+  user: ubuntu
+  sudo: yes
+  vars:
+    server_network: {{ server_network }}
+    is_persistent: {{ is_persistent }}
+    vpn_subnet: {{ vpn_subnet }}
+    clients_can_see_each_other: {{ clients_can_see_each_other }}
+    port_number: {{ port_number }}
+    protocol: {{ protocol }}
+    pki_dir: {{ pki_dir }}
+
+  roles:
+    - openvpn
diff --git a/xos/synchronizers/vpn/stop.sh b/xos/synchronizers/vpn/stop.sh
new file mode 100755
index 0000000..4178688
--- /dev/null
+++ b/xos/synchronizers/vpn/stop.sh
@@ -0,0 +1,2 @@
+# Kill the observer
+pkill -9 -f vpn-synchronizer.py
diff --git a/xos/synchronizers/vpn/vpn-synchronizer.py b/xos/synchronizers/vpn/vpn-synchronizer.py
new file mode 100755
index 0000000..3227ed9
--- /dev/null
+++ b/xos/synchronizers/vpn/vpn-synchronizer.py
@@ -0,0 +1,10 @@
+#!/usr/bin/env python
+
+import importlib
+import os
+import sys
+observer_path = os.path.join(os.path.dirname(
+    os.path.realpath(__file__)), "../../synchronizers/base")
+sys.path.append(observer_path)
+mod = importlib.import_module("xos-synchronizer")
+mod.main()
diff --git a/xos/synchronizers/vpn/vpn_config b/xos/synchronizers/vpn/vpn_config
new file mode 100644
index 0000000..2cdb192
--- /dev/null
+++ b/xos/synchronizers/vpn/vpn_config
@@ -0,0 +1,23 @@
+# Required by XOS
+[db]
+name=xos
+user=postgres
+password=password
+host=localhost
+port=5432
+
+# Required by XOS
+[api]
+nova_enabled=True
+
+# Sets options for the synchronizer
+[observer]
+name=vpn
+dependency_graph=/opt/xos/synchronizers/vpn/model-deps
+steps_dir=/opt/xos/synchronizers/vpn/steps
+sys_dir=/opt/xos/synchronizers/vpn/sys
+logfile=/var/log/xos_backend.log
+pretend=False
+backoff_disabled=True
+save_ansible_output=True
+proxy_ssh=False
diff --git a/xos/tools/xos-manage b/xos/tools/xos-manage
index 23cda72..925c38c 100755
--- a/xos/tools/xos-manage
+++ b/xos/tools/xos-manage
@@ -60,13 +60,13 @@
         echo Waiting for postgres to start
         sleep 1
         sudo -u postgres psql -c '\q'
-    done 
+    done
 }
 
 function db_exists {
-   sudo -u postgres psql $DBNAME -c '\q' 2>/dev/null    
+   sudo -u postgres psql $DBNAME -c '\q' 2>/dev/null
    return $?
-} 
+}
 
 function createdb {
     wait_postgres
@@ -145,6 +145,7 @@
     python ./manage.py makemigrations cord
     python ./manage.py makemigrations ceilometer
     python ./manage.py makemigrations helloworldservice_complete
+    python ./manage.py makemigrations vpn
     python ./manage.py makemigrations onos
     python ./manage.py makemigrations vtr
     python ./manage.py makemigrations vrouter
diff --git a/xos/xos/settings.py b/xos/xos/settings.py
index f457476..ec6055b 100644
--- a/xos/xos/settings.py
+++ b/xos/xos/settings.py
@@ -180,6 +180,7 @@
     'services.ceilometer',
     'services.requestrouter',
     'services.syndicate_storage',
+    'services.vpn',
     'services.vtr',
     'services.vrouter',
     'geoposition',