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',