일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- API
- spring boot
- 그리디
- 코딩테스트
- Spring
- cleancode
- 개발
- framework
- Java
- JPA
- 데이터베이스
- 엘라스틱서치
- 코딩
- 클린코드
- 그리디알고리즘
- 읽기쉬운코드
- 애자일
- 백준
- 자바
- 스프링
- Baekjoon
- 애자일프로그래밍
- Elasticsearch
- 코드
- ES
- 알고리즘
- 개발자
- 프레임워크
- database
- 애자일기법
- Today
- Total
튼튼발자 개발 성장기🏋️
푸르지오 스마트홈 가전제어를 언어로 손쉽게 (ios) 본문
나는 순수 전기차를 사용하고 있기 때문에 전기차 충전소를 자주 이용한다. 집밥이라고 불리는 집 충전소에서도 거의 매일 이용하고 있는데, 공용 충전소인 만큼 최대 충전 시간이 법적으로 정해져 있다. 여기서 이웃주민들끼리 문제가 생기기 시작한다. 충전을 사용하지 않더라도 충전소에 주차를 해놓거나 충전이 다 되어도 이동주차를 하지 않는 비매너 행동과 법적으로 정해진 시간 이상 주차를 해놓는 경우가 매일 생긴다. 새벽출근에 저녁퇴근하는 나는 평일에 충전을 할 수 없는 경우가 종종 생겨 스트레스가 이만저만이 아니다. 거기다 푸르지오 스마트홈을 통해 전기차 충전소 각 자리의 상태(충전 중 혹은 충전 가능)를 알 수 있는데, 매번 주차장 앞에서 앱을 키고 조회하기가 번거롭기도 하고 푸르지오 서버가 이슈가 많고 하루에 몇 번이나 장애가 발생하는 경우를 자주 볼 수 있기 때문에 이에 대한 스트레스를 최소화 하고자 배치를 만드려고 한다. 그 외에도 Iphone의 siri(시리)로 편하게 엘리베이터 호출이라던가, 최근 차량 출입 조회, 전등 On/Off 등을 컨트롤 하는 것이 목표다. |
Step 1. 패킷 감청 및 분석
[그림 1]과 같이 푸르지오 앱에서 특정 액션을 취했을 때 어떤 통신을 하는지, 어떤 데이터가 오고 가는지 알기 위해서 proxy를 통해 패킷을 감청하고 분석한다. proxy는 trio_http_proxy를 입맛대로 수정하여 최종적으로 .pcap 파일로 export하는 로직을 추가했다. 나는 개인 서버 PC에서 proxy를 실행했지만 로컬에서 실행해도 된다.
scp -P {port} {username}@{ip}:/your/proxy/home/prugio.pcap .
이렇게 얻은 패킷(pcap)을 위와 같이 서버에서 로컬로 가지고온다. 이제 이 패킷을 분석하기만하면 된다. 분석은 wireshark 툴을 사용했다. 무료버전이더라도 가장 많은 기능을 제공해주고 인터넷 정보도 많아서 유용하게 사용할 수 있을 것 같다.
wireshark를 다운 받았다면 [File] -> [Open] 을 통해서 내 pcap를 열면 패킷 데이터가 우수수하게 보인다.
패킷을 분석하다보면 알 수 있는 사실은 모든 데이터가 암호화되어 있다는 사실이었다. 사실 2년 전까지만해도 암호화하지 않았다는 이야기를 들은 적이 있었는데...어느새 암호화를 한 모양이다. 이를 해석하려면 복호화 키를 가지고 있어야하는데, 복호화 키는 앱에 내장되어 있을거라는 확신을 가지고 앱을 까보기 시작한다.
Step 2. 앱 백업 및 분석
앱을 까보기 위해서 먼저 백업을 해야한다. 아이폰을 로컬에 연결하여 백업한다.나는 iMazing을 사용하여 백업했다.
[그림 3]과 같이 백업하고난 뒤에 푸르지오 스마트홈 앱을 IPA 파일로 export하면 디컴파일할 수 있는 파일을 얻을 수 있다. IPA파일은 class-dump 혹은 hopper를 사용하여 디컴파일할 수 있다. 이제 해보.....잠깐.
확실하지 않지만 디컴파일하고 앱을 까보는 행위는 법적인 문제가 될 수 있을 것이라는 이야기를 들었다. 따라서 다른 방법을 찾아 보자. (내 간은 콩알만하니까...절대 내 간 지켜!!!)
Step 3. 앱의 Tree 분석 / 또 다시 패킷 감청
어떤 프로그램이든 실행하기 위한 메타데이터가 같이 설치된다. 디컴파일과 같이 법적으로 문제되지 않게 메타데이터를 볼 수 있는 방법이있다. ibackup viewer로 아이폰을 백업하면 쉽게 열람할 수 있다. 잘 찾아보면 salt나 public key와 같은 암/복호화에 사용되는 데이터를 . 볼 수 있었다.
또 다른 방법은 charles라는 강력한 proxy를 사용하는 것이다. 푸르지오 스마트홈 서버 통신은 https, wss protocol을 사용하기 때문에 인증서를 등록해주어야한다. 아이폰과 맥북에 각각 인증서를 등록해주고 charles을 실행해주면 된다.(주의: 보안을 위해 사용이 끝나고 인증서를 삭제하자.)
- 인증서 등록 방법: [Help] -> {SSL Proxying] -> [Install Charles Root Certificate]
각 디바이스에 인증서를 등록했다면 이제 SSL Proxy 설정을 해주어야한다. [그림 5]와 같이 [Proxy] -> [SSL Proxying Settings]에서 푸르지오 스마트홈 서버를 include해준다. port를 모르겠다면 아스트리스크(*)를 사용해도 된다.
모든 설정은 마쳤다. 이제 charles를 재시작해주고 푸르지오 스마트홈을 실행하여 원하는 액션을 취하면 된다. [그림 6]와 같이 전기차 충전소 상태, 방문자 조회, 조명, 엘리베이터 호출에 대한 api 정보를 볼 수 있다. contents를 보면 인증토큰과 basic 토큰이 그대로 노출되는 것을 볼 수 있다.
이제 우리는 애플홈킷을 이용해서 시리에게 가전제어를 시킬 수 있게되었다!!!
[참고]
이것 저것 시도해보다가 푸르지오 스마트홈에서 사용하는 javascript module을 찾았다. 아래 코드는 해당 모듈에서 사용하는 모든 api의 path를 나타낸다.
var n = {
kakaoAuthorize: 'https://kauth.kakao.com/oauth/authorize',
kakaoToken: 'https://kauth.kakao.com/oauth/token',
kakaoProfile: 'https://kapi.kakao.com/v2/user/me',
naverAuthorize: 'https://nid.naver.com/oauth2.0/authorize',
naverToken: 'https://nid.naver.com/oauth2.0/token',
naverProfile: 'https://openapi.naver.com/v1/nid/me',
facebookProfile: 'https://graph.facebook.com/me',
user: '/user',
userSns: '/user/sns',
userInit: '/user/init',
logIn: '/login',
logInSns: '/login/sns',
userToken: '/user/token',
userCheck: '/user/{user_id}/check',
userComplex: '/user/complex',
userFindId: '/user/find-id',
userCert: '/user/cert',
userCertConfirm: '/user/cert/confirm',
userPassword: '/user/{user_id}/password',
logOut: '/logout',
userAddress: '/user/address',
userPushToken: '/user/push/token',
setting: '/setting',
settingMy: '/setting/my',
settingFamily: '/setting/family',
settingFamilyCurrent: '/setting/family/current',
settingFamilyRequest: '/setting/family/request',
settingFamilyRepresent: '/setting/family/represent',
settingFamilyResident: '/setting/family/resident',
settingFamilyResidentCancel: '/setting/family/resident_cancel',
settingPush: '/setting/push',
settingPassword: '/setting/password',
settingBanner: '/setting/banner',
settingManual: '/setting/manual',
main: '/main',
mainNotice: '/main/notice',
mainNoticeRead: '/main/notice',
communityNotice: '/community/notice',
communitySurvey: '/community/survey',
communitySurveyQuestion: '/community/survey/question',
communitySurveyAnswer: '/community/survey/answer',
communityComplaint: '/community/complaint',
communityComplaintBlltNo: '/community/complaint/{bllt_no}',
communityOffice: '/community/office',
communityAppDrawers: '/community/appdrawer',
communityLinkApps: '/community/link/apps',
convenienceElevator: '/convenience/elevator',
convenienceParcelBox: '/convenience/parcel-box',
convenienceVisitor: '/convenience/visitor',
convenienceCar: '/convenience/car',
convenienceParking: '/convenience/parking',
convenienceCarVisit: '/convenience/car/visit',
convenienceCctv: '/convenience/cctv',
convenienceFamily: '/convenience/family',
convenienceChargingPoint: '/convenience/charging/point',
convenienceComplexLayout: '/convenience/complex/layout',
convenienceComplexOutLine: '/convenience/complex/overview/{houscplx_cd}',
convenienceComplexPlan: '/convenience/complex/plan',
convenienceComplexInfo: '/convenience/complex/info',
convenienceWeather: '/convenience/weather',
convenienceWeatherWarning: '/convenience/weatherWarning',
PerformanceList: '/community/performance/list',
PerformanceDetail: '/community/performance/detail',
convenienceMsg: '/convenience/msg',
convenienceBusStations: '/convenience/bus_stations/{houscplx_cd}',
convenienceBusArrivals: '/convenience/bus_stations',
control: '/control',
controlDevice: '/control/device',
controlUser: '/control/user',
controlUserControlId: '/control/user/{user_set_ctl_id}',
controlUserDevice: '/control/userdevice',
controlGuidingText: '/control/guidingtext',
controlDeviceLastResult: '/control/last_result',
statsEnergyMonth: '/stats/energy/month/{yyyymm}',
statsEnergyPast: '/stats/energy/past',
statsEnergyGoal: '/stats/energy/goal',
statsMaintenanceFee: '/stats/maintenance-fee',
userDeviceStatus: '/user/device/status',
reserveInfo: '/control/reserve',
reserveInfoDelete: '/control/reserve_delete',
reserveInfoModify: '/control/reserve_modify',
reserveInfoList: '/control/reserve/list',
reserveInfoDetail: '/control/reserve/detail',
systemPopup: '/community/notice/main',
systemPopupRead: '/community/notice/checkRead',
smartEleList: '/SmartApp/StDeviceList',
smartDeviceDetail: '/SmartApp/StDeviceStatus',
controlSmartEleDevice: '/SmartApp/StDeviceExecute',
getInstallAppId: '/SmartApp/StAppId',
LG: {
list: '/LgThinQ/LgDeviceList',
profile: '/LgThinQ/LgDeviceProfile',
status: '/LgThinQ/LgDeviceStatus',
control: '/LgThinQ/LgDeviceExecute',
tokenRefresh: '/LgThinQ/LgUserToken'
},
LG_Temp: {
list: '/id',
profile: '/profile',
status: '/status',
control: '/status'
},
convenienceComplexFacilities: '/convenience/complex/{houscplx_cd}/facilities',
communityNoticeBlltNo: '/community/notice/{bllt_no}',
faq: '/cm/faq',
vocLanding: "/app/mobile",
vocList: "/app/request/list",
vocRegister: "/app/request/register",
vocDetial: "/app/request/{request_id}/detail",
vocFeedback: "/app/request/{request_id}/feedback",
mallLanding: "/app/mobile?complexId={complexId}",
mallHome: "/app/mall/{mall-id}",
mallImage: "/app/mall/{mall-id}/image",
mallSearch: "/app/mall/{mall-id}/search",
mallEvent: "/app/mall/{mall-id}/coupon",
mallNotice: "/app/mall/{mall-id}/notice",
mallNoticeDetail: "/app/mall/{mall-id}/notice/{notice-id}",
mallStoreOverview: "/app/mall/{mall-id}/store/{store-id}/overview",
mallStoreImage: "/app/mall/{mall-id}/store/{store-id}/image",
mallStoreMenu: "/app/mall/{mall-id}/store/{store-id}/menu",
mallStoreMenuImage: "/app/mall/{mall-id}/store/{store-id}/menu/{menu-id}/image",
mallStoreEvent: "/app/mall/{mall-id}/store/{store-id}/coupon",
mallStoreCoupon: "/app/mall/{mall-id}/store/{store-id}/coupon/{coupon-id}"
};
그래서 전기차 충전소 상태 조회는?
가장 필요했던 전기차 충전소 상태 조회 부분은 어떻게 사용되는지도 알 수 있었다. Charles를 통해서도 알 수 있었지만, 전기차 충전소 상태 조회가 특이하게도 POST method를 사용하고 있다. 더 이상한 것은 응답으로 201 Created를 리턴하며 body에는 ctl_id라는 값을 json 형식으로 주고있다는 점이다. 그런데 앱에서는 각 충전소들의 상태가 최신화된다. 분명 데이터를 받지 못했는데 상태가 최신화된다는 이야기는 event-driven 혹은 socket 통신, SSE 방식 등을 사용한다고 예측을 할 수 있었다.
아래 js module 중 전기차 충전소 상태 조회 관련 코드를 보면 Charles에서 확인했던 api를 비동기로 호출하는 postConvenienceChargingPointAsync를 볼 수 있다. 그러나 정작 상태를 받아 처리하는 로직은 receivePushMessage로 따로 정의되어있다. 그렇다면 receivePushMessage를 사용하는 부분에서 cp_sts_list를 어떻게 받아오는지를 확인해야하는데... 그 방법을 모르겠다.
__d(function (g, r, i, a, m, e, d) {
var t = r(d[0]);
Object.defineProperty(e, "__esModule", {
value: !0
}),
e.default = void 0;
var n,
o,
s,
c = t(r(d[1])),
u = t(r(d[2])),
p = t(r(d[3])),
l = t(r(d[4])),
f = t(r(d[5])),
h = (t(r(d[6])), r(d[7])),
v = r(d[8]),
P = t(r(d[9])),
y = t(r(d[10])),
b = t(r(d[11])),
C = r(d[12]),
_ = (
n = (function () {
function t() {
(0, p.default)(this, t),
(0, u.default)(this, "chargingPoints", o, this),
(0, u.default)(this, "isPending", s, this)
}
return (0, l.default)(t, [
{
key: "receivePushMessage",
value: function (t) {
var n = t.data.cp_sts_list,
o = [];
if (n) {
var s = (0, v.decode)(n);
o = JSON.parse(decodeURIComponent(escape(s)))
}
return this.chargingPoints = o,
this.isPending = !1,
o
}
}, {
key: "postConvenienceChargingPointAsync",
value: function () {
var t,
n,
o = this;
return c
.default
.async (function (s) {
for (;;)
switch (s.prev = s.next) {
case 0:
return this.chargingPoints = [],
this.isPending = !0,
t = P
.
default
.get(),
n = t.certf_tp_cd,
s.prev = 3,
s.next = 6,
c
.
default
.awrap(y.default.post(b.default.convenienceChargingPoint, {certf_tp_cd: n}));
case 6:
setTimeout(function () {
return o.isPending = !1
}, C.pushMessageTimeOut),
s.next = 13;
break;
case 9:
s.prev = 9,
s.t0 = s.catch(3),
this.isPending = !1,
console.info('postConvenienceChargingPointAsync error :: ', s.t0);
case 13:
case "end":
return s.stop()
}
}, null, this, [
[3, 9]
], Promise)
}
}
]),
t
})(),
o = (0, f.default)(n.prototype, "chargingPoints", [h.observable], {
configurable: !0,
enumerable: !0,
writable: !0,
initializer: function () {
return []
}
}),
s = (0, f.default)(n.prototype, "isPending", [h.observable], {
configurable: !0,
enumerable: !0,
writable: !0,
initializer: function () {
return !1
}
}),
(0, f.default)(
n.prototype,
"receivePushMessage",
[h.action],
Object.getOwnPropertyDescriptor(n.prototype, "receivePushMessage"),
n.prototype
),
(0, f.default)(
n.prototype,
"postConvenienceChargingPointAsync",
[h.action],
Object.getOwnPropertyDescriptor(n.prototype, "postConvenienceChargingPointAsync"),
n.prototype
),
n
);
e.default = _
}, 1601, [
1,
2,
1516,
15,
16,
1517,
1518,
676,
1507,
1468,
1506,
1530,
1595
]);
그래서 하나씩 시도해보기로 했다.
1. websocket: 401 unauthorized.
import asyncio
import aiohttp
import logging
import json
from typing import List
logging.basicConfig(level=logging.INFO)
class ChargingService:
def __init__(self):
self.charging_points = []
self.is_pending = False
def set_charging_points(self, new_points: List[dict]):
self.charging_points = new_points
logging.info(f"Charging points updated: {self.charging_points}")
async def post_convenience_charging_point_async(self):
self.is_pending = True
try:
async with aiohttp.ClientSession() as session:
ws_url = "wss://svc.smartprugio.com:18888/v1/convenience/charging/point"
headers = {
"authorization": {Basic token},
"token": {token}
}
async with session.ws_connect(ws_url, heartbeat=5, headers=headers) as ws:
logging.info("Connected to WSS and sent request.")
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
data = msg.json()
await self.receive_push_message(data)
elif msg.type == aiohttp.WSMsgType.ERROR:
logging.error(f"WebSocket error: {msg.data}")
break
except Exception as e:
logging.error(f"post_convenience_charging_point_async error: {e}")
raise e
finally:
self.is_pending = False
async def receive_push_message(self, message: dict):
if 'data' in message and 'cp_sts_list' in message['data']:
cp_sts_list = message['data']['cp_sts_list']
decoded_data = await self.decode_data(cp_sts_list)
self.set_charging_points(decoded_data)
self.is_pending = False
async def decode_data(self, data: str) -> List[dict]:
return json.loads(data)
async def main():
service = ChargingService()
await service.post_convenience_charging_point_async()
if __name__ == "__main__":
asyncio.run(main())
2. SSE: 400 Bad Request
import json
from sseclient import SSEClient
headers = {
"Host": "svc.smartprugio.com:18888",
"app_version": "1.2.0-v14",
"content-type": "application/json",
"Authorization": {Basic token},
"accept-language": "ko-KR,ko;q=0.9",
"user-agent": "Smart%20Home/212 CFNetwork/1568.200.51 Darwin/24.1.0",
"token": {token}
}
data = {
"certf_tp_cd": "KAKAO"
}
url = "https://svc.smartprugio.com:18888/v1/convenience/charging/point"
sse_client = SSEClient(
url,
headers=headers,
data=json.dumps(data)
)
for event in sse_client:
print(f"Received event: {event.data}")
대체 cp_sts_list는 어디서 어떻게 받아오는걸까..................
'프로젝트 > 토이프로젝트' 카테고리의 다른 글
#4. 코인 자동 매매 프로그램 만들기 (1) | 2022.02.28 |
---|---|
#3. 코인 자동 매매 프로그램 만들기 (0) | 2022.02.07 |
#2. 코인 자동 매매 프로그램 만들기 (0) | 2022.02.07 |
#1. 코인 자동 매매 프로그램 만들기 (0) | 2022.01.28 |