2025-06-16 13:38:00
angad.me
Picture this, you’re a teenager in the middle of the ocean on a cruise ship. All is good, except you’re lacking your lifeblood: internet. You could pay $170 for seven days of throttled internet on a single device, and perhaps split it via a travel router or hotspot, but that still seems less than ideal.
I’ve been travelling Europe with family and am currently on Princess Cruises’ Sun Princess cruise ship. For the past two days, the ship has mostly been in port, so I was able to use a cellular connection. This quickly became not feasible as we got further from land, where there was no coverage. At around the same time, I wanted to download the Princess Cruises app on my phone, and realized that it would give me a one-time 15-minute internet connection. I activated it, and quickly realized that it didn’t limit you to just the Play Store or App Store: all websites could be accessed. I soon realized that this “one-time” download was MAC-address dependent and thus, could be bypassed by switching MAC addresses. However, doing so logs you out of the MedallionNet portal, which is required to get the 15-minutes of free internet.
This means that in order to get just 15 minutes of internet, the following is required:
- Change MAC address
- Login to MedallionNet with date-of-birth and room number, binding the MAC address to your identity
- Send a request with your booking ID activating 15 minutes of free internet, intended to be used for downloading the Princess app
- Not completely sure, but it did initially seem that to activate unrestricted access to the internet, you would need to send a simple HTTP to play.google.com, although I don’t think this is needed.
This process, on the surface, seems extremely arduous. But if this could be automated, it would suddenly become a much more viable proposition. After looking at the fetch requests the MedallionNet portal was sending to login and activate the free sessions, I realized that it shouldn’t be too hard to automate.
Conveniently, my family also brought a travel router running OpenWRT as we were planning on purchasing the throttled single-device plan (which is ~$170 for the entire cruise) and using the router as a hotspot so we could connect multiple devices to the internet. This router (a GL.iNet) allows you to change the MAC address via the admin portal, needing only a single POST request. This meant that if I could string together the API requests to change the MAC address (after getting a token from the router login endpoint), login to MedallionNet, and request the free internet session, I would have free internet.
I first tried copying the requests as cURL commands via DevTools into Notepad and using a local LLM to vibe-code a simple bash file. Realizing this wasn’t going to work, I began work on a Python script instead. I converted the cURL commands to requests
via Copilot (yes, I used LLMs to an extent when building this) and started chaining together the requests.
The only issues I faced that took some time to overcome were figuring out how to repeat the requests when needed and being resistant to unexpected HTTP errors. For the former, I initially tried repeating it on an interval (first through a while True
loop and later via shell scripting in the container) but later realized it was much easier by checking if internet access expired by sending a request to example.com and checking if it fails. For the latter, I used while True
loops to allow the requests to be retried and executed break
when the requests succeeded. The only other issue, that still exists, is that occasionally, the connection will drop out while the session is refreshed, although this seems to happen less than every 15 minutes and only lasts for a minute or two.

The container running in Docker Desktop, refreshing the session when needed
After comparing speeds with another guy I met on the cruise (who also happens to be a high-school programmer), who had the highest-level MedallionNet plan, there seems to be no additional throttling (7+ Mbps). Even better, after connecting my power bank’s integrated USB-C cable to the router, I’m able to move around the ship for hours. So far, I’ve been using the router for nearly ~7 hours and my 10,000 mAh power bank is still at 42% battery. In fact, I’m writing this very post while connected to the router.
The script
Here’s the code that I’ve been using and a sample .env, although I have a repository
with the Dockerfile and compose file as well. Also, I’m aware the code isn’t pretty, I mostly just wrote it as a proof-of-concept that turned out to work amazingly well. I also doubt I’ll update the code after my cruise ends, since it won’t really be possible to test it. If you try to use this later, and it’s broken, you can go to DevTools, go to the network tab, go to “Fetch/XHR”, and then reload, which should allow you to reverse-engineer the API if you want to try to fix the script.
FIRST_NAME=YourFirstName
LAST_NAME=YourLastName
DOB=YYYY-MM-DD
BOOKING_ID=YourBookingID
ROOM_NUMBER=YourRoomNumber
PASSWORD=YourPassword
# Not doing uv's script syntax
import time
import requests
from rich.pretty import pprint
import os
from dotenv import load_dotenv
import random
from netaddr import EUI, mac_unix_expanded
load_dotenv()
# ONLY_RUN_ONCE = False
FIRST_NAME = os.getenv("FIRST_NAME")
LAST_NAME = os.getenv("LAST_NAME")
DOB = os.getenv("DOB")
BOOKING_ID = os.getenv("BOOKING_ID")
PASSWORD = os.getenv("PASSWORD")
ROOM_NUMBER = os.getenv("ROOM_NUMBER")
USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1"
def app_access() -> bool:
url = "https://mnet.su.ocean.com/captiveportal/api/v1/pcaAppDownload/usr/appValidAccess"
headers = {
"accept": "application/json",
"accept-language": "en",
"apicaller": "3",
"content-type": "application/json",
"origin": "https://mnet.su.ocean.com",
"priority": "u=1, i",
"referer": "https://mnet.su.ocean.com/MednetWifiWeb/plan",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"sec-gpc": "1",
"user-agent": USER_AGENT
}
data = {
"bookingId": BOOKING_ID
}
response = requests.post(url, headers=headers, json=data)
try:
pprint(response.json())
if response.json()["isTimeRemaining"] is False:
return True
except Exception as e:
pprint({"error getting internet access via app download method": str(e)})
return
# Make a simple request to https://play.google.com or apple app store
time.sleep(5) # Small delay since I don't think it gives access immediately
try:
url = "https://apps.apple.com/us/app/princess-cruises/id6469049279"
# url = "https://play.google.com"
headers = {
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"accept-language": "en-US,en;q=0.5",
"user-agent": USER_AGENT
}
response = requests.get(url)
pprint(
response.status_code,
)
except requests.exceptions.ConnectionError:
pprint("Couldn't GET app store url, should be fine though")
return False
def login_user(ship_code: str):
url = "https://mnet.su.ocean.com/captiveportal/api/v1/loginUser"
headers = {
"accept": "application/json",
"accept-language": "en",
"apicaller": "3",
"content-type": "application/json",
"origin": "https://mnet.su.ocean.com",
"priority": "u=1, i",
"referer": "https://mnet.su.ocean.com/MednetWifiWeb/login",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"sec-gpc": "1",
"user-agent": USER_AGENT
}
data = {
"cabinNumber": ROOM_NUMBER,
"dob": DOB,
"clickfromOceanConcierge": False,
"shipCode": ship_code,
"tokenType": 1
}
response = requests.post(url, headers=headers, json=data)
# pprint(response.json())
def random_mac_even_first_byte() -> str:
# Generate a random MAC with even first byte
first_byte = random.randint(0, 255) & 0xFE
mac_bytes = [first_byte] + [random.randint(0, 255) for _ in range(5)]
mac = EUI(':'.join(f"{b:02x}" for b in mac_bytes))
mac.dialect = mac_unix_expanded
return str(mac)
def edit_mac(admin_token):
url = "http://192.168.8.1/cgi-bin/api/router/mac/clone"
new_mac = random_mac_even_first_byte() # Use the correct function
headers = {
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "en-US,en;q=0.8",
"Authorization": admin_token,
"Connection": "keep-alive",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Origin": "http://192.168.8.1",
"Referer": "http://192.168.8.1/",
"Sec-GPC": "1",
# "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
"User-Agent": USER_AGENT,
"X-Requested-With": "XMLHttpRequest"
}
cookies = {
"Admin-Token": admin_token
}
data = {
"newmac": new_mac
}
response = requests.post(url, headers=headers, cookies=cookies, data=data, verify=False)
pprint({"new_mac": new_mac, "response": response.json()})
return response.json()
def login_router(password):
"""
Logs into the router and returns the Admin-Token.
"""
url = "http://192.168.8.1/cgi-bin/api/router/login"
headers = {
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "en-US,en;q=0.8",
"Authorization": "undefined",
"Connection": "keep-alive",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Origin": "http://192.168.8.1",
"Referer": "http://192.168.8.1/",
"Sec-GPC": "1",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
"X-Requested-With": "XMLHttpRequest"
}
data = {
"pwd": password
}
response = requests.post(url, headers=headers, data=data, verify=False)
# pprint({"status_code": response.status_code})
# pprint({"headers": dict(response.headers)})
# pprint({"cookies": response.cookies.get_dict()})
try:
token = response.json().get("token")
# pprint({"login_router_response": response.json(), "Admin-Token": token})
return token
except Exception as e:
pprint({"error": str(e)})
return None
def can_connect_to_example() -> bool:
try:
requests.get("https://example.com", timeout=5)
return True
except requests.RequestException:
return False
def get_ship_code() -> str:
# https://mnet.su.ocean.com/captiveportal/api/v1/shipcodes/ship
# {"shipCodesMapping":[{"shipCode":"SU","shipName":"Sun Princess"}]}
url = "https://mnet.su.ocean.com/captiveportal/api/v1/shipcodes/ship"
headers = {
"accept": "application/json",
"accept-language": "en",
"content-type": "application/json",
"user-agent": USER_AGENT
}
response = requests.get(url, headers=headers)
try:
ship_code = response.json()["shipCodesMapping"][0]["shipCode"]
return ship_code
except Exception as e:
pprint({"error getting ship code, defaulting to SU for Sun Princess": str(e)})
return "SU"
def connect_to_internet(ship_code: str):
while True: # Loop until we get internet access
while True: # Loop until we get any response from the app internet request
while True: # Loop until we successfully login
print("Logging in")
try:
login_user(ship_code=ship_code)
except requests.exceptions.ConnectionError:
print("Couldn't connect to login page, sleeping for 2 seconds and retrying")
time.sleep(2)
continue
else:
break
print("Requesting app internet")
try:
# Don't regen mac unless this returns true
print("Randomizing MAC due to session expiration")
regen_mac = app_access()
except requests.exceptions.ConnectionError:
print("Couldn't get app internet, sleeping for 2 seconds and retrying")
time.sleep(2)
continue
else:
break
if regen_mac:
edit_mac(login_router(PASSWORD))
# time.sleep(3)
else:
break
def main():
print("hi")
ship_code = get_ship_code()
while True:
if can_connect_to_example():
print("Internet access detected (can connect to example.com). Waiting 15 seconds before checking again.")
time.sleep(15)
continue
else:
print("Cannot connect to example.com, attempting to activate a 15 minute free cruise internet session")
connect_to_internet(ship_code=ship_code)
print(f"Finished at {time.strftime('%H:%M:%S')}, but sleeping for 30 seconds before verifying internet access")
time.sleep(30) # Sleep for 30 seconds before checking again since it might take a while for internet access to be granted
if __name__ == "__main__":
main()
Keep your files stored safely and securely with the SanDisk 2TB Extreme Portable SSD. With over 69,505 ratings and an impressive 4.6 out of 5 stars, this product has been purchased over 8K+ times in the past month. At only $129.99, this Amazon’s Choice product is a must-have for secure file storage.
Help keep private content private with the included password protection featuring 256-bit AES hardware encryption. Order now for just $129.99 on Amazon!
Help Power Techcratic’s Future – Scan To Support
If Techcratic’s content and insights have helped you, consider giving back by supporting the platform with crypto. Every contribution makes a difference, whether it’s for high-quality content, server maintenance, or future updates. Techcratic is constantly evolving, and your support helps drive that progress.
As a solo operator who wears all the hats, creating content, managing the tech, and running the site, your support allows me to stay focused on delivering valuable resources. Your support keeps everything running smoothly and enables me to continue creating the content you love. I’m deeply grateful for your support, it truly means the world to me! Thank you!
BITCOIN bc1qlszw7elx2qahjwvaryh0tkgg8y68enw30gpvge Scan the QR code with your crypto wallet app |
DOGECOIN D64GwvvYQxFXYyan3oQCrmWfidf6T3JpBA Scan the QR code with your crypto wallet app |
ETHEREUM 0xe9BC980DF3d985730dA827996B43E4A62CCBAA7a Scan the QR code with your crypto wallet app |
Please read the Privacy and Security Disclaimer on how Techcratic handles your support.
Disclaimer: As an Amazon Associate, Techcratic may earn from qualifying purchases.