├── iamovpn ├── __init__.py ├── views │ ├── __init__.py │ ├── front │ │ ├── public │ │ │ ├── robots.txt │ │ │ ├── favicon.ico │ │ │ ├── logo192.png │ │ │ ├── logo512.png │ │ │ ├── manifest.json │ │ │ └── index.html │ │ ├── src │ │ │ ├── history.js │ │ │ ├── modules │ │ │ │ ├── index.js │ │ │ │ ├── log.js │ │ │ │ └── user.js │ │ │ ├── App.test.js │ │ │ ├── App.css │ │ │ ├── index.css │ │ │ ├── components │ │ │ │ ├── Copyright.js │ │ │ │ ├── PasswordForm.js │ │ │ │ ├── Dashboard.js │ │ │ │ ├── Login.js │ │ │ │ ├── LogList.js │ │ │ │ ├── UserForm.js │ │ │ │ ├── MyPage.js │ │ │ │ └── UserList.js │ │ │ ├── App.js │ │ │ ├── index.js │ │ │ ├── serviceWorker.js │ │ │ └── logo.svg │ │ ├── .gitignore │ │ ├── package.json │ │ └── README.md │ ├── index.py │ └── templates │ │ └── index.html ├── models │ ├── __init__.py │ ├── utils.py │ ├── user.py │ └── log.py ├── wsgi.py ├── apis │ ├── __init__.py │ ├── secure.py │ ├── log.py │ └── user.py ├── templates │ ├── configurations │ │ ├── client.ovpn │ │ └── server.conf │ └── ovpn_scripts │ │ ├── connect.py │ │ ├── disconnect.py │ │ ├── login.py │ │ └── util.py ├── __main__.py ├── database.py ├── config.py ├── app.py └── install.py ├── screenshot_admin.png ├── screenshot_client.png ├── config.json.dist ├── requirements.txt ├── LICENSE.txt ├── README.md └── .gitignore /iamovpn/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- -------------------------------------------------------------------------------- /iamovpn/views/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /screenshot_admin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdCarrot/iamovpn/HEAD/screenshot_admin.png -------------------------------------------------------------------------------- /screenshot_client.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdCarrot/iamovpn/HEAD/screenshot_client.png -------------------------------------------------------------------------------- /iamovpn/views/front/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /iamovpn/models/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .user import User 3 | from .log import Log 4 | -------------------------------------------------------------------------------- /iamovpn/views/front/src/history.js: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history' 2 | export default createBrowserHistory({}) -------------------------------------------------------------------------------- /iamovpn/views/front/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdCarrot/iamovpn/HEAD/iamovpn/views/front/public/favicon.ico -------------------------------------------------------------------------------- /iamovpn/views/front/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdCarrot/iamovpn/HEAD/iamovpn/views/front/public/logo192.png -------------------------------------------------------------------------------- /iamovpn/views/front/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/StdCarrot/iamovpn/HEAD/iamovpn/views/front/public/logo512.png -------------------------------------------------------------------------------- /iamovpn/views/front/src/modules/index.js: -------------------------------------------------------------------------------- 1 | export { default as user } from './user'; 2 | export { default as log } from './log'; 3 | -------------------------------------------------------------------------------- /iamovpn/wsgi.py: -------------------------------------------------------------------------------- 1 | from app import create_app 2 | import config 3 | 4 | config.load('./config.json') 5 | app = create_app(config) 6 | -------------------------------------------------------------------------------- /iamovpn/apis/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from . import log, user, secure 3 | 4 | 5 | def get_apis(): 6 | return [module.api for module in [log, user, secure]] 7 | 8 | -------------------------------------------------------------------------------- /iamovpn/views/front/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /iamovpn/views/front/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | } 8 | 9 | .App-header { 10 | background-color: #282c34; 11 | min-height: 100vh; 12 | display: flex; 13 | flex-direction: column; 14 | align-items: center; 15 | justify-content: center; 16 | font-size: calc(10px + 2vmin); 17 | color: white; 18 | } 19 | 20 | .App-link { 21 | color: #09d3ac; 22 | } 23 | -------------------------------------------------------------------------------- /iamovpn/views/front/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /config.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "host": "0.0.0.0", 3 | "port": 8080, 4 | "db_url": "sqlite:///iamovpn.sqlite", 5 | "secret": , 6 | "logging": { 7 | "level": "INFO" 8 | }, 9 | "openvpn": { 10 | "host": , 11 | "path": "/etc/openvpn", 12 | "network": "172.16.0.0", 13 | "netmask": "255.255.0.0", 14 | "redirect_gateway": true, 15 | "routes": [] 16 | } 17 | } -------------------------------------------------------------------------------- /iamovpn/views/front/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /iamovpn/templates/configurations/client.ovpn: -------------------------------------------------------------------------------- 1 | client 2 | dev tun 3 | proto udp 4 | remote {{ host }} {{ port }} 5 | resolv-retry infinite 6 | cipher AES-256-CBC 7 | explicit-exit-notify 2 8 | {% if redirect_gateway %} 9 | redirect-gateway 10 | {% endif %} 11 | 12 | # Keys 13 | # Identity 14 | key-direction 1 15 | remote-cert-tls server 16 | auth-user-pass 17 | auth-nocache 18 | 19 | # Security 20 | nobind 21 | persist-key 22 | persist-tun 23 | comp-lzo 24 | verb 3 25 | 26 | 27 | {{ ca }} 28 | 29 | {{ ta }} 30 | -------------------------------------------------------------------------------- /iamovpn/views/front/src/components/Copyright.js: -------------------------------------------------------------------------------- 1 | import Typography from "@material-ui/core/Typography"; 2 | import Link from "@material-ui/core/Link"; 3 | import React from "react"; 4 | 5 | export default function Copyright() { 6 | return ( 7 | 8 | {'Copyright © '} 9 | 10 | IAMOVPN 11 | {' '} 12 | {new Date().getFullYear()} 13 | {'.'} 14 | 15 | ); 16 | } -------------------------------------------------------------------------------- /iamovpn/templates/ovpn_scripts/connect.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | import os 4 | from datetime import datetime 5 | import util 6 | 7 | util.log( 8 | log_type='connect', 9 | connect_time=datetime.fromtimestamp(int(os.environ['time_unix'])).isoformat(), 10 | remote_ip=os.environ['trusted_ip'], 11 | remote_port=int(os.environ['trusted_port']), 12 | local_ip=os.environ['ifconfig_pool_remote_ip'], 13 | user_id=os.environ['username'], 14 | agent=os.environ.get('IV_GUI_VER') or '', 15 | authorized=True, 16 | connected=True 17 | ) 18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aniso8601==9.0.1 2 | attrs==20.3.0 3 | bcrypt==3.2.0 4 | cffi==1.15.1 5 | click==7.1.2 6 | collections-extended==2.0.2 7 | docopt==0.6.2 8 | Flask==1.1.2 9 | Flask-Cors==3.0.10 10 | flask-restplus==0.13.0 11 | gevent==22.10.2 12 | greenlet==2.0.0 13 | IPy==1.1 14 | itsdangerous==1.1.0 15 | Jinja2==2.11.3 16 | jsonschema==3.2.0 17 | Markdown==3.4.1 18 | MarkupSafe==1.1.1 19 | netifaces==0.10.9 20 | pycparser==2.20 21 | pyrsistent==0.17.3 22 | python-dateutil==2.8.1 23 | pytz==2021.1 24 | six==1.15.0 25 | SQLAlchemy==1.4.42 26 | Werkzeug==0.16.1 27 | wget==3.2 28 | zope.event==4.5.0 29 | zope.interface==5.5.1 -------------------------------------------------------------------------------- /iamovpn/views/front/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /iamovpn/models/utils.py: -------------------------------------------------------------------------------- 1 | 2 | def get_orders(cls, cols): 3 | order_cols = [] 4 | for order_col in cols: 5 | desc = order_col.endswith('_desc') 6 | if desc: 7 | col_name = '_'.join(order_col.split('_')[:-1]) 8 | else: 9 | col_name = order_col 10 | 11 | if not hasattr(cls, col_name): 12 | raise ValueError('Invalid column name in order: {0}, {1}'.format(cls.__name__, order_col)) 13 | order_col = getattr(cls, col_name) 14 | 15 | if desc: 16 | order_cols.append(order_col.desc()) 17 | 18 | order_cols.append(order_col) 19 | 20 | return order_cols 21 | -------------------------------------------------------------------------------- /iamovpn/templates/ovpn_scripts/disconnect.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | import os 4 | from datetime import datetime 5 | import util 6 | 7 | util.log( 8 | log_type='connect', 9 | connect_time=datetime.fromtimestamp(int(os.environ['time_unix'])).isoformat(), 10 | remote_ip=os.environ['trusted_ip'], 11 | remote_port=int(os.environ['trusted_port']), 12 | local_ip=os.environ['ifconfig_pool_remote_ip'], 13 | user_id=os.environ['username'], 14 | agent=os.environ.get('IV_GUI_VER') or '', 15 | authorized=True, 16 | connected=False, 17 | in_bytes=int(os.environ['bytes_received']), 18 | out_bytes=int(os.environ['bytes_sent']), 19 | duration=int(os.environ['time_duration']) 20 | ) 21 | -------------------------------------------------------------------------------- /iamovpn/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """IAM OVPN 3 | Usage: 4 | iamovpn install [--config ] 5 | iamovpn run standalone [--config ] 6 | iamovpn run user_auth [--config ] 7 | iamovpn run user_connect [--config ] 8 | iamovpn run user_disconnect [--config ] 9 | 10 | Options: 11 | -h, --help Show this screen 12 | --config Specify config.json file [default: ./config.json] 13 | """ 14 | 15 | from docopt import docopt 16 | import logging 17 | import config 18 | import install 19 | import app 20 | 21 | args = docopt(__doc__) 22 | config.load(args['--config']) 23 | logging.basicConfig(level=getattr(logging, config['logging']['level'])) 24 | 25 | 26 | if args.get('install'): 27 | install.run(config) 28 | 29 | elif args.get('run') and args.get('standalone'): 30 | app.run(config) 31 | -------------------------------------------------------------------------------- /iamovpn/database.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from sqlalchemy import create_engine 3 | from sqlalchemy.orm import scoped_session, sessionmaker, configure_mappers 4 | from sqlalchemy.ext.declarative import declarative_base 5 | import config 6 | 7 | Base = declarative_base() 8 | session = None 9 | engine = None 10 | 11 | 12 | def connect(): 13 | global session 14 | global engine 15 | engine = create_engine(config['db_url']) 16 | session = scoped_session(sessionmaker(autocommit=False, 17 | autoflush=False, 18 | bind=engine)) 19 | Base.query = session.query_property() 20 | 21 | 22 | def create_all(): 23 | import models 24 | configure_mappers() 25 | Base.metadata.create_all(bind=engine) 26 | session.commit() 27 | 28 | 29 | def drop_all(): 30 | import models 31 | configure_mappers() 32 | Base.metadata.drop_all(bind=engine) 33 | session.commit() 34 | -------------------------------------------------------------------------------- /iamovpn/templates/ovpn_scripts/login.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import sys 5 | import util 6 | 7 | resp_code = util.login(os.environ['username'], os.environ['password']) 8 | 9 | if resp_code == 200: 10 | resp_code = 0 11 | 12 | if resp_code: 13 | util.log( 14 | log_type='login', 15 | connect_time=None, 16 | remote_ip=os.environ['untrusted_ip'], 17 | remote_port=int(os.environ['untrusted_port']), 18 | local_ip=None, 19 | user_id=os.environ['username'], 20 | agent=os.environ.get('IV_GUI_VER') or '', 21 | authorized=False, 22 | connected=False 23 | ) 24 | else: 25 | util.log( 26 | log_type='login', 27 | connect_time=None, 28 | remote_ip=os.environ['untrusted_ip'], 29 | remote_port=int(os.environ['untrusted_port']), 30 | local_ip=None, 31 | user_id=os.environ['username'], 32 | agent=os.environ.get('IV_GUI_VER') or '', 33 | authorized=True, 34 | connected=False 35 | ) 36 | 37 | sys.exit(resp_code) 38 | 39 | 40 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 IAMOVPN - yhbu@stdc.so 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /iamovpn/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | import os 4 | import json 5 | 6 | 7 | class ConfigModule(dict): 8 | default = { 9 | "db_url": "sqlite://./iamovpn.sqlite", 10 | "logging": { 11 | "level": "INFO" 12 | }, 13 | "debug": False, 14 | "ovpn": { 15 | "path": "/etc/openvpn", 16 | "network": "172.16.0.0", 17 | "netmask": "255.255.0.0", 18 | "redirect_gateway": True, 19 | "routes": [] 20 | } 21 | } 22 | 23 | def __init__(self): 24 | super().__init__() 25 | self.path = './config.json' 26 | self.update(ConfigModule.default) 27 | 28 | def load(self, path=None): 29 | if path: 30 | self.path = os.path.abspath(path) 31 | elif self.path is None: 32 | raise ValueError('Config path can not be None') 33 | with open(self.path, 'r') as config_file: 34 | self.update(json.load(config_file)) 35 | 36 | def save(self, path=None): 37 | path = path or self.path 38 | with open(path, 'w') as config_file: 39 | json.dump(self, config_file, indent=' ', sort_keys=True) 40 | 41 | 42 | sys.modules[__name__] = ConfigModule() 43 | -------------------------------------------------------------------------------- /iamovpn/views/front/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "IAMOVPN", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@material-ui/core": "^4.7.0", 7 | "@material-ui/icons": "^4.5.1", 8 | "axios": "^0.21.1", 9 | "axios-progress-bar": "^1.2.0", 10 | "immutable": "^4.0.0-rc.12", 11 | "moment": "^2.24.0", 12 | "notistack": "^0.9.7", 13 | "react": "^16.12.0", 14 | "react-dom": "^16.12.0", 15 | "react-redux": "^7.1.3", 16 | "react-router": "^5.1.2", 17 | "react-router-dom": "^5.1.2", 18 | "react-scripts": "3.2.0", 19 | "redux": "^4.0.4", 20 | "redux-actions": "^2.6.5", 21 | "redux-axios-middleware": "^4.0.1", 22 | "redux-devtools": "^3.5.0" 23 | }, 24 | "scripts": { 25 | "start": "react-scripts start", 26 | "build": "react-scripts build", 27 | "test": "react-scripts test", 28 | "eject": "react-scripts eject" 29 | }, 30 | "eslintConfig": { 31 | "extends": "react-app" 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /iamovpn/templates/configurations/server.conf: -------------------------------------------------------------------------------- 1 | ## Server 2 | mode server 3 | proto udp 4 | port {{ port }} 5 | dev tun 6 | management 127.0.0.1 1193 7 | 8 | 9 | ## KEY, CERTS ## 10 | ca ca.crt 11 | cert server.crt 12 | key server.key 13 | dh dh.pem 14 | tls-auth ta.key 0 15 | cipher AES-256-CBC 16 | 17 | 18 | ## Network 19 | server {{ network }} {{ netmask }} 20 | 21 | {% for route in routes %} 22 | push "route {{ route.network }} {{ route.netmask }}" 23 | {% endfor %} 24 | 25 | # Redirect all IP network traffic 26 | {% if redirect_gateway %} 27 | push "redirect-gateway def1" 28 | {% endif %} 29 | 30 | # Ping every 10 seconds and if after 300 seconds the client doesn't respond we disconnect 31 | keepalive 10 300 32 | # Regenerate key each 12 hours (disconnect the client) 33 | reneg-sec 43200 34 | 35 | 36 | ## SECURITY ## 37 | user nobody 38 | group nogroup 39 | 40 | persist-key 41 | persist-tun 42 | comp-lzo 43 | 44 | 45 | ## LOG ## 46 | verb 3 47 | mute 20 48 | status openvpn-status.log 49 | log-append /var/log/openvpn.log 50 | 51 | 52 | ## Authorization ## 53 | 54 | # Allow running external scripts with password in ENV variables 55 | script-security 3 56 | 57 | max-clients 255 58 | 59 | username-as-common-name 60 | verify-client-cert none 61 | auth-user-pass-verify scripts/login.py via-env 62 | client-connect scripts/connect.py 63 | client-disconnect scripts/disconnect.py 64 | 65 | # Notify the client that when the server restarts so it 66 | # can automatically reconnect. 67 | explicit-exit-notify 1 -------------------------------------------------------------------------------- /iamovpn/views/front/src/modules/log.js: -------------------------------------------------------------------------------- 1 | import { createAction, handleActions } from 'redux-actions'; 2 | import { Map } from 'immutable'; 3 | import moment from "moment"; 4 | 5 | const GET_LIST = 'iamovpn/log/GET_LIST'; 6 | const GET_LIST_SUCCESS = 'iamovpn/log/GET_LIST_SUCCESS'; 7 | const GET_LIST_FAIL = 'iamovpn/log/GET_LIST_FAIL'; 8 | 9 | 10 | export const getList = createAction( 11 | GET_LIST, 12 | (keyword, offset, length) => { 13 | return { 14 | request: { 15 | method: 'GET', 16 | url: '/api/log', 17 | params: { 18 | keyword: keyword, 19 | offset: offset, 20 | length: length 21 | } 22 | } 23 | } 24 | } 25 | ); 26 | 27 | const initialState = Map({ 28 | logs: [], 29 | log_count: 0 30 | }); 31 | const log = handleActions({ 32 | [GET_LIST]: (state, action) => { 33 | return state 34 | }, 35 | [GET_LIST_SUCCESS]: (state, action) => { 36 | const logs = action.payload.data.logs; 37 | logs.forEach(log => { 38 | log.created = new moment(log.created); 39 | log.updated = new moment(log.updated); 40 | log.connected = new moment(log.connected); 41 | }); 42 | return state.set('logs', logs) 43 | .set('log_count', action.payload.data.count); 44 | }, 45 | [GET_LIST_FAIL]: (state, action) => { 46 | return state.set('log', []).set('log_count', 0) 47 | }, 48 | }, initialState); 49 | export default log; -------------------------------------------------------------------------------- /iamovpn/views/front/src/App.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {connect} from 'react-redux'; 3 | import {SnackbarProvider} from 'notistack'; 4 | 5 | import './App.css'; 6 | import * as userActions from "./modules/user"; 7 | import history from "./history"; 8 | 9 | 10 | class App extends Component { 11 | routeUser() { 12 | const {dispatch, me} = this.props; 13 | 14 | if (me) { 15 | if (me.admin) { 16 | history.push('/dashboard') 17 | } else { 18 | history.push('/user') 19 | } 20 | } else { 21 | dispatch(userActions.checkSession()) 22 | .then((response) => { 23 | console.log('Authorized!'); 24 | const user = response.payload.data.user; 25 | if (user.admin === true) { 26 | history.push('/dashboard'); 27 | } else { 28 | history.push('/my'); 29 | } 30 | }) 31 | .catch((response) => { 32 | history.push('/login'); 33 | }); 34 | } 35 | }; 36 | 37 | componentDidMount() { 38 | this.routeUser(); 39 | }; 40 | 41 | componentDidUpdate(prevProps, prevState, snapshot) { 42 | this.routeUser(); 43 | }; 44 | 45 | render() { 46 | let layout = ( 47 |
{this.props.children}
48 | ); 49 | return ( 50 | 51 | {layout} 52 | 53 | ); 54 | } 55 | } 56 | 57 | const mapStateToProps = (state) => { 58 | return { 59 | me: state.user.get('me') 60 | } 61 | }; 62 | export default connect(mapStateToProps)(App); -------------------------------------------------------------------------------- /iamovpn/templates/ovpn_scripts/util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from urllib import request, parse, error 3 | import json 4 | 5 | api_host = 'http://{{ api_host }}:{{ api_port }}/' 6 | api_key = '{{ api_key }}' 7 | 8 | 9 | def login(id_, password): 10 | data = json.dumps({ 11 | 'id': id_, 12 | 'password': password 13 | }).encode() 14 | req = request.Request( 15 | url=parse.urljoin(api_host, '/api/secure/login'), 16 | data=data, 17 | method='POST', 18 | headers={'Content-Type': 'application/json'} 19 | ) 20 | try: 21 | resp = request.urlopen(req) 22 | status = resp.getcode() 23 | except error.HTTPError as e: 24 | status = e.getcode() 25 | 26 | return status 27 | 28 | 29 | def log(log_type, connect_time, remote_ip, remote_port, local_ip, user_id, agent, authorized, connected, 30 | in_bytes=None, out_bytes=None, duration=None): 31 | data = json.dumps({ 32 | 'log_type': log_type, 33 | 'connect_time': connect_time, 34 | 'remote_ip': remote_ip, 35 | 'remote_port': remote_port, 36 | 'local_ip': local_ip, 37 | 'user_id': user_id, 38 | 'agent': agent, 39 | 'authorized': authorized, 40 | 'connected': connected, 41 | 'in_bytes': in_bytes, 42 | 'out_bytes': out_bytes, 43 | 'duration': duration 44 | }).encode() 45 | headers = { 46 | 'Content-Type': 'application/json', 47 | 'X-API-Key': api_key 48 | } 49 | 50 | req = request.Request( 51 | url=parse.urljoin(api_host, '/api/log'), 52 | data=data, 53 | method='POST', 54 | headers=headers 55 | ) 56 | try: 57 | resp = request.urlopen(req) 58 | status = resp.getcode() 59 | except error.HTTPError as e: 60 | status = e.getcode() 61 | 62 | return status 63 | -------------------------------------------------------------------------------- /iamovpn/views/index.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import json 3 | from flask import Blueprint, render_template 4 | import config 5 | 6 | view_path = os.path.abspath(os.path.dirname(__file__)) 7 | app = Blueprint( 8 | 'view.index', 9 | __name__, 10 | template_folder=os.path.join(view_path, 'templates'), 11 | static_folder=os.path.join(view_path, 'front/build/static') 12 | ) 13 | _assets = None 14 | _manifest = None 15 | 16 | 17 | def get_assets(): 18 | global _assets 19 | if _assets is None or len(_assets) == 0: 20 | asset_manifest_path = os.path.join(view_path, 'front/build/asset-manifest.json') 21 | if not os.path.exists(asset_manifest_path): 22 | raise RuntimeError('You MUST build front page before using views') 23 | _assets = {'js': {}, 'css': {}} 24 | with open(asset_manifest_path, 'r') as f: 25 | for asset in json.load(f)['entrypoints']: 26 | if asset.endswith('.js'): 27 | if 'runtime' in asset: 28 | _assets['js']['runtime'] = asset 29 | elif 'main' in asset: 30 | _assets['js']['main'] = asset 31 | else: 32 | _assets['js']['bundle'] = asset 33 | elif asset.endswith('.css'): 34 | if 'main' in asset: 35 | _assets['css']['main'] = asset 36 | else: 37 | _assets['css']['bundle'] = asset 38 | 39 | return _assets 40 | 41 | 42 | @app.route('/') 43 | @app.route('/my') 44 | @app.route('/login') 45 | @app.route('/dashboard') 46 | def index(): 47 | context = get_assets() 48 | context.update({ 49 | 'host': config['openvpn']['host'] if '0.0.0.0' in config['host'] else config['host'], 50 | 'port': config['port'] 51 | }) 52 | return render_template('index.html', **context) 53 | -------------------------------------------------------------------------------- /iamovpn/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import uuid 3 | from flask import Flask, Blueprint 4 | from flask_restplus import Api 5 | from flask_cors import CORS 6 | import database as db 7 | import logging 8 | import apis 9 | import views.index 10 | 11 | 12 | def create_app(config): 13 | app = Flask(__name__, template_folder='templates', static_folder=None) 14 | app.debug = config.get('debug') or False 15 | app.secret_key = config.get('secret') or 'DANGEROUS SECURE' 16 | db.connect() 17 | config['api_keys'] = (config.get('api_keys') or []) + [str(uuid.uuid5(uuid.NAMESPACE_DNS, app.secret_key))] 18 | 19 | def teardown(exception=None): 20 | db.session.remove() 21 | return exception 22 | 23 | def exception_handler(exception): 24 | db.session.rollback() 25 | return exception 26 | 27 | blueprint = Blueprint('api', 'api', url_prefix='/api') 28 | api = Api( 29 | blueprint, 30 | endpoint='api', 31 | authorizations={ 32 | 'apikey': { 33 | 'type': 'apiKey', 34 | 'in': 'header', 35 | 'name': 'X-API-Key' 36 | }, 37 | 'cookieAuth': { 38 | 'type': 'apiKey', 39 | 'in': 'cookie', 40 | 'name': 'session', 41 | } 42 | } 43 | ) 44 | for a in apis.get_apis(): 45 | api.add_namespace(a) 46 | 47 | app.teardown_request(teardown) 48 | app.register_error_handler(Exception, exception_handler) 49 | app.register_blueprint(blueprint) 50 | app.register_blueprint(views.index.app) 51 | app.url_map.strict_slashes = False 52 | 53 | if config['debug']: 54 | logging.info(app.url_map) 55 | cors = CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True) 56 | 57 | return app 58 | 59 | 60 | def run(config): 61 | app = create_app(config) 62 | app.run(config['host'], config['port'], debug=config['debug']) 63 | -------------------------------------------------------------------------------- /iamovpn/views/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | IAMOVPN 23 | 24 | 25 | 26 | 27 | 28 |
29 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /iamovpn/views/front/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 | 32 | 33 |
34 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /iamovpn/views/front/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Route, Switch } from 'react-router'; 4 | import { Router } from 'react-router-dom'; 5 | 6 | import { combineReducers, createStore, applyMiddleware, compose} from 'redux'; 7 | import { Provider } from 'react-redux'; 8 | 9 | import axios from 'axios'; 10 | import axiosMiddleware from 'redux-axios-middleware'; 11 | import { loadProgressBar } from 'axios-progress-bar'; 12 | import 'axios-progress-bar/dist/nprogress.css'; 13 | 14 | import Login from './components/Login'; 15 | import Dashboard from './components/Dashboard'; 16 | import MyPage from "./components/MyPage"; 17 | 18 | import history from './history'; 19 | import * as reducers from './modules'; 20 | 21 | import './index.css'; 22 | import App from './App'; 23 | import * as serviceWorker from './serviceWorker'; 24 | 25 | 26 | const apiClient = axios.create({ 27 | baseURL: document.querySelector('#host').value, 28 | responseType: 'json', 29 | withCredentials: true 30 | }); 31 | loadProgressBar({}, apiClient); 32 | 33 | 34 | let composeEnhancers = compose; 35 | if (document.querySelector('#development') !== undefined) { 36 | composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 37 | } 38 | 39 | 40 | const store = createStore( 41 | combineReducers(reducers), 42 | composeEnhancers( 43 | applyMiddleware( 44 | axiosMiddleware(apiClient, {returnRejectedPromiseOnError: true}) 45 | ) 46 | ) 47 | ); 48 | 49 | 50 | ReactDOM.render( 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | , 63 | document.getElementById('root') 64 | ); 65 | 66 | // If you want your app to work offline and load faster, you can change 67 | // unregister() to register() below. Note this comes with some pitfalls. 68 | // Learn more about service workers: https://bit.ly/CRA-PWA 69 | serviceWorker.unregister(); 70 | -------------------------------------------------------------------------------- /iamovpn/models/user.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import database as db 3 | import bcrypt 4 | import uuid 5 | from datetime import datetime 6 | 7 | from sqlalchemy import or_, Column, DateTime, Boolean, Text 8 | from sqlalchemy.ext.hybrid import hybrid_property as property 9 | 10 | import config 11 | from .utils import get_orders 12 | 13 | 14 | class User(db.Base): 15 | __tablename__ = 'user' 16 | 17 | # Fields 18 | uid = Column(Text, primary_key=True) 19 | created = Column(DateTime(timezone=False)) 20 | updated = Column(DateTime(timezone=False)) 21 | id = Column(Text, index=True, unique=True) 22 | _password = Column('password', Text) 23 | name = Column(Text) 24 | admin = Column(Boolean, default=False) 25 | active = Column(Boolean, default=True) 26 | 27 | def __init__(self, id, password, name, admin=False, active=True): 28 | self.uid = str(uuid.uuid4()) 29 | self.created = datetime.now() 30 | self.updated = self.created 31 | self.id = id 32 | self.password = password 33 | self.name = name 34 | self.admin = admin 35 | self.active = active 36 | 37 | def __eq__(self, other): 38 | if other is None or not isinstance(other, self.__class__): 39 | return False 40 | for attr in ['uid', 'id']: 41 | if getattr(self, attr) != getattr(other, attr, None): 42 | return False 43 | return True 44 | 45 | def __repr__(self): 46 | return '' % (str(self.name), str(self.id)) 47 | 48 | @property 49 | def dict(self): 50 | return { 51 | 'uid': self.uid, 52 | 'created': self.created.isoformat(), 53 | 'updated': self.updated.isoformat(), 54 | 'id': self.id, 55 | 'name': self.name, 56 | 'admin': self.admin, 57 | 'active': self.active 58 | } 59 | 60 | @property 61 | def password(self): 62 | return self._password 63 | 64 | @password.setter 65 | def password(self, passwd): 66 | self._password = bcrypt.hashpw((passwd + config['secret']).encode(), bcrypt.gensalt()).decode() 67 | 68 | def is_valid_password(self, passwd): 69 | return bcrypt.checkpw((passwd + config['secret']).encode(), self._password.encode()) 70 | 71 | @staticmethod 72 | def find(keyword=None, admin=None, active=None, order_by=('updated_desc', ), offset=0, length=25): 73 | query = User.query 74 | 75 | if keyword: 76 | keyword = '%{0}%'.format(keyword) 77 | query = query.filter(or_( 78 | User.name.ilike(keyword), 79 | User.id.ilike(keyword) 80 | )) 81 | if admin is not None: 82 | query = query.filter(User.admin.is_(admin)) 83 | if active is not None: 84 | query = query.filter(User.active.is_(active)) 85 | 86 | return query.order_by(*get_orders(User, order_by)).offset(offset).limit(length).all(), query.count() 87 | -------------------------------------------------------------------------------- /iamovpn/views/front/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `npm run build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /iamovpn/apis/secure.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import database as db 3 | from datetime import datetime 4 | from functools import wraps 5 | from flask import session, g, abort, request 6 | from flask_restplus import Resource, abort, Namespace, fields 7 | from models import User 8 | import config 9 | 10 | api = Namespace('secure') 11 | 12 | 13 | def user_setter(): 14 | if 'user_uid' in session and session['user_uid']: 15 | g.user = User.query\ 16 | .filter(User.uid == session['user_uid'])\ 17 | .filter(User.active.is_(True))\ 18 | .first() 19 | if g.user is None: 20 | session.clear() 21 | raise abort(404) 22 | return True 23 | return False 24 | 25 | 26 | def check_session(): 27 | if 'user_uid' not in session: 28 | return False 29 | else: 30 | return user_setter() 31 | 32 | 33 | def need_session(admin=False, api_key=None): 34 | def deco(func): 35 | @wraps(func) 36 | def wrapper(*args, **kwargs): 37 | if api_key: 38 | if request.headers.get('X-API-Key') not in (config.get('api_keys') or []): 39 | abort(401) 40 | else: 41 | if not check_session(): 42 | abort(401) 43 | 44 | if admin and not g.user.admin: 45 | abort(403) 46 | 47 | return func(*args, **kwargs) 48 | return wrapper 49 | return deco 50 | 51 | 52 | @api.route('/login') 53 | @api.response(401, 'Unauthorized') 54 | class LoginApi(Resource): 55 | login_model = api.model( 56 | 'Login', 57 | { 58 | 'id': fields.String(description='User ID', min_length=4, required=True), 59 | 'password': fields.String(description='User password', min_length=4, required=True) 60 | } 61 | ) 62 | 63 | @api.expect(login_model) 64 | def post(self): 65 | data = request.json 66 | self.login_model.validate(data) 67 | 68 | user = User.query\ 69 | .filter(User.id == data['id'])\ 70 | .filter(User.active.is_(True))\ 71 | .first() 72 | if user and user.is_valid_password(data['password']): 73 | session['user_uid'] = user.uid 74 | return {'state': 'success', 'user': user.dict} 75 | 76 | session.clear() 77 | abort(401) 78 | 79 | def delete(self): 80 | session.pop('user_uid') 81 | return { 82 | 'state': 'success' 83 | } 84 | 85 | 86 | @api.route('/password') 87 | @api.response(401, 'Unauthorized') 88 | class PasswordChange(Resource): 89 | password_model = api.model( 90 | 'ChangePassword', 91 | { 92 | 'old': fields.String(title='Old password', required=True), 93 | 'new': fields.String(title='New password', required=True) 94 | } 95 | ) 96 | 97 | @need_session(admin=False) 98 | @api.expect(password_model) 99 | @api.doc(security='cookieAuth') 100 | def put(self): 101 | data = request.json 102 | self.password_model.validate(data) 103 | 104 | if not g.user.is_valid_password(data['old']): 105 | abort(401, 'Old password is wrong.') 106 | 107 | if len(data['new']) < 6: 108 | abort(400, 'New password is too short.') 109 | 110 | g.user.password = data['new'] 111 | g.user.updated = datetime.now() 112 | db.session.commit() 113 | 114 | return {'state': 'success'} 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IAMOVPN 2 | 3 | Administrate OpenVPN user and connections with Web UI in single server. 4 | 5 | 6 | ## Feature 7 | - OpenVPN auto-setup 8 | - User management dashboard 9 | 10 | ![screenshot_admin](./screenshot_admin.png) 11 | 12 | ![screenshot_client](./screenshot_client.png) 13 | 14 | 15 | ## How it work 16 | OpenVPN server have provide management terminal and callback scripts when client event occured. 17 | In this project, use below commands and callbacks. 18 | - kill cn: Force disconnect user 19 | - auth-user-pass-verify: User auth 20 | - client-connect: connection log 21 | - client-disconnect: disconnection log 22 | 23 | 24 | ## Tested on 25 | Tested only Ubuntu 18.04, so it can be broken other version of Linux. 26 | 27 | 28 | ## Requirement 29 | - Stable Linux distribution which support IPv4 forwarding like Ubuntu or Debian. 30 | - Two open ports which can accessed by Internet IP. 31 | One port for OpenVPN, the other for WebUI. 32 | - Not configured OpenVPN 33 | - Python3 34 | - NodeJS(>= 8.x) 35 | 36 | 37 | ## Installation 38 | 1. Make sure requirements were installed 39 | ``` 40 | # apt install git python3 python3-pip openvpn npm 41 | ``` 42 | 43 | 2. Clone the project 44 | ``` 45 | $ git clone https://github.com/StdCarrot/iamovpn.git 46 | $ cd iamovpn 47 | ``` 48 | 49 | 3. Setup config 50 | ``` 51 | $ cp config.json.dist config.json 52 | ``` 53 | You must change `secret` and `openvpn.host`. 54 | 55 | If you want to set custom route, append subnet information like below to `openvpn.routes`: 56 | ```json 57 | { 58 | "network": "172.16.0.0", 59 | "netmask": "255.255.255.0" 60 | } 61 | ``` 62 | 63 | If you set `openvpn.redirect_gateway` as `false`, Internet traffic will not pass through VPN. 64 | 65 | 4. (Optional) Prepare database 66 | Create user and database and make db connection url and set it `db_url` in config.json. 67 | See also: https://docs.sqlalchemy.org/en/13/faq/connections.html 68 | 69 | 5. (Optional) Init virtualenv 70 | ``` 71 | # apt install virtualenv 72 | $ virtualenv -p python3 env 73 | $ source env/bin/activate 74 | ``` 75 | 76 | 6. Install python libraries 77 | ``` 78 | $ pip3 install -r requirements.txt 79 | ``` 80 | 81 | 7. Install IAMOVPN with root permission 82 | In this process, you may enter information to generate RSA keys. 83 | ``` 84 | # python3 iamovpn install --config 85 | ``` 86 | If you use virtualenv, follow this: 87 | ``` 88 | # /bin/python3 iamovpn install --config 89 | ``` 90 | 91 | 8. (Optional) Build reverse proxy with nginx or apache2. And all tsl layer for https. 92 | See also: https://certbot.eff.org/instructions 93 | 94 | If you build reverse proxy, *MUST* check /etc/openvpn/scripts/util.py. 95 | You may modify `api_host`'s port information. 96 | 97 | 9. Start server. 98 | ``` 99 | # systemctl restart openvpn@server 100 | $ python3 iamovpn run standalone --config 101 | ``` 102 | You can use gunicorn or uWSGI to serving and also register as service. 103 | 104 | 10. Open Web UI 105 | In browser, go `http:///` 106 | Login default account: 107 | - ID: admin 108 | - Password: need2change 109 | 110 | *MUST* change password as soon as possible. 111 | 112 | 113 | 114 | ## Warning 115 | IAMOVPN api server *MUST BE RUN* while using vpn. 116 | User authorization and connection logging uses apis. 117 | 118 | 119 | ## Road map 120 | - Force disconnect client 121 | - Server utilization view 122 | - Docker compose 123 | 124 | -------------------------------------------------------------------------------- /iamovpn/apis/log.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -* 2 | import dateutil.parser 3 | from flask import request 4 | from flask_restplus import Namespace, Resource, fields, abort 5 | from models import Log 6 | from .secure import need_session 7 | from IPy import IP 8 | import config 9 | import database as db 10 | 11 | api = Namespace('log') 12 | 13 | 14 | def make_nullable(cls): 15 | schema_type = ['null'] 16 | if isinstance(cls.__schema_type__, list): 17 | schema_type += cls.__schema_type__ 18 | else: 19 | schema_type.append(cls.__schema_type__) 20 | 21 | class NullableField(cls): 22 | __schema_type__ = schema_type 23 | __schema_example__ = ' '.join(['nullable', cls.__schema_example__ or '']) 24 | 25 | return NullableField 26 | 27 | 28 | @api.route('') 29 | @api.response(401, 'Unauthorized') 30 | @api.response(403, 'Forbidden') 31 | class LogsApi(Resource): 32 | log_find_parser = api.parser() 33 | log_find_parser.add_argument('keyword', type=str, location='args', 34 | help='Keyword: type, user_id, remote_ip, local_ip, agent') 35 | log_find_parser.add_argument('offset', type=int, help='Page offset', location='args', default=0) 36 | log_find_parser.add_argument('length', type=int, help='Page length', location='args', default=20) 37 | 38 | log_model = api.model( 39 | 'Log', 40 | { 41 | 'log_type': fields.String(description='Remote IP', enum=('login', 'connect', 'disconnect'), required=True), 42 | 'connect_time': make_nullable(fields.DateTime)(description='Connected time', required=True), 43 | 'remote_ip': fields.String(description='Remote IP', required=True), 44 | 'remote_port': fields.Integer(description='Remote port', required=True), 45 | 'local_ip': make_nullable(fields.String)(description='Local IP', required=True), 46 | 'user_id': fields.String(description='User ID', required=True), 47 | 'agent': make_nullable(fields.String)(description='Client agent', required=True), 48 | 'authorized': fields.Boolean(description='User authorized', required=True), 49 | 'connected': fields.Boolean(description='Now connected', required=True), 50 | 'in_bytes': make_nullable(fields.Integer)(description='In bytes from client', required=True), 51 | 'out_bytes': make_nullable(fields.Integer)(description='Out bytes to client', required=True), 52 | 'duration': make_nullable(fields.Integer)(description='Connection duration as seconds', required=True) 53 | } 54 | ) 55 | 56 | @need_session(admin=True) 57 | @api.expect(log_find_parser) 58 | @api.doc(security='cookieAuth') 59 | def get(self): 60 | args = self.log_find_parser.parse_args() 61 | if args['length'] > 500: 62 | abort(400, '"length" must be <= 100') 63 | 64 | logs, count = Log.find(**args) 65 | return { 66 | 'state': 'success', 67 | 'logs': [log.dict for log in logs], 68 | 'count': count 69 | } 70 | 71 | @need_session(api_key=True) 72 | @api.expect(log_model) 73 | @api.doc(security='apikey') 74 | def post(self): 75 | data = request.json 76 | self.log_model.validate(data) 77 | 78 | for k in ['remote_ip', 'local_ip']: 79 | try: 80 | IP(data.get(k) or '0.0.0.0') 81 | except ValueError: 82 | abort(400, '{} is invalid IP'.format(data[k])) 83 | 84 | data['connect_time'] = dateutil.parser.parse(data['connect_time']) if data['connect_time'] else None 85 | 86 | data = { 87 | k: data[k] 88 | for k in self.log_model.keys() 89 | } 90 | log = Log(**data) 91 | db.session.add(log) 92 | db.session.commit() 93 | return { 94 | 'state': 'success', 95 | 'log': log.dict 96 | } -------------------------------------------------------------------------------- /iamovpn/views/front/src/components/PasswordForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { 5 | TextField, 6 | Button, 7 | ButtonGroup 8 | } from '@material-ui/core'; 9 | import { withSnackbar } from 'notistack'; 10 | 11 | import * as userActions from "../modules/user"; 12 | 13 | 14 | class PasswordForm extends Component { 15 | constructor(props) { 16 | super(props); 17 | const callback = this.props.callback || (() => {}); 18 | const uid = this.props.uid || null; 19 | 20 | this.state = { 21 | callback: callback, 22 | uid: uid, 23 | old: "", 24 | new: "", 25 | error: { 26 | old: false, 27 | new: false 28 | } 29 | }; 30 | }; 31 | 32 | componentDidUpdate(prevProps, prevState, snapshot) { 33 | const callback = this.props.callback || (() => {}); 34 | const uid = this.props.uid || null; 35 | 36 | if (uid !== this.state.uid) { 37 | this.setState({ 38 | callback: callback, 39 | uid: uid, 40 | old: "", 41 | new: "", 42 | error: { 43 | old: false, 44 | new: false 45 | } 46 | }); 47 | } 48 | }; 49 | 50 | handlePasswordChange(e) { 51 | const {error} = this.state; 52 | error[e.target.name] = (e.target.value || '').length < 6; 53 | this.setState({[e.target.name]: e.target.value, error: error }); 54 | }; 55 | 56 | savePassword() { 57 | const {dispatch, enqueueSnackbar} = this.props; 58 | let req = null; 59 | if (this.state.uid === null) { 60 | req = dispatch(userActions.modifyMyPassword(this.state.old, this.state.new)); 61 | } else { 62 | req = dispatch(userActions.modifyPassword(this.state.uid, this.state.new)); 63 | } 64 | req.then(response => { 65 | enqueueSnackbar("Saved", {variant: "success"}); 66 | this.state.callback(true); 67 | }) 68 | .catch(response => { 69 | const {status, data} = response.error.response; 70 | if (data.hasOwnProperty('errors')) { 71 | Object.values(data.errors).forEach(msg => { 72 | enqueueSnackbar(msg, {variant: "error"}); 73 | }); 74 | } else { 75 | enqueueSnackbar(`${status}: ${data.message}`, {variant: "error"}); 76 | } 77 | }); 78 | }; 79 | 80 | render() { 81 | return
e.preventDefault()}> 82 | {this.state.uid === null? 83 | 93 | :null 94 | } 95 | 105 | 106 | 109 | 112 | 113 | 114 | } 115 | } 116 | 117 | const mapStateToProps = (state) => { 118 | return { 119 | me: state.user.get('me') 120 | } 121 | }; 122 | export default connect(mapStateToProps)(withSnackbar(PasswordForm)); -------------------------------------------------------------------------------- /iamovpn/models/log.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import database as db 3 | import uuid 4 | from datetime import datetime 5 | 6 | from sqlalchemy import Column, DateTime, Boolean, Text, BigInteger, Integer, or_ 7 | from sqlalchemy.ext.hybrid import hybrid_property as property 8 | 9 | from .utils import get_orders 10 | 11 | 12 | class Log(db.Base): 13 | __tablename__ = 'log' 14 | 15 | # Fields 16 | uid = Column(Text, primary_key=True) 17 | created = Column(DateTime(timezone=False)) 18 | updated = Column(DateTime(timezone=False)) 19 | _log_type = Column('log_type', Text) 20 | connect_time = Column(DateTime(timezone=False)) 21 | remote_ip = Column(Text) 22 | remote_port = Column(Integer) 23 | local_ip = Column(Text) 24 | user_id = Column(Text, index=True) 25 | agent = Column(Text) 26 | authorized = Column(Boolean) 27 | connected = Column(Boolean) 28 | in_bytes = Column(BigInteger) 29 | out_bytes = Column(BigInteger) 30 | duration = Column(BigInteger) 31 | 32 | def __init__(self, log_type, connect_time, remote_ip, remote_port, local_ip, user_id, 33 | agent, authorized, connected, in_bytes, out_bytes, duration): 34 | self.uid = str(uuid.uuid4()) 35 | self.created = datetime.now() 36 | self.updated = self.created 37 | self.log_type = log_type 38 | self.connect_time = connect_time 39 | self.remote_ip = remote_ip 40 | self.remote_port = remote_port 41 | self.local_ip = local_ip 42 | self.agent = agent 43 | self.user_id = user_id 44 | self.authorized = authorized 45 | self.connected = connected 46 | self.in_bytes = in_bytes 47 | self.out_bytes = out_bytes 48 | self.duration = duration 49 | 50 | def __eq__(self, other): 51 | if other is None or not isinstance(other, self.__class__): 52 | return False 53 | for attr in ['uid', 'ip', 'port', 'user_id', 'connect_time']: 54 | if getattr(self, attr) != getattr(other, attr, None): 55 | return False 56 | return True 57 | 58 | def __repr__(self): 59 | return '' % (str(self.uid), str(self.user_id)) 60 | 61 | @property 62 | def log_type(self): 63 | return self._log_type 64 | 65 | @log_type.setter 66 | def log_type(self, v): 67 | if v not in ('login', 'connect', 'disconnect'): 68 | raise ValueError 69 | self._log_type = v 70 | 71 | @property 72 | def dict(self): 73 | return { 74 | 'uid': self.uid, 75 | 'log_type': self.log_type, 76 | 'connect_time': self.connect_time.isoformat() 77 | if isinstance(self.connect_time, datetime) else self.connect_time, 78 | 'created': self.created.isoformat(), 79 | 'updated': self.updated.isoformat(), 80 | 'remote_ip': self.remote_ip, 81 | 'remote_port': self.remote_port, 82 | 'local_ip': self.local_ip, 83 | 'agent': self.agent, 84 | 'user_id': self.user_id, 85 | 'authorized': self.authorized, 86 | 'connected': self.connected, 87 | 'in_bytes': self.in_bytes, 88 | 'out_bytes': self.out_bytes, 89 | 'duration': self.duration 90 | } 91 | 92 | @staticmethod 93 | def find(keyword=None, user_id=None, remote_ip=None, remote_port=None, local_ip=None, agent=None, 94 | order_by=('created_desc', ), offset=0, length=25): 95 | query = Log.query 96 | 97 | if keyword: 98 | keyword = '%{}%'.format(keyword) 99 | query = query \ 100 | .filter(or_( 101 | Log.log_type.ilike(keyword), 102 | Log.user_id.ilike(keyword), 103 | Log.remote_ip.ilike(keyword), 104 | Log.local_ip.ilike(keyword), 105 | Log.agent.ilike(keyword) 106 | )) 107 | if agent: 108 | query = query.filter(Log.agent.ilike('%{0}%'.format(agent))) 109 | if user_id: 110 | query = query.filter(Log.user_id == user_id) 111 | if remote_ip: 112 | query = query.filter(Log.remote_ip.like('%{0}%'.format(remote_ip))) 113 | if remote_port: 114 | query = query.filter(Log.port == remote_port) 115 | if local_ip: 116 | query = query.filter(Log.remote_ip.like('%{0}%'.format(local_ip))) 117 | 118 | return query.order_by(*get_orders(Log, order_by)).offset(offset).limit(length).all(), query.count() 119 | -------------------------------------------------------------------------------- /iamovpn/views/front/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /iamovpn/views/front/src/components/Dashboard.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { 5 | CssBaseline, 6 | Box, 7 | AppBar, 8 | Toolbar, 9 | Typography, 10 | Divider, 11 | IconButton, 12 | Container, 13 | Grid 14 | } from '@material-ui/core'; 15 | import { withStyles } from '@material-ui/core/styles'; 16 | import PowerSettingsNewIcon from '@material-ui/icons/PowerSettingsNew'; 17 | 18 | import UserList from "./UserList"; 19 | import LogList from "./LogList"; 20 | import Copyright from "./Copyright"; 21 | import * as userActions from "../modules/user"; 22 | import history from "../history"; 23 | 24 | 25 | const drawerWidth = 240; 26 | 27 | const styles = theme => ({ 28 | root: { 29 | display: 'flex', 30 | }, 31 | toolbar: { 32 | paddingRight: 24, // keep right padding when drawer closed 33 | }, 34 | toolbarIcon: { 35 | display: 'flex', 36 | alignItems: 'center', 37 | justifyContent: 'flex-end', 38 | padding: '0 8px', 39 | ...theme.mixins.toolbar, 40 | }, 41 | appBar: { 42 | zIndex: theme.zIndex.drawer + 1, 43 | transition: theme.transitions.create(['width', 'margin'], { 44 | easing: theme.transitions.easing.sharp, 45 | duration: theme.transitions.duration.leavingScreen, 46 | }), 47 | }, 48 | appBarShift: { 49 | marginLeft: drawerWidth, 50 | width: `calc(100% - ${drawerWidth}px)`, 51 | transition: theme.transitions.create(['width', 'margin'], { 52 | easing: theme.transitions.easing.sharp, 53 | duration: theme.transitions.duration.enteringScreen, 54 | }), 55 | }, 56 | menuButton: { 57 | marginRight: 36, 58 | }, 59 | menuButtonHidden: { 60 | display: 'none', 61 | }, 62 | title: { 63 | flexGrow: 1, 64 | }, 65 | drawerPaper: { 66 | position: 'relative', 67 | whiteSpace: 'nowrap', 68 | width: drawerWidth, 69 | transition: theme.transitions.create('width', { 70 | easing: theme.transitions.easing.sharp, 71 | duration: theme.transitions.duration.enteringScreen, 72 | }), 73 | }, 74 | drawerPaperClose: { 75 | overflowX: 'hidden', 76 | transition: theme.transitions.create('width', { 77 | easing: theme.transitions.easing.sharp, 78 | duration: theme.transitions.duration.leavingScreen, 79 | }), 80 | width: theme.spacing(7), 81 | [theme.breakpoints.up('sm')]: { 82 | width: theme.spacing(9), 83 | }, 84 | }, 85 | appBarSpacer: theme.mixins.toolbar, 86 | content: { 87 | flexGrow: 1, 88 | height: '100vh', 89 | overflow: 'auto', 90 | }, 91 | container: { 92 | paddingTop: theme.spacing(4), 93 | paddingBottom: theme.spacing(4), 94 | }, 95 | paper: { 96 | padding: theme.spacing(2), 97 | display: 'flex', 98 | overflow: 'auto', 99 | flexDirection: 'column', 100 | }, 101 | fixedHeight: { 102 | height: 240, 103 | } 104 | }); 105 | 106 | class Dashboard extends Component { 107 | logout() { 108 | const { dispatch } = this.props; 109 | 110 | dispatch(userActions.logout()) 111 | .then((response) => { 112 | history.push('/') 113 | }); 114 | } 115 | 116 | render() { 117 | const { classes, me } = this.props; 118 | if (!(me || {admin: false}).admin) return null; 119 | 120 | return ( 121 |
122 | 123 | 124 | 125 | 126 | IAMOVPN 127 | 128 | 129 | 131 | 132 | 133 | 134 | 135 |
136 |
137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 |
154 |
155 | ); 156 | } 157 | } 158 | 159 | 160 | const mapStateToProps = (state) => { 161 | return { 162 | me: state.user.get('me') 163 | } 164 | }; 165 | export default connect(mapStateToProps)(withStyles(styles)(Dashboard)); -------------------------------------------------------------------------------- /iamovpn/views/front/src/components/Login.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { 5 | Avatar, 6 | Button, 7 | CssBaseline, 8 | TextField, 9 | Box, 10 | Typography, 11 | Container 12 | } from '@material-ui/core'; 13 | import LockOutlinedIcon from '@material-ui/icons/LockOutlined'; 14 | import { withStyles } from '@material-ui/core/styles'; 15 | import { withSnackbar } from 'notistack'; 16 | 17 | import Copyright from "./Copyright"; 18 | 19 | import history from "../history"; 20 | import * as userActions from '../modules/user'; 21 | 22 | 23 | const styles = theme => ({ 24 | paper: { 25 | marginTop: theme.spacing(8), 26 | display: 'flex', 27 | flexDirection: 'column', 28 | alignItems: 'center', 29 | }, 30 | avatar: { 31 | margin: theme.spacing(1), 32 | backgroundColor: theme.palette.secondary.main, 33 | }, 34 | form: { 35 | width: '100%', // Fix IE 11 issue. 36 | marginTop: theme.spacing(1), 37 | }, 38 | submit: { 39 | margin: theme.spacing(3, 0, 2), 40 | }, 41 | }); 42 | 43 | 44 | class Login extends Component { 45 | constructor(props) { 46 | super(props); 47 | this.state = { 48 | id: { 49 | value: '', 50 | error: false 51 | }, 52 | password: { 53 | value: '', 54 | error: false 55 | } 56 | }; 57 | } 58 | 59 | handleChange(e) { 60 | let value = e.target.value; 61 | let error = false; 62 | if (value.length < 4) { 63 | error = true 64 | } 65 | 66 | this.setState({ 67 | [e.target.name]: { 68 | value: value, 69 | error: error 70 | } 71 | }); 72 | } 73 | 74 | handleSubmit(e) { 75 | const { dispatch, enqueueSnackbar } = this.props; 76 | let state = this.state; 77 | let error = false; 78 | 79 | ['id', 'password'].forEach(k => { 80 | if (state[k].value < 4) { 81 | error = state[k].error = true; 82 | } 83 | }); 84 | if (error) { 85 | this.setState(state); 86 | } else { 87 | dispatch( 88 | userActions.login( 89 | state.id.value, 90 | state.password.value 91 | ) 92 | ).then(response => { 93 | const user = response.payload.data.user; 94 | if (user.admin === true) { 95 | history.push('/dashboard'); 96 | } else { 97 | history.push('/my'); 98 | } 99 | }) 100 | .catch(response => { 101 | const {id, password} = this.state; 102 | const {status, data} = response.error.response; 103 | if (data.hasOwnProperty('errors')) { 104 | Object.values(data.errors).forEach(msg => { 105 | enqueueSnackbar(msg, {variant: "error"}); 106 | }); 107 | } else { 108 | enqueueSnackbar(`${status}: ${data.message}`, {variant: "error"}); 109 | } 110 | 111 | id.error = password.error = true; 112 | this.setState({ 113 | id: id, 114 | password: password 115 | }); 116 | }); 117 | } 118 | 119 | e.preventDefault(); 120 | } 121 | 122 | render() { 123 | const { classes } = this.props; 124 | 125 | return ( 126 | 127 | 128 |
129 | 130 | 131 | 132 | 133 | Sign in 134 | 135 |
140 | 154 | 168 | 177 | 178 |
179 | 180 | 181 | 182 |
183 | ); 184 | } 185 | } 186 | 187 | export default connect()(withSnackbar(withStyles(styles)(Login))); 188 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/node,python,jetbrains+all 3 | # Edit at https://www.gitignore.io/?templates=node,python,jetbrains+all 4 | 5 | ### JetBrains+all ### 6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 8 | 9 | # User-specific stuff 10 | .idea/**/workspace.xml 11 | .idea/**/tasks.xml 12 | .idea/**/usage.statistics.xml 13 | .idea/**/dictionaries 14 | .idea/**/shelf 15 | 16 | # Generated files 17 | .idea/**/contentModel.xml 18 | 19 | # Sensitive or high-churn files 20 | .idea/**/dataSources/ 21 | .idea/**/dataSources.ids 22 | .idea/**/dataSources.local.xml 23 | .idea/**/sqlDataSources.xml 24 | .idea/**/dynamic.xml 25 | .idea/**/uiDesigner.xml 26 | .idea/**/dbnavigator.xml 27 | 28 | # Gradle 29 | .idea/**/gradle.xml 30 | .idea/**/libraries 31 | 32 | # Gradle and Maven with auto-import 33 | # When using Gradle or Maven with auto-import, you should exclude module files, 34 | # since they will be recreated, and may cause churn. Uncomment if using 35 | # auto-import. 36 | # .idea/modules.xml 37 | # .idea/*.iml 38 | # .idea/modules 39 | # *.iml 40 | # *.ipr 41 | 42 | # CMake 43 | cmake-build-*/ 44 | 45 | # Mongo Explorer plugin 46 | .idea/**/mongoSettings.xml 47 | 48 | # File-based project format 49 | *.iws 50 | 51 | # IntelliJ 52 | out/ 53 | 54 | # mpeltonen/sbt-idea plugin 55 | .idea_modules/ 56 | 57 | # JIRA plugin 58 | atlassian-ide-plugin.xml 59 | 60 | # Cursive Clojure plugin 61 | .idea/replstate.xml 62 | 63 | # Crashlytics plugin (for Android Studio and IntelliJ) 64 | com_crashlytics_export_strings.xml 65 | crashlytics.properties 66 | crashlytics-build.properties 67 | fabric.properties 68 | 69 | # Editor-based Rest Client 70 | .idea/httpRequests 71 | 72 | # Android studio 3.1+ serialized cache file 73 | .idea/caches/build_file_checksums.ser 74 | 75 | ### JetBrains+all Patch ### 76 | # Ignores the whole .idea folder and all .iml files 77 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 78 | 79 | .idea/ 80 | 81 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 82 | 83 | *.iml 84 | modules.xml 85 | .idea/misc.xml 86 | *.ipr 87 | 88 | # Sonarlint plugin 89 | .idea/sonarlint 90 | 91 | ### Node ### 92 | # Logs 93 | logs 94 | *.log 95 | npm-debug.log* 96 | yarn-debug.log* 97 | yarn-error.log* 98 | lerna-debug.log* 99 | 100 | # Diagnostic reports (https://nodejs.org/api/report.html) 101 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 102 | 103 | # Runtime data 104 | pids 105 | *.pid 106 | *.seed 107 | *.pid.lock 108 | 109 | # Directory for instrumented libs generated by jscoverage/JSCover 110 | lib-cov 111 | 112 | # Coverage directory used by tools like istanbul 113 | coverage 114 | *.lcov 115 | 116 | # nyc test coverage 117 | .nyc_output 118 | 119 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 120 | .grunt 121 | 122 | # Bower dependency directory (https://bower.io/) 123 | bower_components 124 | 125 | # node-waf configuration 126 | .lock-wscript 127 | 128 | # Compiled binary addons (https://nodejs.org/api/addons.html) 129 | build/Release 130 | 131 | # Dependency directories 132 | node_modules/ 133 | jspm_packages/ 134 | 135 | # TypeScript v1 declaration files 136 | typings/ 137 | 138 | # TypeScript cache 139 | *.tsbuildinfo 140 | 141 | # Optional npm cache directory 142 | .npm 143 | 144 | # Optional eslint cache 145 | .eslintcache 146 | 147 | # Optional REPL history 148 | .node_repl_history 149 | 150 | # Output of 'npm pack' 151 | *.tgz 152 | 153 | # Yarn Integrity file 154 | .yarn-integrity 155 | 156 | # dotenv environment variables file 157 | .env 158 | .env.test 159 | 160 | # parcel-bundler cache (https://parceljs.org/) 161 | .cache 162 | 163 | # next.js build output 164 | .next 165 | 166 | # nuxt.js build output 167 | .nuxt 168 | 169 | # react / gatsby 170 | public/ 171 | 172 | # vuepress build output 173 | .vuepress/dist 174 | 175 | # Serverless directories 176 | .serverless/ 177 | 178 | # FuseBox cache 179 | .fusebox/ 180 | 181 | # DynamoDB Local files 182 | .dynamodb/ 183 | 184 | ### Python ### 185 | # Byte-compiled / optimized / DLL files 186 | __pycache__/ 187 | *.py[cod] 188 | *$py.class 189 | 190 | # C extensions 191 | *.so 192 | 193 | # Distribution / packaging 194 | .Python 195 | build/ 196 | develop-eggs/ 197 | dist/ 198 | downloads/ 199 | eggs/ 200 | .eggs/ 201 | lib/ 202 | lib64/ 203 | parts/ 204 | sdist/ 205 | var/ 206 | wheels/ 207 | pip-wheel-metadata/ 208 | share/python-wheels/ 209 | *.egg-info/ 210 | .installed.cfg 211 | *.egg 212 | MANIFEST 213 | 214 | # PyInstaller 215 | # Usually these files are written by a python script from a template 216 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 217 | *.manifest 218 | *.spec 219 | 220 | # Installer logs 221 | pip-log.txt 222 | pip-delete-this-directory.txt 223 | 224 | # Unit test / coverage reports 225 | htmlcov/ 226 | .tox/ 227 | .nox/ 228 | .coverage 229 | .coverage.* 230 | nosetests.xml 231 | coverage.xml 232 | *.cover 233 | .hypothesis/ 234 | .pytest_cache/ 235 | 236 | # Translations 237 | *.mo 238 | *.pot 239 | 240 | # Scrapy stuff: 241 | .scrapy 242 | 243 | # Sphinx documentation 244 | docs/_build/ 245 | 246 | # PyBuilder 247 | target/ 248 | 249 | # pyenv 250 | .python-version 251 | 252 | # pipenv 253 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 254 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 255 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 256 | # install all needed dependencies. 257 | #Pipfile.lock 258 | 259 | # celery beat schedule file 260 | celerybeat-schedule 261 | 262 | # SageMath parsed files 263 | *.sage.py 264 | 265 | # Spyder project settings 266 | .spyderproject 267 | .spyproject 268 | 269 | # Rope project settings 270 | .ropeproject 271 | 272 | # Mr Developer 273 | .mr.developer.cfg 274 | .project 275 | .pydevproject 276 | 277 | # mkdocs documentation 278 | /site 279 | 280 | # mypy 281 | .mypy_cache/ 282 | .dmypy.json 283 | dmypy.json 284 | 285 | # Pyre type checker 286 | .pyre/ 287 | 288 | 289 | ### VirtualEnv ### 290 | # Virtualenv 291 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 292 | .Python 293 | [Bb]in 294 | [Ii]nclude 295 | [Ll]ib 296 | [Ll]ib64 297 | [Ll]ocal 298 | [Ss]cripts 299 | pyvenv.cfg 300 | .env 301 | .venv 302 | env/ 303 | venv/ 304 | ENV/ 305 | env.bak/ 306 | venv.bak/ 307 | pip-selfcheck.json 308 | 309 | 310 | # End of https://www.gitignore.io/api/node,python,jetbrains+all 311 | 312 | 313 | ## zsh auto env 314 | .autoenv* 315 | 316 | ## config file 317 | config.json 318 | 319 | # OS X 320 | .DS_Store -------------------------------------------------------------------------------- /iamovpn/views/front/src/components/LogList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { 5 | Paper, 6 | Table, 7 | TableBody, 8 | TableCell, 9 | TableHead, 10 | TableRow, 11 | TableFooter, 12 | TablePagination, 13 | TextField, 14 | IconButton, Typography 15 | } from '@material-ui/core'; 16 | import SearchIcon from '@material-ui/icons/Search'; 17 | 18 | import * as logActions from "../modules/log"; 19 | 20 | 21 | class LogList extends Component { 22 | constructor(props) { 23 | super(props); 24 | const {size} = this.props; 25 | 26 | this.state = { 27 | tableSize: size, 28 | page: 0, 29 | keyword: '' 30 | }; 31 | } 32 | 33 | componentDidMount() { 34 | const { dispatch } = this.props; 35 | dispatch(logActions.getList( 36 | this.state.keyword, 37 | this.state.page * this.state.tableSize, 38 | this.state.tableSize, 39 | )) 40 | }; 41 | 42 | handleFindKeyword(e) { 43 | const { dispatch } = this.props; 44 | dispatch(logActions.getList( 45 | this.state.keyword, 46 | this.state.page * this.state.tableSize, 47 | this.state.tableSize, 48 | )); 49 | 50 | e.preventDefault(); 51 | }; 52 | 53 | handleChangePage(event, newPage) { 54 | const { dispatch } = this.props; 55 | 56 | this.setState({ 57 | page: newPage 58 | }); 59 | 60 | dispatch(logActions.getList( 61 | this.state.keyword, 62 | newPage * this.state.tableSize, 63 | this.state.tableSize, 64 | )) 65 | }; 66 | 67 | handleChangeRowsPerPage(event) { 68 | const { dispatch } = this.props; 69 | const newTableSize = parseInt(event.target.value, 10); 70 | 71 | this.setState({ 72 | tableSize: newTableSize, 73 | page: 0 74 | }); 75 | 76 | dispatch(logActions.getList( 77 | this.state.keyword, 78 | 0, 79 | newTableSize, 80 | )) 81 | }; 82 | 83 | handleChangeKeyword(e) { 84 | this.setState({ 85 | keyword: e.target.value 86 | }) 87 | }; 88 | 89 | renderLogs() { 90 | const {logs} = this.props; 91 | let result = []; 92 | 93 | if (logs.length === 0) { 94 | result = [ 95 | 96 | Empty 97 | 98 | ] 99 | } else { 100 | logs.forEach(log => { 101 | result.push( 102 | 103 | {log.log_type} 104 | {log.user_id} 105 | {log.authorized? "True" : "False"} 106 | {log.updated.format("YYYY-MM-DD HH:mm")} 107 | {log.remote_ip} 108 | {log.remote_port} 109 | {log.local_ip} 110 | {log.in_bytes? `${log.in_bytes} Bytes` :""} 111 | {log.out_bytes? `${log.out_bytes} Bytes` :""} 112 | {log.duration} 113 | 114 | ) 115 | }) 116 | } 117 | 118 | return result; 119 | }; 120 | 121 | render() { 122 | return 123 | 124 | 125 | 126 | 127 | 131 | Logs 132 | 133 | 134 | 135 |
136 | 141 | 142 | 143 | 144 | 145 |
146 |
147 | 148 | Type 149 | User ID 150 | Authorized 151 | Last Seen 152 | Remote IP 153 | Remote Port 154 | Local IP 155 | In Bytes 156 | Out Bytes 157 | Duration seconds 158 | 159 |
160 | 161 | {this.renderLogs()} 162 | 163 | 164 | 165 | 166 | 167 | 175 | 176 | 177 |
178 |
179 | } 180 | } 181 | 182 | 183 | const mapStateToProps = (state) => { 184 | return { 185 | logs: state.log.get('logs'), 186 | log_count: state.log.get('log_count') 187 | } 188 | }; 189 | export default connect(mapStateToProps)(LogList) -------------------------------------------------------------------------------- /iamovpn/views/front/src/components/UserForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { 5 | FormControlLabel, 6 | FormGroup, 7 | Checkbox, 8 | TextField, 9 | Button, 10 | ButtonGroup, 11 | Typography 12 | } from '@material-ui/core'; 13 | import { withSnackbar } from 'notistack'; 14 | 15 | import * as userActions from "../modules/user"; 16 | 17 | const defaultUser = { 18 | uid: null, 19 | id: '', 20 | name: '', 21 | password: '', 22 | admin: false, 23 | active: true 24 | }; 25 | 26 | 27 | class UserForm extends Component { 28 | constructor(props) { 29 | super(props); 30 | const callback = this.props.callback || (() => {}); 31 | const user = this.props.user || defaultUser; 32 | 33 | this.state = { 34 | callback: callback, 35 | user: this.marshalUser(user), 36 | error: { 37 | id: false, 38 | name: false, 39 | password: false, 40 | admin: false, 41 | active: false 42 | } 43 | }; 44 | }; 45 | 46 | marshalUser(user) { 47 | const marshaled = defaultUser; 48 | Object.keys(marshaled).forEach(key => { 49 | if (user[key] || (typeof user[key] === 'boolean')) { 50 | marshaled[key] = user[key]; 51 | } 52 | }); 53 | if (user.uid !== null) { 54 | delete marshaled['password']; 55 | } 56 | return marshaled; 57 | } 58 | 59 | componentDidMount() { 60 | const {dispatch, enqueueSnackbar, user} = this.props; 61 | 62 | if ((user || null) !== null && (user.uid || null) !== null) { 63 | dispatch(userActions.get(user.uid)) 64 | .then(response => { 65 | this.setState({ 66 | user: this.marshalUser(response.payload.data.user) 67 | }); 68 | }).catch(response => { 69 | const {status, data} = response.error.response; 70 | enqueueSnackbar(`${status}: ${data.message}`, {variant: "error"}); 71 | }) 72 | } else { 73 | this.setState({ 74 | user: defaultUser 75 | }); 76 | } 77 | }; 78 | 79 | componentDidUpdate(prevProps, prevState, snapshot) { 80 | const prevUser = prevProps.user || {uid: ''}; 81 | const currentUser = this.props.user || {uid: ''}; 82 | if (prevUser.uid !== currentUser.uid) { 83 | this.setState({ 84 | user: this.marshalUser(this.props.user || defaultUser) 85 | }); 86 | } 87 | }; 88 | 89 | handleUserChange(e) { 90 | const {user} = this.state; 91 | user[e.target.name] = (typeof user[e.target.name] === "boolean")? e.target.checked : e.target.value; 92 | 93 | this.setState({user: user}) 94 | }; 95 | 96 | saveUser() { 97 | const {dispatch, enqueueSnackbar} = this.props; 98 | const user = this.state.user; 99 | 100 | if ((user.uid || null) !== null) { 101 | dispatch(userActions.modify(user.uid, user)) 102 | .then(response => { 103 | enqueueSnackbar("Saved", {variant: "success"}); 104 | this.state.callback(true); 105 | }) 106 | .catch(response => { 107 | const {status, data} = response.error.response; 108 | if (data.hasOwnProperty('errors')) { 109 | Object.values(data.errors).forEach(msg => { 110 | enqueueSnackbar(msg, {variant: "error"}); 111 | }); 112 | } else { 113 | enqueueSnackbar(`${status}: ${data.message}`, {variant: "error"}); 114 | } 115 | }) 116 | } else { 117 | dispatch(userActions.create(user)) 118 | .then(response => { 119 | enqueueSnackbar("Saved", {variant: "success"}); 120 | this.state.callback(true); 121 | }) 122 | .catch(response => { 123 | const {status, data} = response.error.response; 124 | if (data.hasOwnProperty('errors')) { 125 | Object.values(data.errors).forEach(msg => { 126 | enqueueSnackbar(msg, {variant: "error"}); 127 | }); 128 | } else { 129 | enqueueSnackbar(`${status}: ${data.message}`, {variant: "error"}); 130 | } 131 | }) 132 | } 133 | }; 134 | 135 | renderForm() { 136 | return Object.keys(this.state.user).map(attr => { 137 | if (attr !== 'uid') { 138 | if (typeof defaultUser[attr] === 'boolean') { 139 | return 140 | } 146 | label={`is ${attr}`} 147 | /> 148 | 149 | } else { 150 | return 161 | } 162 | } 163 | return null; 164 | }); 165 | } 166 | 167 | render() { 168 | const {title} = this.props; 169 | return
170 | {title? {title}:null} 171 |
e.preventDefault()}> 172 | {this.renderForm()} 173 | 174 | 177 | 180 | 181 |
182 |
183 | } 184 | } 185 | 186 | export default connect()(withSnackbar(UserForm)); -------------------------------------------------------------------------------- /iamovpn/apis/user.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from copy import deepcopy 4 | from flask import g, request 5 | from flask_restplus import Resource, abort, fields 6 | import database as db 7 | from datetime import datetime 8 | from models import User 9 | from .secure import need_session 10 | from flask_restplus import Namespace 11 | import config 12 | 13 | api = Namespace('user') 14 | 15 | user_model = api.model( 16 | 'User', 17 | { 18 | 'id': fields.String(description="User id"), 19 | 'name': fields.String(description="User name"), 20 | 'admin': fields.Boolean(description="Is admin"), 21 | 'active': fields.Boolean(description="Is not blocked") 22 | } 23 | ) 24 | 25 | user_create_model = api.model( 26 | 'UserCreate', 27 | dict({ 28 | 'password': fields.String(description="User password") 29 | }, **deepcopy(user_model)) 30 | ) 31 | for field in user_create_model.values(): 32 | field.required = True 33 | 34 | 35 | @api.route('') 36 | @api.response(401, 'Unauthorized') 37 | @api.response(403, 'Forbidden') 38 | class UsersApi(Resource): 39 | user_find_parser = api.parser() 40 | user_find_parser.add_argument('keyword', type=str, help='Keyword: ID, name', location='args') 41 | user_find_parser.add_argument('offset', type=int, help='Page offset', location='args', default=0) 42 | user_find_parser.add_argument('length', type=int, help='Page length', location='args', default=20) 43 | 44 | @need_session(admin=True) 45 | @api.expect(user_find_parser) 46 | @api.doc(security='cookieAuth') 47 | def get(self): 48 | args = self.user_find_parser.parse_args() 49 | if args['length'] > 100: 50 | abort(400, '"length" must be <= 100') 51 | 52 | users, count = User.find( 53 | keyword=args.get('keyword'), 54 | offset=args['offset'], 55 | length=args['length'] 56 | ) 57 | 58 | return { 59 | 'state': 'success', 60 | 'users': [ 61 | user.dict 62 | for user in users 63 | ], 64 | 'count': count 65 | } 66 | 67 | @need_session(admin=True) 68 | @api.response(406, 'Invalid data') 69 | @api.response(409, 'ID exists') 70 | @api.expect(user_create_model) 71 | @api.doc(security='cookieAuth') 72 | def post(self): 73 | data = request.json 74 | user_create_model.validate(data) 75 | data = { 76 | k: data[k] 77 | for k in user_create_model.keys() 78 | } 79 | 80 | user = User.query.filter(User.id == data['id']).first() 81 | if user: 82 | abort(409, 'ID exists') 83 | 84 | data['id'] = data['id'].strip() 85 | if ' ' in data['id'] or '\t' in data['id']: 86 | abort(406, 'ID can not contain spaces') 87 | 88 | data['password'] = data['password'].strip() 89 | if len(data['password']) < 6: 90 | abort(406, 'Password too short. It must be longer than 5.') 91 | 92 | user = User(**data) 93 | db.session.add(user) 94 | db.session.commit() 95 | 96 | return { 97 | 'state': 'success', 98 | 'user': user.dict 99 | } 100 | 101 | 102 | @api.route('/') 103 | @api.response(401, 'Unauthorized') 104 | @api.response(403, 'Forbidden') 105 | @api.response(404, 'Not Found') 106 | class UserApi(Resource): 107 | @need_session(admin=False) 108 | @api.doc(security='cookieAuth') 109 | def get(self, uid): 110 | if uid.lower() == 'me': 111 | return { 112 | 'state': 'success', 113 | 'user': g.user.dict 114 | } 115 | 116 | if g.user.uid != uid and not g.user.admin: 117 | abort(403) 118 | 119 | query = User.query.filter(User.uid == uid) 120 | if not g.user.admin: 121 | query = query.filter(User.active.is_(True)) 122 | 123 | user = query.first() 124 | if user is None: 125 | abort(404, 'User not found') 126 | 127 | return { 128 | 'state': 'success', 129 | 'user': user.dict 130 | } 131 | 132 | @need_session(admin=False) 133 | @api.expect(user_model) 134 | @api.doc(security='cookieAuth') 135 | def put(self, uid): 136 | if uid.lower() == 'me': 137 | uid = g.user.uid 138 | 139 | if g.user.uid != uid and not g.user.admin: 140 | abort(403) 141 | 142 | query = User.query.filter(User.uid == uid) 143 | if not g.user.admin: 144 | query = query.filter(User.active.is_(True)) 145 | 146 | user = query.first() 147 | if user is None: 148 | abort(404, 'User not found') 149 | 150 | data = request.json 151 | user_model.validate(data) 152 | data = { 153 | k: data[k] 154 | for k in data if k not in ('id', 'uid') and k in user_model 155 | } 156 | 157 | if data.get('admin') is not None and not g.user.admin: 158 | abort(403) 159 | 160 | if 'name' in data: 161 | data['name'] = data['name'].strip() 162 | 163 | for k, v in data.items(): 164 | setattr(user, k, v) 165 | 166 | user.updated = datetime.now() 167 | db.session.commit() 168 | return { 169 | 'state': 'success', 170 | 'user': user.dict 171 | } 172 | 173 | 174 | @api.route('//password') 175 | @api.response(401, 'Unauthorized') 176 | @api.response(406, 'New password is not valid') 177 | class UserPasswordChangeAPI(Resource): 178 | password_model = api.model( 179 | 'Password', 180 | { 181 | 'password': fields.String(description="New password", required=True) 182 | } 183 | ) 184 | 185 | @need_session(admin=True) 186 | @api.expect(password_model) 187 | @api.doc(security='cookieAuth') 188 | def put(self, uid): 189 | data = request.json 190 | self.password_model.validate(data) 191 | 192 | if len(data['password']) < 6: 193 | abort(406, 'Password too short. It must be longer than 5.') 194 | 195 | user = User.query.filter(User.uid == uid).first() 196 | if user is None: 197 | abort(404, 'User not found') 198 | 199 | user.password = data['password'] 200 | user.updated = datetime.now() 201 | db.session.commit() 202 | 203 | return {'state': 'success'} 204 | 205 | 206 | @api.route('/config') 207 | @api.response(401, 'Unauthorized') 208 | class UserConfigAPI(Resource): 209 | 210 | @need_session(admin=False) 211 | @api.doc(security='cookieAuth') 212 | def get(self): 213 | ovpn_path = os.path.join(config['openvpn']['path'], 'client.ovpn') 214 | with open(ovpn_path, 'r') as f: 215 | return { 216 | 'state': 'success', 217 | 'config': f.read() 218 | } 219 | 220 | -------------------------------------------------------------------------------- /iamovpn/install.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import shutil 4 | import wget 5 | import subprocess 6 | import netifaces 7 | from IPy import IP 8 | import database as db 9 | from app import create_app 10 | from models import User 11 | from flask import render_template 12 | from copy import deepcopy 13 | 14 | 15 | def init_db(): 16 | print('## Init database ##') 17 | db.connect() 18 | db.create_all() 19 | print('Done') 20 | 21 | 22 | def install_ovpn_scripts(config, context): 23 | print('## Install openvpn callback script ##') 24 | context['api_host'] = '127.0.0.1' if '0.0.0.0' in config['host'] else config['host'] 25 | context['api_port'] = config['port'] 26 | context['api_key'] = config['api_keys'][0] 27 | 28 | os.makedirs(os.path.join(config['openvpn']['path'], 'scripts'), mode=0o755, exist_ok=True) 29 | for script_name in ['login.py', 'connect.py', 'disconnect.py', 'util.py']: 30 | script = render_template(os.path.join('ovpn_scripts', script_name), **context) 31 | script_path = os.path.join(config['openvpn']['path'], 'scripts', script_name) 32 | with open(script_path, 'w') as f: 33 | f.write(script) 34 | os.chmod(script_path, 0o755) 35 | print('Done\n') 36 | 37 | 38 | def install_ovpn_config(config, context): 39 | print('## Install openvpn configuration files ##') 40 | ovpn_path = config['openvpn']['path'] 41 | context = deepcopy(context) 42 | context.update(config['openvpn']) 43 | 44 | with open(os.path.join(ovpn_path, 'ca.crt')) as f: 45 | context['ca'] = f.read() 46 | with open(os.path.join(ovpn_path, 'ta.key')) as f: 47 | context['ta'] = f.read() 48 | 49 | server_conf = render_template('configurations/server.conf', **context) 50 | server_conf_path = os.path.join(ovpn_path, 'server.conf') 51 | with open(server_conf_path, 'w') as f: 52 | f.write(server_conf) 53 | os.chmod(server_conf_path, 0o600) 54 | print('Server configuration has saved as', server_conf_path) 55 | 56 | client_conf = render_template('configurations/client.ovpn', **context) 57 | client_conf_path = os.path.join(ovpn_path, 'client.ovpn') 58 | with open(client_conf_path, 'w') as f: 59 | f.write(client_conf) 60 | os.chmod(client_conf_path, 0o644) 61 | print('Client configuration has saved as', client_conf_path) 62 | print('Done\n') 63 | 64 | 65 | def install_rsa_keys(config): 66 | print('## Generate and install rsa keys ##') 67 | ovpn_path = config['openvpn']['path'] 68 | ez_rsa_tgz = os.path.join(ovpn_path, 'easy-rsa.tgz') 69 | ez_rsa_path = os.path.join(ovpn_path, 'easy-rsa') 70 | 71 | if os.path.exists(ez_rsa_path): 72 | print('easy-rsa found. skip download.') 73 | else: 74 | print('Download easy-rsa') 75 | wget.download( 76 | url='https://github.com/OpenVPN/easy-rsa/releases/download/v3.1.1/EasyRSA-3.1.1.tgz', 77 | out=os.path.join(ovpn_path, 'easy-rsa.tgz') 78 | ) 79 | shutil.unpack_archive(ez_rsa_tgz, ovpn_path) 80 | shutil.move( 81 | os.path.join(ovpn_path, 'EasyRSA-3.1.1'), 82 | ez_rsa_path 83 | ) 84 | 85 | print('\nGenerate rsa keys') 86 | subprocess.call(['./easyrsa', 'init-pki'], cwd=ez_rsa_path) 87 | subprocess.call(['./easyrsa', 'build-ca', 'nopass'], cwd=ez_rsa_path) 88 | subprocess.call(['./easyrsa', 'gen-dh'], cwd=ez_rsa_path) 89 | subprocess.call(['./easyrsa', 'build-server-full', 'server', 'nopass'], cwd=ez_rsa_path) 90 | subprocess.call(['openvpn', '--genkey', '--secret', 'pki/ta.key'], cwd=ez_rsa_path) 91 | 92 | print('Install rsa keys') 93 | for fname in ['ca.crt', 'ta.key', 'issued/server.crt', 'private/server.key', 'dh.pem']: 94 | shutil.copy( 95 | os.path.join(ez_rsa_path, 'pki', fname), 96 | ovpn_path 97 | ) 98 | print('Done\n') 99 | 100 | 101 | def update_system_config(config): 102 | print('## Update system config') 103 | gws = netifaces.gateways() 104 | gw_dev = gws['default'][netifaces.AF_INET][1] 105 | vpn_net = str(IP('/'.join([config['openvpn']['network'], config['openvpn']['netmask']]))) 106 | 107 | print('Set iptables') 108 | subprocess.call([ 109 | 'iptables', 110 | '-I', 'FORWARD', 111 | '-i', 'tun0', 112 | '-j', 'ACCEPT']) 113 | subprocess.call([ 114 | 'iptables', 115 | '-I', 'FORWARD', 116 | '-o', 'tun0', 117 | '-j', 'ACCEPT']) 118 | subprocess.call([ 119 | 'iptables', 120 | '-I', 'OUTPUT', 121 | '-o', 'tun0', 122 | '-j', 'ACCEPT']) 123 | subprocess.call([ 124 | 'iptables', 125 | '-A', 'FORWARD', 126 | '-i', 'tun0', 127 | '-o', gw_dev, 128 | '-j', 'ACCEPT']) 129 | subprocess.call([ 130 | 'iptables', 131 | '-t', 'nat', 132 | '-A', 'POSTROUTING', 133 | '-o', gw_dev, 134 | '-j', 'MASQUERADE']) 135 | subprocess.call([ 136 | 'iptables', 137 | '-t', 'nat', 138 | '-A', 'POSTROUTING', 139 | '-s', vpn_net, 140 | '-o', gw_dev, 141 | '-j', 'MASQUERADE']) 142 | subprocess.call([ 143 | 'iptables', 144 | '-t', 'nat', 145 | '-A', 'POSTROUTING', 146 | '-s', vpn_net, 147 | '-o', gw_dev, 148 | '-j', 'MASQUERADE']) 149 | 150 | print('Set ipv4forward') 151 | with open('/proc/sys/net/ipv4/ip_forward', 'w') as f: 152 | f.write('1') 153 | 154 | print('Set ipv4forward persistent') 155 | with open('/etc/sysctl.conf', 'a') as f: 156 | f.write('\nnet.ipv4.ip_forward = 1\n') 157 | 158 | print('Done\n') 159 | 160 | 161 | def build_front(context): 162 | print('## Build web console') 163 | front_path = os.path.join(context['project_path'], 'iamovpn/views/front') 164 | subprocess.call(['npm', 'install'], cwd=front_path) 165 | subprocess.call(['npm', 'run', 'build'], cwd=front_path) 166 | print('Done\n') 167 | 168 | 169 | def run(config): 170 | app = create_app(config) 171 | 172 | init_db() 173 | 174 | # Insert default admin 175 | if User.query.filter(User.id == 'admin').first() is None: 176 | db.session.add( 177 | User( 178 | 'admin', 179 | 'need2change', 180 | 'Administrator', 181 | admin=True 182 | ) 183 | ) 184 | db.session.commit() 185 | 186 | install_rsa_keys(config) 187 | update_system_config(config) 188 | 189 | with app.app_context(): 190 | context = { 191 | 'venv': os.environ.get('VIRTUAL_ENV'), 192 | 'config_path': config.path, 193 | 'project_path': os.path.abspath( 194 | os.path.join( 195 | os.path.dirname(os.path.abspath(__file__)), 196 | '../' 197 | ) 198 | ) 199 | } 200 | install_ovpn_scripts(config, context) 201 | install_ovpn_config(config, context) 202 | build_front(context) 203 | 204 | print('Installation has been done.') 205 | print('Please enter below command when you start service') 206 | print('# systemctl restart openvpn@server') 207 | print('$ python3 iamovpn run standalone --config {}'.format(config.path)) 208 | -------------------------------------------------------------------------------- /iamovpn/views/front/src/modules/user.js: -------------------------------------------------------------------------------- 1 | import { createAction, handleActions } from "redux-actions"; 2 | import { Map } from "immutable"; 3 | import moment from "moment"; 4 | 5 | const CHECK_SESSION = "iamovpn/user/CHECK_SESSION"; 6 | const CHECK_SESSION_SUCCESS = "iamovpn/user/CHECK_SESSION_SUCCESS"; 7 | const CHECK_SESSION_FAIL = "iamovpn/user/CHECK_SESSION_FAIL"; 8 | const LOGIN = "iamovpn/user/LOGIN"; 9 | const LOGIN_SUCCESS = "iamovpn/user/LOGIN_SUCCESS"; 10 | const LOGOUT = "iamovpn/user/LOGOUT"; 11 | const GET = "iamovpn/user/GET"; 12 | const GET_SUCCESS = "iamovpn/user/GET_SUCCESS"; 13 | const GET_FAIL = "iamovpn/user/GET_FAIL"; 14 | const CREATE = "iamovpn/user/CREATE"; 15 | const CREATE_SUCCESS = "iamovpn/user/CREATE_SUCCESS"; 16 | const CREATE_FAIL = "iamovpn/user/CREATE_FAIL"; 17 | const MODIFY = "iamovpn/user/MODIFY"; 18 | const MODIFY_SUCCESS = "iamovpn/user/MODIFY_SUCCESS"; 19 | const MODIFY_FAIL = "iamovpn/user/MODIFY_FAIL"; 20 | const MODIFY_PASSWORD = "iamovpn/user/MODIFY_PASSWORD"; 21 | const MODIFY_PASSWORD_SUCCESS = "iamovpn/user/MODIFY_PASSWORD_SUCCESS"; 22 | const MODIFY_PASSWORD_FAIL = "iamovpn/user/MODIFY_PASSWORD_FAIL"; 23 | const MODIFY_MY_PASSWORD = "iamovpn/user/MODIFY_MY_PASSWORD"; 24 | const MODIFY_MY_PASSWORD_SUCCESS = "iamovpn/user/MODIFY_MY_PASSWORD_SUCCESS"; 25 | const MODIFY_MY_PASSWORD_FAIL = "iamovpn/user/MODIFY_MY_PASSWORD_FAIL"; 26 | const GET_LIST = "iamovpn/user/GET_LIST"; 27 | const GET_LIST_SUCCESS = "iamovpn/user/GET_LIST_SUCCESS"; 28 | const GET_LIST_FAIL = "iamovpn/user/GET_LIST_FAIL"; 29 | const GET_CONFIG = "iamovpn/user/GET_CONFIG"; 30 | const GET_CONFIG_SUCCESS = "iamovpn/user/GET_CONFIG_SUCCESS"; 31 | const GET_CONFIG_FAIL = "iamovpn/user/GET_CONFIG_FAIL"; 32 | 33 | 34 | export const checkSession = createAction( 35 | CHECK_SESSION, 36 | () => { 37 | return { 38 | request: { 39 | method: "GET", 40 | url: "/api/user/me" 41 | } 42 | } 43 | } 44 | ); 45 | export const login = createAction( 46 | LOGIN, 47 | (id, pwd) => { 48 | return { 49 | request: { 50 | method: "POST", 51 | url: "/api/secure/login", 52 | data: { 53 | id: id, 54 | password: pwd 55 | } 56 | } 57 | } 58 | } 59 | ); 60 | export const logout = createAction( 61 | LOGOUT, 62 | () => { 63 | return { 64 | request: { 65 | method: "DELETE", 66 | url: "/api/secure/login" 67 | } 68 | } 69 | } 70 | ); 71 | export const get = createAction( 72 | GET, 73 | (uid) => { 74 | return { 75 | request: { 76 | method: "GET", 77 | url: `/api/user/${uid}` 78 | } 79 | } 80 | } 81 | ); 82 | export const create = createAction( 83 | CREATE, 84 | (user) => { 85 | console.log(user); 86 | return { 87 | request: { 88 | method: "POST", 89 | url: "/api/user", 90 | data: user 91 | } 92 | } 93 | } 94 | ); 95 | export const modify = createAction( 96 | MODIFY, 97 | (user_uid, user) => { 98 | return { 99 | request: { 100 | method: "PUT", 101 | url: `/api/user/${user_uid}`, 102 | data: user 103 | } 104 | } 105 | } 106 | ); 107 | export const modifyPassword = createAction( 108 | MODIFY_PASSWORD, 109 | (user_uid, password) => { 110 | return { 111 | request: { 112 | method: "PUT", 113 | url: `/api/user/${user_uid}/password`, 114 | data: { 115 | password: password 116 | } 117 | } 118 | } 119 | } 120 | ); 121 | export const modifyMyPassword = createAction( 122 | MODIFY_MY_PASSWORD, 123 | (old_pwd, new_pwd) => { 124 | return { 125 | request: { 126 | method: "PUT", 127 | url: `/api/secure/password`, 128 | data: { 129 | old: old_pwd, 130 | new: new_pwd 131 | } 132 | } 133 | } 134 | } 135 | ); 136 | export const getList = createAction( 137 | GET_LIST, 138 | (keyword, offset, length) => { 139 | return { 140 | request: { 141 | method: "GET", 142 | url: "/api/user", 143 | params: { 144 | keyword: keyword, 145 | offset: offset, 146 | length: length 147 | } 148 | } 149 | } 150 | } 151 | ); 152 | export const getConfig = createAction( 153 | GET_CONFIG, 154 | () => { 155 | return { 156 | request: { 157 | method: "GET", 158 | url: "/api/user/config" 159 | } 160 | } 161 | } 162 | ); 163 | 164 | const initialState = Map({ 165 | me: null, 166 | user: null, 167 | users: [], 168 | user_count: 0, 169 | config: null 170 | }); 171 | const noUpdate = (state, action) => { 172 | return state 173 | }; 174 | const user = handleActions({ 175 | [CHECK_SESSION]: noUpdate, 176 | [CHECK_SESSION_SUCCESS]: (state, action) => { 177 | return state.set("me", action.payload.data.user); 178 | }, 179 | [CHECK_SESSION_FAIL]: (state, action) => { 180 | return state.set("me", null); 181 | }, 182 | [LOGIN]: noUpdate, 183 | [LOGIN_SUCCESS]: (state, action) => { 184 | return state.set("me", action.payload.data.user); 185 | }, 186 | [LOGOUT]: (state, action) => { 187 | return state.set("me", null); 188 | }, 189 | [GET]: noUpdate, 190 | [GET_SUCCESS]: (state, action) => { 191 | return state.set("user", action.payload.data.user); 192 | }, 193 | [GET_FAIL]: (state, action) => { 194 | return state.set("user", null); 195 | }, 196 | [CREATE]: noUpdate, 197 | [CREATE_SUCCESS]: noUpdate, 198 | [CREATE_FAIL]: noUpdate, 199 | [MODIFY]: noUpdate, 200 | [MODIFY_SUCCESS]: (state, action) => { 201 | const user = action.payload.data.user; 202 | user.created = new moment(user.created); 203 | user.updated = new moment(user.updated); 204 | return state.set("user", user); 205 | }, 206 | [MODIFY_FAIL]: noUpdate, 207 | [MODIFY_PASSWORD]: noUpdate, 208 | [MODIFY_PASSWORD_SUCCESS]: noUpdate, 209 | [MODIFY_PASSWORD_FAIL]: noUpdate, 210 | [MODIFY_MY_PASSWORD]: noUpdate, 211 | [MODIFY_MY_PASSWORD_SUCCESS]: noUpdate, 212 | [MODIFY_MY_PASSWORD_FAIL]: noUpdate, 213 | [GET_LIST]: noUpdate, 214 | [GET_LIST_SUCCESS]: (state, action) => { 215 | const users = action.payload.data.users; 216 | users.forEach(user => { 217 | user.created = new moment(user.created); 218 | user.updated = new moment(user.updated); 219 | }); 220 | return state.set("users", users) 221 | .set("user_count", action.payload.data.count); 222 | }, 223 | [GET_LIST_FAIL]: (state, action) => { 224 | return state.set("users", []).set("user_count", 0) 225 | }, 226 | [GET_CONFIG]: noUpdate, 227 | [GET_CONFIG_SUCCESS]: (state, action) => { 228 | return state.set("config", action.payload.data.config); 229 | }, 230 | [GET_CONFIG_FAIL]: noUpdate, 231 | }, initialState); 232 | export default user; -------------------------------------------------------------------------------- /iamovpn/views/front/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iamovpn/views/front/src/components/MyPage.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { 5 | CssBaseline, 6 | Box, 7 | AppBar, 8 | Toolbar, 9 | Typography, 10 | Divider, 11 | IconButton, 12 | Container, 13 | Grid, 14 | ButtonGroup, 15 | Button, 16 | Modal, 17 | Paper, 18 | TextareaAutosize 19 | } from '@material-ui/core'; 20 | import { withStyles } from '@material-ui/core/styles'; 21 | import PowerSettingsNewIcon from '@material-ui/icons/PowerSettingsNew'; 22 | 23 | 24 | 25 | import PasswordForm from "./PasswordForm"; 26 | import Copyright from "./Copyright"; 27 | import * as userActions from "../modules/user"; 28 | import history from "../history"; 29 | 30 | 31 | const drawerWidth = 240; 32 | 33 | const styles = theme => ({ 34 | root: { 35 | display: 'flex', 36 | }, 37 | toolbar: { 38 | paddingRight: 24, // keep right padding when drawer closed 39 | }, 40 | toolbarIcon: { 41 | display: 'flex', 42 | alignItems: 'center', 43 | justifyContent: 'flex-end', 44 | padding: '0 8px', 45 | ...theme.mixins.toolbar, 46 | }, 47 | appBar: { 48 | zIndex: theme.zIndex.drawer + 1, 49 | transition: theme.transitions.create(['width', 'margin'], { 50 | easing: theme.transitions.easing.sharp, 51 | duration: theme.transitions.duration.leavingScreen, 52 | }), 53 | }, 54 | appBarShift: { 55 | marginLeft: drawerWidth, 56 | width: `calc(100% - ${drawerWidth}px)`, 57 | transition: theme.transitions.create(['width', 'margin'], { 58 | easing: theme.transitions.easing.sharp, 59 | duration: theme.transitions.duration.enteringScreen, 60 | }), 61 | }, 62 | menuButton: { 63 | marginRight: 36, 64 | }, 65 | menuButtonHidden: { 66 | display: 'none', 67 | }, 68 | title: { 69 | flexGrow: 1, 70 | }, 71 | drawerPaper: { 72 | position: 'relative', 73 | whiteSpace: 'nowrap', 74 | width: drawerWidth, 75 | transition: theme.transitions.create('width', { 76 | easing: theme.transitions.easing.sharp, 77 | duration: theme.transitions.duration.enteringScreen, 78 | }), 79 | }, 80 | drawerPaperClose: { 81 | overflowX: 'hidden', 82 | transition: theme.transitions.create('width', { 83 | easing: theme.transitions.easing.sharp, 84 | duration: theme.transitions.duration.leavingScreen, 85 | }), 86 | width: theme.spacing(7), 87 | [theme.breakpoints.up('sm')]: { 88 | width: theme.spacing(9), 89 | }, 90 | }, 91 | appBarSpacer: theme.mixins.toolbar, 92 | content: { 93 | flexGrow: 1, 94 | height: '100vh', 95 | overflow: 'auto', 96 | }, 97 | container: { 98 | paddingTop: theme.spacing(4), 99 | paddingBottom: theme.spacing(4), 100 | }, 101 | paper: { 102 | padding: theme.spacing(2), 103 | display: 'flex', 104 | overflow: 'auto', 105 | flexDirection: 'column', 106 | }, 107 | fixedHeight: { 108 | height: 240, 109 | }, 110 | modal: { 111 | display: 'flex', 112 | alignItems: 'center', 113 | justifyContent: 'center', 114 | }, 115 | configArea: { 116 | width: '100%' 117 | } 118 | }); 119 | 120 | class MyPage extends Component { 121 | constructor(props) { 122 | super(props); 123 | this.state = { 124 | changePassword: false 125 | }; 126 | } 127 | 128 | logout() { 129 | const { dispatch } = this.props; 130 | 131 | dispatch(userActions.logout()) 132 | .then((response) => { 133 | history.push('/') 134 | }); 135 | } 136 | 137 | getConfig() { 138 | const { dispatch } = this.props; 139 | dispatch(userActions.getConfig()); 140 | } 141 | 142 | handlePasswordForm(success) { 143 | if (success) { 144 | this.setState({ 145 | changePassword: false 146 | }); 147 | } 148 | } 149 | 150 | render() { 151 | const { classes, config } = this.props; 152 | 153 | return ( 154 |
155 | 156 | 157 | 158 | 159 | IAMOVPN 160 | 161 | 162 | 164 | 165 | 166 | 167 | 168 |
169 |
170 | 171 | 172 | {/* Recent Orders */} 173 | 174 | this.setState({changePassword: false})} 178 | > 179 | 180 | 181 | 182 | 183 | 184 | 187 | 188 | 189 | 190 | 191 | 194 | 195 | 196 | {config? 197 | 198 | 199 | Copy and save below text as "iamovpn.ovpn". 200 | 201 | 206 | 207 | :null} 208 | 209 | 210 | 211 | 212 | 213 | 214 |
215 |
216 | ); 217 | } 218 | } 219 | 220 | 221 | const mapStateToProps = (state) => { 222 | return { 223 | me: state.user.get('me'), 224 | config: state.user.get('config') 225 | } 226 | }; 227 | export default connect(mapStateToProps)(withStyles(styles)(MyPage)); -------------------------------------------------------------------------------- /iamovpn/views/front/src/components/UserList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { 5 | Paper, 6 | Table, 7 | TableBody, 8 | TableCell, 9 | TableHead, 10 | TableRow, 11 | TableFooter, 12 | TablePagination, 13 | TextField, 14 | IconButton, 15 | Typography, 16 | Tooltip, 17 | Modal 18 | } from '@material-ui/core'; 19 | import SearchIcon from '@material-ui/icons/Search'; 20 | import AddBox from '@material-ui/icons/AddBox'; 21 | import Edit from '@material-ui/icons/Edit'; 22 | import LockOpen from '@material-ui/icons/LockOpen'; 23 | import Lock from '@material-ui/icons/Lock'; 24 | import VpnKey from '@material-ui/icons/VpnKey'; 25 | import { withStyles } from '@material-ui/core/styles'; 26 | 27 | import UserForm from "./UserForm"; 28 | import PasswordForm from "./PasswordForm"; 29 | import * as userActions from "../modules/user"; 30 | 31 | 32 | const styles = theme => ({ 33 | modal: { 34 | display: 'flex', 35 | alignItems: 'center', 36 | justifyContent: 'center', 37 | }, 38 | paper: { 39 | backgroundColor: theme.palette.background.paper, 40 | border: '2px solid #000', 41 | boxShadow: theme.shadows[5], 42 | padding: theme.spacing(2, 4, 3), 43 | } 44 | }); 45 | 46 | 47 | class UserList extends Component { 48 | constructor(props) { 49 | super(props); 50 | const {size} = this.props; 51 | 52 | this.state = { 53 | tableSize: size, 54 | page: 0, 55 | keyword: '', 56 | selectedUser: null, 57 | createUser: false, 58 | passwordChange: null 59 | }; 60 | } 61 | 62 | componentDidMount() { 63 | const { dispatch } = this.props; 64 | dispatch(userActions.getList( 65 | this.state.keyword, 66 | this.state.page * this.state.tableSize, 67 | this.state.tableSize, 68 | )) 69 | }; 70 | 71 | handleFindKeyword(e) { 72 | const { dispatch } = this.props; 73 | dispatch(userActions.getList( 74 | this.state.keyword, 75 | this.state.page * this.state.tableSize, 76 | this.state.tableSize, 77 | )); 78 | 79 | e.preventDefault(); 80 | }; 81 | 82 | handleChangePage(event, newPage) { 83 | const { dispatch } = this.props; 84 | 85 | this.setState({ 86 | page: newPage 87 | }); 88 | 89 | dispatch(userActions.getList( 90 | this.state.keyword, 91 | newPage * this.state.tableSize, 92 | this.state.tableSize, 93 | )) 94 | }; 95 | 96 | handleChangeRowsPerPage(event) { 97 | const { dispatch } = this.props; 98 | const newTableSize = parseInt(event.target.value, 10); 99 | 100 | this.setState({ 101 | tableSize: newTableSize, 102 | page: 0 103 | }); 104 | 105 | dispatch(userActions.getList( 106 | this.state.keyword, 107 | 0, 108 | newTableSize, 109 | )) 110 | }; 111 | 112 | handleChangeKeyword(e) { 113 | this.setState({ 114 | keyword: e.target.value 115 | }) 116 | }; 117 | 118 | handleUserForm(success) { 119 | const { dispatch } = this.props; 120 | const newState = { 121 | createUser: false, 122 | selectedUser: null, 123 | passwordChange: null 124 | }; 125 | 126 | if (success) { 127 | newState.page = 0; 128 | dispatch(userActions.getList( 129 | this.state.keyword, 130 | 0, 131 | this.state.tableSize 132 | )) 133 | } 134 | 135 | this.setState(newState); 136 | }; 137 | 138 | renderUsers() { 139 | const {users} = this.props; 140 | let result = []; 141 | 142 | if (users.length === 0) { 143 | result = [ 144 | 145 | Empty 146 | 147 | ] 148 | } else { 149 | users.forEach(user => { 150 | result.push( 151 | 152 | {user.id} 153 | {user.name} 154 | {user.admin? "True" : "False"} 155 | 156 | {user.active? 157 | 158 | 159 | 160 | 161 | 162 | : 163 | 164 | 165 | 166 | 167 | 168 | } 169 | 170 | {user.created.format("YYYY-MM-DD HH:mm")} 171 | {user.updated.format("YYYY-MM-DD HH:mm")} 172 | 173 | 174 | this.setState({selectedUser: user})} 176 | > 177 | 178 | 179 | 180 | 181 | 182 | this.setState({passwordChange: user})} 184 | > 185 | 186 | 187 | 188 | 189 | 190 | ) 191 | }) 192 | } 193 | 194 | return result; 195 | }; 196 | 197 | render() { 198 | const {classes} = this.props; 199 | return 200 | this.setState({createUser: false})} 204 | > 205 | 207 | 211 | 212 | 213 | this.setState({selectedUser: null})} 217 | > 218 | 220 | 225 | 226 | 227 | this.setState({passwordChange: null})} 231 | > 232 | 234 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 249 | Users 250 | 251 | this.setState({createUser: true})} 253 | color="primary" 254 | > 255 | 256 | 257 | 258 | 259 | 260 | 261 |
262 | 267 | 268 | 269 | 270 | 271 |
272 |
273 | 274 | ID 275 | Name 276 | Admin 277 | Block 278 | Created 279 | Updated 280 | Action 281 | 282 |
283 | 284 | {this.renderUsers()} 285 | 286 | 287 | 288 | 289 | 290 | 298 | 299 | 300 |
301 |
302 | } 303 | } 304 | 305 | 306 | const mapStateToProps = (state) => { 307 | return { 308 | users: state.user.get('users'), 309 | user_count: state.user.get('user_count') 310 | } 311 | }; 312 | export default connect(mapStateToProps)(withStyles(styles)(UserList)); --------------------------------------------------------------------------------