Add multi-region launcher support

Add custom launcher as well as support for both the original TW and JP
launchers.
This commit is contained in:
Andrew Gutekanst
2019-12-30 07:38:12 +09:00
parent a014b0d3b9
commit 501cfc2267
14 changed files with 596 additions and 37 deletions

View File

@@ -1,31 +1,65 @@
# Erupe
## WARNING
This project is in it's infancy and currently doesn't do anything worth noting. Additionally, it has no documentation, no support, and cannot be used without binary resources that are not in the repo.
This project is in its infancy and currently doesn't do anything worth noting. Additionally, it has no documentation, no support, and cannot be used without binary resources that are not in the repo.
# General info
Based on the TW version. Requires a local mirror of the original launcher site to be placed in `./www/g6_launcher` until I can RE the launcher and figure out which JS callbacks it requires.
Originally based on the TW version, but (slowly) transitioning to JP.
## Installation
Clone the repo
Install PostgreSQL, launch psql shell, `CREATE DATABASE erupe;`.
# Installation
1. Clone the repo with `git clone https://github.com/Andoryuuta/Erupe.git`
2. Install PostgreSQL
3. Launch psql shell, `CREATE DATABASE erupe;`.
Setup db with golang-migrate:
`go get -tags 'postgres' -u github.com/golang-migrate/migrate/cmd/migrate`
`set POSTGRESQL_URL=postgres://postgres:password@localhost:5432/erupe?sslmode=disable`
`migrate -database %POSTGRESQL_URL% -path migrations up`
4. Setup database with golang-migrate:
```
> go get -tags 'postgres' -u github.com/golang-migrate/migrate/cmd/migrate
Open psql shell and manually insert an account into the users table.
> set POSTGRESQL_URL=postgres://postgres:password@localhost:5432/erupe?sslmode=disable
Run `test.py` with python 3 to generate an entrance server response binary because the code isn't ported to Go yet.
> migrate -database %POSTGRESQL_URL% -path migrations up
```
Place a copy of the original TW launcher html/js/css in `./www/g6_launcher/`, and a copy of the serverlist at `./www/server/serverlist.xml`.
5. Open psql shell and manually insert an account into the users table.
Manually extract the binary response from a pcap, strip the header, and decrypt the ~50 packets that are used in `./channelserver/session.go`, and place them in `./bin_resp/{OPCODE}_resp.bin`.
6. Run `test.py` with python 3 to generate an entrance server response binary because the code isn't ported to Go yet. (**This requires a binary response that is not included in the repo**)
7. Manually extract the binary response from a pcap, strip the header, and decrypt the ~50 packets that are used in `./channelserver/session.go`, and place them in `./bin_resp/{OPCODE}_resp.bin`. (**These are not included in the repo**)
# Use
## Launcher
Erupe now ships with a rudimentary custom launcher, so you don't need to obtain the original TW/JP files to simply get ingame. However, it does still support using the original files if you choose to. To set this up, place a copy of the original launcher html/js/css in `./www/tw/`, and `/www/jp/` for the TW and JP files respectively.
Then, modify the the `/launcher/js/launcher.js` file as such:
* Find the call to `startUpdateProcess();` in a case statement and replace it with `finishUpdateProcess();`. (This disables the file check and updating)
* (JP ONLY): replace all uses of "https://" with "http://" in the file.
Finally, edit `main.go` and change:
`go serveLauncherHTML(":80", false)`
to:
`go serveLauncherHTML(":80", true)`
# Usage
### Note: If you are switching to/from the custom launcher html, you will have to clear your IE cache @ `C:\Users\<user>\AppData\Local\Microsoft\Windows\INetCache`.
## Server
```
cd Erupe
go run .
```
## Client
Add to hosts:
```
127.0.0.1 mhfg.capcom.com.tw
127.0.0.1 mhf-n.capcom.com.tw
127.0.0.1 cog-members.mhf-z.jp
127.0.0.1 www.capcom-onlinegames.jp
127.0.0.1 srv-mhf.capcom-networks.jp
```
Run mhf.exe normally (with locale emulator or appropriate timezone).

1
go.mod
View File

@@ -8,6 +8,7 @@ require (
github.com/BurntSushi/toml v0.3.1
github.com/golang-migrate/migrate v3.5.4+incompatible // indirect
github.com/gorilla/handlers v1.4.2
github.com/gorilla/mux v1.7.3
github.com/julienschmidt/httprouter v1.3.0
github.com/lib/pq v1.3.0
)

2
go.sum
View File

@@ -9,6 +9,8 @@ github.com/golang-migrate/migrate v3.5.4+incompatible h1:R7OzwvCJTCgwapPCiX6DyBi
github.com/golang-migrate/migrate v3.5.4+incompatible/go.mod h1:IsVUlFN5puWOmXrqjgGUfIRIbU7mr8oNBE2tyERd9Wk=
github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg=
github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=

View File

@@ -2,40 +2,116 @@ package main
import (
"fmt"
"html"
"log"
"net"
"net/http"
"net/http/httputil"
"os"
"github.com/gorilla/handlers"
"github.com/julienschmidt/httprouter"
"github.com/gorilla/mux"
//"github.com/julienschmidt/httprouter"
)
func g6Index(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
http.ServeFile(w, r, "www/g6_launcher/index.html")
// GetOutboundIP4 gets the preferred outbound ip4 of this machine
// From https://stackoverflow.com/a/37382208
func GetOutboundIP4() net.IP {
conn, err := net.Dial("udp4", "8.8.8.8:80")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
localAddr := conn.LocalAddr().(*net.UDPAddr)
return localAddr.IP.To4()
}
func serverList(w http.ResponseWriter, r *http.Request) {
// TODO(Andoryuuta): Redo launcher server to allow configurable serverlist host and port.
fmt.Fprintf(w, `<?xml version="1.0"?><server_groups><group idx='0' nam='Erupe' ip='%s' port="53312"/></server_groups>`, GetOutboundIP4().String())
}
func serverUniqueName(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
dump, err := httputil.DumpRequest(r, true)
if err != nil {
http.Error(w, fmt.Sprint(err), http.StatusInternalServerError)
return
}
fmt.Println(string(dump))
func serverUniqueName(w http.ResponseWriter, r *http.Request) {
// TODO(Andoryuuta): Implement checking for unique character name.
fmt.Fprintf(w, `<?xml version="1.0" encoding="ISO-8859-1"?><uniq code="200">OK</uniq>`)
}
// serveLauncherHTML is responsible for serving the launcher HTML and (HACK) serverlist.xml.
func serveLauncherHTML(listenAddr string) {
// Manually route the folder root to index.html? Is there a better way to do this?
router := httprouter.New()
router.GET("/g6_launcher/", g6Index)
router.GET("/server/unique.php", serverUniqueName)
func jpLogin(w http.ResponseWriter, r *http.Request) {
// HACK: Return the given password back as the `skey` to defer the login logic to the sign server.
static := httprouter.New()
static.ServeFiles("/*filepath", http.Dir("www"))
router.NotFound = static
resultJSON := fmt.Sprintf(`{"result": "Ok", "skey": "%s", "code": "000", "msg": ""}`, r.FormValue("pw"))
fmt.Fprintf(w,
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<body onload="doPost();">
<script type="text/javascript">
function doPost(){
parent.postMessage(document.getElementById("result").getAttribute("value"), "http://cog-members.mhf-z.jp");
}
</script>
<input id="result" value="%s"/>
</body>
</html>`, html.EscapeString(resultJSON))
http.ListenAndServe(listenAddr, handlers.LoggingHandler(os.Stdout, router))
}
func setupServerlistRoutes(r *mux.Router) {
// TW
twServerList := r.Host("mhf-n.capcom.com.tw").Subrouter()
twServerList.HandleFunc("/server/unique.php", serverUniqueName) // Name checking is also done on this host.
twServerList.HandleFunc("/server/serverlist.xml", serverList)
// JP
jpServerList := r.Host("srv-mhf.capcom-networks.jp").Subrouter()
jpServerList.HandleFunc("/serverlist.xml", serverList)
}
func setupOriginalLauncherRotues(r *mux.Router) {
// TW
twMain := r.Host("mhfg.capcom.com.tw").Subrouter()
twMain.PathPrefix("/").Handler(http.FileServer(http.Dir("./www/tw/")))
// JP
jpMain := r.Host("cog-members.mhf-z.jp").Subrouter()
jpMain.PathPrefix("/").Handler(http.FileServer(http.Dir("./www/jp/")))
// JP Launcher does additional auth over HTTP that the TW launcher doesn't.
jpAuth := r.Host("www.capcom-onlinegames.jp").Subrouter()
jpAuth.HandleFunc("/auth/launcher/login", jpLogin) //.Methods("POST")
jpAuth.PathPrefix("/auth/").Handler(http.StripPrefix("/auth/", http.FileServer(http.Dir("./www/jp/auth/"))))
}
func setupCustomLauncherRotues(r *mux.Router) {
// TW
twMain := r.Host("mhfg.capcom.com.tw").Subrouter()
twMain.PathPrefix("/g6_launcher/").Handler(http.StripPrefix("/g6_launcher/", http.FileServer(http.Dir("./www/erupe/"))))
// JP
jpMain := r.Host("cog-members.mhf-z.jp").Subrouter()
jpMain.PathPrefix("/launcher/").Handler(http.StripPrefix("/launcher/", http.FileServer(http.Dir("./www/erupe"))))
}
// serveLauncherHTML is responsible for serving the launcher HTML, serverlist, unique name check, and JP auth.
func serveLauncherHTML(listenAddr string, useOriginalLauncher bool) {
r := mux.NewRouter()
setupServerlistRoutes(r)
if useOriginalLauncher {
setupOriginalLauncherRotues(r)
} else {
setupCustomLauncherRotues(r)
}
/*
http.ListenAndServe(listenAddr, handlers.CustomLoggingHandler(os.Stdout, r, func(writer io.Writer, params handlers.LogFormatterParams) {
dump, _ := httputil.DumpRequest(params.Request, true)
writer.Write(dump)
}))
*/
http.ListenAndServe(listenAddr, handlers.LoggingHandler(os.Stdout, r))
}

View File

@@ -30,7 +30,7 @@ func main() {
}
// Finally start our server(s).
go serveLauncherHTML(":80")
go serveLauncherHTML(":80", false)
go doEntranceServer(":53310")
signServer := signserver.NewServer(

View File

@@ -47,6 +47,8 @@ func (session *Session) handlePacket(pkt []byte) error {
bf := byteframe.NewByteFrameFromBytes(pkt)
reqType := string(bf.ReadNullTerminatedBytes())
switch reqType {
case "DLTSKEYSIGN:100":
fallthrough
case "DSGN:100":
session.handleDSGNRequest(bf)
break

View File

@@ -64,6 +64,8 @@ func (s *Server) acceptClients() {
}
func (s *Server) handleConnection(sid int, conn net.Conn) {
fmt.Println("Got connection to sign server")
// Client initalizes the connection with a one-time buffer of 8 NULL bytes.
nullInit := make([]byte, 8)
_, err := io.ReadFull(conn, nullInit)

75
www/erupe/charsel.html Normal file
View File

@@ -0,0 +1,75 @@
<!DOCTYPE HTML PUBLIC"-//W3C//DTD HTML 4.01 Transitional//EN""http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Simple MHF launcher</title>
<!--
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/firebug-lite/1.4.0/firebug-lite.js#startOpened,overrideConsole"></script>
-->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.0/css/bootstrap.min.css">
<!--[if IE 9]>
<link href="css/bootstrap-ie9.css" rel="stylesheet">
<![endif]-->
<!--[if lt IE 9]>
<link href="css/bootstrap-ie8.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/g/html5shiv@3.7.3"></script>
<![endif]-->
<!--[if gte IE 9]><!-->
<script src="https://code.jquery.com/jquery-3.4.1.slim.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.0/js/bootstrap.bundle.min.js"></script>
<!--<![endif]-->
<!--[if IE 9]>
<script src="js/bootstrap-ie9.js"></script>
<![endif]-->
<!--[if lte IE 8]>
<script src="https://code.jquery.com/jquery-1.12.4.min.js"></script>
<script src="js/bootstrap-ie8.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.0/js/bootstrap.js"></script>
<![endif]-->
<link rel="stylesheet" href="css/main.css">
<script type="text/javascript" src="js/charsel.js"></script>
</head>
<body>
<div id="alertBox"></div>
<div class="container">
<div class="row">
<div class="col-sm-12 text-center">
<div style="height:5em; margin-top:3em;">
<h2>
Erupe Simple Launcher
</h2>
</div>
</div>
</div>
<div class="row">
<div class="col-2"></div>
<div class="col-8 text-center">
<h5 class="mb-3">Character Select</h5>
<ul id="characterlist" class="list-group char-select-list">
</ul>
</div>
<div class="col-2"></div>
</div>
<div class="row">
<div class="col-12 text-center">
<button id="selectButton" class="btn btn-primary">Select</button>
<!--
<button id="newButton" class="btn btn-primary">New</button>
<button id="deleteButton" class="btn btn-primary">Delete</button>
-->
</div>
</div>
</div>
</body>
</html>

13
www/erupe/css/main.css Normal file
View File

@@ -0,0 +1,13 @@
body {
background-color: #f8f9fa;
}
html,body {
height: 100%;
overflow:hidden;
}
.char-select-list {
height: 15em;
overflow-y: scroll;
}

57
www/erupe/index.html Normal file
View File

@@ -0,0 +1,57 @@
<!DOCTYPE HTML PUBLIC"-//W3C//DTD HTML 4.01 Transitional//EN""http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Simple MHF launcher</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.0/css/bootstrap.min.css">
<!--[if IE 9]>
<link href="css/bootstrap-ie9.css" rel="stylesheet">
<![endif]-->
<!--[if lt IE 9]>
<link href="css/bootstrap-ie8.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/g/html5shiv@3.7.3"></script>
<![endif]-->
<!--[if gte IE 9]><!-->
<script src="https://code.jquery.com/jquery-3.4.1.slim.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.0/js/bootstrap.bundle.min.js"></script>
<!--<![endif]-->
<!--[if IE 9]>
<script src="js/bootstrap-ie9.js"></script>
<![endif]-->
<!--[if lte IE 8]>
<script src="https://code.jquery.com/jquery-1.12.4.min.js"></script>
<script src="js/bootstrap-ie8.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.0/js/bootstrap.js"></script>
<![endif]-->
<link rel="stylesheet" href="css/main.css">
<script type="text/javascript" src="js/main.js"></script>
</head>
<body>
<nav id="titlebar" class="navbar navbar-expand-lg navbar-dark bg-dark">
<a class="navbar-brand" href="#">MHF <i>Erupe</i></a>
<ul class="navbar-nav mr-auto"></ul>
<ul class="navbar-nav">
<li class="nav-item">
<a id="exit" class="nav-link" href="#">&times;</a>
</li>
</ul>
</nav>
<div id="alertBox"></div>
<div class="container-fluid px-0 h-100">
<div class="row no-gutters h-100">
<div class="col-12">
<iframe src="login.html" style="width: 100%; height: 100%; border: none; overflow:hidden;" frameBorder="0" scrolling="no"></iframe>
</div>
</div>
</div>
</body>
</html>

98
www/erupe/js/charsel.js Normal file
View File

@@ -0,0 +1,98 @@
function createErrorAlert(message) {
parent.postMessage(message, "*");
}
function createCharListItem(name, uid, weapon, HR, GR, lastLogin, sex) {
var topDiv = $('<div/>')
.attr("href", "#")
.attr("uid", uid)
.addClass("char-list-entry list-group-item list-group-item-action flex-column align-items-start");
var topLine = $('<div/>')
.addClass("d-flex w-100 justify-content-between")
.append(
$('<h5/>')
.addClass("mb-1")
.text(name)
)
.append(
$('<small/>')
.text('ID:' + uid)
);
var bottomLine = $('<div/>')
.addClass("d-flex w-100 justify-content-between")
.append($('<small/>').text('Weapon: ' + weapon))
.append($('<small/>').text('HR: ' + HR))
.append($('<small/>').text('GR: ' + GR))
.append($('<small/>').text('LastLogin: ' + lastLogin))
.append($('<small/>').text('Sex: ' + sex));
topDiv.append(topLine);
topDiv.append(bottomLine);
$("#characterlist").append(topDiv);
}
$(function() {
try {
var charInfo = window.external.getCharacterInfo();
} catch (e) {
createErrorAlert("Error on getCharacterInfo!");
}
try{
// JQuery's parseXML isn't working properly on IE11, use the activeX XMLDOM instead.
//$xmlDoc = $.parseXML(charInfo),
$xmlDoc = new ActiveXObject("Microsoft.XMLDOM");
$xmlDoc.async = "false";
$xmlDoc.loadXML(charInfo);
$xml = $($xmlDoc);
} catch (e) {
createErrorAlert("Error parsing character info xml!" + e);
}
// Go over each "Character" element in the XML and then create a new list item for it.
try {
$($xml).find("Character").each(function(){
createCharListItem(
$(this).attr('name'),
$(this).attr('uid'),
$(this).attr('weapon'),
$(this).attr('HR'),
$(this).attr('GR'),
$(this).attr('lastLogin'),
$(this).attr('sex')
);
});
} catch (e) {
createErrorAlert("Error searching character info xml!");
}
// Set the active character selection on click.
$(".char-list-entry").click(function(){
if(!$(this).hasClass("active")) {
$(".char-list-entry.active").removeClass("active");
$(this).addClass("active");
}
});
$("#selectButton").on("click", function() {
// Get the active character selection.
var selectedUid = $(".char-list-entry.active").attr("uid");
// Call into the native launcher select function.
try{
window.external.selectCharacter(selectedUid, selectedUid)
} catch(e) {
createErrorAlert("Error on select character!");
}
// If we didn't error before, just close the launcher to start the game.
setTimeout(function(){
window.external.exitLauncher();
}, 500);
});
});

38
www/erupe/js/login.js Normal file
View File

@@ -0,0 +1,38 @@
function createErrorAlert(message) {
parent.postMessage(message, "*");
}
// Function to continually check if we got a login result yet,
// then navigating to the character selection if we did.
function checkAuthResult() {
var loginResult = window.external.getLastAuthResult();
console.log('|' + loginResult + '|');
if(loginResult == "AUTH_PROGRESS") {
setTimeout(checkAuthResult, 1500);
} else if (loginResult == "AUTH_SUCCESS") {
window.location.href = 'charsel.html'
} else {
createErrorAlert("Error logging in!");
}
}
$(function() {
// Login form submission.
$("#loginform").submit(function(e){
e.preventDefault();
username = $("#username").val();
password = $("#password").val();
try{
window.external.loginCog(username, password, password);
} catch(e){
createErrorAlert("Error on loginCog: " + e);
}
checkAuthResult();
});
});

93
www/erupe/js/main.js Normal file
View File

@@ -0,0 +1,93 @@
// Helper function to dynamically create a bootstrap alert box.
function createErrorAlert(message) {
var tmpDiv = $('<div/>')
.attr("id", "myAlertBoxID")
.attr("role", "alert")
.addClass("alert alert-danger alert-dismissible fade show")
tmpDiv.append(message);
tmpDiv.append($("<button/>")
.attr("type", "button")
.addClass("close")
.attr("data-dismiss", "alert")
.attr("aria-label", "Close")
.append($("<span/>")
.attr("aria-hidden", "true")
.text("×")
));
$("#alertBox").append(tmpDiv);
}
function doLauncherInitalize() {
try{
window.external.getMhfMutexNumber();
} catch(e){
createErrorAlert("Error getting Mhf mutex number! " + e);
}
try{
var serverListXml = window.external.getServerListXml();
} catch(e){
createErrorAlert("Error getting serverlist.xml! " + e);
}
if(serverListXml == ""){
createErrorAlert("Got empty serverlist.xml!");
}
console.log(serverListXml);
try{
var lastServerIndex = window.external.getIniLastServerIndex();
} catch(e){
createErrorAlert("Error on getIniLastServerIndex: " + e);
}
console.log("Last server index:" + lastServerIndex);
try{
window.external.setIniLastServerIndex(0);
} catch(e){
createErrorAlert("Error on setIniLastServerIndex: " + e);
}
try{
var mhfBootMode = window.external.getMhfBootMode();
} catch(e){
createErrorAlert("Error on getMhfBootMode: " + e);
}
console.log("mhfBootMode:" + mhfBootMode);
try{
var userId = window.external.getUserId();
} catch(e){
createErrorAlert("Error on getUserId: " + e);
}
console.log("userId:" + userId);
try{
var password = window.external.getPassword();
} catch(e){
createErrorAlert("Error on getPassword: " + e);
}
console.log("password:" + password);
}
$(function() {
// Setup the titlebar and exit button so that the window works how you would expect.
$("#titlebar").on("click", function(e) {
window.external.beginDrag(true);
});
$("#exit").on("click", function(e) {
window.external.closeWindow();
});
// Setup the error message passthrough
$(window).on("message onmessage", function(e) {
var data = e.originalEvent.data;
createErrorAlert(data)
});
// Initialize the launcher by calling the native/external functions it exposes in the proper order.
doLauncherInitalize();
});

68
www/erupe/login.html Normal file
View File

@@ -0,0 +1,68 @@
<!DOCTYPE HTML PUBLIC"-//W3C//DTD HTML 4.01 Transitional//EN""http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Simple MHF launcher</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.0/css/bootstrap.min.css">
<!--[if IE 9]>
<link href="css/bootstrap-ie9.css" rel="stylesheet">
<![endif]-->
<!--[if lt IE 9]>
<link href="css/bootstrap-ie8.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/g/html5shiv@3.7.3"></script>
<![endif]-->
<!--[if gte IE 9]><!-->
<script src="https://code.jquery.com/jquery-3.4.1.slim.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.0/js/bootstrap.bundle.min.js"></script>
<!--<![endif]-->
<!--[if IE 9]>
<script src="js/bootstrap-ie9.js"></script>
<![endif]-->
<!--[if lte IE 8]>
<script src="https://code.jquery.com/jquery-1.12.4.min.js"></script>
<script src="js/bootstrap-ie8.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.0/js/bootstrap.js"></script>
<![endif]-->
<link rel="stylesheet" href="css/main.css">
<script type="text/javascript" src="js/login.js"></script>
</head>
<body>
<div id="alertBox"></div>
<div class="container">
<div class="row">
<div class="col-sm-12 text-center">
<div style="height:5em; margin-top:3em;">
<h2>
Erupe Simple Launcher
</h2>
</div>
</div>
</div>
<div class="row">
<div class="col-4"></div>
<div class="col-4 text-center">
<div class="segment">
<h5 class="mb-3">Login</h5>
<form id="loginform">
<div class="form-group">
<input type="text" class="form-control" id="username" placeholder="Username">
</div>
<div class="form-group">
<input type="password" class="form-control" id="password" placeholder="Password">
</div>
<button id="submitButton" type="submit" class="btn btn-primary">Submit</button>
</form>
</div>
</div>
<div class="col-4"></div>
</div>
</div>
</body>
</html>