Compare commits

...

72 Commits

Author SHA1 Message Date
KF7EEL
4685686053 config and peer managment, masters WIP 2021-06-09 13:03:00 -07:00
KF7EEL
b3f12587c1 add link for non-registered users 2021-06-04 21:53:43 -07:00
KF7EEL
1d93ef13ea add commented line in hblink, allow all RIDs 2021-06-04 18:33:29 -07:00
KF7EEL
6d093b4b08 remove limit 2021-06-04 18:16:31 -07:00
KF7EEL
7429db3a03 add sort by ip 2021-06-04 18:14:39 -07:00
KF7EEL
ea39d5fa6a add MMDVM server to auth log 2021-06-04 17:46:53 -07:00
KF7EEL
5f57ceae66 add link to edit user in auth log 2021-06-04 17:30:05 -07:00
KF7EEL
434c9a8e80 chang log order 2021-06-04 16:22:04 -07:00
KF7EEL
38a1ee2c0f cosmetic changes 2021-06-04 14:34:39 -07:00
KF7EEL
6362b8cd5a add sorting by dmr id to log 2021-06-04 13:57:56 -07:00
KF7EEL
275af6168d clean some pages 2021-06-04 13:41:40 -07:00
KF7EEL
ccc5801b59 change DB delete method for user 2021-06-04 13:20:09 -07:00
KF7EEL
d7266ac387 test 3 of flushing of user log 2021-06-04 13:17:10 -07:00
KF7EEL
2503e448c6 test 2 of flushing of user log 2021-06-04 13:10:14 -07:00
KF7EEL
6258ccc074 test flushing of user log 2021-06-04 13:08:24 -07:00
KF7EEL
3ce2e2c683 fix username, add notes to list, general improvements 2021-06-04 12:54:53 -07:00
KF7EEL
017f55eef3 add not registered to log for sorting 2021-06-04 10:48:00 -07:00
KF7EEL
ccaee1015b tweak login.html 2021-06-04 10:26:05 -07:00
KF7EEL
e71c2046d1 modify login.html 2021-06-04 10:25:10 -07:00
KF7EEL
7bea2fbe18 clean up 2021-06-04 10:08:17 -07:00
KF7EEL
3be127d0f4 clead edit user page, improve auth log 2021-06-04 10:06:13 -07:00
KF7EEL
9267fef2cb start add color to log 2021-06-04 04:43:32 -07:00
KF7EEL
413044aba8 fix radioid.net update bug 2021-06-03 21:23:58 -07:00
KF7EEL
de08cc897e rewrite auth log 2021-06-03 21:08:31 -07:00
KF7EEL
ac7b827c36 finalize burn list 2021-06-03 17:19:44 -07:00
KF7EEL
3f3b980b98 generate passphrases after burn 2021-06-03 14:48:48 -07:00
KF7EEL
c16036b58d download burnlist on start 2021-06-03 13:38:47 -07:00
KF7EEL
f94d68ae81 implement burn list 2021-06-03 09:49:35 -07:00
KF7EEL
d87c67acb0 clean code from hblink.py 2021-06-03 09:15:01 -07:00
KF7EEL
06cc28fe82 fix auth log 2021-05-26 11:58:20 -07:00
KF7EEL
92e0e2e405 add legacy override 2021-05-26 11:06:12 -07:00
KF7EEL
a03a9e1018 add email user 2021-05-26 10:19:02 -07:00
KF7EEL
c9436e54ec add missing section of code 2021-05-26 06:28:24 -07:00
KF7EEL
eb0a6b49d4 change layout 2021-05-23 09:23:34 -07:00
KF7EEL
d8e4351ad3 add auth log 2021-05-23 09:21:47 -07:00
KF7EEL
1b9bb3a83f add role to user auth API 2021-05-22 18:01:31 -07:00
KF7EEL
886a15945d add user API 2021-05-22 16:43:23 -07:00
KF7EEL
90aa470bb9 update db, add message 2021-05-22 11:55:18 -07:00
KF7EEL
37af7aab2a add help.html 2021-05-18 22:10:43 -07:00
KF7EEL
dd0825c005 improve app.py 2021-05-18 12:12:57 -07:00
KF7EEL
6ac2ffe091 clean radioid.net requests 2021-05-18 12:05:57 -07:00
KF7EEL
b6f1a92a60 add admin approve feature 2021-05-18 11:34:33 -07:00
KF7EEL
cb81326da2 add update from radioid.net 2021-05-18 10:10:40 -07:00
KF7EEL
d1a7faf29c add email confirmation 2021-05-17 10:42:05 -07:00
KF7EEL
257232078a update admin 2021-05-17 07:07:15 -07:00
KF7EEL
09e1fca1cb add multi admin 2021-05-16 20:49:03 -07:00
KF7EEL
aa9e68ae5a fix index bug 2021-05-16 17:10:20 -07:00
KF7EEL
ed31308b26 fix list user view 2021-05-16 17:08:23 -07:00
KF7EEL
6827cda57a fix admin bug 2021-05-16 17:06:50 -07:00
KF7EEL
fa11f3bdf4 updateb app.py 2021-05-16 17:00:35 -07:00
KF7EEL
7d890bc504 update admin stuff 2021-05-16 16:37:49 -07:00
KF7EEL
df1b69612a add admin user list 2021-05-16 16:16:05 -07:00
KF7EEL
a60e1b2cac update theme 2021-05-16 15:39:25 -07:00
KF7EEL
5915463b71 add phonetic spelling 2021-05-12 16:05:41 -07:00
KF7EEL
080b88d43f add option to shorten passphrase 2021-05-12 13:17:20 -07:00
Eric
c362e3e675 update script generator 2021-05-12 12:01:29 -07:00
Eric
2af99e7f46 add script generator 2021-05-12 11:37:05 -07:00
KF7EEL
52ac438fcc begin admin UI 2021-05-10 19:25:10 -07:00
KF7EEL
bda66fbdde complicate passphrase 2021-05-10 17:13:22 -07:00
KF7EEL
16744c61e5 update hblink.py 2021-05-10 13:39:03 -07:00
KF7EEL
2159d09525 fix issue with hblink.py 2021-05-10 13:02:53 -07:00
KF7EEL
6ddfb2b1c1 add db location 2021-05-10 12:17:15 -07:00
KF7EEL
8adb80f188 initial commit of working app 2021-05-10 09:32:15 -07:00
KF7EEL
3ed20d48ac update template 2021-05-07 09:24:28 -07:00
KF7EEL
bc2b146f79 add example for testing 2021-05-07 09:01:48 -07:00
KF7EEL
f1541c7d7d update sample config 2021-05-07 08:07:13 -07:00
KF7EEL
912d875d11 generate passphrase via web ui, add shared secret to hblink.py 2021-05-07 07:56:21 -07:00
KF7EEL
213a5c6d8f improve authentication stability 2021-05-06 18:14:20 -07:00
KF7EEL
a93a61d05b save progress, made config options 2021-05-06 08:09:47 -07:00
KF7EEL
72d0ca87e5 add base for user management, working POST function for hblink authentication 2021-05-05 21:18:37 -07:00
KF7EEL
1902234e87 update README 2021-05-02 19:17:31 -07:00
KF7EEL
5de722fcf1 initial commit, working auth from txt file in form of python dictionary 2021-05-02 19:15:54 -07:00
18 changed files with 3707 additions and 11 deletions

View File

@ -1,3 +1,5 @@
### This is a branch of HBLink3 for development of a user authentication system for hotspots for PNW Digital.
---
### FOR SUPPORT, DISCUSSION, GETTING INVOLVED ###

113
bridge.py
View File

@ -42,12 +42,13 @@ from twisted.protocols.basic import NetstringReceiver
from twisted.internet import reactor, task
# Things we import from the main hblink module
from hblink import HBSYSTEM, OPENBRIDGE, systems, hblink_handler, reportFactory, REPORT_OPCODES, mk_aliases
from hblink import HBSYSTEM, OPENBRIDGE, systems, hblink_handler, reportFactory, REPORT_OPCODES, mk_aliases, download_burnlist
from dmr_utils3.utils import bytes_3, int_id, get_alias
from dmr_utils3 import decode, bptc, const
import config
import log
from const import *
from hashlib import sha256
# Stuff for socket reporting
import pickle
@ -55,8 +56,8 @@ import pickle
# The module needs logging, but handlers, etc. are controlled by the parent
import logging
logger = logging.getLogger(__name__)
import os, ast
import json, requests
# Does anybody read this stuff? There's a PEP somewhere that says I should do this.
__author__ = 'Cortney T. Buffington, N0MJS'
__copyright__ = 'Copyright (c) 2016-2019 Cortney T. Buffington, N0MJS and the K0USY Group'
@ -65,6 +66,86 @@ __license__ = 'GNU GPLv3'
__maintainer__ = 'Cort Buffington, N0MJS'
__email__ = 'n0mjs@me.com'
##import os, ast
def download_config(L_CONFIG_FILE, cli_file):
user_man_url = L_CONFIG_FILE['USER_MANAGER']['URL']
shared_secret = str(sha256(L_CONFIG_FILE['USER_MANAGER']['SHARED_SECRET'].encode()).hexdigest())
config_check = {
'get_config':L_CONFIG_FILE['USER_MANAGER']['THIS_SERVER_NAME'],
'secret':shared_secret
}
json_object = json.dumps(config_check, indent = 4)
try:
req = requests.post(user_man_url, data=json_object, headers={'Content-Type': 'application/json'})
resp = json.loads(req.text)
print(resp)
## print(type(resp))
## conf = config.build_config(resp['config'])
## print(conf)
## with open('/tmp/conf_telp.cfg', 'w') as f:
## f.write(str(resp['config']))
## print(resp)
iterate_config = resp['peers'].copy()
corrected_config = resp['config'].copy()
corrected_config['SYSTEMS'] = {}
corrected_config['LOGGER'] = {}
corrected_config['SYSTEMS'].update(iterate_config)
corrected_config['LOGGER'].update(L_CONFIG_FILE['LOGGER'])
corrected_config['USER_MANAGER'].update(L_CONFIG_FILE['USER_MANAGER'])
print(iterate_config)
## corrected_config = CONFIG_FILE.copy()
## print(corrected_config)
## print()
## print(iterate_config['config']['SYSTEMS'])
## print(resp['config'])
## print((iterate_config['test']))
## print(corrected_config)
corrected_config['GLOBAL']['TG1_ACL'] = config.acl_build(corrected_config['GLOBAL']['TG1_ACL'], 16776415)
corrected_config['GLOBAL']['TG2_ACL'] = config.acl_build(corrected_config['GLOBAL']['TG2_ACL'], 16776415)
corrected_config['GLOBAL']['REG_ACL'] = config.acl_build(corrected_config['GLOBAL']['REG_ACL'], 16776415)
corrected_config['GLOBAL']['SUB_ACL'] = config.acl_build(corrected_config['GLOBAL']['SUB_ACL'], 16776415)
## corrected_config['SYSTEMS'] = {}
for i in iterate_config:
print(i)
## corrected_config['SYSTEMS'][i] = {}
if iterate_config[i]['MODE'] == 'MASTER' or iterate_config[i]['MODE'] == 'PROXY':
corrected_config['SYSTEMS'][i]['TG1_ACL'] = config.acl_build(iterate_config[i]['TG1_ACL'], 16776415)
corrected_config['SYSTEMS'][i]['TG2_ACL'] = config.acl_build(iterate_config[i]['TG2_ACL'], 16776415)
else:
corrected_config['SYSTEMS'][i]['RADIO_ID'] = int(iterate_config[i]['RADIO_ID']).to_bytes(4, 'big')
corrected_config['SYSTEMS'][i]['TG1_ACL'] = config.acl_build(iterate_config[i]['TG1_ACL'], 16776415)
corrected_config['SYSTEMS'][i]['TG2_ACL'] = config.acl_build(iterate_config[i]['TG2_ACL'], 16776415)
corrected_config['SYSTEMS'][i]['USE_ACL'] = iterate_config[i]['USE_ACL']
corrected_config['SYSTEMS'][i]['SUB_ACL'] = config.acl_build(iterate_config[i]['SUB_ACL'], 16776415)
corrected_config['SYSTEMS'][i]['MASTER_SOCKADDR'] = tuple(iterate_config[i]['MASTER_SOCKADDR'])
corrected_config['SYSTEMS'][i]['SOCK_ADDR'] = tuple(iterate_config[i]['SOCK_ADDR'])
corrected_config['SYSTEMS'][i].update({'STATS':{
'CONNECTION': 'NO', # NO, RTPL_SENT, AUTHENTICATED, CONFIG-SENT, YES
'CONNECTED': None,
'PINGS_SENT': 0,
'PINGS_ACKD': 0,
'NUM_OUTSTANDING': 0,
'PING_OUTSTANDING': False,
'LAST_PING_TX_TIME': 0,
'LAST_PING_ACK_TIME': 0,
}})
print(corrected_config)
## config.process_acls(corrected_config)
## print(corrected_config)
print('-------')
return corrected_config
# For exception, write blank dict
except requests.ConnectionError:
logger.error('Config server unreachable, defaulting to local config')
return config.build_config(cli_file)
# Module gobal varaibles
# Dictionary for dynamically mapping unit (subscriber) to a system.
@ -1096,8 +1177,28 @@ if __name__ == '__main__':
cli_args.CONFIG_FILE = os.path.dirname(os.path.abspath(__file__))+'/hblink.cfg'
# Call the external routine to build the configuration dictionary
LOCAL_CONFIG = config.build_config(cli_args.CONFIG_FILE)
#print(LOCAL_CONFIG)
#print(download_config(LOCAL_CONFIG))
#if LOCAL_CONFIG['USER_MANAGER']['REMOTE_CONFIG_ENABLED']:
#print(download_config(LOCAL_CONFIG)['config'])
## CONFIG = config.build_config(download_config(LOCAL_CONFIG))
#CONFIG = download_config(LOCAL_CONFIG)
#if not LOCAL_CONFIG['USER_MANAGER']['REMOTE_CONFIG_ENABLED']:
## print(download_config(LOCAL_CONFIG)['config'])
## CONFIG = config.build_config(cli_args.CONFIG_FILE)
#print((CONFIG))
## config.process_acls(LOCAL_CONFIG)
if LOCAL_CONFIG['USER_MANAGER']['REMOTE_CONFIG_ENABLED']:
CONFIG = download_config(LOCAL_CONFIG, cli_args.CONFIG_FILE)
else:
CONFIG = config.build_config(cli_args.CONFIG_FILE)
## print(CONFIG)
# Ensure we have a path for the rules file, if one wasn't specified, then use the default (top of file)
if not cli_args.RULES_FILE:
cli_args.RULES_FILE = os.path.dirname(os.path.abspath(__file__))+'/rules.py'
@ -1170,4 +1271,10 @@ if __name__ == '__main__':
stream_trimmer = stream_trimmer_task.start(5)
stream_trimmer.addErrback(loopingErrHandle)
# Download burn list
with open(CONFIG['USER_MANAGER']['BURN_FILE'], 'w') as f:
f.write(str(download_burnlist(CONFIG)))
reactor.run()

View File

@ -107,6 +107,7 @@ def build_config(_config_file):
CONFIG['REPORTS'] = {}
CONFIG['LOGGER'] = {}
CONFIG['ALIASES'] = {}
CONFIG['USER_MANAGER'] = {}
CONFIG['SYSTEMS'] = {}
try:
@ -153,6 +154,19 @@ def build_config(_config_file):
'STALE_TIME': config.getint(section, 'STALE_DAYS') * 86400,
})
elif section == 'USER_MANAGER':
CONFIG['USER_MANAGER'].update({
'THIS_SERVER_NAME': config.get(section, 'THIS_SERVER_NAME'),
'URL': config.get(section, 'URL'),
'APPEND_INT': config.getint(section, 'APPEND_INT'),
'SHARED_SECRET': config.get(section, 'SHARED_SECRET'),
'SHORTEN_PASSPHRASE': config.getboolean(section, 'SHORTEN_PASSPHRASE'),
'BURN_FILE': config.get(section, 'BURN_FILE'),
'BURN_INT': config.getint(section, 'BURN_INT'),
})
elif config.getboolean(section, 'ENABLED'):
if config.get(section, 'MODE') == 'PEER':
CONFIG['SYSTEMS'].update({section: {
@ -249,6 +263,7 @@ def build_config(_config_file):
CONFIG['SYSTEMS'].update({section: {
'MODE': config.get(section, 'MODE'),
'ENABLED': config.getboolean(section, 'ENABLED'),
'USE_USER_MAN': config.getboolean(section, 'USE_USER_MAN'),
'REPEAT': config.getboolean(section, 'REPEAT'),
'MAX_PEERS': config.getint(section, 'MAX_PEERS'),
'IP': gethostbyname(config.get(section, 'IP')),

4
db.txt Normal file
View File

@ -0,0 +1,4 @@
{
3153597:b'passw0rd',
1234567:b'password'
}

View File

@ -126,7 +126,7 @@ STALE_DAYS: 7
# Otherwise ACLs work as described in the global stanza
[OBP-1]
MODE: OPENBRIDGE
ENABLED: True
ENABLED: False
IP:
PORT: 62035
NETWORK_ID: 3129100
@ -138,6 +138,23 @@ USE_ACL: True
SUB_ACL: DENY:1
TGID_ACL: PERMIT:ALL
# USER MANAGER
# This is where to configure the details for use with a user managment script
[USER_MANAGER]
THIS_SERVER_NAME: My MMDVM Server
USE_USER_MAN: True
# URL of the user managment server
URL: http://localhost:8080/auth
# Integer appended to DMR ID during the generation of a passphrase
APPEND_INT: 1
# Secret used to authenticate with user managment server, before checking if user login is approved
SHARED_SECRET: test
# Shorten passphrases to 8 characters
SHORTEN_PASSPHRASE: False
BURN_FILE: ./burn_ids.txt
BURN_INT: 5
# MASTER INSTANCES - DUPLICATE SECTION FOR MULTIPLE MASTERS
# HomeBrew Protocol Master instances go here.
# IP may be left blank if there's one interface on your system.
@ -154,6 +171,10 @@ TGID_ACL: PERMIT:ALL
[MASTER-1]
MODE: MASTER
ENABLED: True
# Use the user manager? If False, MASTER instance will operate as normal.
USE_USER_MAN: False
REPEAT: True
MAX_PEERS: 10
EXPORT_AMBE: False
@ -181,7 +202,7 @@ TGID_TS2_ACL: PERMIT:ALL
# See comments in the GLOBAL stanza
[REPEATER-1]
MODE: PEER
ENABLED: True
ENABLED: False
LOOSE: False
EXPORT_AMBE: False
IP:
@ -213,7 +234,7 @@ TGID_TS2_ACL: PERMIT:ALL
[XLX-1]
MODE: XLXPEER
ENABLED: True
ENABLED: False
LOOSE: True
EXPORT_AMBE: False
IP:

149
hblink.py
View File

@ -55,6 +55,13 @@ from reporting_const import *
import logging
logger = logging.getLogger(__name__)
# Used for user auth
import os, ast
import requests, json
import base64
import libscrc
# Does anybody read this stuff? There's a PEP somewhere that says I should do this.
__author__ = 'Cortney T. Buffington, N0MJS'
__copyright__ = 'Copyright (c) 2016-2019 Cortney T. Buffington, N0MJS and the K0USY Group'
@ -100,6 +107,23 @@ def acl_check(_id, _acl):
return not _acl[0]
def download_burnlist(_CONFIG):
user_man_url = _CONFIG['USER_MANAGER']['URL']
shared_secret = str(sha256(_CONFIG['USER_MANAGER']['SHARED_SECRET'].encode()).hexdigest())
burn_check = {
'burn_list':True,
'secret':shared_secret
}
json_object = json.dumps(burn_check, indent = 4)
try:
req = requests.post(user_man_url, data=json_object, headers={'Content-Type': 'application/json'})
resp = json.loads(req.text)
return resp['burn_list']
# For exception, write blank dict
except requests.ConnectionError:
return {}
#************************************************
# OPENBRIDGE CLASS
#************************************************
@ -230,6 +254,91 @@ class HBSYSTEM(DatagramProtocol):
self.datagramReceived = self.peer_datagramReceived
self.dereg = self.peer_dereg
def check_user_man(self, _id, server_name, peer_ip):
#Change this to a config value
user_man_url = self._CONFIG['USER_MANAGER']['URL']
shared_secret = str(sha256(self._CONFIG['USER_MANAGER']['SHARED_SECRET'].encode()).hexdigest())
#print(int(str(int_id(_id))[:7]))
auth_check = {
'secret':shared_secret,
'login_id':int(str(int_id(_id))[:7]),
'login_ip': peer_ip,
'login_server': server_name
}
json_object = json.dumps(auth_check, indent = 4)
try:
req = requests.post(user_man_url, data=json_object, headers={'Content-Type': 'application/json'})
resp = json.loads(req.text)
return resp
except requests.ConnectionError:
return {'allow':True}
def send_login_conf(self, _id, server_name, peer_ip, old_auth):
#Change this to a config value
user_man_url = self._CONFIG['USER_MANAGER']['URL']
shared_secret = str(sha256(self._CONFIG['USER_MANAGER']['SHARED_SECRET'].encode()).hexdigest())
#print(int(str(int_id(_id))[:7]))
auth_conf = {
'secret':shared_secret,
'login_id':int(str(int_id(_id))[:7]),
'login_ip': peer_ip,
'login_server': server_name,
'login_confirmed': True,
'old_auth': old_auth
}
json_object = json.dumps(auth_conf, indent = 4)
try:
req = requests.post(user_man_url, data=json_object, headers={'Content-Type': 'application/json'})
# resp = json.loads(req.text)
#return resp
except Exception as e:
logger.info(e)
def calc_passphrase(self, peer_id, _salt_str):
burn_id = ast.literal_eval(os.popen('cat ' + self._CONFIG['USER_MANAGER']['BURN_FILE']).read())
peer_id_trimmed = int(str(int_id(peer_id))[:7])
try:
print(self.ums_response)
if self.ums_response['mode'] == 'legacy':
_calc_hash = bhex(sha256(_salt_str+self._config['PASSPHRASE']).hexdigest())
calc_passphrase = self._config['PASSPHRASE']
if self.ums_response['mode'] == 'override':
_calc_hash = bhex(sha256(_salt_str+str.encode(self.ums_response['value'])).hexdigest())
if self.ums_response['mode'] == 'normal':
_new_peer_id = bytes_4(int(str(int_id(peer_id))[:7]))
peer_id_trimmed = str(peer_id_trimmed)
try:
if burn_id[peer_id_trimmed]:
logger.info('User ID has been burned. Requiring passphrase version: ' + str(burn_id[peer_id_trimmed]))
calc_passphrase = base64.b64encode(bytes.fromhex(str(hex(libscrc.ccitt((_new_peer_id) + burn_id[peer_id_trimmed].to_bytes(2, 'big') + self._CONFIG['USER_MANAGER']['BURN_INT'].to_bytes(2, 'big') + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big') + bytes.fromhex(str(hex(libscrc.posix((_new_peer_id) + burn_id[peer_id_trimmed].to_bytes(2, 'big') + self._CONFIG['USER_MANAGER']['BURN_INT'].to_bytes(2, 'big') + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big'))))[2:].zfill(8)))))[2:].zfill(4)) + (_new_peer_id) + burn_id[peer_id_trimmed].to_bytes(2, 'big') + self._CONFIG['USER_MANAGER']['BURN_INT'].to_bytes(2, 'big') + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big') + bytes.fromhex(str(hex(libscrc.posix((_new_peer_id) + burn_id[peer_id_trimmed].to_bytes(2, 'big') + self._CONFIG['USER_MANAGER']['BURN_INT'].to_bytes(2, 'big') + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big'))))[2:].zfill(8)))
except:
calc_passphrase = base64.b64encode(bytes.fromhex(str(hex(libscrc.ccitt((_new_peer_id) + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big') + bytes.fromhex(str(hex(libscrc.posix((_new_peer_id) + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big'))))[2:].zfill(8)))))[2:].zfill(4)) + (_new_peer_id) + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big') + bytes.fromhex(str(hex(libscrc.posix((_new_peer_id) + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big'))))[2:].zfill(8)))
if self._CONFIG['USER_MANAGER']['SHORTEN_PASSPHRASE'] == True:
calc_passphrase = calc_passphrase[-8:]
if self._CONFIG['USER_MANAGER']['SHORTEN_PASSPHRASE'] == False:
pass
_calc_hash = bhex(sha256(_salt_str+calc_passphrase).hexdigest())
#If exception, assume UMS down and default to calculated passphrase
except Exception as e:
logger.info('Execption, UMS possibly down')
_new_peer_id = bytes_4(int(str(int_id(peer_id))[:7]))
if peer_id_trimmed in burn_id:
logger.info('User ID has been burned. Requiring passphrase version: ' + str(burn_id[peer_id_trimmed]))
calc_passphrase = base64.b64encode(bytes.fromhex(str(hex(libscrc.ccitt((_new_peer_id) + burn_id[peer_id_trimmed].to_bytes(2, 'big') + self._CONFIG['USER_MANAGER']['BURN_INT'].to_bytes(2, 'big') + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big') + bytes.fromhex(str(hex(libscrc.posix((_new_peer_id) + burn_id[peer_id_trimmed].to_bytes(2, 'big') + self._CONFIG['USER_MANAGER']['BURN_INT'].to_bytes(2, 'big') + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big'))))[2:].zfill(8)))))[2:].zfill(4)) + (_new_peer_id) + burn_id[peer_id_trimmed].to_bytes(2, 'big') + self._CONFIG['USER_MANAGER']['BURN_INT'].to_bytes(2, 'big') + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big') + bytes.fromhex(str(hex(libscrc.posix((_new_peer_id) + burn_id[peer_id_trimmed].to_bytes(2, 'big') + self._CONFIG['USER_MANAGER']['BURN_INT'].to_bytes(2, 'big') + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big'))))[2:].zfill(8)))
else:
calc_passphrase = base64.b64encode(bytes.fromhex(str(hex(libscrc.ccitt((_new_peer_id) + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big') + bytes.fromhex(str(hex(libscrc.posix((_new_peer_id) + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big'))))[2:].zfill(8)))))[2:].zfill(4)) + (_new_peer_id) + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big') + bytes.fromhex(str(hex(libscrc.posix((_new_peer_id) + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big'))))[2:].zfill(8)))
#calc_passphrase = base64.b64encode(bytes.fromhex(str(hex(libscrc.ccitt((_new_peer_id) + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big') + bytes.fromhex(str(hex(libscrc.posix((_new_peer_id) + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big'))))[2:].zfill(8)))))[2:].zfill(4)) + (_new_peer_id) + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big') + bytes.fromhex(str(hex(libscrc.posix((_new_peer_id) + self._CONFIG['USER_MANAGER']['APPEND_INT'].to_bytes(2, 'big'))))[2:].zfill(8)))
if self._CONFIG['USER_MANAGER']['SHORTEN_PASSPHRASE'] == True:
calc_passphrase = calc_passphrase[-8:]
if self._CONFIG['USER_MANAGER']['SHORTEN_PASSPHRASE'] == False:
pass
_calc_hash = bhex(sha256(_salt_str+calc_passphrase).hexdigest())
print(calc_passphrase)
# print(_calc_hash)
return _calc_hash
def startProtocol(self):
# Set up periodic loop for tracking pings from peers. Run every 'PING_TIME' seconds
self._system_maintenance = task.LoopingCall(self.maintenance_loop)
@ -326,6 +435,7 @@ class HBSYSTEM(DatagramProtocol):
# Aliased in __init__ to datagramReceived if system is a master
def master_datagramReceived(self, _data, _sockaddr):
global user_db
# Keep This Line Commented Unless HEAVILY Debugging!
# logger.debug('(%s) RX packet from %s -- %s', self._system, _sockaddr, ahex(_data))
@ -405,7 +515,22 @@ class HBSYSTEM(DatagramProtocol):
# Check to see if we've reached the maximum number of allowed peers
if len(self._peers) < self._config['MAX_PEERS']:
# Check for valid Radio ID
#print(self.check_user_man(_peer_id))
if self._config['USE_USER_MAN'] == True:
self.ums_response = self.check_user_man(_peer_id, self._CONFIG['USER_MANAGER']['THIS_SERVER_NAME'], _sockaddr[0])
## print(self.ums_response)
#Will allow anyone to attempt authentication, used for a transition period
## if acl_check(_peer_id, self._CONFIG['GLOBAL']['REG_ACL']) and self.ums_response['allow'] or acl_check(_peer_id, self._CONFIG['GLOBAL']['REG_ACL']) and acl_check(_peer_id, self._config['REG_ACL']):
if acl_check(_peer_id, self._CONFIG['GLOBAL']['REG_ACL']) and self.ums_response['allow']:
user_auth = self.ums_response['allow']
else:
user_auth = False
print(user_auth)
if self._config['USE_USER_MAN'] == False:
# print('False')
if acl_check(_peer_id, self._CONFIG['GLOBAL']['REG_ACL']) and acl_check(_peer_id, self._config['REG_ACL']):
user_auth = True
if user_auth == True:
# Build the configuration data strcuture for the peer
self._peers.update({_peer_id: {
'CONNECTION': 'RPTL-RECEIVED',
@ -437,6 +562,7 @@ class HBSYSTEM(DatagramProtocol):
self.send_peer(_peer_id, b''.join([RPTACK, _salt_str]))
self._peers[_peer_id]['CONNECTION'] = 'CHALLENGE_SENT'
logger.info('(%s) Sent Challenge Response to %s for login: %s', self._system, int_id(_peer_id), self._peers[_peer_id]['SALT'])
## print(self._peers)
else:
self.transport.write(b''.join([MSTNAK, _peer_id]), _sockaddr)
logger.warning('(%s) Invalid Login from %s Radio ID: %s Denied by Registation ACL', self._system, _sockaddr[0], int_id(_peer_id))
@ -453,11 +579,27 @@ class HBSYSTEM(DatagramProtocol):
_this_peer['LAST_PING'] = time()
_sent_hash = _data[8:]
_salt_str = bytes_4(_this_peer['SALT'])
# Used to allow config passphrase AND calculated.
_ocalc_hash = bhex(sha256(_salt_str+self._config['PASSPHRASE']).hexdigest())
#print(self.ums_response)
if self._config['USE_USER_MAN'] == True:
# print(self.calc_passphrase(_peer_id, _salt_str))
_calc_hash = self.calc_passphrase(_peer_id, _salt_str)
if self._config['USE_USER_MAN'] == False:
_calc_hash = bhex(sha256(_salt_str+self._config['PASSPHRASE']).hexdigest())
if _sent_hash == _calc_hash:
# Uncomment below to only accept calculated passphrase
# if _sent_hash == _calc_hash:
# Condition below accepts either calculated passphrase or config passphrase
if _sent_hash == _calc_hash or _sent_hash == _ocalc_hash:
_this_peer['CONNECTION'] = 'WAITING_CONFIG'
self.send_peer(_peer_id, b''.join([RPTACK, _peer_id]))
logger.info('(%s) Peer %s has completed the login exchange successfully', self._system, _this_peer['RADIO_ID'])
#self.send_login_conf(_peer_id, self._CONFIG['USER_MANAGER']['THIS_SERVER_NAME'], _sockaddr[0], False)
if _sent_hash == _ocalc_hash:
self.send_login_conf(_peer_id, self._CONFIG['USER_MANAGER']['THIS_SERVER_NAME'], _sockaddr[0], True)
else:
self.send_login_conf(_peer_id, self._CONFIG['USER_MANAGER']['THIS_SERVER_NAME'], _sockaddr[0], False)
else:
logger.info('(%s) Peer %s has FAILED the login exchange successfully', self._system, _this_peer['RADIO_ID'])
self.transport.write(b''.join([MSTNAK, _peer_id]), _sockaddr)
@ -818,6 +960,7 @@ if __name__ == '__main__':
peer_ids, subscriber_ids, talkgroup_ids = mk_aliases(CONFIG)
# INITIALIZE THE REPORTING LOOP
if CONFIG['REPORTS']['REPORT']:
report_server = config_reports(CONFIG, reportFactory)
@ -836,4 +979,8 @@ if __name__ == '__main__':
reactor.listenUDP(CONFIG['SYSTEMS'][system]['PORT'], systems[system], interface=CONFIG['SYSTEMS'][system]['IP'])
logger.debug('(GLOBAL) %s instance created: %s, %s', CONFIG['SYSTEMS'][system]['MODE'], system, systems[system])
# Download burn list
with open(CONFIG['USER_MANAGER']['BURN_FILE'], 'w') as f:
f.write(str(download_burnlist(CONFIG)))
reactor.run()

2987
user_managment/app.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,62 @@
'''
Settings for user management portal.
'''
# Database location
db_location = 'sqlite:///./users.db'
# Legacy passphrase used in hblink.cfg
legacy_passphrase = 'passw0rd'
# Trim passphrases to 8 characters
use_short_passphrase = False
# Title of the Dashboard
title = 'MMDVM User Portal'
# Port to run server
ums_port = 8080
# IP to run server on
ums_host = '127.0.0.1'
url = 'http://localhost:8080'
append_int = 1
shared_secrets = ['test']
burn_int = 5
legacy_passphrase = 'passw0rd'
# Email settings
MAIL_SERVER = 'smtp.gmail.com'
MAIL_PORT = 465
MAIL_USE_SSL = True
MAIL_USE_TLS = False
MAIL_USERNAME = 'app@gmail.com'
MAIL_PASSWORD = 'password'
MAIL_DEFAULT_SENDER = '"' + title + '" <app@gmail.com>'
# UMS settings
secret_key = 'SUPER SECRET LONG KEY'
USER_ENABLE_EMAIL = True
USER_ENABLE_USERNAME = True # Enable username authentication
USER_REQUIRE_RETYPE_PASSWORD = True # Simplify register form
USER_ENABLE_CHANGE_USERNAME = False
USER_ENABLE_MULTIPLE_EMAILS = True
USER_ENABLE_CONFIRM_EMAIL = True
USER_ENABLE_REGISTER = True
USER_AUTO_LOGIN_AFTER_CONFIRM = False
USER_SHOW_USERNAME_DOES_NOT_EXIST = True
# Gateway contact info displayed on about page.
contact_name = 'your name'
contact_call = 'N0CALL'
contact_email = 'email@example.org'
contact_website = 'https://hbl.ink'
# Time format for display
time_format = '%H:%M:%S - %m/%d/%y'

View File

@ -0,0 +1,5 @@
def gen_script(dmr_id, passphrase):
script = '''
DMR ID: ''' + str(dmr_id) + ''' \n Passphrase: ''' + str(passphrase) + '''
'''
return script

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -0,0 +1,30 @@
{% extends 'flask_user/_authorized_base.html' %}
{% block content %}
{% from "flask_user/_macros.html" import render_field, render_checkbox_field, render_submit_field %}
<h1>{%trans%}User profile{%endtrans%}</h1>
<form action="" method="POST" class="form" role="form">
{{ form.hidden_tag() }}
{% for field in form %}
{% if not field.flags.hidden %}
{% if field.type=='SubmitField' %}
{{ render_submit_field(field, tabindex=loop.index*10) }}
{% else %}
{{ render_field(field, tabindex=loop.index*10) }}
{% endif %}
{% endif %}
{% endfor %}
</form>
<br/>
<p><a href="../update_ids"><strong>Update your information from RadioID.net</strong></a></p>
{% if not user_manager.USER_ENABLE_AUTH0 %}
{% if user_manager.USER_ENABLE_CHANGE_USERNAME %}
<p><a href="{{ url_for('user.change_username') }}">{%trans%}Change username{%endtrans%}</a></p>
{% endif %}
{% if user_manager.USER_ENABLE_CHANGE_PASSWORD %}
<p><a href="{{ url_for('user.change_password') }}">{%trans%}Change password{%endtrans%}</a></p>
{% endif %}
{% endif %}
{% endblock %}

View File

@ -0,0 +1,8 @@
<p>Dear {{ user.email }} - {{ user.username }},</p>
{% block message %}
{% endblock %}
<p>Sincerely,<br/>
{{ app_name }}
</p>

View File

@ -0,0 +1,69 @@
{% extends 'flask_user/_public_base.html' %}
{% block content %}
{% from "flask_user/_macros.html" import render_field, render_checkbox_field, render_submit_field %}
<h1>{%trans%}Sign in{%endtrans%}</h1>
<p>&nbsp;</p>
<strong>Your username MUST be your callsign or email address.</strong>
<p>&nbsp;</p>
<form action="" method="POST" class="form" role="form">
{{ form.hidden_tag() }}
{# Username or Email field #}
{% set field = form.username if user_manager.USER_ENABLE_USERNAME else form.email %}
<div class="form-group {% if field.errors %}has-error{% endif %}">
{# Label on left, "New here? Register." on right #}
<div class="row">
<div class="col-xs-6">
<label for="{{ field.id }}" class="control-label">{{ field.label.text }}</label>
</div>
<div class="col-xs-6 text-right">
{% if user_manager.USER_ENABLE_REGISTER and not user_manager.USER_REQUIRE_INVITATION %}
<a href="{{ url_for('user.register') }}" tabindex='190'>
{%trans%}New here? Register.{%endtrans%}</a>
{% endif %}
</div>
</div>
{{ field(class_='form-control', tabindex=110) }}
{% if field.errors %}
{% for e in field.errors %}
<p class="help-block">{{ e }}</p>
{% endfor %}
{% endif %}
</div>
{# Password field #}
{% set field = form.password %}
<div class="form-group {% if field.errors %}has-error{% endif %}">
{# Label on left, "Forgot your Password?" on right #}
<div class="row">
<div class="col-xs-6">
<label for="{{ field.id }}" class="control-label">{{ field.label.text }}</label>
</div>
<div class="col-xs-6 text-right">
{% if user_manager.USER_ENABLE_FORGOT_PASSWORD %}
<a href="{{ url_for('user.forgot_password') }}" tabindex='195'>
{%trans%}Forgot your Password?{%endtrans%}</a>
{% endif %}
</div>
</div>
{{ field(class_='form-control', tabindex=120) }}
{% if field.errors %}
{% for e in field.errors %}
<p class="help-block">{{ e }}</p>
{% endfor %}
{% endif %}
</div>
{# Remember me #}
{% if user_manager.USER_ENABLE_REMEMBER_ME %}
{{ render_checkbox_field(login_form.remember_me, tabindex=130) }}
{% endif %}
{# Submit button #}
{{ render_submit_field(form.submit, tabindex=180) }}
</form>
{% endblock %}

View File

@ -0,0 +1,50 @@
{% extends 'flask_user/_public_base.html' %}
{% block content %}
{% from "flask_user/_macros.html" import render_field, render_submit_field %}
<h1>{%trans%}Register{%endtrans%}</h1>
<p>&nbsp;</p>
<strong>Your username MUST be your callsign.</strong> After filling out the fields, a confirmation link will be emailed to you.
<p>&nbsp;</p>
<form action="" method="POST" novalidate formnovalidate class="form" role="form">
{{ form.hidden_tag() }}
{# Username or Email #}
{% set field = form.username if user_manager.USER_ENABLE_USERNAME else form.email %}
<div class="form-group {% if field.errors %}has-error{% endif %}">
{# Label on left, "Already registered? Sign in." on right #}
<div class="row">
<div class="col-xs-6">
<label for="{{ field.id }}" class="control-label">{{ field.label.text }}</label>
</div>
<div class="col-xs-6 text-right">
{% if user_manager.USER_ENABLE_REGISTER %}
<a href="{{ url_for('user.login') }}" tabindex='290'>
{%trans%}Already registered? Sign in.{%endtrans%}</a>
{% endif %}
</div>
</div>
{{ field(class_='form-control', tabindex=210) }}
{% if field.errors %}
{% for e in field.errors %}
<p class="help-block">{{ e }}</p>
{% endfor %}
{% endif %}
</div>
{% if user_manager.USER_ENABLE_EMAIL and user_manager.USER_ENABLE_USERNAME %}
{{ render_field(form.email, tabindex=220) }}
{% endif %}
{{ render_field(form.password, tabindex=230) }}
{% if user_manager.USER_REQUIRE_RETYPE_PASSWORD %}
{{ render_field(form.retype_password, tabindex=240) }}
{% endif %}
{{ render_submit_field(form.submit, tabindex=280) }}
</form>
{% endblock %}

View File

@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ user_manager.USER_APP_NAME }}</title>
<!-- Bootstrap -->
<link href="//netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css" rel="stylesheet">
<!-- In-lining styles to avoid needing a separate .css file -->
<style>
hr { border-color: #cccccc; margin: 0; }
.no-margins { margin: 0px; }
.with-margins { margin: 10px; }
.col-centered { float: none; margin: 0 auto; }
</style>
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
<script src="//cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7/html5shiv.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/respond.js/1.4.2/respond.js"></script>
<![endif]-->
{# *** Allow sub-templates to insert extra html to the head section *** #}
{% block extra_css %}{% endblock %}
</head>
<body>
<h1 style="text-align: center;">{{ user_manager.USER_APP_NAME }}</h1>
<p><img style="display: block; margin-left: auto; margin-right: auto;" src="{{ url_for('static', filename='HBlink.png') }}" alt="Logo" width="300" height="144" /></p>
<h1 style="text-align: center;">{{title}}</h1>
<hr />
<table style="width: 700px; margin-left: auto; margin-right: auto;" border="black" cellspacing="3" cellpadding="3">
<tbody>
<tr>
<td style="text-align: center;"><a href={{url}}/>Home</a></td>
{% if not call_or_get(current_user.is_authenticated) %}
<td style="text-align: center;"><a href={{ url_for('user.register') }}>Register</a></td>
<td style="text-align: center;"><a href={{ url_for('user.login') }}>Sign in</a></td>
{% endif %}
{% if call_or_get(current_user.is_authenticated) %}
{% if call_or_get(current_user.has_roles('Admin')) %}
<td style="text-align: center;"><a href={{url}}/add_user><strong>Add a User</strong></a></td>
<td style="text-align: center;"><a href={{url}}/list_users><strong>Edit Users</strong></a></td>
<td style="text-align: center;"><a href={{url}}/approve_users><strong>Waiting Approval</strong></a></td>
<td style="text-align: center;"><a href={{url}}/auth_log><strong>Auth Log</strong></a></td>
{% endif %}
<td style="text-align: center;"><a href={{url}}/help>Help</a></td>
<td style="text-align: center;"><a href={{url}}/generate_passphrase>View Passphrase(s)</a></td>
<td style="text-align: center;"><a href="{{ url_for('user.edit_user_profile') }}">Edit {{ current_user.username or current_user.email }}</a></td>
<td style="text-align: center;"><a href={{ url_for('user.logout') }}>Sign out</a></td>
{% endif %}
</tr>
</tbody>
</table>
{% if call_or_get(current_user.is_authenticated) %}
{% if call_or_get(current_user.has_roles('Admin')) %}
<table style="width: 700px; margin-left: auto; margin-right: auto;" border="black" cellspacing="3" cellpadding="3">
<tbody>
<tr>
<td style="text-align: center;"><a href={{url}}/edit_server>Manage Server Configs</a></td>
<td style="text-align: center;"><a href={{url}}/edit_peer>Manage Peers</a></td>
<td style="text-align: center;"><a href={{url}}/edit_peer>Manage Masters Configs</a></td>
</tr>
</tbody>
</table>
{% endif %}
{% endif %}
<hr />
{% block body %}
<!--
<div id="header-div" class="clearfix with-margins">
<div class="pull-left"><a href="/"><h1 class="no-margins">{{ user_manager.USER_APP_NAME }}</h1></a></div>
<div class="pull-right">
{% if call_or_get(current_user.is_authenticated) %}
<a href="{{ url_for('user.edit_user_profile') }}">{{ current_user.username or current_user.email }}</a>
&nbsp; | &nbsp;
<a href="{{ url_for('user.logout') }}">{%trans%}Sign out{%endtrans%}</a>
{% else %}
<a href="{{ url_for('user.login') }}">{%trans%}Sign in{%endtrans%}</a>
{% endif %}
</div>
</div>
{% block menu %}
<div id="menu-div" class="with-margins">
<a href="/">{%trans%}Home page{%endtrans%}</a>
</div>
{% endblock %}
-->
<hr class="no-margins"/>
<div id="main-div" class="with-margins">
{# One-time system messages called Flash messages #}
{% block flash_messages %}
{%- with messages = get_flashed_messages(with_categories=true) -%}
{% if messages %}
{% for category, message in messages %}
{% if category=='error' %}
{% set category='danger' %}
{% endif %}
<div class="alert alert-{{category}}">{{ message|safe }}</div>
{% endfor %}
{% endif %}
{%- endwith %}
{% endblock %}
{% block main %}
{% block content %}
{{markup_content}}
{% endblock %}
{% endblock %}
</div>
<br/>
<hr class="no-margins"/>
<div id="footer-div" class="clearfix with-margins">
<p style="text-align: center;"><strong>{{ user_manager.USER_APP_NAME }}<br /></strong><a href="https://github.com/kf7eel/hblink3">HBNet DMR Server</a><br />Created by KF7EEL<br />Contributors:&nbsp; W7NCX, N9VW, KC7AAD, NO7RF</p>
<!--
<div class="pull-left">{{ user_manager.USER_APP_NAME }}</div>
<div class="pull-right">Credits: KF7EEL, W7NCX, N9VW, KC7AAD, NO7RF</div>
-->
</div>
{% endblock %}
<!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
<script src="//code.jquery.com/jquery-1.11.0.min.js"></script>
<!-- Bootstrap -->
<script src="//netdna.bootstrapcdn.com/bootstrap/3.1.1/js/bootstrap.min.js"></script>
{# *** Allow sub-templates to insert extra html to the bottom of the body *** #}
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,4 @@
{% extends 'flask_user/_public_base.html' %}
{% block content %}
This is a help page.</p>
{% endblock %}

View File

@ -0,0 +1,4 @@
{% extends 'flask_user/_public_base.html' %}
{% block content %}
<p>Welcome to the <strong>{{ user_manager.USER_APP_NAME }}</strong>. This tool is used to manage your access.</p>
{% endblock %}

View File

@ -0,0 +1,34 @@
{% extends 'flask_user/_public_base.html' %}
{% block content %}
<p>&nbsp;</p><h4 style="text-align: center;"><a href="/generate_passphrase/pi-star">Click here</a> for automated Pi-Star script.</h4><p>&nbsp;</p>
<table style="width: 900px; margin-left: auto; margin-right: auto;">
<tbody>
<tr>
<td>
<table style="width: 218px;" border="1">
<tbody>
<tr>
<td style="width: 53.8333px;">Name:</td>
<td style="width: 147.167px; text-align: center;"><strong>My Server</strong></td>
</tr>
<tr>
<td style="width: 53.8333px;">Host/IP:</td>
<td style="width: 147.167px; text-align: center;"><strong>127.0.0.1</strong></td>
</tr>
<tr>
<td style="width: 53.8333px;">Port:</td>
<td style="width: 147.167px; text-align: center;"><strong>62030</strong></td>
</tr>
</tbody>
</table>
</td>
<td>{{markup_content}}
</td>
</tr>
</tbody>
</table>
<p>&nbsp;</p>
{% endblock %}